From c5be109e76e48285638e70562f12acf67180bd4b Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Wed, 23 Oct 2024 15:05:05 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Fix=20creating=20entities=20related=20to=20?= =?UTF-8?q?compound=20id=20ent=E2=80=99s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/api/rest/index.ts | 55 +++++++++++++++++++++----- packages/server/tests/api/rest.test.ts | 35 ++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 2e0bcaec5..b35ca9ce5 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -719,6 +719,18 @@ class RequestHandler extends APIHandlerBase { const parsed = this.createUpdatePayloadSchema.parse(body); const attributes: any = parsed.data.attributes; + // Map in the compound id relationships as attributes, as they are expected by the zod schema + if (parsed.data.relationships) { + for (const [key, data] of Object.entries(parsed.data.relationships)) { + const typeInfo = this.typeMap[key]; + if (typeInfo.idFields.length > 1) { + typeInfo.idFields.forEach((field, index) => { + attributes[field.name] = this.coerce(field.type, data.data.id.split(this.idDivider)[index]); + }); + } + } + } + if (attributes) { const schemaName = `${upperCaseFirst(type)}${upperCaseFirst(mode)}Schema`; // zod-parse attributes if a schema is provided @@ -756,6 +768,19 @@ class RequestHandler extends APIHandlerBase { } const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); + + if (relationships) { + // Remove attributes that are present in compound id relationships, as they are not expected by Prisma + for (const [key] of Object.entries(relationships)) { + const typeInfo = this.typeMap[key]; + if (typeInfo.idFields.length > 1) { + typeInfo.idFields.forEach((field) => { + delete attributes[field.name]; + }); + } + } + } + if (error) { return error; } @@ -776,18 +801,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 +891,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), }, }; } @@ -1261,6 +1282,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); } 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', () => { From f54fbcb398fcee01c1daf38bbeb1343d91221159 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Wed, 23 Oct 2024 15:29:02 +0200 Subject: [PATCH 2/5] Fix type check --- packages/server/src/api/rest/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index b35ca9ce5..a92e50125 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -723,7 +723,7 @@ class RequestHandler extends APIHandlerBase { if (parsed.data.relationships) { for (const [key, data] of Object.entries(parsed.data.relationships)) { const typeInfo = this.typeMap[key]; - if (typeInfo.idFields.length > 1) { + if (typeInfo && typeInfo.idFields.length > 1) { typeInfo.idFields.forEach((field, index) => { attributes[field.name] = this.coerce(field.type, data.data.id.split(this.idDivider)[index]); }); @@ -773,7 +773,7 @@ class RequestHandler extends APIHandlerBase { // Remove attributes that are present in compound id relationships, as they are not expected by Prisma for (const [key] of Object.entries(relationships)) { const typeInfo = this.typeMap[key]; - if (typeInfo.idFields.length > 1) { + if (typeInfo && typeInfo.idFields.length > 1) { typeInfo.idFields.forEach((field) => { delete attributes[field.name]; }); From b39b1d1d1dc923795d1c624bd81b0cddeaa7c94f Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Wed, 23 Oct 2024 15:50:29 +0200 Subject: [PATCH 3/5] Update openapi spec to match --- packages/plugins/openapi/src/rest-generator.ts | 5 +++-- .../tests/baseline/rest-3.0.0.baseline.yaml | 15 --------------- .../tests/baseline/rest-3.1.0.baseline.yaml | 17 ----------------- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 7cf465d9e..19d56fcb3 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -847,8 +847,9 @@ 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 + 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: From 6a101f3cebd1e6d1ea799e6da4539ca7563faf35 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Wed, 23 Oct 2024 19:48:47 +0200 Subject: [PATCH 4/5] Poke tests --- packages/plugins/openapi/src/rest-generator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 19d56fcb3..8927198cc 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -847,7 +847,8 @@ 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 fields for read operations + // 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)); From 76ce4f4f953bbce9bee1f6c6f75f50d4a7a9aa8a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 24 Oct 2024 19:44:22 -0700 Subject: [PATCH 5/5] additional fixes - use zod schema that contains only non-relation fields to validate mutation payload - make sure id fields are always included in the base zod schema (even if they're also FK fields) --- packages/schema/src/plugins/zod/generator.ts | 5 +++- packages/server/src/api/rest/index.ts | 29 ++------------------ 2 files changed, 7 insertions(+), 27 deletions(-) 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/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index a92e50125..e4ec06ff7 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -719,21 +719,10 @@ class RequestHandler extends APIHandlerBase { const parsed = this.createUpdatePayloadSchema.parse(body); const attributes: any = parsed.data.attributes; - // Map in the compound id relationships as attributes, as they are expected by the zod schema - if (parsed.data.relationships) { - for (const [key, data] of Object.entries(parsed.data.relationships)) { - const typeInfo = this.typeMap[key]; - if (typeInfo && typeInfo.idFields.length > 1) { - typeInfo.idFields.forEach((field, index) => { - attributes[field.name] = this.coerce(field.type, data.data.id.split(this.idDivider)[index]); - }); - } - } - } - 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); @@ -769,18 +758,6 @@ class RequestHandler extends APIHandlerBase { const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); - if (relationships) { - // Remove attributes that are present in compound id relationships, as they are not expected by Prisma - for (const [key] of Object.entries(relationships)) { - const typeInfo = this.typeMap[key]; - if (typeInfo && typeInfo.idFields.length > 1) { - typeInfo.idFields.forEach((field) => { - delete attributes[field.name]; - }); - } - } - } - if (error) { return error; }