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

RESTApiHandler support compound ids (@@id) #1754

Merged
merged 15 commits into from
Oct 7, 2024
108 changes: 64 additions & 44 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const urlPatterns = {
relationship: new UrlPattern('/:type/:id/relationships/:relationship'),
};

export const idDivider = '_';
export const compoundIdKey = 'compoundId';
thomassnielsen marked this conversation as resolved.
Show resolved Hide resolved

/**
* Request handler options
*/
Expand Down Expand Up @@ -59,9 +62,13 @@ type RelationshipInfo = {
isOptional: boolean;
};

type IdField = {
thomassnielsen marked this conversation as resolved.
Show resolved Hide resolved
name: string;
type: string;
};

type ModelInfo = {
idField: string;
idFieldType: string;
idFields: IdField[];
fields: Record<string, FieldInfo>;
relationships: Record<string, RelationshipInfo>;
};
Expand Down Expand Up @@ -129,10 +136,6 @@ class RequestHandler extends APIHandlerBase {
status: 400,
title: 'Model without an ID field is not supported',
},
multiId: {
status: 400,
title: 'Model with multiple ID fields is not supported',
},
invalidId: {
status: 400,
title: 'Resource ID is invalid',
Expand Down Expand Up @@ -387,7 +390,7 @@ class RequestHandler extends APIHandlerBase {
return this.makeUnsupportedModelError(type);
}

const args: any = { where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId) };
const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) };

// include IDs of relation fields so that they can be serialized
this.includeRelationshipIds(type, args, 'include');
Expand All @@ -405,7 +408,12 @@ class RequestHandler extends APIHandlerBase {
include = allIncludes;
}

const entity = await prisma[type].findUnique(args);
let entity = await prisma[type].findUnique(args);

if (typeInfo.idFields.length > 1) {
entity = { ...entity, [compoundIdKey]: resourceId };
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
}

if (entity) {
return {
status: 200,
Expand Down Expand Up @@ -451,7 +459,7 @@ class RequestHandler extends APIHandlerBase {

select = select ?? { [relationship]: true };
const args: any = {
where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId),
where: this.makeIdFilter(typeInfo.idFields, resourceId),
select,
};

Expand Down Expand Up @@ -510,7 +518,7 @@ class RequestHandler extends APIHandlerBase {
}

const args: any = {
where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId),
where: this.makeIdFilter(typeInfo.idFields, resourceId),
select: this.makeIdSelect(type, modelMeta),
};

Expand Down Expand Up @@ -801,8 +809,11 @@ class RequestHandler extends APIHandlerBase {
}

const updateArgs: any = {
where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId),
select: { [typeInfo.idField]: true, [relationship]: { select: { [relationInfo.idField]: true } } },
where: this.makeIdFilter(typeInfo.idFields, resourceId),
select: {
...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}),
[relationship]: { select: { [relationInfo.idField]: true } },
},
};

if (!relationInfo.isCollection) {
Expand Down Expand Up @@ -897,7 +908,7 @@ class RequestHandler extends APIHandlerBase {
}

const updatePayload: any = {
where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId),
where: this.makeIdFilter(typeInfo.idFields, resourceId),
data: { ...attributes },
};

Expand Down Expand Up @@ -948,7 +959,7 @@ class RequestHandler extends APIHandlerBase {
}

await prisma[type].delete({
where: this.makeIdFilter(typeInfo.idField, typeInfo.idFieldType, resourceId),
where: this.makeIdFilter(typeInfo.idFields, resourceId),
});
return {
status: 204,
Expand All @@ -966,14 +977,9 @@ class RequestHandler extends APIHandlerBase {
logWarning(logger, `Not including model ${model} in the API because it has no ID field`);
continue;
}
if (idFields.length > 1) {
logWarning(logger, `Not including model ${model} in the API because it has multiple ID fields`);
continue;
}

this.typeMap[model] = {
idField: idFields[0].name,
idFieldType: idFields[0].type,
idFields,
relationships: {},
fields,
};
Expand All @@ -990,18 +996,15 @@ class RequestHandler extends APIHandlerBase {
);
continue;
}
if (fieldTypeIdFields.length > 1) {
logWarning(
logger,
`Not including relation ${model}.${field} in the API because it has multiple ID fields`
);
continue;
}

// TODO: Multi id relationship support
const idField = fieldTypeIdFields.length > 1 ? 'id' : fieldTypeIdFields[0].name;
const idFieldType = fieldTypeIdFields.length > 1 ? 'string' : fieldTypeIdFields[0].type;

