Skip to content

Commit caac46c

Browse files
feat(server): upsert support for rest api handler (#1863)
1 parent 6422064 commit caac46c

File tree

2 files changed

+266
-2
lines changed

2 files changed

+266
-2
lines changed

packages/server/src/api/rest/index.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ class RequestHandler extends APIHandlerBase {
209209
data: z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })),
210210
});
211211

212+
private upsertMetaSchema = z.object({
213+
meta: z.object({
214+
operation: z.literal('upsert'),
215+
matchFields: z.array(z.string()).min(1),
216+
}),
217+
});
218+
212219
// all known types and their metadata
213220
private typeMap: Record<string, ModelInfo>;
214221

@@ -309,8 +316,29 @@ class RequestHandler extends APIHandlerBase {
309316

310317
let match = this.urlPatterns.collection.match(path);
311318
if (match) {
312-
// resource creation
313-
return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas);
319+
const body = requestBody as any;
320+
const upsertMeta = this.upsertMetaSchema.safeParse(body);
321+
if (upsertMeta.success) {
322+
// resource upsert
323+
return await this.processUpsert(
324+
prisma,
325+
match.type,
326+
query,
327+
requestBody,
328+
modelMeta,
329+
zodSchemas
330+
);
331+
} else {
332+
// resource creation
333+
return await this.processCreate(
334+
prisma,
335+
match.type,
336+
query,
337+
requestBody,
338+
modelMeta,
339+
zodSchemas
340+
);
341+
}
314342
}
315343

316344
match = this.urlPatterns.relationship.match(path);
@@ -809,6 +837,90 @@ class RequestHandler extends APIHandlerBase {
809837
};
810838
}
811839

