Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): upsert support for rest api handler #1863

Merged
merged 11 commits into from
Nov 26, 2024
135 changes: 128 additions & 7 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,14 @@ 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;

Expand All @@ -740,7 +748,7 @@ class RequestHandler extends APIHandlerBase {
}
}

return { attributes, relationships: parsed.data.relationships };
return { attributes, relationships: parsed.data.relationships, operation, matchFields };
}

private async processCreate(
Expand All @@ -756,12 +764,111 @@ class RequestHandler extends APIHandlerBase {
return this.makeUnsupportedModelError(type);
}

const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create');
const { error, attributes, relationships, operation, matchFields } = this.processRequestBody(
type,
requestBody,
zodSchemas,
'create'
);

if (error) {
return error;
}

let entity: 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;
}
thomassnielsen marked this conversation as resolved.
Show resolved Hide resolved

return {
status: 201,
body: await this.serializeItems(type, entity),
};
}

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)))
) {
return this.makeError('invalidPayload', 'Match fields must be unique fields', 400);
}

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]))),
};

if (relationships) {
for (const [key, data] of Object.entries<any>(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] = {
connect: enumerate(data.data).map((item: any) =>
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
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),
};
}
}
}
ymc9 marked this conversation as resolved.
Show resolved Hide resolved

// 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 } };

// turn relationship payload into Prisma connect objects
Expand Down Expand Up @@ -802,11 +909,7 @@ class RequestHandler extends APIHandlerBase {
// 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),
};
return prisma[type].create(createPayload);
}

private async processRelationshipCRUD(
Expand Down Expand Up @@ -1296,6 +1399,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;
}
thomassnielsen marked this conversation as resolved.
Show resolved Hide resolved

private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
const typeInfo = this.typeMap[model];
if (!typeInfo) {
Expand Down
135 changes: 135 additions & 0 deletions packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
thomassnielsen marked this conversation as resolved.
Show resolved Hide resolved

const idDivider = '_';

Expand Down Expand Up @@ -1800,6 +1801,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', () => {
Expand Down
Loading