From 223b86fb328a33eb89099b0c103887cded0ed051 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 15 Nov 2024 11:27:07 +0100 Subject: [PATCH 01/11] WIP --- packages/server/src/api/rest/index.ts | 86 ++++++++++++++++----------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1c9c56f4e..6bd286fe6 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -716,6 +716,14 @@ class RequestHandler extends APIHandlerBase { body = SuperJSON.deserialize({ json: body, meta: body.meta.serialization }); } + let method: 'create' | 'update' | 'upsert' = mode; + let matchFields = []; + + if (body.meta?.operation === 'upsert' && body.meta?.matchFields.length) { + method = 'upsert'; + matchFields = body.meta.matchFields; + } + const parsed = this.createUpdatePayloadSchema.parse(body); const attributes: any = parsed.data.attributes; @@ -740,7 +748,7 @@ class RequestHandler extends APIHandlerBase { } } - return { attributes, relationships: parsed.data.relationships }; + return { attributes, relationships: parsed.data.relationships, method, matchFields }; } private async processCreate( @@ -756,53 +764,63 @@ class RequestHandler extends APIHandlerBase { return this.makeUnsupportedModelError(type); } - const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); + const { error, attributes, relationships, method, matchFields } = this.processRequestBody( + type, + requestBody, + zodSchemas, + 'create' + ); if (error) { return error; } - const createPayload: any = { data: { ...attributes } }; + let entity: any; + if (method === 'upsert') { + entity = await prisma[type].upsert(createPayload); + } else { + const createPayload: any = { data: { ...attributes } }; - // turn relationship payload into Prisma connect objects - if (relationships) { - for (const [key, data] of Object.entries(relationships)) { - if (!data?.data) { - return this.makeError('invalidRelationData'); - } + // turn relationship payload into Prisma connect objects + 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); - } + const relationInfo = typeInfo.relationships[key]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(type, key, 400); + } - if (relationInfo.isCollection) { - createPayload.data[key] = { - connect: enumerate(data.data).map((item: any) => - this.makeIdConnect(relationInfo.idFields, item.id) - ), - }; - } else { - if (typeof data.data !== 'object') { - return this.makeError('invalidRelationData'); + if (relationInfo.isCollection) { + createPayload.data[key] = { + 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.makeIdConnect(relationInfo.idFields, data.data.id), + }; } - createPayload.data[key] = { - connect: this.makeIdConnect(relationInfo.idFields, data.data.id), + + // make sure ID fields are included for result serialization + createPayload.include = { + ...createPayload.include, + [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, }; } - - // make sure ID fields are included for result serialization - createPayload.include = { - ...createPayload.include, - [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, - }; } - } - // include IDs of relation fields so that they can be serialized. - this.includeRelationshipIds(type, createPayload, 'include'); + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, createPayload, 'include'); - const entity = await prisma[type].create(createPayload); + entity = await prisma[type].create(createPayload); + } return { status: 201, body: await this.serializeItems(type, entity), From f82e2394f4e49c18e3adee200f0be318a305f395 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 15 Nov 2024 13:56:57 +0100 Subject: [PATCH 02/11] Functioning upsert support --- packages/server/src/api/rest/index.ts | 154 ++++++++++++++++++------- packages/server/tests/api/rest.test.ts | 39 +++++++ 2 files changed, 152 insertions(+), 41 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 6bd286fe6..21894b934 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -716,11 +716,11 @@ class RequestHandler extends APIHandlerBase { body = SuperJSON.deserialize({ json: body, meta: body.meta.serialization }); } - let method: 'create' | 'update' | 'upsert' = mode; + let operation: 'create' | 'update' | 'upsert' = mode; let matchFields = []; if (body.meta?.operation === 'upsert' && body.meta?.matchFields.length) { - method = 'upsert'; + operation = 'upsert'; matchFields = body.meta.matchFields; } @@ -748,7 +748,7 @@ class RequestHandler extends APIHandlerBase { } } - return { attributes, relationships: parsed.data.relationships, method, matchFields }; + return { attributes, relationships: parsed.data.relationships, operation, matchFields }; } private async processCreate( @@ -764,7 +764,7 @@ class RequestHandler extends APIHandlerBase { return this.makeUnsupportedModelError(type); } - const { error, attributes, relationships, method, matchFields } = this.processRequestBody( + const { error, attributes, relationships, operation, matchFields } = this.processRequestBody( type, requestBody, zodSchemas, @@ -776,55 +776,127 @@ class RequestHandler extends APIHandlerBase { } let entity: any; - if (method === 'upsert') { - entity = await prisma[type].upsert(createPayload); + if (operation === 'upsert') { + entity = await this.runUsert(typeInfo, type, prisma, attributes, relationships, matchFields); + } else if (operation === 'create') { + entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); } else { - const createPayload: any = { data: { ...attributes } }; + return this.makeError('invalidPayload'); + } + + return { + status: 201, + body: await this.serializeItems(type, entity), + }; + } + + private async runUsert( + typeInfo: ModelInfo, + type: string, + prisma: DbClientContract, + attributes: any, + relationships: any, + matchFields: any[] + ) { + const upsertPayload: any = {}; + upsertPayload.where = matchFields.reduce((acc: any, field: string) => { + acc[field] = attributes[field] ?? null; + return acc; + }, {}); + + upsertPayload.create = { ...attributes }; + upsertPayload.update = { ...attributes }; + + 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); + } - // turn relationship payload into Prisma connect objects - if (relationships) { - for (const [key, data] of Object.entries(relationships)) { - if (!data?.data) { + if (relationInfo.isCollection) { + upsertPayload.create[key] = { + connect: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id) + ), + }; + upsertPayload.update[key] = { + connect: 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), + }; + } + } + } - const relationInfo = typeInfo.relationships[key]; - if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, key, 400); - } + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, upsertPayload, 'include'); - if (relationInfo.isCollection) { - createPayload.data[key] = { - 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.makeIdConnect(relationInfo.idFields, data.data.id), - }; - } + return prisma[type].upsert(upsertPayload); + } + + private async runCreate( + typeInfo: ModelInfo, + type: string, + prisma: DbClientContract, + attributes: any, + relationships: any + ) { + const createPayload: any = { data: { ...attributes } }; + + // turn relationship payload into Prisma connect objects + 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); + } - // make sure ID fields are included for result serialization - createPayload.include = { - ...createPayload.include, - [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, + if (relationInfo.isCollection) { + createPayload.data[key] = { + 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.makeIdConnect(relationInfo.idFields, data.data.id), }; } + + // make sure ID fields are included for result serialization + createPayload.include = { + ...createPayload.include, + [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, + }; } + } - // include IDs of relation fields so that they can be serialized. - this.includeRelationshipIds(type, createPayload, 'include'); + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, createPayload, 'include'); - entity = await prisma[type].create(createPayload); - } - return { - status: 201, - body: await this.serializeItems(type, entity), - }; + return prisma[type].create(createPayload); } private async processRelationshipCRUD( diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 1b5463650..a062f9d8d 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -6,6 +6,7 @@ import { createPostgresDb, dropPostgresDb, loadSchema, run } from '@zenstackhq/t import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import makeHandler from '../../src/api/rest'; +import { query } from 'express'; const idDivider = '_'; @@ -1800,6 +1801,44 @@ 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: [], + }, + }, + }, + }); + }); }); describe('PUT', () => { From e26f0dd7a7c91efe647b0dd4ae0c16da6bc645fa Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 15 Nov 2024 14:11:55 +0100 Subject: [PATCH 03/11] Test upsert update --- packages/server/src/api/rest/index.ts | 4 +++- packages/server/tests/api/rest.test.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 21894b934..f5e8a009b 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -805,7 +805,9 @@ class RequestHandler extends APIHandlerBase { }, {}); upsertPayload.create = { ...attributes }; - upsertPayload.update = { ...attributes }; + upsertPayload.update = { + ...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))), + }; if (relationships) { for (const [key, data] of Object.entries(relationships)) { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index a062f9d8d..b7079c6dc 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -1839,6 +1839,39 @@ describe('REST server tests', () => { }, }); }); + + 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' }, + }, + }); + }); }); describe('PUT', () => { From d14ef9f9861460468ed7cf6da2fc235aa1b17dc5 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 15 Nov 2024 15:15:35 +0100 Subject: [PATCH 04/11] Support compound id for upserts --- packages/server/src/api/rest/index.ts | 71 ++++++++++++++++++++------ packages/server/tests/api/rest.test.ts | 63 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index f5e8a009b..e729ea09a 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -776,12 +776,25 @@ class RequestHandler extends APIHandlerBase { } let entity: any; - if (operation === 'upsert') { - entity = await this.runUsert(typeInfo, type, prisma, attributes, relationships, matchFields); - } else if (operation === 'create') { - entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); - } else { - return this.makeError('invalidPayload'); + + try { + if (operation === 'upsert') { + entity = await this.runUpsert( + typeInfo, + type, + prisma, + modelMeta, + attributes, + relationships, + matchFields + ); + } else if (operation === 'create') { + entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); + } else { + return this.makeError('invalidPayload'); + } + } catch (e) { + return e as any; } return { @@ -790,19 +803,25 @@ class RequestHandler extends APIHandlerBase { }; } - private async runUsert( + private async runUpsert( typeInfo: ModelInfo, type: string, prisma: DbClientContract, + modelMeta: ModelMeta, attributes: any, relationships: any, matchFields: any[] ) { + const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields); + + if ( + !uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field))) + ) { + throw this.makeError('invalidPayload', 'Match fields must be unique fields', 400); + } + const upsertPayload: any = {}; - upsertPayload.where = matchFields.reduce((acc: any, field: string) => { - acc[field] = attributes[field] ?? null; - return acc; - }, {}); + upsertPayload.where = this.makeUpsertWhere(matchFields, attributes, typeInfo); upsertPayload.create = { ...attributes }; upsertPayload.update = { @@ -812,12 +831,12 @@ class RequestHandler extends APIHandlerBase { if (relationships) { for (const [key, data] of Object.entries(relationships)) { if (!data?.data) { - return this.makeError('invalidRelationData'); + throw this.makeError('invalidRelationData'); } const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, key, 400); + throw this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -833,7 +852,7 @@ class RequestHandler extends APIHandlerBase { }; } else { if (typeof data.data !== 'object') { - return this.makeError('invalidRelationData'); + throw this.makeError('invalidRelationData'); } upsertPayload.create[key] = { connect: this.makeIdConnect(relationInfo.idFields, data.data.id), @@ -864,12 +883,12 @@ class RequestHandler extends APIHandlerBase { if (relationships) { for (const [key, data] of Object.entries(relationships)) { if (!data?.data) { - return this.makeError('invalidRelationData'); + throw this.makeError('invalidRelationData'); } const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - return this.makeUnsupportedRelationshipError(type, key, 400); + throw this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -880,7 +899,7 @@ class RequestHandler extends APIHandlerBase { }; } else { if (typeof data.data !== 'object') { - return this.makeError('invalidRelationData'); + throw this.makeError('invalidRelationData'); } createPayload.data[key] = { connect: this.makeIdConnect(relationInfo.idFields, data.data.id), @@ -1388,6 +1407,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 b7079c6dc..4c1589e72 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -1872,6 +1872,69 @@ describe('REST server tests', () => { }, }); }); + + 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 f2d86b9c61288d3ad07dbd9d56598539188ee590 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Fri, 15 Nov 2024 15:34:36 +0100 Subject: [PATCH 05/11] Revert to silly entity status check --- packages/server/src/api/rest/index.ts | 42 +++++++++++---------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index e729ea09a..a78d83aef 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -777,24 +777,16 @@ class RequestHandler extends APIHandlerBase { let entity: any; - try { - if (operation === 'upsert') { - entity = await this.runUpsert( - typeInfo, - type, - prisma, - modelMeta, - attributes, - relationships, - matchFields - ); - } else if (operation === 'create') { - entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); - } else { - return this.makeError('invalidPayload'); - } - } catch (e) { - return e as any; + if (operation === 'upsert') { + entity = await this.runUpsert(typeInfo, type, prisma, modelMeta, attributes, relationships, matchFields); + } else if (operation === 'create') { + entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); + } else { + return this.makeError('invalidPayload'); + } + + if (entity.status) { + return entity; } return { @@ -817,7 +809,7 @@ class RequestHandler extends APIHandlerBase { if ( !uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field))) ) { - throw this.makeError('invalidPayload', 'Match fields must be unique fields', 400); + return this.makeError('invalidPayload', 'Match fields must be unique fields', 400); } const upsertPayload: any = {}; @@ -831,12 +823,12 @@ class RequestHandler extends APIHandlerBase { if (relationships) { for (const [key, data] of Object.entries(relationships)) { if (!data?.data) { - throw this.makeError('invalidRelationData'); + return this.makeError('invalidRelationData'); } const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - throw this.makeUnsupportedRelationshipError(type, key, 400); + return this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -852,7 +844,7 @@ class RequestHandler extends APIHandlerBase { }; } else { if (typeof data.data !== 'object') { - throw this.makeError('invalidRelationData'); + return this.makeError('invalidRelationData'); } upsertPayload.create[key] = { connect: this.makeIdConnect(relationInfo.idFields, data.data.id), @@ -883,12 +875,12 @@ class RequestHandler extends APIHandlerBase { if (relationships) { for (const [key, data] of Object.entries(relationships)) { if (!data?.data) { - throw this.makeError('invalidRelationData'); + return this.makeError('invalidRelationData'); } const relationInfo = typeInfo.relationships[key]; if (!relationInfo) { - throw this.makeUnsupportedRelationshipError(type, key, 400); + return this.makeUnsupportedRelationshipError(type, key, 400); } if (relationInfo.isCollection) { @@ -899,7 +891,7 @@ class RequestHandler extends APIHandlerBase { }; } else { if (typeof data.data !== 'object') { - throw this.makeError('invalidRelationData'); + return this.makeError('invalidRelationData'); } createPayload.data[key] = { connect: this.makeIdConnect(relationInfo.idFields, data.data.id), From 8188a28a62dd3e403926b7a2c10af2e3d2b3791d Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Sat, 16 Nov 2024 21:27:58 +0100 Subject: [PATCH 06/11] Refactor to treat upsert same as create --- packages/server/src/api/rest/index.ts | 167 +++++++++++++------------- 1 file changed, 84 insertions(+), 83 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index a78d83aef..3b6d7acd0 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -309,8 +309,28 @@ 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; + if (body?.meta?.operation === 'upsert' && body?.meta?.matchFields?.length) { + // 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); @@ -716,14 +736,6 @@ class RequestHandler extends APIHandlerBase { body = SuperJSON.deserialize({ json: body, meta: body.meta.serialization }); } - let operation: 'create' | 'update' | 'upsert' = mode; - let matchFields = []; - - if (body.meta?.operation === 'upsert' && body.meta?.matchFields.length) { - operation = 'upsert'; - matchFields = body.meta.matchFields; - } - const parsed = this.createUpdatePayloadSchema.parse(body); const attributes: any = parsed.data.attributes; @@ -748,7 +760,7 @@ class RequestHandler extends APIHandlerBase { } } - return { attributes, relationships: parsed.data.relationships, operation, matchFields }; + return { attributes, relationships: parsed.data.relationships }; } private async processCreate( @@ -764,46 +776,78 @@ class RequestHandler extends APIHandlerBase { return this.makeUnsupportedModelError(type); } - const { error, attributes, relationships, operation, matchFields } = this.processRequestBody( - type, - requestBody, - zodSchemas, - 'create' - ); + const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create'); if (error) { return error; } - let entity: any; + const createPayload: any = { data: { ...attributes } }; - if (operation === 'upsert') { - entity = await this.runUpsert(typeInfo, type, prisma, modelMeta, attributes, relationships, matchFields); - } else if (operation === 'create') { - entity = await this.runCreate(typeInfo, type, prisma, attributes, relationships); - } else { - return this.makeError('invalidPayload'); - } + // turn relationship payload into Prisma connect objects + 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) { + createPayload.data[key] = { + 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.makeIdConnect(relationInfo.idFields, data.data.id), + }; + } - if (entity.status) { - return entity; + // make sure ID fields are included for result serialization + createPayload.include = { + ...createPayload.include, + [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, + }; + } } + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, createPayload, 'include'); + + const entity = await prisma[type].create(createPayload); return { status: 201, body: await this.serializeItems(type, entity), }; } - private async runUpsert( - typeInfo: ModelInfo, - type: string, + private async processUpsert( prisma: DbClientContract, + type: string, + _query: Record | undefined, + requestBody: unknown, modelMeta: ModelMeta, - attributes: any, - relationships: any, - matchFields: any[] + 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 uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields); if ( @@ -812,6 +856,8 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidPayload', 'Match fields must be unique fields', 400); } + const matchFields = (requestBody as any).meta.matchFields; + const upsertPayload: any = {}; upsertPayload.where = this.makeUpsertWhere(matchFields, attributes, typeInfo); @@ -859,57 +905,12 @@ class RequestHandler extends APIHandlerBase { // include IDs of relation fields so that they can be serialized. this.includeRelationshipIds(type, upsertPayload, 'include'); - return prisma[type].upsert(upsertPayload); - } - - private async runCreate( - typeInfo: ModelInfo, - type: string, - prisma: DbClientContract, - attributes: any, - relationships: any - ) { - const createPayload: any = { data: { ...attributes } }; + const entity = await prisma[type].upsert(upsertPayload); - // turn relationship payload into Prisma connect objects - 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) { - createPayload.data[key] = { - 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.makeIdConnect(relationInfo.idFields, data.data.id), - }; - } - - // make sure ID fields are included for result serialization - createPayload.include = { - ...createPayload.include, - [key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } }, - }; - } - } - - // include IDs of relation fields so that they can be serialized. - this.includeRelationshipIds(type, createPayload, 'include'); - - return prisma[type].create(createPayload); + return { + status: 201, + body: await this.serializeItems(type, entity), + }; } private async processRelationshipCRUD( From 6ab1e8381d1b3c6a71fe7849b3650257aa96b26e Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Sat, 16 Nov 2024 21:37:19 +0100 Subject: [PATCH 07/11] Clean up upsert --- packages/server/src/api/rest/index.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 3b6d7acd0..adf7ada74 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -848,6 +848,7 @@ class RequestHandler extends APIHandlerBase { return error; } + const matchFields = (requestBody as any).meta.matchFields; const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields); if ( @@ -856,14 +857,12 @@ class RequestHandler extends APIHandlerBase { return this.makeError('invalidPayload', 'Match fields must be unique fields', 400); } - const matchFields = (requestBody as any).meta.matchFields; - - const upsertPayload: any = {}; - upsertPayload.where = this.makeUpsertWhere(matchFields, attributes, typeInfo); - - upsertPayload.create = { ...attributes }; - upsertPayload.update = { - ...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))), + 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) { From 2c1b7607e463b9699a5bd3eab3b82c3d700488a0 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 19 Nov 2024 09:32:45 +0100 Subject: [PATCH 08/11] Remove accidental import --- packages/server/tests/api/rest.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 4c1589e72..ec974494d 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -6,7 +6,6 @@ import { createPostgresDb, dropPostgresDb, loadSchema, run } from '@zenstackhq/t import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import makeHandler from '../../src/api/rest'; -import { query } from 'express'; const idDivider = '_'; From db755a0b9ddfea5080d02eccc7a101a41627b98c Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 19 Nov 2024 09:33:27 +0100 Subject: [PATCH 09/11] connect -> set --- packages/server/src/api/rest/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index adf7ada74..a855f74bd 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -883,7 +883,7 @@ class RequestHandler extends APIHandlerBase { ), }; upsertPayload.update[key] = { - connect: enumerate(data.data).map((item: any) => + set: enumerate(data.data).map((item: any) => this.makeIdConnect(relationInfo.idFields, item.id) ), }; From 7206145bd4e6a9e4fa0006154c6f9e0dd9520db6 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 26 Nov 2024 09:51:49 +0100 Subject: [PATCH 10/11] Use zod to parse upsert metadata --- packages/server/src/api/rest/index.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index a855f74bd..abea2aee9 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; @@ -310,7 +317,8 @@ class RequestHandler extends APIHandlerBase { let match = this.urlPatterns.collection.match(path); if (match) { const body = requestBody as any; - if (body?.meta?.operation === 'upsert' && body?.meta?.matchFields?.length) { + const upsertMeta = this.upsertMetaSchema.safeParse(body.meta); + if (upsertMeta.success) { // resource upsert return await this.processUpsert( prisma, @@ -848,7 +856,8 @@ class RequestHandler extends APIHandlerBase { return error; } - const matchFields = (requestBody as any).meta.matchFields; + const matchFields = this.upsertMetaSchema.parse(requestBody).meta.matchFields; + const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields); if ( From 3204e33271080ea8a57d6c1b9ca3ab82f1384f59 Mon Sep 17 00:00:00 2001 From: Thomas Sunde Nielsen Date: Tue, 26 Nov 2024 11:23:27 +0100 Subject: [PATCH 11/11] Fix check --- packages/server/src/api/rest/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index abea2aee9..a2df2707f 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -317,7 +317,7 @@ class RequestHandler extends APIHandlerBase { let match = this.urlPatterns.collection.match(path); if (match) { const body = requestBody as any; - const upsertMeta = this.upsertMetaSchema.safeParse(body.meta); + const upsertMeta = this.upsertMetaSchema.safeParse(body); if (upsertMeta.success) { // resource upsert return await this.processUpsert(