840+
private async processUpsert(
841+
prisma: DbClientContract,
842+
type: string,
843+
_query: Record<string, string | string[]> | undefined,
844+
requestBody: unknown,
845+
modelMeta: ModelMeta,
846+
zodSchemas?: ZodSchemas
847+
) {
848+
const typeInfo = this.typeMap[type];
849+
if (!typeInfo) {
850+
return this.makeUnsupportedModelError(type);
851+
}
852+
853+
const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create');
854+
855+
if (error) {
856+
return error;
857+
}
858+
859+
const matchFields = this.upsertMetaSchema.parse(requestBody).meta.matchFields;
860+
861+
const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields);
862+
863+
if (
864+
!uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field)))
865+
) {
866+
return this.makeError('invalidPayload', 'Match fields must be unique fields', 400);
867+
}
868+
869+
const upsertPayload: any = {
870+
where: this.makeUpsertWhere(matchFields, attributes, typeInfo),
871+
create: { ...attributes },
872+
update: {
873+
...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))),
874+
},
875+
};
876+
877+
if (relationships) {
878+
for (const [key, data] of Object.entries<any>(relationships)) {
879+
if (!data?.data) {
880+
return this.makeError('invalidRelationData');
881+
}
882+
883+
const relationInfo = typeInfo.relationships[key];
884+
if (!relationInfo) {
885+
return this.makeUnsupportedRelationshipError(type, key, 400);
886+
}
887+
888+
if (relationInfo.isCollection) {
889+
upsertPayload.create[key] = {
890+
connect: enumerate(data.data).map((item: any) =>
891+
this.makeIdConnect(relationInfo.idFields, item.id)
892+
),
893+
};
894+
upsertPayload.update[key] = {
895+
set: enumerate(data.data).map((item: any) =>
896+
this.makeIdConnect(relationInfo.idFields, item.id)
897+
),
898+
};
899+
} else {
900+
if (typeof data.data !== 'object') {
901+
return this.makeError('invalidRelationData');
902+
}
903+
upsertPayload.create[key] = {
904+
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
905+
};
906+
upsertPayload.update[key] = {
907+
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
908+
};
909+
}
910+
}
911+
}
912+
913+
// include IDs of relation fields so that they can be serialized.
914+
this.includeRelationshipIds(type, upsertPayload, 'include');
915+
916+
const entity = await prisma[type].upsert(upsertPayload);
917+
918+
return {
919+
status: 201,
920+
body: await this.serializeItems(type, entity),
921+
};
922+
}
923+
812924
private async processRelationshipCRUD(
813925
prisma: DbClientContract,
814926
mode: 'create' | 'update' | 'delete',
@@ -1296,6 +1408,24 @@ class RequestHandler extends APIHandlerBase {
12961408
return idFields.map((idf) => item[idf.name]).join(this.idDivider);
12971409
}
12981410

1411+
private makeUpsertWhere(matchFields: any[], attributes: any, typeInfo: ModelInfo) {
1412+
const where = matchFields.reduce((acc: any, field: string) => {
1413+
acc[field] = attributes[field] ?? null;
1414+
return acc;
1415+
}, {});
1416+
1417+
if (
1418+
typeInfo.idFields.length > 1 &&
1419+
matchFields.some((mf) => typeInfo.idFields.map((idf) => idf.name).includes(mf))
1420+
) {
1421+
return {
1422+
[this.makePrismaIdKey(typeInfo.idFields)]: where,
1423+
};
1424+
}
1425+
1426+
return where;
1427+
}
1428+
12991429
private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
13001430
const typeInfo = this.typeMap[model];
13011431
if (!typeInfo) {

packages/server/tests/api/rest.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,6 +1800,140 @@ describe('REST server tests', () => {
18001800

18011801
expect(r.status).toBe(201);
18021802
});
1803+
1804+
it('upsert a new entity', async () => {
1805+
const r = await handler({
1806+
method: 'post',
1807+
path: '/user',
1808+
query: {},
1809+
requestBody: {
1810+
data: {
1811+
type: 'user',
1812+
attributes: { myId: 'user1', email: '[email protected]' },
1813+
},
1814+
meta: {
1815+
operation: 'upsert',
1816+
matchFields: ['myId'],
1817+
},
1818+
},
1819+
prisma,
1820+
});
1821+
1822+
expect(r.status).toBe(201);
1823+
expect(r.body).toMatchObject({
1824+
jsonapi: { version: '1.1' },
1825+
data: {
1826+
type: 'user',
1827+
id: 'user1',
1828+
attributes: { email: '[email protected]' },
1829+
relationships: {
1830+
posts: {
1831+
links: {
1832+
self: 'http://localhost/api/user/user1/relationships/posts',
1833+
related: 'http://localhost/api/user/user1/posts',
1834+
},
1835+
data: [],
1836+
},
1837+
},
1838+
},
1839+
});
1840+
});
1841+
1842+
it('upsert an existing entity', async () => {
1843+
await prisma.user.create({
1844+
data: { myId: 'user1', email: '[email protected]' },
1845+
});
1846+
1847+
const r = await handler({
1848+
method: 'post',
1849+
path: '/user',
1850+
query: {},
1851+
requestBody: {
1852+
data: {
1853+
type: 'user',
1854+
attributes: { myId: 'user1', email: '[email protected]' },
1855+
},
1856+
meta: {
1857+
operation: 'upsert',
1858+
matchFields: ['myId'],
1859+
},
1860+
},
1861+
prisma,
1862+
});
1863+
1864+
expect(r.status).toBe(201);
1865+
expect(r.body).toMatchObject({
1866+
jsonapi: { version: '1.1' },
1867+
data: {
1868+
type: 'user',
1869+
id: 'user1',
1870+
attributes: { email: '[email protected]' },
1871+
},
1872+
});
1873+
});
1874+
1875+
it('upsert fails if matchFields are not unique', async () => {
1876+
await prisma.user.create({
1877+
data: { myId: 'user1', email: '[email protected]' },
1878+
});
1879+
1880+
const r = await handler({
1881+
method: 'post',
1882+
path: '/profile',
1883+
query: {},
1884+
requestBody: {
1885+
data: {
1886+
type: 'profile',
1887+
attributes: { gender: 'male' },
1888+
relationships: {
1889+
user: {
1890+
data: { type: 'user', id: 'user1' },
1891+
},
1892+
},
1893+
},
1894+
meta: {
1895+
operation: 'upsert',
1896+
matchFields: ['gender'],
1897+
},
1898+
},
1899+
prisma,
1900+
});
1901+
1902+
expect(r.status).toBe(400);
1903+
expect(r.body).toMatchObject({
1904+
errors: [
1905+
{
1906+
status: 400,
1907+
code: 'invalid-payload',
1908+
},
1909+
],
1910+
});
1911+
});
1912+
1913+
it('upsert works with compound id', async () => {
1914+
await prisma.user.create({ data: { myId: 'user1', email: '[email protected]' } });
1915+
await prisma.post.create({ data: { id: 1, title: 'Post1' } });
1916+
1917+
const r = await handler({
1918+
method: 'post',
1919+
path: '/postLike',
1920+
query: {},
1921+
requestBody: {
1922+
data: {
1923+
type: 'postLike',
1924+
id: `1${idDivider}user1`,
1925+
attributes: { userId: 'user1', postId: 1, superLike: false },
1926+
},
1927+
meta: {
1928+
operation: 'upsert',
1929+
matchFields: ['userId', 'postId'],
1930+
},
1931+
},
1932+
prisma,
1933+
});
1934+
1935+
expect(r.status).toBe(201);
1936+
});
18031937
});
18041938

18051939
describe('PUT', () => {

0 commit comments

Comments
 (0)