From caac46c15a4a9633b0f9f913e772d51a332e7b98 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 26 Nov 2024 18:29:58 +0100 Subject: [PATCH 1/4] feat(server): upsert support for rest api handler (#1863) --- packages/server/src/api/rest/index.ts | 134 ++++++++++++++++++++++++- packages/server/tests/api/rest.test.ts | 134 +++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1c9c56f4e..a2df2707f 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -209,6 +209,13 @@ class RequestHandler extends APIHandlerBase { data: z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })), }); + private upsertMetaSchema = z.object({ + meta: z.object({ + operation: z.literal('upsert'), + matchFields: z.array(z.string()).min(1), + }), + }); + // all known types and their metadata private typeMap: Record; @@ -309,8 +316,29 @@ class RequestHandler extends APIHandlerBase { let match = this.urlPatterns.collection.match(path); if (match) { - // resource creation - return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas); + const body = requestBody as any; + const upsertMeta = this.upsertMetaSchema.safeParse(body); + if (upsertMeta.success) { + // resource upsert + return await this.processUpsert( + prisma, + match.type, + query, + requestBody, + modelMeta, + zodSchemas + ); + } else { + // resource creation + return await this.processCreate( + prisma, + match.type, + query, + requestBody, + modelMeta, + zodSchemas + ); + } } match = this.urlPatterns.relationship.match(path); @@ -809,6 +837,90 @@ class RequestHandler extends APIHandlerBase { }; } + private async processUpsert( + prisma: DbClientContract, + type: string, + _query: Record | undefined, + requestBody: unknown, + modelMeta: ModelMeta, + zodSchemas?: ZodSchemas + ) { + const typeInfo = this.typeMap[type]; + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); + + if (error) { + return error; + } + + const matchFields = this.upsertMetaSchema.parse(requestBody).meta.matchFields; + + const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields); + + if ( + !uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field))) + ) { + return this.makeError('invalidPayload', 'Match fields must be unique fields', 400); + } + + const upsertPayload: any = { + where: this.makeUpsertWhere(matchFields, attributes, typeInfo), + create: { ...attributes }, + update: { + ...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))), + }, + }; + + if (relationships) { + for (const [key, data] of Object.entries(relationships)) { + if (!data?.data) { + return this.makeError('invalidRelationData'); + } + + const relationInfo = typeInfo.relationships[key]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(type, key, 400); + } + + if (relationInfo.isCollection) { + upsertPayload.create[key] = { + connect: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id) + ), + }; + upsertPayload.update[key] = { + set: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id) + ), + }; + } else { + if (typeof data.data !== 'object') { + return this.makeError('invalidRelationData'); + } + upsertPayload.create[key] = { + connect: this.makeIdConnect(relationInfo.idFields, data.data.id), + }; + upsertPayload.update[key] = { + connect: this.makeIdConnect(relationInfo.idFields, data.data.id), + }; + } + } + } + + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, upsertPayload, 'include'); + + const entity = await prisma[type].upsert(upsertPayload); + + return { + status: 201, + body: await this.serializeItems(type, entity), + }; + } + private async processRelationshipCRUD( prisma: DbClientContract, mode: 'create' | 'update' | 'delete', @@ -1296,6 +1408,24 @@ class RequestHandler extends APIHandlerBase { return idFields.map((idf) => item[idf.name]).join(this.idDivider); } + private makeUpsertWhere(matchFields: any[], attributes: any, typeInfo: ModelInfo) { + const where = matchFields.reduce((acc: any, field: string) => { + acc[field] = attributes[field] ?? null; + return acc; + }, {}); + + if ( + typeInfo.idFields.length > 1 && + matchFields.some((mf) => typeInfo.idFields.map((idf) => idf.name).includes(mf)) + ) { + return { + [this.makePrismaIdKey(typeInfo.idFields)]: where, + }; + } + + return where; + } + private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') { const typeInfo = this.typeMap[model]; if (!typeInfo) { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 1b5463650..ec974494d 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -1800,6 +1800,140 @@ describe('REST server tests', () => { expect(r.status).toBe(201); }); + + it('upsert a new entity', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { myId: 'user1', email: 'user1@abc.com' }, + }, + meta: { + operation: 'upsert', + matchFields: ['myId'], + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [], + }, + }, + }, + }); + }); + + it('upsert an existing entity', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { myId: 'user1', email: 'user2@abc.com' }, + }, + meta: { + operation: 'upsert', + matchFields: ['myId'], + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'user', + id: 'user1', + attributes: { email: 'user2@abc.com' }, + }, + }); + }); + + it('upsert fails if matchFields are not unique', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/profile', + query: {}, + requestBody: { + data: { + type: 'profile', + attributes: { gender: 'male' }, + relationships: { + user: { + data: { type: 'user', id: 'user1' }, + }, + }, + }, + meta: { + operation: 'upsert', + matchFields: ['gender'], + }, + }, + prisma, + }); + + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-payload', + }, + ], + }); + }); + + it('upsert works with compound id', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + id: `1${idDivider}user1`, + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + meta: { + operation: 'upsert', + matchFields: ['userId', 'postId'], + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + }); }); describe('PUT', () => { From 545898bd93c041224aeb7cb6e432177dec4fa806 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 26 Nov 2024 18:30:57 +0100 Subject: [PATCH 2/4] fix(openapi): bugfix for compound id create and update (#1855) --- .../plugins/openapi/src/rest-generator.ts | 8 +++--- .../tests/baseline/rest-3.0.0.baseline.yaml | 3 --- .../tests/baseline/rest-3.1.0.baseline.yaml | 1 - packages/server/src/api/rest/index.ts | 2 +- packages/server/tests/api/rest.test.ts | 26 ++++++++++++++++++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 86ed9736a..e6da0268b 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -857,10 +857,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, - // but not required for write operations - const fields = - idFields.length > 1 && mode === 'read' ? model.fields : model.fields.filter((f) => !isIdField(f)); + // For compound ids each component is also exposed as a separate fields. + const fields = idFields.length > 1 ? model.fields : model.fields.filter((f) => !isIdField(f)); const attributes: Record = {}; const relationships: Record = {}; @@ -911,7 +909,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { if (mode === 'create') { // 'id' is required if there's no default value const idFields = model.fields.filter((f) => isIdField(f)); - if (idFields.length && idFields.every((f) => !hasAttribute(f, '@default'))) { + if (idFields.length === 1 && !hasAttribute(idFields[0], '@default')) { properties = { id: { type: 'string' }, ...properties }; toplevelRequired.unshift('id'); } 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 0ea258018..b6e0ad750 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 @@ -3123,14 +3123,11 @@ components: type: object description: The "PostLike" model required: - - id - type - attributes properties: type: type: string - attributes: - type: object 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 9bd34467c..364062e4e 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 @@ -3135,7 +3135,6 @@ components: type: object description: The "PostLike" model required: - - id - type - attributes properties: diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index a2df2707f..1107fbc64 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -1071,7 +1071,7 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidRelationData'); } updatePayload.data[key] = { - set: { + connect: { [this.makePrismaIdKey(relationInfo.idFields)]: data.data.id, }, }; diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index ec974494d..7367a4b64 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -1742,7 +1742,6 @@ describe('REST server tests', () => { requestBody: { data: { type: 'postLike', - id: `1${idDivider}user1`, attributes: { userId: 'user1', postId: 1, superLike: false }, }, }, @@ -2141,6 +2140,31 @@ describe('REST server tests', () => { expect(r.status).toBe(200); }); + it('update the id of an item 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.post.create({ data: { id: 2, title: 'Post2' } }); + await prisma.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'put', + path: `/postLike/1${idDivider}user1`, + query: {}, + requestBody: { + data: { + type: 'postLike', + relationships: { + post: { data: { type: 'post', id: 2 } }, + }, + }, + }, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body.data.id).toBe(`2${idDivider}user1`); + }); + it('update a single relation', async () => { await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); await prisma.post.create({ From 39f13e79faa552a69a8979bc4558a0383265c6ba Mon Sep 17 00:00:00 2001 From: Yiming Date: Tue, 26 Nov 2024 09:35:12 -0800 Subject: [PATCH 3/4] chore: bump version (#1891) --- package.json | 2 +- packages/ide/jetbrains/build.gradle.kts | 2 +- packages/ide/jetbrains/package.json | 2 +- packages/language/package.json | 2 +- packages/misc/redwood/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/swr/package.json | 2 +- packages/plugins/tanstack-query/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 0bd3a8a42..15d26d19a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.9.3", + "version": "2.9.4", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 2ed65c07c..0e066738e 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.9.3" +version = "2.9.4" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index f4622937f..41163c4fc 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.9.3", + "version": "2.9.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 a8e24b909..e6d407c5b 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.9.3", + "version": "2.9.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 dbb9d5396..e495257d6 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.9.3", + "version": "2.9.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 193362632..47e72c32d 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.9.3", + "version": "2.9.4", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 247cfbab0..09e898c39 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.9.3", + "version": "2.9.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 3445bfdee..8efaa6fae 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.9.3", + "version": "2.9.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 28afe51f6..4ee8d1111 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.9.3", + "version": "2.9.4", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index be3a898f4..1cda8186f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.9.3", + "version": "2.9.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 d2e5ec0c3..643aa3572 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.9.3", + "version": "2.9.4", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d67256074..033fce7ca 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.9.3", + "version": "2.9.4", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 3c6d7526a..742032ed6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.9.3", + "version": "2.9.4", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 3ff48eb62..8805de1f9 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.9.3", + "version": "2.9.4", "description": "ZenStack Test Tools", "main": "index.js", "private": true, From a747d95e1fd11b5ae2cda5a9b2c5755981888926 Mon Sep 17 00:00:00 2001 From: Simon Zimmerman Date: Wed, 27 Nov 2024 02:16:16 +0000 Subject: [PATCH 4/4] refactor: correct minor mistakes picked up by coderabbitai in nitpick comments (#1887) Co-authored-by: Simon Zimmerman --- packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx | 4 ++-- packages/plugins/tanstack-query/tests/test-model-meta.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx index 7739e67e3..3559f4528 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -387,7 +387,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { expect(userResult.current.data).toHaveLength(1); }); - // pupulate the cache with a category + // populate the cache with a category const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; nock(BASE_URL) @@ -501,7 +501,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { it('optimistic update with optional one-to-many relationship', async () => { const { queryClient, wrapper } = createWrapper(); - // populate the cache with a post, with an optional category relatonship + // populate the cache with a post, with an optional category relationship const postData: any = { id: '1', title: 'post1', diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 1c59b956b..6e08ce2ca 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -59,7 +59,6 @@ export const modelMeta: ModelMeta = { type: 'Category', name: 'category', isDataModel: true, - isOptional: true, isRelationOwner: true, backLink: 'posts', foreignKeyMapping: { id: 'categoryId' },