From 69fba94706ab11c6b5aa35440619665175780e8b Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Wed, 17 Jun 2026 11:38:39 +0200 Subject: [PATCH] fix(rest): round-trip compound id with a null segment (#2716) --- packages/server/src/api/rest/index.ts | 22 ++++++++++++----- packages/server/test/api/rest.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index cd8d0f66a..159810f10 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -2088,14 +2088,14 @@ export class RestApiHandler implements Api private makeIdFilter(idFields: FieldDef[], resourceId: string, nested: boolean = true) { const decodedId = decodeURIComponent(resourceId); if (idFields.length === 1) { - return { [idFields[0]!.name]: this.coerce(idFields[0]!, decodedId) }; + return { [idFields[0]!.name]: this.coerce(idFields[0]!, decodedId, true) }; } else if (nested) { return { // TODO: support `@@id` with custom name [idFields.map((idf) => idf.name).join(DEFAULT_ID_DIVIDER)]: idFields.reduce( (acc, curr, idx) => ({ ...acc, - [curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx]), + [curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx], true), }), {}, ), @@ -2104,7 +2104,7 @@ export class RestApiHandler implements Api return idFields.reduce( (acc, curr, idx) => ({ ...acc, - [curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx]), + [curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx], true), }), {}, ); @@ -2120,13 +2120,13 @@ export class RestApiHandler implements Api private makeIdConnect(idFields: FieldDef[], id: string | number) { if (idFields.length === 1) { - return { [idFields[0]!.name]: this.coerce(idFields[0]!, id) }; + return { [idFields[0]!.name]: this.coerce(idFields[0]!, id, true) }; } else { return { [this.makeDefaultIdKey(idFields)]: idFields.reduce( (acc, curr, idx) => ({ ...acc, - [curr.name]: this.coerce(curr, `${id}`.split(this.idDivider)[idx]), + [curr.name]: this.coerce(curr, `${id}`.split(this.idDivider)[idx], true), }), {}, ), @@ -2175,8 +2175,18 @@ export class RestApiHandler implements Api } } - private coerce(fieldDef: FieldDef, value: any) { + private coerce(fieldDef: FieldDef, value: any, emptyAsNull: boolean = false) { if (typeof value === 'string') { + // A null segment of a compound id is serialized as an empty string (see + // `makeCompoundId`, which joins the segments). Mirror that when parsing an + // id back, so the id the handler itself emits round-trips: an empty segment + // for an optional field is `null` (matched via `IS NULL`) rather than being + // run through the type coercion below (e.g. `parseInt('')` -> NaN -> 400). + // Only applied for id parsing (`emptyAsNull`), not filter values. See #2716. + if (emptyAsNull && value === '' && fieldDef.optional) { + return null; + } + if (fieldDef.attributes?.some((attr) => attr.name === '@json')) { try { return JSON.parse(value); diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts index 52d7242b2..b834325bb 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -3187,6 +3187,14 @@ describe('REST server tests', () => { author User? @relation(fields: [authorId], references: [id]) authorId Int? } + + model Notice { + id Int @id @default(autoincrement()) + number String + lot Int? + + @@unique([number, lot]) + } `; beforeEach(async () => { client = await createTestClient(schema); @@ -3197,6 +3205,7 @@ describe('REST server tests', () => { externalIdMapping: { User: 'name_source', Post: 'short_title', + Notice: 'number_lot', }, }); handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); @@ -3250,6 +3259,32 @@ describe('REST server tests', () => { id: 'User1_a', }); }); + + it('round-trips a compound id with a null segment (#2716)', async () => { + // Notice is exposed by the `number_lot` unique pair, where `lot` is + // optional. A row with `lot = null` is serialized with an empty trailing + // segment, e.g. `N1_`. That id must be fetchable, not 400 — the handler + // emits it, so it has to accept it back. + await client.notice.create({ data: { id: 1, number: 'N1', lot: 1 } }); + await client.notice.create({ data: { id: 2, number: 'N1', lot: null } }); + + // The null-lot row is serialized as `N1_` (trailing empty), not `N1_0`. + let r = await handler({ method: 'get', path: '/notice', query: {}, client }); + expect(r.status).toBe(200); + const nullRow = (r.body.data as any[]).find((d) => d.attributes.lot === null); + expect(nullRow.id).toBe(`N1${idDivider}`); + + // A present segment still round-trips unchanged. + r = await handler({ method: 'get', path: `/notice/N1${idDivider}1`, query: {}, client }); + expect(r.status).toBe(200); + expect(r.body.data.attributes.lot).toBe(1); + + // The emitted null-lot id round-trips: the empty segment parses as NULL. + r = await handler({ method: 'get', path: `/notice/N1${idDivider}`, query: {}, client }); + expect(r.status).toBe(200); + expect(r.body.data.id).toBe(`N1${idDivider}`); + expect(r.body.data.attributes.lot).toBeNull(); + }); }); describe('REST server tests - procedures', () => {