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

Commit 2371d8f

Browse files
committed
add support for mapped unique keys in externalIdMapping
1 parent c7ad7d7 commit 2371d8f

2 files changed

Lines changed: 137 additions & 14 deletions

File tree

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

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,20 +1338,89 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
13381338
return Object.values(modelDef.fields).filter((f) => modelDef.idFields.includes(f.name));
13391339
}
13401340

1341-
// map external ID name to unique constraint field
13421341
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 {
1349-
// compound unique fields
1350-
return Object.keys(info).map((f) => this.requireField(model, f));
1342+
1343+
// 1. Model-level @@unique(map: ...) (compound keys only)
1344+
if (Array.isArray(modelDef.attributes)) {
1345+
const uniqueAttr = modelDef.attributes.find(
1346+
(attr: any) =>
1347+
attr.name === '@@unique' &&
1348+
Array.isArray(attr.args) &&
1349+
attr.args.some((a: any) => a.name === 'map' && a.value?.value === externalIdName),
1350+
);
1351+
if (uniqueAttr) {
1352+
const fieldsArg = uniqueAttr.args.find((a: any) => a.name === 'fields');
1353+
if (fieldsArg && Array.isArray(fieldsArg.value?.items)) {
1354+
if (fieldsArg.value.items.length > 1) {
1355+
const fieldNames = fieldsArg.value.items.map((item: any) => {
1356+
let fieldName: string | undefined = undefined;
1357+
if (typeof item.field === 'string' && modelDef.fields[item.field]) {
1358+
fieldName = item.field;
1359+
} else if (
1360+
typeof item.value === 'object' &&
1361+
typeof item.value.name === 'string' &&
1362+
modelDef.fields[item.value.name]
1363+
) {
1364+
fieldName = item.value.name;
1365+
}
1366+
if (!fieldName) {
1367+
throw new Error(
1368+
`Invalid unique field mapping in model ${model}: ${JSON.stringify(item)}`,
1369+
);
1370+
}
1371+
return fieldName;
1372+
});
1373+
return fieldNames.map((fieldName: string) => this.requireField(model, fieldName));
1374+
}
13511375
}
13521376
}
13531377
}
13541378

1379+
// 2. uniqueFields by key name
1380+
const uniqueFieldEntry = Object.entries(modelDef.uniqueFields).find(([name]) => name === externalIdName);
1381+
if (uniqueFieldEntry) {
1382+
const [name, info] = uniqueFieldEntry;
1383+
if (typeof info.type === 'string') {
1384+
return [this.requireField(model, name)];
1385+
}
1386+
const fieldNames = Object.keys(info).filter((f) => {
1387+
if (!(f in modelDef.fields)) return false;
1388+
const fieldInfo = (info as Record<string, any>)[f];
1389+
return typeof fieldInfo === 'object' && fieldInfo !== null && typeof fieldInfo.type === 'string';
1390+
});
1391+
if (fieldNames.length === 0) {
1392+
throw new Error(
1393+
`No valid field names found for compound unique key ${externalIdName} in model ${model}`,
1394+
);
1395+
}
1396+
return fieldNames.map((f) => this.requireField(model, f));
1397+
}
1398+
1399+
// 3. Field-level unique attribute (with or without map)
1400+
const field = Object.values(modelDef.fields).find((fieldDef) => {
1401+
if (!Array.isArray(fieldDef.attributes)) return false;
1402+
// @unique(map: ...)
1403+
const hasMappedUnique = fieldDef.attributes.some(
1404+
(attr: any) =>
1405+
attr.name === '@unique' &&
1406+
Array.isArray(attr.args) &&
1407+
attr.args.some((a: any) => a.name === 'map' && a.value?.value === externalIdName),
1408+
);
1409+
if (hasMappedUnique) return true;
1410+
// @unique (no map), field name matches
1411+
const hasSimpleUnique = fieldDef.attributes.some(
1412+
(attr: any) =>
1413+
attr.name === '@unique' &&
1414+
(!Array.isArray(attr.args) || !attr.args.some((a: any) => a.name === 'map')) &&
1415+
fieldDef.name === externalIdName,
1416+
);
1417+
return hasSimpleUnique;
1418+
});
1419+
1420+
if (field) {
1421+
return [field];
1422+
}
1423+
13551424
throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
13561425
}
13571426

@@ -2060,9 +2129,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
20602129
}
20612130
} else {
20622131
if (op === 'between') {
2063-
const parts = value
2064-
.split(',')
2065-
.map((v) => this.coerce(fieldDef, v));
2132+
const parts = value.split(',').map((v) => this.coerce(fieldDef, v));
20662133
if (parts.length !== 2) {
20672134
throw new InvalidValueError(`"between" expects exactly 2 comma-separated values`);
20682135
}

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

Lines changed: 58 additions & 2 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], map: "short_title_post")
3201+
}
3202+
3203+
model Image {
3204+
3205+
id Int @id @default(autoincrement())
3206+
content String
3207+
short_title String @unique(map: "short_title")
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',
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', () => {

0 commit comments

Comments
 (0)