Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/26909-transport-select-empty-projection.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata type or stored shape moves: these are transport request declarations, never stored, and the values they stop admitting are values the Data API doors already refused. A caller names at least one field or leaves the parameter out; there is nothing for a migration to rewrite and no tombstone. -->

**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.
6 changes: 3 additions & 3 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| 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<string, any> \| 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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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` |
Expand Down Expand Up @@ -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. |


Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| 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<string, any> \| 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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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` |
Expand Down Expand Up @@ -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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| 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<string, any> \| 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<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { 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` |
Expand Down
29 changes: 26 additions & 3 deletions packages/rest/src/data-empty-projection-count-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -59,11 +59,23 @@ const REFUSED_GET: ReadonlyArray<readonly [string, Record<string, string>, strin

/** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */
const REFUSED_POST: ReadonlyArray<readonly [string, Record<string, unknown>, 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.<key>`, and the member for a blank name (the #25269 precedent
* for the page window).
*/
const REFUSED_POST_AT_SCHEMA: ReadonlyArray<readonly [string, Record<string, unknown>, 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 () => {} };
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 23 additions & 1 deletion packages/runtime/src/domains/data-empty-projection-count.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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.<key>`, 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, unknown>, 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);
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/api/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading