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
24 changes: 24 additions & 0 deletions .changeset/27166-query-get-record-empty-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

fix(spec)!: `QuerySchema.fields` (and so every nested `expand` entry) and the flow `get_record` node's `fields` refuse an empty projection, as every other published projection declaration and the in-process engine do (#27166)

Clause-②: no (narrowing) — two published declarations of a read's projection, `QuerySchema.fields` (the QueryAST, which is also the value type of every `expand` entry, `EngineQueryOptionsSchema.expand` included, and the canonical `fields` of the transport-aware query slot) and `GetRecordConfigSchema.fields` (the flow `get_record` node), refuse a list naming no field, which they admitted before. Nothing is widened and no key is added, removed or renamed.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` teaches `fields` and nested `expand` `fields` only as non-empty lists (`skills/objectstack-query/SKILL.md`, the `expand` value row and the nested-expand example), and teaches the `get_record` node without `fields` (`skills/objectstack-automation/SKILL.md`); no file teaches an empty projection, and every taught use stays true.

<!-- adr-0087: registered query-and-get-record-empty-projection-refused -->

**BREAKING** for a flow `get_record` node, or a query you build, that carries an empty projection. In this same release the in-process engine starts refusing `fields: []` at its `find` / `findOne` entry, top level and at any `expand` depth; without this change the two declarations below would still admit it, so such a `get_record` node would save clean and fail at its first run.

- **Before (`@objectstack/spec@18.2.0`, the last release).** `QuerySchema.fields` and `GetRecordConfigSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name. A `get_record` node with `fields: []` passed `os validate`, `defineStack` and `registerFlow`, and its run read every column of the row. A `POST /data/:object/query` body with `fields: []`, top level or on an `expand` entry, answered `200` with every column.
- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). Both read the rule from the declaration `EngineQueryOptionsSchema.fields` and `QueryTransportParamsSchema.$select` read, so the refusal says the same thing: "Name at least one field", or "A field name cannot be blank" for a blank member. A `get_record` node with `fields: []` is refused where a flow is saved or registered, at `flows.N.nodes.M.config.fields` (`STACK_SCHEMA_INVALID` from `defineStack`), and its executor refuses the config before any read. An `expand` entry with `fields: []` is refused at `expand.<field>.fields`. A `POST /data/:object/query` body with `fields: []` is refused by the body schema, `400 VALIDATION_FAILED` at `query.fields`, which is the answer its `$select: []` spelling gets; an `expand` entry's `fields: []` in that body is refused the same way, at `query.expand.<field>.fields`.
- **`@objectstack/service-automation`.** The `get_record` node's designer form (`configSchema`) declares `fields` with `minItems: 1`, as its `limit` already mirrors the contract's floor, so the form no longer offers an empty list the save refuses.
- **Stored flows.** They are not rewritten, and there is no conversion for `os migrate meta --stored` to apply. A stored flow whose `get_record` node carries `fields: []` is refused by `registerFlow` on every pull of the stored definitions, so it stays unregistered, and its triggers unbound, until the row is edited: name the fields, or remove the key.
- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving `fields` out to read every column.

**Supersedes, in the same release:** `26954-engine-projection-empty.md` says, under **Unchanged**, that the in-process ObjectQL engine does not parse its options with that schema and that `engine.find(object, { fields: [] })` still reads every column, as before. The engine change in this release (`27008-engine-empty-projection-refused.md`) refuses that call at its `find` / `findOne` entry, so that sentence no longer holds: an empty projection is refused by the declarations and by the engine alike, and leaving `fields` out is the one way to read every column.

**The remedy.** Name the fields to read, or remove `fields` to read every field; on an `expand` entry, the same for the related record. There is no mechanical rewrite: dropping the key reads every column, which is the widening the rule refuses, so the choice is the author's.
11 changes: 7 additions & 4 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,13 @@ retired, read them with the operator-only
them before `os migrate apply --allow-destructive` drops the column. No runtime door serves
them.

`fields` names at least one column. An empty list (`fields: []`, `$select=`) is refused with
`400 VALIDATION_ERROR` — on the data API, and by `engine.find()` / `findOne()` for an in-process
caller (a plugin, a hook, a flow), the top-level `fields` and an `expand` entry's alike — rather
than read as "every column". Leave `fields` out to read every column.
`fields` names at least one column. An empty list, or a list with a blank name, is refused
rather than read as "every column", the top-level `fields` and an `expand` entry's alike: by the
query schema itself (`QuerySchema`), so a `POST /data/:object/query` body with `fields: []` answers
`400 VALIDATION_FAILED` naming `query.fields`; on the querystring (`?fields=`, `$select=`) with
`400 VALIDATION_ERROR`; and by `engine.find()` / `findOne()` for an in-process caller (a plugin, a
hook). A flow `get_record` node with `fields: []` is refused when the flow is saved. Leave
`fields` out to read every column.

### Nested / Related Fields

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ const result = ApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object name (e.g. account) |
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. 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. |
| **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) |
Expand Down Expand Up @@ -633,7 +633,7 @@ const result = ApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object name (e.g. account) |
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. 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. |
| **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) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ Enable package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object name (e.g. account) |
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. 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. |
| **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) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to query |
| **filter** | `Record<string, any>` | optional | Field/value pairs to match: each operand — a field's value, an operator's comparand (`{"$ne": null}`), a list comparand's element — is a CEL value envelope `{ dialect: 'cel', source }` evaluated before the query (one that evaluates to null refuses the node), a query placeholder such as `{current_user_id}` or `{today}` the query engine resolves, or a literal; a flow `{…}` template token is refused |
| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) |
| **fields** | `string[]` | optional | Field projection — only these fields are read; omit it to read all. An empty list or a blank name is refused |
| **limit** | `integer` | optional | Max records to return — a positive integer; >1 switches to a multi-record query |
| **outputVariable** | `string` | optional | Flow variable the result is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |

Expand Down
Loading
Loading