this.typeMap[model].relationships[field] = {
type: fieldInfo.type,
idField: fieldTypeIdFields[0].name,
idFieldType: fieldTypeIdFields[0].type,
idField,
idFieldType,
isCollection: !!fieldInfo.isArray,
isOptional: !!fieldInfo.isOptional,
};
Expand All @@ -1019,7 +1022,8 @@ class RequestHandler extends APIHandlerBase {

for (const model of Object.keys(modelMeta.models)) {
const ids = getIdFields(modelMeta, model);
if (ids.length !== 1) {

if (ids.length < 1) {
continue;
}

Expand All @@ -1042,7 +1046,7 @@ class RequestHandler extends APIHandlerBase {

const serializer = new Serializer(model, {
version: '1.1',
idKey: ids[0].name,
idKey: ids.length > 1 ? compoundIdKey : ids[0].name,
linkers: {
resource: linker,
document: linker,
Expand All @@ -1069,7 +1073,7 @@ class RequestHandler extends APIHandlerBase {
continue;
}
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
if (fieldIds.length === 1) {
if (fieldIds.length > 0) {
const relator = new Relator(
async (data) => {
return (data as any)[field];
Expand Down Expand Up @@ -1107,10 +1111,10 @@ class RequestHandler extends APIHandlerBase {
return undefined;
}
const ids = getIdFields(modelMeta, model);
if (ids.length === 1) {
return data[ids[0].name];
} else {
if (ids.length === 0) {
return undefined;
} else {
return data[ids.map((id) => id.name).join(idDivider)];
}
}

Expand Down Expand Up @@ -1178,18 +1182,31 @@ class RequestHandler extends APIHandlerBase {
return r.toString();
}

private makeIdFilter(idField: string, idFieldType: string, resourceId: string) {
return { [idField]: this.coerce(idFieldType, resourceId) };
private makeIdFilter(idFields: IdField[], resourceId: string) {
if (idFields.length === 1) {
return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) };
} else {
return {
[idFields.map((idf) => idf.name).join('_')]: idFields.reduce(
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
(acc, curr, idx) => ({
...acc,
[curr.name]: this.coerce(curr.type, resourceId.split(idDivider)[idx]),
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
}),
{}
),
};
}
}

private makeIdSelect(model: string, modelMeta: ModelMeta) {
const idFields = getIdFields(modelMeta, model);
if (idFields.length === 0) {
throw this.errors.noId;
} else if (idFields.length > 1) {
throw this.errors.multiId;
} else if (idFields.length === 1) {
return { [idFields[0].name]: true };
} else {
return { [idFields.map((idf) => idf.name).join(',')]: true };
}
return { [idFields[0].name]: true };
}

private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
Expand Down Expand Up @@ -1425,7 +1442,10 @@ class RequestHandler extends APIHandlerBase {
if (!relationType) {
return { sort: undefined, error: this.makeUnsupportedModelError(fieldInfo.type) };
}
curr[fieldInfo.name] = { [relationType.idField]: dir };
curr[fieldInfo.name] = relationType.idFields.reduce((acc: any, idField: IdField) => {
acc[idField.name] = dir;
return acc;
}, {});
} else {
// regular field
curr[fieldInfo.name] = dir;
Expand Down Expand Up @@ -1509,11 +1529,11 @@ class RequestHandler extends APIHandlerBase {
const values = value.split(',').filter((i) => i);
const filterValue =
values.length > 1
? { OR: values.map((v) => this.makeIdFilter(info.idField, info.idFieldType, v)) }
: this.makeIdFilter(info.idField, info.idFieldType, value);
? { OR: values.map((v) => this.makeIdFilter(info.idFields, v)) }
: this.makeIdFilter(info.idFields, value);
return { some: filterValue };
} else {
return { is: this.makeIdFilter(info.idField, info.idFieldType, value) };
return { is: this.makeIdFilter(info.idFields, value) };
}
} else {
const coerced = this.coerce(fieldInfo.type, value);
Expand Down
38 changes: 37 additions & 1 deletion packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CrudFailureReason, type ModelMeta } from '@zenstackhq/runtime';
import { loadSchema, run } from '@zenstackhq/testtools';
import { Decimal } from 'decimal.js';
import SuperJSON from 'superjson';
import makeHandler from '../../src/api/rest';
import makeHandler, { idDivider } from '../../src/api/rest';

describe('REST server tests', () => {
let prisma: any;
Expand Down Expand Up @@ -63,6 +63,13 @@ describe('REST server tests', () => {
post Post @relation(fields: [postId], references: [id])
postId Int @unique
}

model PostLike {
postId Int
userId String
superLike Boolean
@@id([postId, userId])
}
`;

beforeAll(async () => {
Expand Down Expand Up @@ -1283,6 +1290,35 @@ describe('REST server tests', () => {
next: null,
});
});

it('compound id', async () => {
await prisma.user.create({
data: { myId: 'user1', email: '[email protected]', posts: { create: { title: 'Post1' } } },
});
await prisma.user.create({
data: { myId: 'user2', email: '[email protected]' },
});
await prisma.postLike.create({
data: { userId: 'user2', postId: 1, superLike: false },
});

const r = await handler({
method: 'get',
path: `/postLike/1${idDivider}user2`, // Order of ids is same as in the model @@id
prisma,
});

console.log(r.body);

expect(r.status).toBe(200);
expect(r.body).toMatchObject({
data: {
type: 'postLike',
id: `1${idDivider}user2`,
attributes: { userId: 'user2', postId: 1, superLike: false },
},
});
});
});

describe('POST', () => {
Expand Down
Loading