Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit 68642ba

Browse files
committed
make it optionally possible to define externalIdMapping using an array of columns and not a unique key name
1 parent 9e819e8 commit 68642ba

3 files changed

Lines changed: 125 additions & 27 deletions

File tree

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

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ export type RestApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
6161
modelNameMapping?: Record<string, string>;
6262

6363
/**
64-
* Mapping from model names to unique field name to be used as resource's ID.
64+
* Mapping from model names to unique index fields names to be used as resource's ID.
6565
*/
66-
externalIdMapping?: Record<string, string>;
66+
externalIdMapping?: Record<string, string | string[]>;
6767
};
6868

6969
type RelationshipInfo = {
@@ -261,7 +261,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
261261
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
262262
private modelNameMapping: Record<string, string>;
263263
private reverseModelNameMapping: Record<string, string>;
264-
private externalIdMapping: Record<string, string>;
264+
private externalIdMapping: Record<string, string | string[]>;
265265

266266
constructor(private readonly options: RestApiHandlerOptions<Schema>) {
267267
this.validateOptions(options);
@@ -297,7 +297,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
297297
idDivider: z.string().min(1).optional(),
298298
urlSegmentCharset: z.string().min(1).optional(),
299299
modelNameMapping: z.record(z.string(), z.string()).optional(),
300-
externalIdMapping: z.record(z.string(), z.string()).optional(),
300+
externalIdMapping: z.record(z.string(), z.union([z.string(), z.array(z.string()).min(1)])).optional(),
301301
});
302302
const parseResult = schema.safeParse(options);
303303
if (!parseResult.success) {
@@ -1335,24 +1335,53 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13351335
const modelDef = this.requireModel(model);
13361336
const modelLower = lowerCaseFirst(model);
13371337
if (!(modelLower in this.externalIdMapping)) {
1338-
return Object.values(modelDef.fields).filter((f) => modelDef.idFields.includes(f.name));
1339-
}
1340-
1341-
// map external ID name to unique constraint field
1342-
const externalIdName = this.externalIdMapping[modelLower];
1343-
for (const [name, info] of Object.entries(modelDef.uniqueFields)) {
1344-
if (name === externalIdName) {
1345-
if (typeof info.type === 'string') {
1346-
// single unique field
1347-
return [this.requireField(model, info.type)];
1348-
} else {
1338+
return Object.values(modelDef.fields).filter(
1339+
(f) => (f as FieldDef).name && modelDef.idFields.includes((f as FieldDef).name),
1340+
) as FieldDef[];
1341+
}
1342+
1343+
// map external ID name or columns to unique constraint field(s)
1344+
const externalId = this.externalIdMapping[modelLower];
1345+
let resolved: FieldDef[] | undefined;
1346+
if (typeof externalId === 'string') {
1347+
// Try to match unique key name first
1348+
for (const [name, info] of Object.entries(modelDef.uniqueFields as Record<string, unknown>)) {
1349+
const infoTyped = info as any;
1350+
if (name === externalId) {
1351+
if (typeof infoTyped.type === 'string') {
1352+
// single unique field
1353+
resolved = [this.requireField(model, name)];
1354+
break;
1355+
}
13491356
// compound unique fields
1350-
return Object.keys(info).map((f) => this.requireField(model, f));
1357+
resolved = Object.keys(infoTyped).map((f) => this.requireField(model, f));
1358+
break;
13511359
}
13521360
}
1353-
}
1354-
1355-
throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
1361+
// If not found, treat as a single column name (for single-column unique index with underscores)
1362+
if (modelDef.fields[externalId]) {
1363+
resolved = [this.requireField(model, externalId)];
1364+
}
1365+
} else if (Array.isArray(externalId)) {
1366+
// Array of column names (for compound or single unique index)
1367+
resolved = externalId.map((col) => this.requireField(model, col));
1368+
}
1369+
1370+
if (!resolved) {
1371+
throw new Error(`Invalid externalIdMapping for model ${model}`);
1372+
}
1373+
const resolvedNames = resolved.map((f) => f.name);
1374+
const uniqueSets = this.getUniqueFieldSets(model);
1375+
const isUnique =
1376+
uniqueSets.some(
1377+
(set) => set.length === resolvedNames.length && set.every((f, i) => f === resolvedNames[i]),
1378+
) ||
1379+
(modelDef.idFields.length === resolvedNames.length &&
1380+
modelDef.idFields.every((f, i) => f === resolvedNames[i]));
1381+
if (!isUnique) {
1382+
throw new Error(`Model ${model} externalIdMapping must reference unique fields`);
1383+
}
1384+
return resolved;
13561385
}
13571386

13581387
private requireField(model: string, field: string): FieldDef {
@@ -1366,7 +1395,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13661395

13671396
private buildTypeMap() {
13681397
this.typeMap = {};
1369-
for (const [model, { fields }] of Object.entries(this.schema.models)) {
1398+
for (const [model, modelVal] of Object.entries(this.schema.models as Record<string, unknown>)) {
1399+
const fields = (modelVal as any).fields as Record<string, FieldDef>;
13701400
const idFields = this.getIdFields(model);
13711401
if (idFields.length === 0) {
13721402
log(this.options.log, 'warn', `Not including model ${model} in the API because it has no ID field`);
@@ -1380,7 +1410,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13801410
fields,
13811411
});
13821412

1383-
for (const [field, fieldInfo] of Object.entries(fields)) {
1413+
for (const [field, fieldInfoRaw] of Object.entries(fields)) {
1414+
const fieldInfo = fieldInfoRaw as any;
13841415
if (!fieldInfo.relation) {
13851416
continue;
13861417
}

packages/server/test/api/options-validation.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe('API Handler Options Validation', () => {
192192
}).toThrow('Invalid options');
193193
});
194194

195-
it('should throw error when externalIdMapping values are not strings', () => {
195+
it('should throw error when externalIdMapping values are not string or string[]', () => {
196196
expect(() => {
197197
new RestApiHandler({
198198
schema: client.$schema,
@@ -202,6 +202,16 @@ describe('API Handler Options Validation', () => {
202202
}).toThrow('Invalid options');
203203
});
204204

205+
it('should accept externalIdMapping as string[]', () => {
206+
expect(() => {
207+
new RestApiHandler({
208+
schema: client.$schema,
209+
endpoint: 'http://localhost/api',
210+
externalIdMapping: { User: ['email'] },
211+
});
212+
}).not.toThrow();
213+
});
214+
205215
it('should throw error when log is invalid type', () => {
206216
expect(() => {
207217
new RestApiHandler({

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

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3185,6 +3185,28 @@ describe('REST server tests', () => {
31853185
title String
31863186
author User? @relation(fields: [authorId], references: [id])
31873187
authorId Int?
3188+
comments Comment[]
3189+
images Image[]
3190+
}
3191+
3192+
model Comment {
3193+
3194+
id Int @id @default(autoincrement())
3195+
content String
3196+
short_title String
3197+
post_id Int
3198+
post Post @relation(fields: [post_id], references: [id])
3199+
3200+
@@unique([short_title, post_id])
3201+
}
3202+
3203+
model Image {
3204+
3205+
id Int @id @default(autoincrement())
3206+
content String
3207+
short_title String @unique
3208+
post_id Int
3209+
post Post @relation(fields: [post_id], references: [id])
31883210
}
31893211
`;
31903212
beforeEach(async () => {
@@ -3195,16 +3217,27 @@ describe('REST server tests', () => {
31953217
endpoint: 'http://localhost/api',
31963218
externalIdMapping: {
31973219
User: 'name_source',
3220+
Image: 'short_title',
3221+
Comment: ['short_title', 'post_id'],
31983222
},
31993223
});
32003224
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3201-
});
32023225

3203-
it('works with id mapping', async () => {
32043226
await client.user.create({
32053227
data: { id: 1, name: 'User1', source: 'a' },
32063228
});
3229+
await client.post.create({
3230+
data: { id: 2, title: 'Some title', authorId: 1 },
3231+
});
3232+
await client.comment.create({
3233+
data: { id: 3, short_title: 'comment-title', post_id: 2, content: 'Comment1' },
3234+
});
3235+
await client.image.create({
3236+
data: { id: 1, short_title: 'image-title', post_id: 2, content: 'Image1' },
3237+
});
3238+
});
32073239

3240+
it('works with id mapping', async () => {
32083241
// user is no longer exposed using the `id` field
32093242
let r = await handler({
32103243
method: 'get',
@@ -3248,6 +3281,29 @@ describe('REST server tests', () => {
32483281
id: 'User1_a',
32493282
});
32503283
});
3284+
3285+
it('works with externalIdMapping as single column with underscore (single unique index)', async () => {
3286+
const r = await handler({
3287+
method: 'get',
3288+
path: '/image/image-title',
3289+
query: {},
3290+
client,
3291+
});
3292+
expect(r.status).toBe(200);
3293+
expect(r.body.data.attributes.content).toBe('Image1');
3294+
});
3295+
3296+
it('works with externalIdMapping as array of fields (compound unique index)', async () => {
3297+
const r = await handler({
3298+
method: 'get',
3299+
path: '/comment/comment-title_2',
3300+
query: {},
3301+
client,
3302+
});
3303+
expect(r.status).toBe(200);
3304+
expect(r.body.data.attributes.short_title).toBe('comment-title');
3305+
expect(r.body.data.attributes.post_id).toBe(2);
3306+
});
32513307
});
32523308

32533309
describe('REST server tests - procedures', () => {
@@ -3352,7 +3408,8 @@ mutation procedure sum(a: Int, b: Int): Int
33523408
const b = args?.b as number | undefined;
33533409
return (a ?? 0) + (b ?? 0);
33543410
},
3355-
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) => (args.ids as number[]).reduce((acc, x) => acc + x, 0),
3411+
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) =>
3412+
(args.ids as number[]).reduce((acc, x) => acc + x, 0),
33563413
echoRole: async ({ args }: ProcCtx<EchoRoleArgs>) => args.r,
33573414
echoOverview: async ({ args }: ProcCtx<EchoOverviewArgs>) => args.o,
33583415
sum: async ({ args }: ProcCtx<SumArgs>) => args.a + args.b,
@@ -3373,7 +3430,7 @@ mutation procedure sum(a: Int, b: Int): Int
33733430
const r = await handler({
33743431
method: 'get',
33753432
path: '/$procs/echoDecimal',
3376-
query: { ...json as object, meta: { serialization: meta } } as any,
3433+
query: { ...(json as object), meta: { serialization: meta } } as any,
33773434
client,
33783435
});
33793436

@@ -3486,7 +3543,7 @@ mutation procedure sum(a: Int, b: Int): Int
34863543
const r = await handler({
34873544
method: 'post',
34883545
path: '/$procs/sum',
3489-
requestBody: { ...json as object, meta: { serialization: meta } } as any,
3546+
requestBody: { ...(json as object), meta: { serialization: meta } } as any,
34903547
client,
34913548
});
34923549

0 commit comments

Comments
 (0)