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

Commit a73882f

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

3 files changed

Lines changed: 124 additions & 32 deletions

File tree

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

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ export type RestApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
6363
/**
6464
* Mapping from model names to unique field name to be used as resource's ID.
6565
*/
66-
externalIdMapping?: Record<string, string>;
66+
/**
67+
* Mapping from model names to unique key name or array of column names to be used as resource's ID.
68+
*/
69+
externalIdMapping?: Record<string, string | string[]>;
6770
};
6871

6972
type RelationshipInfo = {
@@ -260,7 +263,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
260263
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
261264
private modelNameMapping: Record<string, string>;
262265
private reverseModelNameMapping: Record<string, string>;
263-
private externalIdMapping: Record<string, string>;
266+
private externalIdMapping: Record<string, string | string[]>;
264267

265268
constructor(private readonly options: RestApiHandlerOptions<Schema>) {
266269
this.validateOptions(options);
@@ -296,7 +299,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
296299
idDivider: z.string().min(1).optional(),
297300
urlSegmentCharset: z.string().min(1).optional(),
298301
modelNameMapping: z.record(z.string(), z.string()).optional(),
299-
externalIdMapping: z.record(z.string(), z.string()).optional(),
302+
externalIdMapping: z.record(z.string(), z.union([z.string(), z.array(z.string()).min(1)])).optional(),
300303
});
301304
const parseResult = schema.safeParse(options);
302305
if (!parseResult.success) {
@@ -1334,24 +1337,53 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13341337
const modelDef = this.requireModel(model);
13351338
const modelLower = lowerCaseFirst(model);
13361339
if (!(modelLower in this.externalIdMapping)) {
1337-
return Object.values(modelDef.fields).filter((f) => modelDef.idFields.includes(f.name));
1338-
}
1339-
1340-
// map external ID name to unique constraint field
1341-
const externalIdName = this.externalIdMapping[modelLower];
1342-
for (const [name, info] of Object.entries(modelDef.uniqueFields)) {
1343-
if (name === externalIdName) {
1344-
if (typeof info.type === 'string') {
1345-
// single unique field
1346-
return [this.requireField(model, info.type)];
1347-
} else {
1340+
return Object.values(modelDef.fields).filter(
1341+
(f) => (f as FieldDef).name && modelDef.idFields.includes((f as FieldDef).name),
1342+
) as FieldDef[];
1343+
}
1344+
1345+
// map external ID name or columns to unique constraint field(s)
1346+
const externalId = this.externalIdMapping[modelLower];
1347+
let resolved: FieldDef[] | undefined;
1348+
if (typeof externalId === 'string') {
1349+
// Try to match unique key name first
1350+
for (const [name, info] of Object.entries(modelDef.uniqueFields as Record<string, unknown>)) {
1351+
const infoTyped = info as any;
1352+
if (name === externalId) {
1353+
if (typeof infoTyped.type === 'string') {
1354+
// single unique field
1355+
resolved = [this.requireField(model, name)];
1356+
break;
1357+
}
13481358
// compound unique fields
1349-
return Object.keys(info).map((f) => this.requireField(model, f));
1359+
resolved = Object.keys(infoTyped).map((f) => this.requireField(model, f));
1360+
break;
13501361
}
13511362
}
1352-
}
1353-
1354-
throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
1363+
// If not found, treat as a single column name (for single-column unique index with underscores)
1364+
if (modelDef.fields[externalId]) {
1365+
resolved = [this.requireField(model, externalId)];
1366+
}
1367+
} else if (Array.isArray(externalId)) {
1368+
// Array of column names (for compound or single unique index)
1369+
resolved = externalId.map((col) => this.requireField(model, col));
1370+
}
1371+
1372+
if (!resolved) {
1373+
throw new Error(`Invalid externalIdMapping for model ${model}`);
1374+
}
1375+
const resolvedNames = resolved.map((f) => f.name);
1376+
const uniqueSets = this.getUniqueFieldSets(model);
1377+
const isUnique =
1378+
uniqueSets.some(
1379+
(set) => set.length === resolvedNames.length && set.every((f, i) => f === resolvedNames[i]),
1380+
) ||
1381+
(modelDef.idFields.length === resolvedNames.length &&
1382+
modelDef.idFields.every((f, i) => f === resolvedNames[i]));
1383+
if (!isUnique) {
1384+
throw new Error(`Model ${model} externalIdMapping must reference unique fields`);
1385+
}
1386+
return resolved;
13551387
}
13561388

13571389
private requireField(model: string, field: string): FieldDef {
@@ -1365,7 +1397,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13651397

13661398
private buildTypeMap() {
13671399
this.typeMap = {};
1368-
for (const [model, { fields }] of Object.entries(this.schema.models)) {
1400+
for (const [model, modelVal] of Object.entries(this.schema.models as Record<string, unknown>)) {
1401+
const fields = (modelVal as any).fields as Record<string, FieldDef>;
13691402
const idFields = this.getIdFields(model);
13701403
if (idFields.length === 0) {
13711404
log(this.options.log, 'warn', `Not including model ${model} in the API because it has no ID field`);
@@ -1379,7 +1412,8 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13791412
fields,
13801413
});
13811414

1382-
for (const [field, fieldInfo] of Object.entries(fields)) {
1415+
for (const [field, fieldInfoRaw] of Object.entries(fields)) {
1416+
const fieldInfo = fieldInfoRaw as any;
13831417
if (!fieldInfo.relation) {
13841418
continue;
13851419
}

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: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3092,6 +3092,8 @@ describe('REST server tests', () => {
30923092
posts Post[]
30933093
30943094
@@unique([name, source])
3095+
@@unique([email_with_underscore])
3096+
email_with_underscore String @unique
30953097
}
30963098
30973099
model Post {
@@ -3101,9 +3103,8 @@ describe('REST server tests', () => {
31013103
authorId Int?
31023104
}
31033105
`;
3104-
beforeEach(async () => {
3106+
it('works with id mapping (unique key name as string)', async () => {
31053107
client = await createTestClient(schema);
3106-
31073108
const _handler = new RestApiHandler({
31083109
schema: client.$schema,
31093110
endpoint: 'http://localhost/api',
@@ -3112,11 +3113,9 @@ describe('REST server tests', () => {
31123113
},
31133114
});
31143115
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3115-
});
31163116

3117-
it('works with id mapping', async () => {
31183117
await client.user.create({
3119-
data: { id: 1, name: 'User1', source: 'a' },
3118+
data: { id: 1, name: 'User1', source: 'a', email_with_underscore: 'e1' },
31203119
});
31213120

31223121
// user is no longer exposed using the `id` field
@@ -3126,7 +3125,6 @@ describe('REST server tests', () => {
31263125
query: {},
31273126
client,
31283127
});
3129-
31303128
expect(r.status).toBe(422);
31313129
expect(r.body.errors[0].code).toBe('validation-error');
31323130

@@ -3137,7 +3135,6 @@ describe('REST server tests', () => {
31373135
query: {},
31383136
client,
31393137
});
3140-
31413138
expect(r.status).toBe(200);
31423139
expect(r.body.data.attributes.source).toBe('a');
31433140
expect(r.body.data.attributes.name).toBe('User1');
@@ -3153,7 +3150,6 @@ describe('REST server tests', () => {
31533150
query: { include: 'author' },
31543151
client,
31553152
});
3156-
31573153
expect(r.status).toBe(200);
31583154
expect(r.body.data.attributes.title).toBe('Title1');
31593155
// Verify author relationship contains the external ID
@@ -3162,6 +3158,57 @@ describe('REST server tests', () => {
31623158
id: 'User1_a',
31633159
});
31643160
});
3161+
3162+
it('works with id mapping (array of columns)', async () => {
3163+
client = await createTestClient(schema);
3164+
const _handler = new RestApiHandler({
3165+
schema: client.$schema,
3166+
endpoint: 'http://localhost/api',
3167+
externalIdMapping: {
3168+
User: ['name', 'source'],
3169+
},
3170+
});
3171+
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3172+
3173+
await client.user.create({
3174+
data: { id: 2, name: 'User2', source: 'b', email_with_underscore: 'e2' },
3175+
});
3176+
3177+
let r = await handler({
3178+
method: 'get',
3179+
path: '/user/User2_b',
3180+
query: {},
3181+
client,
3182+
});
3183+
expect(r.status).toBe(200);
3184+
expect(r.body.data.attributes.source).toBe('b');
3185+
expect(r.body.data.attributes.name).toBe('User2');
3186+
});
3187+
3188+
it('works with id mapping (single column with underscore)', async () => {
3189+
client = await createTestClient(schema);
3190+
const _handler = new RestApiHandler({
3191+
schema: client.$schema,
3192+
endpoint: 'http://localhost/api',
3193+
externalIdMapping: {
3194+
User: ['email_with_underscore'],
3195+
},
3196+
});
3197+
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3198+
3199+
await client.user.create({
3200+
data: { id: 3, name: 'User3', source: 'c', email_with_underscore: 'e3' },
3201+
});
3202+
3203+
let r = await handler({
3204+
method: 'get',
3205+
path: '/user/e3',
3206+
query: {},
3207+
client,
3208+
});
3209+
expect(r.status).toBe(200);
3210+
expect(r.body.data.attributes.email_with_underscore).toBe('e3');
3211+
});
31653212
});
31663213

31673214
describe('REST server tests - procedures', () => {
@@ -3266,7 +3313,8 @@ mutation procedure sum(a: Int, b: Int): Int
32663313
const b = args?.b as number | undefined;
32673314
return (a ?? 0) + (b ?? 0);
32683315
},
3269-
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) => (args.ids as number[]).reduce((acc, x) => acc + x, 0),
3316+
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) =>
3317+
(args.ids as number[]).reduce((acc, x) => acc + x, 0),
32703318
echoRole: async ({ args }: ProcCtx<EchoRoleArgs>) => args.r,
32713319
echoOverview: async ({ args }: ProcCtx<EchoOverviewArgs>) => args.o,
32723320
sum: async ({ args }: ProcCtx<SumArgs>) => args.a + args.b,
@@ -3287,7 +3335,7 @@ mutation procedure sum(a: Int, b: Int): Int
32873335
const r = await handler({
32883336
method: 'get',
32893337
path: '/$procs/echoDecimal',
3290-
query: { ...json as object, meta: { serialization: meta } } as any,
3338+
query: { ...(json as object), meta: { serialization: meta } } as any,
32913339
client,
32923340
});
32933341

@@ -3400,7 +3448,7 @@ mutation procedure sum(a: Int, b: Int): Int
34003448
const r = await handler({
34013449
method: 'post',
34023450
path: '/$procs/sum',
3403-
requestBody: { ...json as object, meta: { serialization: meta } } as any,
3451+
requestBody: { ...(json as object), meta: { serialization: meta } } as any,
34043452
client,
34053453
});
34063454

0 commit comments

Comments
 (0)