diff --git a/package.json b/package.json index 827061e07..679d30067 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack", "packageManager": "pnpm@10.23.0", "type": "module", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 1d451a64c..548e7f434 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -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": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f6af74b6a..bfaf8c507 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 9c962f5a5..6a8f79237 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -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": { diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index ef34a244c..0da0368a2 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -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": { diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 535a6cde1..c01354ce9 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 0b773197a..3d7c2a728 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.5.1", + "version": "3.5.2", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 39ba75aba..2874ccc3a 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.5.1", + "version": "3.5.2", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 21d2f75ef..aa8ab237b 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.5.1", + "version": "3.5.2", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index d47751667..3d44e977c 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.5.1", + "version": "3.5.2", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/language/package.json b/packages/language/package.json index 4fc370d39..afd6284d0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -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": [ diff --git a/packages/language/src/document.ts b/packages/language/src/document.ts index 7426c606d..2d497d796 100644 --- a/packages/language/src/document.ts +++ b/packages/language/src/document.ts @@ -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; @@ -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, }; @@ -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, }; diff --git a/packages/orm/package.json b/packages/orm/package.json index 070b090df..f17586c55 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/orm", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack ORM", "type": "module", "scripts": { diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index cbcdbee30..9e7d0b959 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -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, @@ -23,7 +24,10 @@ export abstract class LateralJoinDialectBase extends B /** * Builds an array aggregation expression. */ - protected abstract buildArrayAgg(arg: Expression): AliasableExpression; + protected abstract buildArrayAgg( + arg: Expression, + orderBy?: { expr: Expression; sort: SortOrder; nulls?: NullsOrder }[], + ): AliasableExpression; override buildRelationSelection( query: SelectQueryBuilder, @@ -172,7 +176,8 @@ export abstract class LateralJoinDialectBase 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'); } @@ -181,6 +186,50 @@ export abstract class LateralJoinDialectBase 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, any, true>, + ): { expr: Expression; sort: SortOrder; nulls?: NullsOrder }[] | undefined { + if (payload === true || !payload.orderBy) { + return undefined; + } + + type ScalarSortValue = SortOrder | { sort: SortOrder; nulls?: NullsOrder }; + const items: { expr: Expression; 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 }); + } + } + } + + return items.length > 0 ? items : undefined; + } + private buildRelationObjectArgs( relationModel: string, relationModelAlias: string, diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 5533a59aa..dff577204 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -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'; @@ -192,7 +192,13 @@ export class MySqlCrudDialect extends LateralJoinDiale return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_'))); } - protected buildArrayAgg(arg: Expression): AliasableExpression { + protected buildArrayAgg( + arg: Expression, + _orderBy?: { expr: Expression; sort: SortOrder; nulls?: NullsOrder }[], + ): AliasableExpression { + // 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()`); } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index a601f8028..48d78b1d4 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -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'; @@ -272,8 +272,24 @@ export class PostgresCrudDialect extends LateralJoinDi // #region other overrides - protected buildArrayAgg(arg: Expression) { - return this.eb.fn.coalesce(sql`jsonb_agg(${arg})`, sql`'[]'::jsonb`); + protected buildArrayAgg( + arg: Expression, + orderBy?: { expr: Expression; 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( diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index dce88a827..cb5d506aa 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/schema/package.json b/packages/schema/package.json index 29dece752..202a80f62 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/schema", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack Runtime Schema", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 36f036753..7b7a3ee24 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 5491cdd89..f905729c6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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": { diff --git a/packages/server/src/api/rest/openapi.ts b/packages/server/src/api/rest/openapi.ts index 9c5bae03f..45986ceae 100644 --- a/packages/server/src/api/rest/openapi.ts +++ b/packages/server/src/api/rest/openapi.ts @@ -61,6 +61,10 @@ export class RestApiSpecGenerator { return this.handlerOptions?.queryOptions; } + private get nestedRoutes(): boolean { + return this.handlerOptions.nestedRoutes ?? false; + } + generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document { this.specOptions = options; return { @@ -124,14 +128,28 @@ export class RestApiSpecGenerator { const relIdFields = this.getIdFields(relModelDef); if (relIdFields.length === 0) continue; - // GET /{model}/{id}/{field} — fetch related - paths[`/${modelPath}/{id}/${fieldName}`] = this.buildFetchRelatedPath( + // GET /{model}/{id}/{field} — fetch related (+ nested create/update when nestedRoutes enabled) + paths[`/${modelPath}/{id}/${fieldName}`] = this.buildRelatedPath( modelName, fieldName, fieldDef, tag, ); + // Nested single resource path: /{model}/{id}/{field}/{childId} + if (this.nestedRoutes && fieldDef.array) { + const nestedSinglePath = this.buildNestedSinglePath( + modelName, + fieldName, + fieldDef, + relModelDef, + tag, + ); + if (Object.keys(nestedSinglePath).length > 0) { + paths[`/${modelPath}/{id}/${fieldName}/{childId}`] = nestedSinglePath; + } + } + // Relationship management path paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath( modelDef, @@ -299,17 +317,17 @@ export class RestApiSpecGenerator { return result; } - private buildFetchRelatedPath( + private buildRelatedPath( modelName: string, fieldName: string, fieldDef: FieldDef, tag: string, ): Record { const isCollection = !!fieldDef.array; + const relModelDef = this.schema.models[fieldDef.type]; const params: any[] = [{ $ref: '#/components/parameters/id' }, { $ref: '#/components/parameters/include' }]; - if (isCollection && this.schema.models[fieldDef.type]) { - const relModelDef = this.schema.models[fieldDef.type]!; + if (isCollection && relModelDef) { params.push( { $ref: '#/components/parameters/sort' }, { $ref: '#/components/parameters/pageOffset' }, @@ -318,7 +336,7 @@ export class RestApiSpecGenerator { ); } - return { + const pathItem: Record = { get: { tags: [tag], summary: `Fetch related ${fieldDef.type} for ${modelName}`, @@ -339,6 +357,153 @@ export class RestApiSpecGenerator { }, }, }; + + if (this.nestedRoutes && relModelDef) { + const mayDeny = this.mayDenyAccess(relModelDef, isCollection ? 'create' : 'update'); + if (isCollection && isOperationIncluded(fieldDef.type, 'create', this.queryOptions)) { + // POST /{model}/{id}/{field} — nested create + pathItem['post'] = { + tags: [tag], + summary: `Create a nested ${fieldDef.type} under ${modelName}`, + operationId: `create${modelName}_${fieldName}`, + parameters: [{ $ref: '#/components/parameters/id' }], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}CreateRequest` }, + }, + }, + }, + responses: { + '201': { + description: `Created ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), + '422': ERROR_422, + }, + }; + } else if (!isCollection && isOperationIncluded(fieldDef.type, 'update', this.queryOptions)) { + // PATCH /{model}/{id}/{field} — nested to-one update + pathItem['patch'] = { + tags: [tag], + summary: `Update nested ${fieldDef.type} under ${modelName}`, + operationId: `update${modelName}_${fieldName}`, + parameters: [{ $ref: '#/components/parameters/id' }], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` }, + }, + }, + }, + responses: { + '200': { + description: `Updated ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), + '404': ERROR_404, + '422': ERROR_422, + }, + }; + } + } + + return pathItem; + } + + private buildNestedSinglePath( + modelName: string, + fieldName: string, + fieldDef: FieldDef, + relModelDef: ModelDef, + tag: string, + ): Record { + const childIdParam = { name: 'childId', in: 'path', required: true, schema: { type: 'string' } }; + const idParam = { $ref: '#/components/parameters/id' }; + const mayDenyUpdate = this.mayDenyAccess(relModelDef, 'update'); + const mayDenyDelete = this.mayDenyAccess(relModelDef, 'delete'); + const result: Record = {}; + + if (isOperationIncluded(fieldDef.type, 'findUnique', this.queryOptions)) { + result['get'] = { + tags: [tag], + summary: `Get a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `get${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam, { $ref: '#/components/parameters/include' }], + responses: { + '200': { + description: `${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '404': ERROR_404, + }, + }; + } + + if (isOperationIncluded(fieldDef.type, 'update', this.queryOptions)) { + result['patch'] = { + tags: [tag], + summary: `Update a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `update${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` }, + }, + }, + }, + responses: { + '200': { + description: `Updated ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDenyUpdate && { '403': ERROR_403 }), + '404': ERROR_404, + '422': ERROR_422, + }, + }; + } + + if (isOperationIncluded(fieldDef.type, 'delete', this.queryOptions)) { + result['delete'] = { + tags: [tag], + summary: `Delete a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `delete${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam], + responses: { + '200': { description: 'Deleted successfully' }, + ...(mayDenyDelete && { '403': ERROR_403 }), + '404': ERROR_404, + }, + }; + } + + return result; } private buildRelationshipPath( diff --git a/packages/server/test/openapi/rest-openapi.test.ts b/packages/server/test/openapi/rest-openapi.test.ts index 4f16c3556..8f6f3fa69 100644 --- a/packages/server/test/openapi/rest-openapi.test.ts +++ b/packages/server/test/openapi/rest-openapi.test.ts @@ -567,6 +567,163 @@ describe('REST OpenAPI spec generation - queryOptions', () => { }); }); +describe('REST OpenAPI spec generation - nestedRoutes', () => { + let handler: RestApiHandler; + let spec: any; + + beforeAll(async () => { + const client = await createTestClient(schema); + handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: true, + }); + spec = await handler.generateSpec(); + }); + + it('does not generate nested single paths when nestedRoutes is false', async () => { + const client = await createTestClient(schema); + const plainHandler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const plainSpec = await plainHandler.generateSpec(); + expect(plainSpec.paths?.['/user/{id}/posts/{childId}']).toBeUndefined(); + expect(plainSpec.paths?.['/post/{id}/comments/{childId}']).toBeUndefined(); + // fetch-related path should not have POST on plain handler + expect((plainSpec.paths as any)['/user/{id}/posts']?.post).toBeUndefined(); + // fetch-related path should not have PATCH for to-one on plain handler + expect((plainSpec.paths as any)['/post/{id}/setting']?.patch).toBeUndefined(); + }); + + it('generates nested single paths for collection relations', () => { + // User -> posts (collection) + expect(spec.paths['/user/{id}/posts/{childId}']).toBeDefined(); + // Post -> comments (collection) + expect(spec.paths['/post/{id}/comments/{childId}']).toBeDefined(); + // User -> likes (collection, compound-ID child: PostLike has @@id([postId, userId])) + expect(spec.paths['/user/{id}/likes/{childId}']).toBeDefined(); + }); + + it('does not generate nested single paths for to-one relations', () => { + // Post -> setting (to-one) + expect(spec.paths['/post/{id}/setting/{childId}']).toBeUndefined(); + // Post -> author (to-one) + expect(spec.paths['/post/{id}/author/{childId}']).toBeUndefined(); + }); + + it('nested single path has GET, PATCH, DELETE', () => { + const path = spec.paths['/user/{id}/posts/{childId}']; + expect(path.get).toBeDefined(); + expect(path.patch).toBeDefined(); + expect(path.delete).toBeDefined(); + }); + + it('nested single path GET returns single resource response', () => { + const getOp = spec.paths['/user/{id}/posts/{childId}'].get; + const schema = getOp.responses['200'].content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostResponse'); + }); + + it('nested single path PATCH uses UpdateRequest body', () => { + const patchOp = spec.paths['/user/{id}/posts/{childId}'].patch; + const schema = patchOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostUpdateRequest'); + }); + + it('nested single path has childId path parameter', () => { + const getOp = spec.paths['/user/{id}/posts/{childId}'].get; + const params = getOp.parameters; + const childIdParam = params.find((p: any) => p.name === 'childId'); + expect(childIdParam).toBeDefined(); + expect(childIdParam.in).toBe('path'); + expect(childIdParam.required).toBe(true); + }); + + it('fetch-related path has POST for collection relation when nestedRoutes enabled', () => { + const postsPath = spec.paths['/user/{id}/posts']; + expect(postsPath.get).toBeDefined(); + expect(postsPath.post).toBeDefined(); + }); + + it('fetch-related POST uses CreateRequest body', () => { + const postOp = spec.paths['/user/{id}/posts'].post; + const schema = postOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostCreateRequest'); + }); + + it('fetch-related POST returns 201 with resource response', () => { + const postOp = spec.paths['/user/{id}/posts'].post; + const schema = postOp.responses['201'].content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostResponse'); + }); + + it('fetch-related path has PATCH for to-one relation when nestedRoutes enabled', () => { + // Post -> setting is to-one + const settingPath = spec.paths['/post/{id}/setting']; + expect(settingPath.get).toBeDefined(); + expect(settingPath.patch).toBeDefined(); + // to-one should not get POST (no nested create for to-one) + expect(settingPath.post).toBeUndefined(); + }); + + it('fetch-related PATCH for to-one uses UpdateRequest body', () => { + const patchOp = spec.paths['/post/{id}/setting'].patch; + const schema = patchOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/SettingUpdateRequest'); + }); + + it('fetch-related path does not have PATCH for to-many (collection) relation', () => { + // User -> posts is a to-many relation; PATCH should only be generated for to-one + const postsPath = spec.paths['/user/{id}/posts']; + expect(postsPath.patch).toBeUndefined(); + }); + + it('spec passes OpenAPI 3.1 validation', async () => { + // Deep clone to avoid validate() mutating $ref strings in the shared spec object + await validate(JSON.parse(JSON.stringify(spec))); + }); + + it('operationIds are unique for nested paths', () => { + const allOperationIds: string[] = []; + for (const pathItem of Object.values(spec.paths as Record)) { + for (const method of ['get', 'post', 'patch', 'put', 'delete']) { + if (pathItem[method]?.operationId) { + allOperationIds.push(pathItem[method].operationId); + } + } + } + const unique = new Set(allOperationIds); + expect(unique.size).toBe(allOperationIds.length); + }); + + it('nestedRoutes respects queryOptions slicing excludedOperations', async () => { + const client = await createTestClient(schema); + const slicedHandler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: true, + queryOptions: { + slicing: { + models: { + post: { excludedOperations: ['create', 'delete', 'update'] }, + }, + } as any, + }, + }); + const s = await slicedHandler.generateSpec(); + + // Nested create (POST /user/{id}/posts) should be absent + expect((s.paths as any)['/user/{id}/posts']?.post).toBeUndefined(); + // Nested single GET should still exist (findUnique not excluded) + expect((s.paths as any)['/user/{id}/posts/{childId}']?.get).toBeDefined(); + // Nested single DELETE should be absent + expect((s.paths as any)['/user/{id}/posts/{childId}']?.delete).toBeUndefined(); + // Nested single PATCH (update) should be absent + expect((s.paths as any)['/user/{id}/posts/{childId}']?.patch).toBeUndefined(); + }); +}); + describe('REST OpenAPI spec generation - @meta description', () => { const metaSchema = ` model User { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 4a516ea8a..a0a068257 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/zod/package.json b/packages/zod/package.json index 7379b41fa..8b35b11c8 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.5.1", + "version": "3.5.2", "description": "ZenStack Zod integration", "type": "module", "scripts": { diff --git a/samples/orm/package.json b/samples/orm/package.json index b2fdf0136..55affe2d9 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.5.1", + "version": "3.5.2", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/orm/client-api/relation/order-by-nested-includes.test.ts b/tests/e2e/orm/client-api/relation/order-by-nested-includes.test.ts new file mode 100644 index 000000000..a95fa6c6c --- /dev/null +++ b/tests/e2e/orm/client-api/relation/order-by-nested-includes.test.ts @@ -0,0 +1,145 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, describe, expect, it } from 'vitest'; + +const schema = ` +model User { + id String @id + email String @unique + posts Post[] + comments Comment[] +} + +model Post { + id String @id + sequence Int + title String + author User @relation(fields: [authorId], references: [id]) + authorId String + comments Comment[] +} + +model Comment { + id String @id + content String + post Post @relation(fields: [postId], references: [id]) + postId String + author User? @relation(fields: [authorId], references: [id]) + authorId String? +} +`; + +function makePostsData(count: number) { + return Array.from({ length: count }, (_, i) => { + const sequence = count - i; // insert descending + return { + id: `p${sequence}`, + sequence, + title: `P${sequence}`, + // Keep outer relation (User -> posts) required. + authorId: 'u1', + }; + }); +} + +function makeCommentsData(count: number) { + return Array.from({ length: count }, (_, i) => { + const sequence = count - i; + return { + id: `c${sequence}`, + postId: `p${sequence}`, + content: `C${sequence}`, + // Make nested to-one include nullable to vary lateral join execution. + authorId: sequence % 11 === 0 ? null : 'u1', + }; + }); +} + +describe('Relation orderBy with nested includes', () => { + let db: any; + + afterEach(async () => { + await db?.$disconnect(); + }); + + it('keeps stable order for to-many include with nested includes', async () => { + const count = 2000; + + db = await createTestClient(schema); + + await db.user.create({ data: { id: 'u1', email: 'u1@example.com' } }); + await db.post.createMany({ data: makePostsData(count) }); + await db.comment.createMany({ data: makeCommentsData(count) }); + + const user = await db.user.findFirst({ + where: { id: 'u1' }, + include: { + posts: { + orderBy: { sequence: 'asc' }, + include: { author: true, comments: { include: { author: true } } }, + }, + }, + }); + + const ascSequences = user.posts.map((p: any) => p.sequence); + expect(ascSequences).toEqual(Array.from({ length: count }, (_, i) => i + 1)); + + const userDesc = await db.user.findFirst({ + where: { id: 'u1' }, + include: { + posts: { + orderBy: { sequence: 'desc' }, + include: { author: true, comments: { include: { author: true } } }, + }, + }, + }); + + const descSequences = userDesc.posts.map((p: any) => p.sequence); + expect(descSequences).toEqual(Array.from({ length: count }, (_, i) => count - i)); + }); + + it('keeps stable order for to-many select with nested selects', async () => { + const count = 2000; + + db = await createTestClient(schema); + + await db.user.create({ data: { id: 'u1', email: 'u1@example.com' } }); + await db.post.createMany({ data: makePostsData(count) }); + await db.comment.createMany({ data: makeCommentsData(count) }); + + const user = await db.user.findFirst({ + where: { id: 'u1' }, + select: { + id: true, + posts: { + orderBy: { sequence: 'asc' }, + select: { + sequence: true, + author: { select: { id: true } }, + comments: { select: { author: { select: { id: true } } } }, + }, + }, + }, + }); + + const ascSequences = user.posts.map((p: any) => p.sequence); + expect(ascSequences).toEqual(Array.from({ length: count }, (_, i) => i + 1)); + + const userDesc = await db.user.findFirst({ + where: { id: 'u1' }, + select: { + id: true, + posts: { + orderBy: { sequence: 'desc' }, + select: { + sequence: true, + author: { select: { id: true } }, + comments: { select: { author: { select: { id: true } } } }, + }, + }, + }, + }); + + const descSequences = userDesc.posts.map((p: any) => p.sequence); + expect(descSequences).toEqual(Array.from({ length: count }, (_, i) => count - i)); + }); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 1d2cc07c9..cf6efee49 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.5.1", + "version": "3.5.2", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index ac60d1418..c74e004a5 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.5.1", + "version": "3.5.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index b073c4103..5432452ad 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.5.1", + "version": "3.5.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 1b069dff2..077a6cd2a 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.5.1", + "version": "3.5.2", "private": true, "type": "module", "scripts": {