diff --git a/package.json b/package.json index a392b07ee..94237146e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.7.3", + "version": "2.7.4", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 5ab74e6ad..a2e6892fe 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.7.3" +version = "2.7.4" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 805f1d2d6..5a43c79e0 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.7.3", + "version": "2.7.4", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index 738f2872f..f5b30d6e0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.7.3", + "version": "2.7.4", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index a37e9a403..752d79017 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "2.7.3", + "version": "2.7.4", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index a34343885..ed83d25b2 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 7cf465d9e..8927198cc 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -847,8 +847,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { private generateModelEntity(model: DataModel, mode: 'read' | 'create' | 'update'): OAPI.SchemaObject { const idFields = model.fields.filter((f) => isIdField(f)); - // For compound ids, each component is also exposed as a separate field - const fields = idFields.length > 1 ? model.fields : model.fields.filter((f) => !isIdField(f)); + // For compound ids each component is also exposed as a separate fields for read operations, + // but not required for write operations + const fields = + idFields.length > 1 && mode === 'read' ? model.fields : model.fields.filter((f) => !isIdField(f)); const attributes: Record = {}; const relationships: Record = {}; diff --git a/packages/plugins/openapi/tests/baseline/rest-3.0.0.baseline.yaml b/packages/plugins/openapi/tests/baseline/rest-3.0.0.baseline.yaml index 96f80d81a..adb9ded12 100644 --- a/packages/plugins/openapi/tests/baseline/rest-3.0.0.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rest-3.0.0.baseline.yaml @@ -3143,14 +3143,6 @@ components: type: string attributes: type: object - required: - - postId - - userId - properties: - postId: - type: string - userId: - type: string relationships: type: object properties: @@ -3178,13 +3170,6 @@ components: type: string type: type: string - attributes: - type: object - properties: - postId: - type: string - userId: - type: string relationships: type: object properties: diff --git a/packages/plugins/openapi/tests/baseline/rest-3.1.0.baseline.yaml b/packages/plugins/openapi/tests/baseline/rest-3.1.0.baseline.yaml index e3f2d6821..f69536b30 100644 --- a/packages/plugins/openapi/tests/baseline/rest-3.1.0.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rest-3.1.0.baseline.yaml @@ -3155,16 +3155,6 @@ components: properties: type: type: string - attributes: - type: object - required: - - postId - - userId - properties: - postId: - type: string - userId: - type: string relationships: type: object properties: @@ -3192,13 +3182,6 @@ components: type: string type: type: string - attributes: - type: object - properties: - postId: - type: string - userId: - type: string relationships: type: object properties: diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index fb9ab1c8d..42de62697 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 6ce063400..05a97815c 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 069e68497..0e7f4afe5 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 880f4f809..088bde289 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.7.3", + "version": "2.7.4", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 467712d8b..efb704ba1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.7.3", + "version": "2.7.4", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index ff2e00b6a..34cf26640 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -515,15 +515,11 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara return source; } - private removeCreateFromDelegateInput( - typeAlias: TypeAliasDeclaration, - delegateModels: DelegateInfo, - source: string - ) { + private removeCreateFromDelegateInput(typeAlias: TypeAliasDeclaration, delegateInfo: DelegateInfo, source: string) { // remove create/connectOrCreate/upsert fields from delegate's input types because // delegate models cannot be created directly const typeName = typeAlias.getName(); - const delegateModelNames = delegateModels.map(([delegate]) => delegate.name); + const delegateModelNames = delegateInfo.map(([delegate]) => delegate.name); const delegateCreateUpdateInputRegex = new RegExp( `^(${delegateModelNames.join('|')})(Unchecked)?(Create|Update).*Input$` ); @@ -538,17 +534,24 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara return source; } - private readonly ModelCreateUpdateInputRegex = /(\S+)(Unchecked)?(Create|Update).*Input/; - private removeDiscriminatorFromConcreteInput( typeAlias: TypeAliasDeclaration, - _delegateInfo: DelegateInfo, + delegateInfo: DelegateInfo, source: string ) { // remove discriminator field from the create/update input because discriminator cannot be set directly const typeName = typeAlias.getName(); - const match = typeName.match(this.ModelCreateUpdateInputRegex); + const delegateModelNames = delegateInfo.map(([delegate]) => delegate.name); + const concreteModelNames = delegateInfo + .map(([_, concretes]) => concretes.flatMap((c) => c.name)) + .flatMap((name) => name); + const allModelNames = [...new Set([...delegateModelNames, ...concreteModelNames])]; + const concreteCreateUpdateInputRegex = new RegExp( + `^(${allModelNames.join('|')})(Unchecked)?(Create|Update).*Input$` + ); + + const match = typeName.match(concreteCreateUpdateInputRegex); if (match) { const modelName = match[1]; const dataModel = this.model.declarations.find( diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 5021a9927..ca26ffabe 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -10,6 +10,7 @@ import { isEnumFieldReference, isForeignKeyField, isFromStdlib, + isIdField, parseOptionAsStrings, resolvePath, } from '@zenstackhq/sdk'; @@ -291,8 +292,10 @@ export class ZodSchemaGenerator { sf.replaceWithText((writer) => { const scalarFields = model.fields.filter( (field) => + // id fields are always included + isIdField(field) || // regular fields only - !isDataModel(field.type.reference?.ref) && !isForeignKeyField(field) + (!isDataModel(field.type.reference?.ref) && !isForeignKeyField(field)) ); const relations = model.fields.filter((field) => isDataModel(field.type.reference?.ref)); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 72de665bc..ea62b7d44 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 137d42c0c..49d897cef 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.7.3", + "version": "2.7.4", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 2e0bcaec5..1c9c56f4e 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -720,8 +720,9 @@ class RequestHandler extends APIHandlerBase { const attributes: any = parsed.data.attributes; if (attributes) { - const schemaName = `${upperCaseFirst(type)}${upperCaseFirst(mode)}Schema`; - // zod-parse attributes if a schema is provided + // use the zod schema (that only contains non-relation fields) to validate the payload, + // if available + const schemaName = `${upperCaseFirst(type)}${upperCaseFirst(mode)}ScalarSchema`; const payloadSchema = zodSchemas?.models?.[schemaName]; if (payloadSchema) { const parsed = payloadSchema.safeParse(attributes); @@ -756,6 +757,7 @@ class RequestHandler extends APIHandlerBase { } const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); + if (error) { return error; } @@ -776,18 +778,16 @@ class RequestHandler extends APIHandlerBase { if (relationInfo.isCollection) { createPayload.data[key] = { - connect: enumerate(data.data).map((item: any) => ({ - [this.makePrismaIdKey(relationInfo.idFields)]: item.id, - })), + connect: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id) + ), }; } else { if (typeof data.data !== 'object') { return this.makeError('invalidRelationData'); } createPayload.data[key] = { - connect: { - [this.makePrismaIdKey(relationInfo.idFields)]: data.data.id, - }, + connect: this.makeIdConnect(relationInfo.idFields, data.data.id), }; } @@ -868,9 +868,7 @@ class RequestHandler extends APIHandlerBase { } else { updateArgs.data = { [relationship]: { - connect: { - [this.makePrismaIdKey(relationInfo.idFields)]: parsed.data.data.id, - }, + connect: this.makeIdConnect(relationInfo.idFields, parsed.data.data.id), }, }; } @@ -1236,11 +1234,11 @@ class RequestHandler extends APIHandlerBase { return r.toString(); } - private makePrismaIdFilter(idFields: FieldInfo[], resourceId: string) { + private makePrismaIdFilter(idFields: FieldInfo[], resourceId: string, nested: boolean = true) { const decodedId = decodeURIComponent(resourceId); if (idFields.length === 1) { return { [idFields[0].name]: this.coerce(idFields[0].type, decodedId) }; - } else { + } else if (nested) { return { // TODO: support `@@id` with custom name [idFields.map((idf) => idf.name).join(prismaIdDivider)]: idFields.reduce( @@ -1251,6 +1249,14 @@ class RequestHandler extends APIHandlerBase { {} ), }; + } else { + return idFields.reduce( + (acc, curr, idx) => ({ + ...acc, + [curr.name]: this.coerce(curr.type, decodedId.split(this.idDivider)[idx]), + }), + {} + ); } } @@ -1261,6 +1267,22 @@ class RequestHandler extends APIHandlerBase { return idFields.reduce((acc, curr) => ({ ...acc, [curr.name]: true }), {}); } + private makeIdConnect(idFields: FieldInfo[], id: string | number) { + if (idFields.length === 1) { + return { [idFields[0].name]: this.coerce(idFields[0].type, id) }; + } else { + return { + [this.makePrismaIdKey(idFields)]: idFields.reduce( + (acc, curr, idx) => ({ + ...acc, + [curr.name]: this.coerce(curr.type, `${id}`.split(this.idDivider)[idx]), + }), + {} + ), + }; + } + } + private makeIdKey(idFields: FieldInfo[]) { return idFields.map((idf) => idf.name).join(this.idDivider); } @@ -1594,11 +1616,11 @@ class RequestHandler extends APIHandlerBase { const values = value.split(',').filter((i) => i); const filterValue = values.length > 1 - ? { OR: values.map((v) => this.makePrismaIdFilter(info.idFields, v)) } - : this.makePrismaIdFilter(info.idFields, value); + ? { OR: values.map((v) => this.makePrismaIdFilter(info.idFields, v, false)) } + : this.makePrismaIdFilter(info.idFields, value, false); return { some: filterValue }; } else { - return { is: this.makePrismaIdFilter(info.idFields, value) }; + return { is: this.makePrismaIdFilter(info.idFields, value, false) }; } } else { const coerced = this.coerce(fieldInfo.type, value); diff --git a/packages/server/src/next/app-route-handler.ts b/packages/server/src/next/app-route-handler.ts index 5c8cbe0e5..d0894a816 100644 --- a/packages/server/src/next/app-route-handler.ts +++ b/packages/server/src/next/app-route-handler.ts @@ -6,11 +6,12 @@ import { AppRouteRequestHandlerOptions } from '.'; import { RPCApiHandler } from '../api'; import { loadAssets } from '../shared'; -type Context = { params: { path: string[] } }; +type Context = { params: Promise<{ path: string[] }> }; /** - * Creates a Next.js 13 "app dir" API route request handler which encapsulates Prisma CRUD operations. + * Creates a Next.js "app dir" API route request handler which encapsulates Prisma CRUD operations. * + * @remarks Since Next.js 15, `context.params` is asynchronous and must be awaited. * @param options Options for initialization * @returns An API route request handler */ @@ -27,10 +28,17 @@ export default function factory( return NextResponse.json({ message: 'unable to get prisma from request context' }, { status: 500 }); } + let params: Awaited; const url = new URL(req.url); const query = Object.fromEntries(url.searchParams); - if (!context.params.path) { + try { + params = await context.params; + } catch { + return NextResponse.json({ message: 'Failed to resolve request parameters' }, { status: 500 }); + } + + if (!params.path) { return NextResponse.json( { message: 'missing path parameter' }, { @@ -38,7 +46,7 @@ export default function factory( } ); } - const path = context.params.path.join('/'); + const path = params.path.join('/'); let requestBody: unknown; if (req.body) { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 3fee62d9a..1b5463650 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -74,8 +74,17 @@ describe('REST server tests', () => { superLike Boolean post Post @relation(fields: [postId], references: [id]) user User @relation(fields: [userId], references: [myId]) + likeInfos PostLikeInfo[] @@id([postId, userId]) } + + model PostLikeInfo { + id Int @id @default(autoincrement()) + text String + postId Int + userId String + postLike PostLike @relation(fields: [postId, userId], references: [postId, userId]) + } `; beforeAll(async () => { @@ -1765,6 +1774,32 @@ describe('REST server tests', () => { expect(r.status).toBe(201); }); + + it('create an entity related to an entity with compound id', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ data: { id: 1, title: 'Post1' } }); + await prisma.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'post', + path: '/postLikeInfo', + query: {}, + requestBody: { + data: { + type: 'postLikeInfo', + attributes: { text: 'LikeInfo1' }, + relationships: { + postLike: { + data: { type: 'postLike', id: `1${idDivider}user1` }, + }, + }, + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + }); }); describe('PUT', () => { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index ab6db5bee..ad909bdb7 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.7.3", + "version": "2.7.4", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/tests/regression/tests/issue-1763.test.ts b/tests/regression/tests/issue-1763.test.ts new file mode 100644 index 000000000..d5ea1d401 --- /dev/null +++ b/tests/regression/tests/issue-1763.test.ts @@ -0,0 +1,47 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1763', () => { + it('regression', async () => { + await loadSchema( + ` + model Post { + id Int @id @default(autoincrement()) + name String + + type String + @@delegate(type) + + // full access by author + @@allow('all', true) + } + + model ConcretePost extends Post { + age Int + } + `, + { + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { PrismaClient as Prisma } from '@prisma/client'; +import { enhance } from '@zenstackhq/runtime'; + +async function test() { + const prisma = new Prisma(); + const db = enhance(prisma); + await db.concretePost.create({ + data: { + id: 5, + name: 'a name', + age: 20, + }, + }); +} `, + }, + ], + } + ); + }); +});