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
134 changes: 132 additions & 2 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ModelInfo>;

Expand Down Expand Up @@ -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.meta);
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);
Expand Down Expand Up @@ -809,6 +837,90 @@ class RequestHandler extends APIHandlerBase {
};
}

private async processUpsert(
prisma: DbClientContract,
type: string,
_query: Record<string, string | string[]> | 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]))),
},
};
ymc9 marked this conversation as resolved.
Show resolved Hide resolved

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] = {
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',
Expand Down Expand Up @@ -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;
}
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
134 changes: 134 additions & 0 deletions packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' },
},
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: '[email protected]' },
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: '[email protected]' },
});

const r = await handler({
method: 'post',
path: '/user',
query: {},
requestBody: {
data: {
type: 'user',
attributes: { myId: 'user1', email: '[email protected]' },
},
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: '[email protected]' },
},
});
});

it('upsert fails if matchFields are not unique', async () => {
await prisma.user.create({
data: { myId: 'user1', email: '[email protected]' },
});

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: '[email protected]' } });
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