Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack",
"packageManager": "pnpm@10.23.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/better-auth",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.5.1",
"version": "3.5.2",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand Down
2 changes: 1 addition & 1 deletion packages/clients/client-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/client-helpers",
"version": "3.5.1",
"version": "3.5.2",
"description": "Helpers for implementing clients that consume ZenStack's CRUD service",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/clients/tanstack-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/tanstack-query",
"version": "3.5.1",
"version": "3.5.2",
"description": "TanStack Query Client for consuming ZenStack v3's CRUD service",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/common-helpers",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack Common Helpers",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/config/eslint-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/eslint-config",
"version": "3.5.1",
"version": "3.5.2",
"type": "module",
"private": true,
"license": "MIT"
Expand Down
2 changes: 1 addition & 1 deletion packages/config/typescript-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/typescript-config",
"version": "3.5.1",
"version": "3.5.2",
"private": true,
"license": "MIT"
}
2 changes: 1 addition & 1 deletion packages/config/vitest-config/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/vitest-config",
"type": "module",
"version": "3.5.1",
"version": "3.5.2",
"private": true,
"license": "MIT",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/create-zenstack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-zenstack",
"version": "3.5.1",
"version": "3.5.2",
"description": "Create a new ZenStack project",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/language/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/language",
"description": "ZenStack ZModel language specification",
"version": "3.5.1",
"version": "3.5.2",
"license": "MIT",
"author": "ZenStack Team",
"files": [
Expand Down
5 changes: 4 additions & 1 deletion packages/language/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ export async function loadDocument(
fileName: string,
additionalModelFiles: string[] = [],
mergeImports: boolean = true,
returnPartialModelForError: boolean = false,
): Promise<
| { success: true; model: Model; warnings: string[]; services: ZModelServices }
| { success: false; errors: string[]; warnings: string[] }
| { success: false; model?: Model; errors: string[]; warnings: string[] }
> {
const { ZModelLanguage: services } = createZModelServices(false);
const extensions = services.LanguageMetaData.fileExtensions;
Expand Down Expand Up @@ -116,6 +117,7 @@ export async function loadDocument(
if (errors.length > 0) {
return {
success: false,
model: returnPartialModelForError ? (document.parseResult.value as Model) : undefined,
errors,
warnings,
};
Expand All @@ -139,6 +141,7 @@ export async function loadDocument(
if (additionalErrors.length > 0) {
return {
success: false,
model: returnPartialModelForError ? (document.parseResult.value as Model) : undefined,
errors: additionalErrors,
warnings,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/orm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/orm",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack ORM",
"type": "module",
"scripts": {
Expand Down
55 changes: 52 additions & 3 deletions packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { invariant } from '@zenstackhq/common-helpers';
import { type AliasableExpression, type Expression, type ExpressionBuilder, type SelectQueryBuilder } from 'kysely';
import type { FieldDef, GetModels, SchemaDef } from '../../../schema';
import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants';
import type { FindArgs } from '../../crud-types';
import type { FindArgs, NullsOrder, SortOrder } from '../../crud-types';
import {
buildJoinPairs,
ensureArray,
getDelegateDescendantModels,
getManyToManyRelation,
isRelationField,
Expand All @@ -23,7 +24,10 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
/**
* Builds an array aggregation expression.
*/
protected abstract buildArrayAgg(arg: Expression<any>): AliasableExpression<any>;
protected abstract buildArrayAgg(
arg: Expression<any>,
orderBy?: { expr: Expression<any>; sort: SortOrder; nulls?: NullsOrder }[],
): AliasableExpression<any>;

override buildRelationSelection(
query: SelectQueryBuilder<any, any, any>,
Expand Down Expand Up @@ -172,7 +176,8 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
);

if (relationFieldDef.array) {
return this.buildArrayAgg(this.buildJsonObject(objArgs)).as('$data');
const orderBy = this.buildRelationOrderByExpressions(relationModel, relationModelAlias, payload);
return this.buildArrayAgg(this.buildJsonObject(objArgs), orderBy).as('$data');
} else {
return this.buildJsonObject(objArgs).as('$data');
}
Expand All @@ -181,6 +186,50 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
return qb;
}

/**
* Extracts scalar `orderBy` clauses from the relation payload and maps them to
* the array-aggregation ordering format.
*
* For to-many relations aggregated into a JSON array (via lateral joins), this
* lets us preserve a stable ordering by passing `{ expr, sort, nulls? }` into
* the dialect's `buildArrayAgg` implementation.
*/
private buildRelationOrderByExpressions(
model: string,
modelAlias: string,
payload: true | FindArgs<Schema, GetModels<Schema>, any, true>,
): { expr: Expression<any>; sort: SortOrder; nulls?: NullsOrder }[] | undefined {
if (payload === true || !payload.orderBy) {
return undefined;
}

type ScalarSortValue = SortOrder | { sort: SortOrder; nulls?: NullsOrder };
const items: { expr: Expression<any>; sort: SortOrder; nulls?: NullsOrder }[] = [];

for (const orderBy of ensureArray(payload.orderBy)) {
for (const [field, value] of Object.entries(orderBy) as [string, ScalarSortValue | undefined][]) {
if (!value || requireField(this.schema, model, field).relation) {
continue;
}

const expr = this.fieldRef(model, field, modelAlias);
let sort = typeof value === 'string' ? value : value.sort;
if (payload.take !== undefined && payload.take < 0) {
// negative `take` requires negated sorting, and the result order
// will be corrected during post-read processing
sort = this.negateSort(sort, true);
}
if (typeof value === 'string') {
items.push({ expr, sort });
} else {
items.push({ expr, sort, nulls: value.nulls });
Comment on lines +216 to +225
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reverse nulls together with sort for negative take.

Backward pagination only works if the full ordering is inverted. Keeping nulls unchanged fetches the wrong window for nullable sort keys.

🐛 Proposed fix
-                let sort = typeof value === 'string' ? value : value.sort;
+                let sort = typeof value === 'string' ? value : value.sort;
+                let nulls = typeof value === 'string' ? undefined : value.nulls;
                 if (payload.take !== undefined && payload.take < 0) {
                     // negative `take` requires negated sorting, and the result order
                     // will be corrected during post-read processing
                     sort = this.negateSort(sort, true);
+                    if (nulls) {
+                        nulls = nulls === 'first' ? 'last' : 'first';
+                    }
                 }
                 if (typeof value === 'string') {
                     items.push({ expr, sort });
                 } else {
-                    items.push({ expr, sort, nulls: value.nulls });
+                    items.push({ expr, sort, nulls });
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let sort = typeof value === 'string' ? value : value.sort;
if (payload.take !== undefined && payload.take < 0) {
// negative `take` requires negated sorting, and the result order
// will be corrected during post-read processing
sort = this.negateSort(sort, true);
}
if (typeof value === 'string') {
items.push({ expr, sort });
} else {
items.push({ expr, sort, nulls: value.nulls });
let sort = typeof value === 'string' ? value : value.sort;
let nulls = typeof value === 'string' ? undefined : value.nulls;
if (payload.take !== undefined && payload.take < 0) {
// negative `take` requires negated sorting, and the result order
// will be corrected during post-read processing
sort = this.negateSort(sort, true);
if (nulls) {
nulls = nulls === 'first' ? 'last' : 'first';
}
}
if (typeof value === 'string') {
items.push({ expr, sort });
} else {
items.push({ expr, sort, nulls });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts` around
lines 216 - 225, When handling negative payload.take in
lateral-join-dialect-base.ts, you negate the sort via this.negateSort(sort,
true) but you do not negate the nulls ordering, which breaks backward pagination
for nullable keys; update the block that prepares items to compute a separate
nulls variable (e.g., let nulls = value === 'string' ? undefined : value.nulls)
and when payload.take < 0 apply the same inversion to nulls (call an existing
helper like this.negateNulls(nulls) or implement equivalent logic) so you push
items with { expr, sort, nulls } where both sort and nulls are inverted together
for negative takes.

}
}
}

return items.length > 0 ? items : undefined;
}

private buildRelationObjectArgs(
relationModel: string,
relationModelAlias: string,
Expand Down
10 changes: 8 additions & 2 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from 'kysely';
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types';
import type { BuiltinType, FieldDef, SchemaDef } from '../../../schema';
import type { SortOrder } from '../../crud-types';
import type { NullsOrder, SortOrder } from '../../crud-types';
import { createInvalidInputError, createNotSupportedError } from '../../errors';
import type { ClientOptions } from '../../options';
import { isTypeDef } from '../../query-utils';
Expand Down Expand Up @@ -192,7 +192,13 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_')));
}

protected buildArrayAgg(arg: Expression<any>): AliasableExpression<any> {
protected buildArrayAgg(
arg: Expression<any>,
_orderBy?: { expr: Expression<any>; sort: SortOrder; nulls?: NullsOrder }[],
): AliasableExpression<any> {
// MySQL doesn't support ORDER BY inside JSON_ARRAYAGG.
// For relation queries that need deterministic ordering, ordering is applied
// by the input subquery before aggregation.
return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`);
}

Expand Down
22 changes: 19 additions & 3 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { parse as parsePostgresArray } from 'postgres-array';
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types';
import type { BuiltinType, FieldDef, SchemaDef } from '../../../schema';
import type { SortOrder } from '../../crud-types';
import type { NullsOrder, SortOrder } from '../../crud-types';
import { createInvalidInputError } from '../../errors';
import type { ClientOptions } from '../../options';
import { isEnum, isTypeDef } from '../../query-utils';
Expand Down Expand Up @@ -272,8 +272,24 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi

// #region other overrides

protected buildArrayAgg(arg: Expression<any>) {
return this.eb.fn.coalesce(sql`jsonb_agg(${arg})`, sql`'[]'::jsonb`);
protected buildArrayAgg(
arg: Expression<any>,
orderBy?: { expr: Expression<any>; sort: SortOrder; nulls?: NullsOrder }[],
) {
if (!orderBy || orderBy.length === 0) {
return this.eb.fn.coalesce(sql`jsonb_agg(${arg})`, sql`'[]'::jsonb`);
}

const orderBySql = sql.join(
orderBy.map(({ expr, sort, nulls }) => {
const dir = sql.raw(sort.toUpperCase());
const nullsSql = nulls ? sql` NULLS ${sql.raw(nulls.toUpperCase())}` : sql``;
return sql`${expr} ${dir}${nullsSql}`;
}),
sql.raw(', '),
);

return this.eb.fn.coalesce(sql`jsonb_agg(${arg} ORDER BY ${orderBySql})`, sql`'[]'::jsonb`);
}

override buildSkipTake(
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/policy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/plugin-policy",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack Policy Plugin",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/schema",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack Runtime Schema",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/sdk",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack SDK",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/server",
"version": "3.5.1",
"version": "3.5.2",
"description": "ZenStack automatic CRUD API handlers and server adapters",
"type": "module",
"scripts": {
Expand Down
Loading
Loading