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

Commit ad75af4

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 ad75af4

3 files changed

Lines changed: 121 additions & 32 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: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3178,6 +3178,8 @@ describe('REST server tests', () => {
31783178
posts Post[]
31793179
31803180
@@unique([name, source])
3181+
@@unique([email_with_underscore])
3182+
email_with_underscore String @unique
31813183
}
31823184
31833185
model Post {
@@ -3189,7 +3191,6 @@ describe('REST server tests', () => {
31893191
`;
31903192
beforeEach(async () => {
31913193
client = await createTestClient(schema);
3192-
31933194
const _handler = new RestApiHandler({
31943195
schema: client.$schema,
31953196
endpoint: 'http://localhost/api',
@@ -3198,11 +3199,9 @@ describe('REST server tests', () => {
31983199
},
31993200
});
32003201
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3201-
});
32023202

3203-
it('works with id mapping', async () => {
32043203
await client.user.create({
3205-
data: { id: 1, name: 'User1', source: 'a' },
3204+
data: { id: 1, name: 'User1', source: 'a', email_with_underscore: 'e1' },
32063205
});
32073206

32083207
// user is no longer exposed using the `id` field
@@ -3212,7 +3211,6 @@ describe('REST server tests', () => {
32123211
query: {},
32133212
client,
32143213
});
3215-
32163214
expect(r.status).toBe(422);
32173215
expect(r.body.errors[0].code).toBe('validation-error');
32183216

@@ -3223,7 +3221,6 @@ describe('REST server tests', () => {
32233221
query: {},
32243222
client,
32253223
});
3226-
32273224
expect(r.status).toBe(200);
32283225
expect(r.body.data.attributes.source).toBe('a');
32293226
expect(r.body.data.attributes.name).toBe('User1');
@@ -3239,7 +3236,6 @@ describe('REST server tests', () => {
32393236
query: { include: 'author' },
32403237
client,
32413238
});
3242-
32433239
expect(r.status).toBe(200);
32443240
expect(r.body.data.attributes.title).toBe('Title1');
32453241
// Verify author relationship contains the external ID
@@ -3248,6 +3244,57 @@ describe('REST server tests', () => {
32483244
id: 'User1_a',
32493245
});
32503246
});
3247+
3248+
it('works with id mapping (array of columns)', async () => {
3249+
client = await createTestClient(schema);
3250+
const _handler = new RestApiHandler({
3251+
schema: client.$schema,
3252+
endpoint: 'http://localhost/api',
3253+
externalIdMapping: {
3254+
User: ['name', 'source'],
3255+
},
3256+
});
3257+
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3258+
3259+
await client.user.create({
3260+
data: { id: 2, name: 'User2', source: 'b', email_with_underscore: 'e2' },
3261+
});
3262+
3263+
let r = await handler({
3264+
method: 'get',
3265+
path: '/user/User2_b',
3266+
query: {},
3267+
client,
3268+
});
3269+
expect(r.status).toBe(200);
3270+
expect(r.body.data.attributes.source).toBe('b');
3271+
expect(r.body.data.attributes.name).toBe('User2');
3272+
});
3273+
3274+
it('works with id mapping (single column with underscore)', async () => {
3275+
client = await createTestClient(schema);
3276+
const _handler = new RestApiHandler({
3277+
schema: client.$schema,
3278+
endpoint: 'http://localhost/api',
3279+
externalIdMapping: {
3280+
User: 'email_with_underscore',
3281+
},
3282+
});
3283+
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3284+
3285+
await client.user.create({
3286+
data: { id: 3, name: 'User3', source: 'c', email_with_underscore: 'e3' },
3287+
});
3288+
3289+
let r = await handler({
3290+
method: 'get',
3291+
path: '/user/e3',
3292+
query: {},
3293+
client,
3294+
});
3295+
expect(r.status).toBe(200);
3296+
expect(r.body.data.attributes.email_with_underscore).toBe('e3');
3297+
});
32513298
});
32523299

32533300
describe('REST server tests - procedures', () => {
@@ -3352,7 +3399,8 @@ mutation procedure sum(a: Int, b: Int): Int
33523399
const b = args?.b as number | undefined;
33533400
return (a ?? 0) + (b ?? 0);
33543401
},
3355-
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) => (args.ids as number[]).reduce((acc, x) => acc + x, 0),
3402+
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) =>
3403+
(args.ids as number[]).reduce((acc, x) => acc + x, 0),
33563404
echoRole: async ({ args }: ProcCtx<EchoRoleArgs>) => args.r,
33573405
echoOverview: async ({ args }: ProcCtx<EchoOverviewArgs>) => args.o,
33583406
sum: async ({ args }: ProcCtx<SumArgs>) => args.a + args.b,
@@ -3373,7 +3421,7 @@ mutation procedure sum(a: Int, b: Int): Int
33733421
const r = await handler({
33743422
method: 'get',
33753423
path: '/$procs/echoDecimal',
3376-
query: { ...json as object, meta: { serialization: meta } } as any,
3424+
query: { ...(json as object), meta: { serialization: meta } } as any,
33773425
client,
33783426
});
33793427

@@ -3486,7 +3534,7 @@ mutation procedure sum(a: Int, b: Int): Int
34863534
const r = await handler({
34873535
method: 'post',
34883536
path: '/$procs/sum',
3489-
requestBody: { ...json as object, meta: { serialization: meta } } as any,
3537+
requestBody: { ...(json as object), meta: { serialization: meta } } as any,
34903538
client,
34913539
});
34923540

0 commit comments

Comments
 (0)