diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1c9c56f4..a2df2707 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 1b546365..ec974494 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', () => {