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
22 changes: 22 additions & 0 deletions .changeset/27683-search-fields-names-a-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': patch
---

fix(spec)!: `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and `FullTextSearchSchema.fields` declare the search-field override the Data API doors accept, instead of any string or list (#27683)

Clause-②: no (narrowing) — the published declarations of the search-field override (`$searchFields`, the canonical `searchFields`, and the structured `search.fields`, reached through `QueryWithTransportSchema` and `FindDataRequestSchema.query`) 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 query declarations no metadata type embeds, 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 key out; there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing of published query declarations (`@objectstack/spec/data`, and `@objectstack/spec/api` through `FindDataRequestSchema`), read by the `POST /data/:object/query` body gate, codegen, the reference docs and clients.

- **Before.** `$searchFields` admitted any string or list: a blank, whitespace-only or comma-only string, and `[]`. `QuerySchema.searchFields` and `FullTextSearchSchema.fields` admitted `[]`. The published JSON Schema admitted what the doors refuse.
- **Now.** A string must name at least one field after the door's own split and trim (published as the pattern `[^\s,]`), and a list must hold at least one name. That is exactly the set the door refuses as naming no field. A list with a blank member is still admitted by the schema, as the door admits it as a value and then refuses the blank name as an unknown field.
- **`POST /data/:object/query`.** That route parses its body with `FindDataRequestSchema`, so an empty `searchFields`, `$searchFields` or structured `search.fields` is now refused at the body schema: still `400`, still before any read, but `VALIDATION_FAILED` with `fields[0].field` naming `query.searchFields`, `query.$searchFields` or `query.search.fields`, where it was `VALIDATION_ERROR` from the list door. This is how that route already answers an empty `select`.
- **Unchanged.** `GET /data/:object` and `GET /data/:object/export` still answer an empty `$searchFields` / `searchFields` with `400 VALIDATION_ERROR` naming it; a named override (`label`, `["label", "note"]`, spaces and empty members between commas trimmed by the door) and an explicit `null` (a withdrawal) are accepted as before. The engine-side declarations (`EngineQueryOptionsSchema.searchFields` and its aggregate and count siblings) still admit `[]`, and the in-process engine still reads an empty override as absent; moving them is a separate change. The structured `search` form those engine declarations share is `FullTextSearchSchema`, so its `fields: []` is refused there too, though the engine itself does not parse `search`.
- **Who sees it.** A caller of `POST /data/:object/query` that branches on the `VALIDATION_ERROR` code for an empty search-field override, code that parses a query bag with these schemas and sent an empty override, or a generated client that trusted the wider declaration.

**Supersedes, in the same release.** The `#27484` entry's "Not yet narrowed" bullet no longer holds: the three declarations it names other than `EngineQueryOptionsSchema.searchFields` refuse `''` and `[]` now, and on `POST /data/:object/query` an empty `searchFields` is the body schema's `400 VALIDATION_FAILED`, the same answer `select: []` gets.

**The remedy.** Send the override naming at least one field, or leave it out to search the object's default search fields.
6 changes: 3 additions & 3 deletions content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ const result = ApiErrorSchema.parse(data);
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
| **where** | `any` | optional | Filtering criteria (WHERE) |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
| **limit** | `number` | optional | Max records to return (LIMIT) |
| **offset** | `number` | optional | Records to skip (OFFSET) |
Expand All @@ -606,7 +606,7 @@ const result = ApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **query** | `string` | ✅ | Search query text |
| **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) |
| **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. |
| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. |
| **boost** | `Record<string, number>` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. |
Expand Down Expand Up @@ -640,7 +640,7 @@ const result = ApiErrorSchema.parse(data);
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
| **where** | `any` | optional | Filtering criteria (WHERE) |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
| **limit** | `number` | optional | Max records to return (LIMIT) |
| **offset** | `number` | optional | Records to skip (OFFSET) |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ Enable package response
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
| **limit** | `integer` | optional | Max records to return (LIMIT) |
| **offset** | `integer` | optional | Records to skip (OFFSET) |
Expand All @@ -923,7 +923,7 @@ Enable package response
| **$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`) |
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
| **$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` |
Expand Down
Loading
Loading