Skip to content

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

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

Merged
merged 11 commits into from
Nov 26, 2024
Merged
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);
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]))),
},
};

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

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: '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