diff --git a/.changeset/26909-transport-select-empty-projection.md b/.changeset/26909-transport-select-empty-projection.md new file mode 100644 index 00000000000..4eaade59977 --- /dev/null +++ b/.changeset/26909-transport-select-empty-projection.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec)!: `QueryTransportParamsSchema.select` / `$select` and `GetDataRequestSchema.select` declare the projection the `findData` and `getData` doors accept, instead of any string or list (#26909) + +Clause-②: no (narrowing) — the published body-side declarations of the list door's projection (`select`, `$select`, reached through `QueryWithTransportSchema` and `FindDataRequestSchema.query`) and the by-id request's `select` refuse a value naming no field, which they admitted before. The doors already refused it with a 400; nothing is widened and no key is added, removed or renamed. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing of published transport declarations (`@objectstack/spec/data`, `@objectstack/spec/api`), read by the `POST /data/:object/query` body gate, codegen, the reference docs and clients. + +- **Before.** `select` / `$select` admitted any string or list: a blank, whitespace-only or comma-only string, `[]`, and a list with a blank name. `GetDataRequestSchema.select` admitted `[]` and blank names. +- **Now.** A string must name at least one field after the door's own split and trim (published as the pattern `[^\s,]`, the same rule `HttpFindQueryParamsSchema.select` states since #26817). A list must hold at least one name, and no name may be blank. `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select` read these rules off `QueryTransportParamsSchema.select` rather than restating them. +- **`POST /data/:object/query`.** That route parses its body with `FindDataRequestSchema`, so an empty `select` / `$select` is now refused at the body schema: still `400`, still before any read, but `VALIDATION_FAILED` with `fields[0].field` naming `query.select` / `query.$select`, where it was `VALIDATION_ERROR` from the list door. A list with a blank name (`["name", ""]`) is likewise `400 VALIDATION_FAILED` at the schema, where it was `400 INVALID_FIELD` (an unknown field). This is how that route already answers a malformed `limit` or `count`. +- **Unchanged.** `GET /data/:object` and `GET /data/:object/:id` still answer an empty `select` with `400 VALIDATION_ERROR` naming it; a named projection (`name,email`, `["name", "email"]`, spaces and empty members between commas trimmed by the door) and an explicit `null` (a withdrawal) are accepted as before. +- **Who sees it.** A caller of `POST /data/:object/query` that branches on the `VALIDATION_ERROR` code for an empty projection, code that parses a query bag with these schemas and sent an empty projection, or a generated client that trusted the wider declaration. + +**The remedy.** Send `select` naming at least one field, or leave it out to read every column. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5a5d08b8ba0..d6ac460674f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -920,14 +920,14 @@ Enable package response | **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as | | **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as | | **$orderby** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) | -| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) | +| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **$expand** | `string \| string[] \| Record \| null` | optional | Transport spelling of `expand` (OData `$expand`) | | **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) | | **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) | | **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) | | **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query | | **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` | -| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` | +| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` | | **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as | | **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` | @@ -1038,7 +1038,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | The object name. | | **id** | `string` | ✅ | The unique record identifier (primary key). | -| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param). | +| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param) — at least one field name; an empty list, or a list with a blank name, is refused. | | **expand** | `string[]` | optional | Lookup/master_detail field names to expand. The engine resolves these via batch $in queries, replacing foreign key IDs with full objects. | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index e0912c795ba..3a6c3d7e689 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -1307,14 +1307,14 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as | | **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as | | **$orderby** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) | -| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) | +| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **$expand** | `string \| string[] \| Record \| null` | optional | Transport spelling of `expand` (OData `$expand`) | | **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) | | **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) | | **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) | | **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query | | **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` | -| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` | +| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` | | **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as | | **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` | @@ -1385,14 +1385,14 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as | | **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as | | **$orderby** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) | -| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) | +| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **$expand** | `string \| string[] \| Record \| null` | optional | Transport spelling of `expand` (OData `$expand`) | | **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) | | **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) | | **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) | | **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query | | **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` | -| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` | +| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` | | **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as | | **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` | diff --git a/packages/rest/src/data-empty-projection-count-door.test.ts b/packages/rest/src/data-empty-projection-count-door.test.ts index 1308594fb8f..07e84a19a5e 100644 --- a/packages/rest/src/data-empty-projection-count-door.test.ts +++ b/packages/rest/src/data-empty-projection-count-door.test.ts @@ -13,7 +13,7 @@ * |:--|:--|:--| * | `GET ?$select=`, `?$select=%20`, `?$select=,`, `?select=`, `?fields=` | 200, every column | 400 naming the spelling | * | `GET ?$count=maybe`, `?$count=1`, `?count=` (with `$top`) | 200, `total` reported | 400 naming the spelling | - * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key | + * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key (`select` at the body schema, `VALIDATION_FAILED`, since #26909) | * | `POST {"count":"maybe"}` | 400 `VALIDATION_FAILED` (the body schema) | unchanged | * | `GET /:id?select=`, `?select=,` | 200, every column | `VALIDATION_ERROR` naming `select`, at this route's 404 (it rewrote every 400, as for `?select=nope`); 400 since #26816 | * | `GET ?$select=label`, `?$count=true` / `false`, absent; `GET /:id?select=label` | 200 | unchanged | @@ -59,11 +59,23 @@ const REFUSED_GET: ReadonlyArray, strin /** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */ const REFUSED_POST: ReadonlyArray, string]> = [ - ['select: ""', { select: '' }, 'select'], - ['$select: ","', { $select: ',' }, '$select'], ['fields: []', { fields: [] }, 'fields'], ]; +/** + * [#26909] The body's published schema (`FindDataRequestSchema`) now declares + * the projection the normalizer enforces, so these are refused where every + * other malformed body is: at the schema, `400 VALIDATION_FAILED`, `fields[0]` + * naming `query.`, and the member for a blank name (the #25269 precedent + * for the page window). + */ +const REFUSED_POST_AT_SCHEMA: ReadonlyArray, string]> = [ + ['select: ""', { select: '' }, 'query.select'], + ['$select: ","', { $select: ',' }, 'query.$select'], + ['select: []', { select: [] }, 'query.select'], + ['select: ["label", ""] (the blank member)', { select: ['label', ''] }, 'query.select.1'], +]; + function createMockServer() { const noop = () => {}; return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; @@ -161,6 +173,17 @@ describe('[#26644] an empty projection and an unrecognised count flag on the Dat expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); }); + it('POST query: `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => { + const before = reads.n; + for (const [name, body, field] of REFUSED_POST_AT_SCHEMA) { + const res = await query(body); + expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, name).toBe('VALIDATION_FAILED'); + expect(res.body.fields?.[0]?.field, name).toBe(field); + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + it("POST query: an unrecognised count is still the schema door's 400 VALIDATION_FAILED, unchanged", async () => { const res = await query({ limit: 2, count: 'maybe' }); expect(res.status, JSON.stringify(res.body)).toBe(400); diff --git a/packages/runtime/src/domains/data-empty-projection-count.test.ts b/packages/runtime/src/domains/data-empty-projection-count.test.ts index 5f82f10ac89..330c2577c5b 100644 --- a/packages/runtime/src/domains/data-empty-projection-count.test.ts +++ b/packages/runtime/src/domains/data-empty-projection-count.test.ts @@ -112,7 +112,6 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr ['GET list ?$select=, (the card)', 'GET', '/data/inquiry', { $select: ',' }, undefined, '$select', 'names no field'], ['GET list ?select= (the card)', 'GET', '/data/inquiry', { select: '' }, undefined, 'select', 'names no field'], ['GET list ?$count=maybe (the card)', 'GET', '/data/inquiry', { $top: '2', $count: 'maybe' }, undefined, '$count', 'must be true or false'], - ['POST query {"select":""}', 'POST', '/data/inquiry/query', {}, { select: '' }, 'select', 'names no field'], ['POST query {"fields":[]}', 'POST', '/data/inquiry/query', {}, { fields: [] }, 'fields', 'names no field'], ['GET by id ?select=', 'GET', '/data/inquiry/r1', { select: '' }, undefined, 'select', 'names no field'], ['GET by id ?select=,', 'GET', '/data/inquiry/r1', { select: ',' }, undefined, 'select', 'names no field'], @@ -127,6 +126,29 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0); }); + // [#26909] The body's published schema (`FindDataRequestSchema`) now + // declares the projection the normalizer enforces, so `select` / `$select` + // naming no field is refused where every other malformed body is: at the + // schema, `400 VALIDATION_FAILED` naming `query.`, and the member for + // a blank name (the #25269 precedent for the page window). Still a 400, + // still before any read. + it.each<[string, Record, string]>([ + ['{"select":""}', { select: '' }, 'query.select'], + ['{"$select":","}', { $select: ',' }, 'query.$select'], + ['{"select":[]}', { select: [] }, 'query.select'], + ['{"select":["name",""]} (the blank member)', { select: ['name', ''] }, 'query.select.1'], + ])('POST query %s: 400 VALIDATION_FAILED naming the body key at the schema — no read', async (_name, body, field) => { + const before = reads.n; + + const answer = await ask('POST', '/data/inquiry/query', {}, body); + + expect(answer.status, JSON.stringify(answer)).toBe(400); + expect(answer.code).toBe('VALIDATION_FAILED'); + const fields = (answer.body?.error?.details?.fields ?? []).map((f: { field: string }) => f.field); + expect(fields).toEqual([field]); + expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0); + }); + it('CONTROL: a named projection and a true / false / absent count answer exactly as before', async () => { const projected = await ask('GET', '/data/inquiry', { $select: 'name', $orderby: 'name' }); expect(projected.status, JSON.stringify(projected)).toBe(200); diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index eb6d4003060..55ace91a446 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -612,6 +612,19 @@ describe('GetDataRequestSchema (select/expand)', () => { } }); + // [#26909] The by-id door reads `select` through the list door's + // projection rule and refuses a list naming no field; every member is then + // checked against the object's fields, where a blank name is never one. + it.each([[[]], [['', ' ']], [['name', '']]])('refuses select=%j — names no field', (select) => { + expect(GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select }).success).toBe(false); + }); + + it.each([[['name']], [['name', 'email']]])('keeps select=%j unchanged', (select) => { + const result = GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.select).toEqual(select); + }); + it('should strip unknown query parameters via strict parsing', () => { const result = GetDataRequestSchema.strict().safeParse({ object: 'contact', @@ -718,6 +731,15 @@ describe('HttpFindQueryParamsSchema', () => { if (result.success) expect(result.data.select).toBe(value); }); + // [#26909] The body side of the same door (`FindDataRequestSchema.query`, + // the `POST /data/:object/query` body) now refuses what this one refuses. + it.each(['', ' ', ',', ' , ', []])('the body-side declaration refuses select=%j too', (select) => { + for (const key of ['select', '$select']) { + const parsed = FindDataRequestSchema.safeParse({ object: 'contact', query: { object: 'contact', [key]: select } }); + expect(parsed.success, key).toBe(false); + } + }); + it('rejects ?distinct — the querystring spelling of the removed query.distinct', () => { const result = HttpFindQueryParamsSchema.safeParse({ distinct: true }); expect(result.success).toBe(false); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 98af862577c..84b9fb2c301 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2381,7 +2381,9 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({ // each member and refuses a value naming no field (blank, whitespace-only, // commas only) with a 400. `[^\s,]` is that rule as a pattern — `\s` is the // set `trim()` strips — so the published JSON Schema states it too. - select: z.string().regex(/[^\s,]/, 'Name at least one field').optional().describe( + // [#26909] Read off the body-side declaration of the same door + // (`QueryTransportParamsSchema.select`, its string arm), not restated. + select: QueryTransportParamsSchema.shape.select.unwrap().unwrap().options[0].optional().describe( 'Comma-separated list of fields to retrieve — at least one field name; a value naming no field ' + '(blank, or only commas and spaces) is refused.' ), @@ -2427,7 +2429,13 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({ export const GetDataRequestSchema = lazySchema(() => z.object({ object: z.string().describe('The object name.'), id: z.string().describe('The unique record identifier (primary key).'), - select: z.array(z.string()).optional().describe('Fields to include in the response (allowlisted query param).'), + // [#26909] The by-id door reads `select` through the list door's own + // projection rule (`projectionFieldsValue`) and refuses `[]`; the array arm + // of the list declaration is that rule, so it is read off it here. + select: QueryTransportParamsSchema.shape.select.unwrap().unwrap().options[1].optional().describe( + 'Fields to include in the response (allowlisted query param) — at least one field name; an empty ' + + 'list, or a list with a blank name, is refused.' + ), expand: z.array(z.string()).optional().describe( 'Lookup/master_detail field names to expand. ' + 'The engine resolves these via batch $in queries, replacing foreign key IDs with full objects.' diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 544987d8f5c..1bf1df25e8a 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -907,6 +907,33 @@ const TransportFilterValueSchema = lazySchema(() => z.union([FilterArraySchema, */ const TransportCountValueSchema = lazySchema(() => z.union([z.boolean(), z.enum(['true', 'false'])])); +/** + * [#26909] The projection's accept set — the `select` / `$select` spellings + * of `fields` — as the doors that read it judge it (`projectionFieldsValue` in + * `@objectstack/metadata-protocol`, shared by the list's `findData` and the + * by-id `getData`). A comma string is split, trimmed and emptied of blank + * members there, and a value naming NO field is refused with a 400: absent + * means "every column", so an empty projection read as absent is a widening. + * + * The string arm is that rule as a pattern: `[^\s,]` holds iff some comma + * segment has a non-whitespace character, and `\s` is exactly the set + * `trim()` strips. A pattern, not a refinement, so the published JSON Schema + * states it too. The array arm is not split: the door keeps every member, + * refuses `[]`, and checks every member against the object's fields, where a + * blank name is never one. + * + * `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select` + * (`../api/protocol.zod.ts`) read these arms off the members below rather + * than restating them, so the three declarations of one door rule cannot + * drift apart. + */ +const PROJECTION_NAMES_NO_FIELD = 'Name at least one field'; +const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD); +const transportProjectionArray = (member: T) => + z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD); +const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, ' + + 'an empty list, or a list with a blank name) is refused'; + /** * [#25269] The page window — `limit` / `offset` and every spelling that folds * onto them (`top`, `$top`, `skip`, `$skip`) — as the Data API list doors @@ -1029,8 +1056,8 @@ export const QueryTransportParamsSchema = lazySchema(() => z.object({ $orderby: DataEngineSortSchema.nullable().optional() .describe('Transport spelling of `orderBy` (OData `$orderby`)'), /** OData spelling of `fields` (two hops: `$select` to `select` to `fields`). */ - $select: z.union([z.string(), z.array(FieldNodeSchema)]).nullable().optional() - .describe('Transport spelling of `fields` (OData `$select`)'), + $select: z.union([TransportProjectionStringSchema, transportProjectionArray(FieldNodeSchema)]).nullable().optional() + .describe(`Transport spelling of \`fields\` (OData \`$select\`) — ${PROJECTION_ACCEPT_SET}`), /** OData spelling of `expand`. */ $expand: z.union([z.string(), z.array(z.string()), z.record(z.string(), QuerySchema)]).nullable().optional() .describe('Transport spelling of `expand` (OData `$expand`)'), @@ -1050,8 +1077,8 @@ export const QueryTransportParamsSchema = lazySchema(() => z.object({ filter: TransportFilterValueSchema.nullable().optional() .describe('Transport spelling of `where`'), /** Bare transport spelling of `fields`. */ - select: z.union([z.string(), z.array(z.string())]).nullable().optional() - .describe('Transport spelling of `fields`'), + select: z.union([TransportProjectionStringSchema, transportProjectionArray(z.string())]).nullable().optional() + .describe(`Transport spelling of \`fields\` — ${PROJECTION_ACCEPT_SET}`), /** Bare transport spelling of `orderBy`. */ sort: DataEngineSortSchema.nullable().optional() .describe('Transport spelling of `orderBy`'), diff --git a/packages/spec/src/data/query-transport.test.ts b/packages/spec/src/data/query-transport.test.ts index 6dd2682b1b7..d115c025c4e 100644 --- a/packages/spec/src/data/query-transport.test.ts +++ b/packages/spec/src/data/query-transport.test.ts @@ -401,3 +401,53 @@ describe('§5 the declared OUTPUT type, pinned where a runtime test cannot reach expect([transportBag, canonicalAst, noObject].length).toBe(3); }); }); + +describe('§6 the projection spellings admit exactly what the doors accept', () => { + /** + * [#26909] `select` / `$select` are read by `projectionFieldsValue` + * (`@objectstack/metadata-protocol`): a comma string is split, trimmed and + * emptied of blank members, an array is kept as sent, and a value naming no + * field is refused with a 400 — absent means "every column", so an empty + * projection is a widening. Every array member is then checked against the + * object's fields, where a blank name is never one. The declaration used to + * admit all of these; the GET querystring's own declaration + * (`HttpFindQueryParamsSchema.select`, #26817) already refused the strings. + */ + const REFUSED: ReadonlyArray = [ + ['a blank string', ''], + ['whitespace only', ' '], + ['commas and spaces only', ' , '], + ['a comma', ','], + ['an empty list', []], + ['an all-blank list', ['', ' ']], + ['a list with a blank name', ['name', '']], + ]; + const KEPT: ReadonlyArray = [ + ['one name', 'name'], + ['a comma list', 'name,email'], + ['a spaced comma list', ' name , email '], + ['a list', ['name', 'email']], + ]; + + for (const key of ['select', '$select'] as const) { + it.each(REFUSED)(`refuses \`${key}\` as %s`, (_name, value) => { + expect(QueryTransportParamsSchema.safeParse({ [key]: value }).success).toBe(false); + const parsed = QueryWithTransportSchema.safeParse({ object: 'a', [key]: value }); + expect(parsed.success).toBe(false); + if (!parsed.success) expect(parsed.error.issues[0].path[0]).toBe(key); + }); + + it.each(KEPT)(`keeps \`${key}\` as %s, unchanged`, (_name, value) => { + const params = QueryTransportParamsSchema.safeParse({ [key]: value }); + expect(params.success).toBe(true); + if (params.success) expect(params.data[key]).toEqual(value); + }); + + it(`still reads an explicit null \`${key}\` as a withdrawal`, () => + expect(QueryWithTransportSchema.parse({ object: 'a', [key]: null })).toEqual({ object: 'a' })); + } + + it('lowers a kept comma list onto `fields` as the door does', () => + expect(QueryWithTransportSchema.parse({ object: 'a', select: ' name , email ' })) + .toEqual({ object: 'a', fields: ['name', 'email'] })); +});