From fa5693d710148d3c38cfb2244a53b4eb27a30081 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:25:27 +0000 Subject: [PATCH 1/2] fix(spec)!: the published searchFields declarations refuse a value naming no field QueryTransportParamsSchema.$searchFields, QuerySchema.searchFields and FullTextSearchSchema.fields take the accept set the protocol door enforces: a blank or comma-only string and an empty list are refused, with one shared sentence in each description. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk --- packages/spec/src/data/data-engine.zod.ts | 11 +- packages/spec/src/data/query.zod.ts | 14 +- .../data/search-fields-names-a-field.test.ts | 150 ++++++++++++++++++ .../src/data/search-fields-names-a-field.ts | 42 +++++ 4 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/spec/src/data/search-fields-names-a-field.test.ts create mode 100644 packages/spec/src/data/search-fields-names-a-field.ts diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index f468673a2ef..16ad24463fe 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -6,6 +6,7 @@ import type { FilterArray, FilterCondition } from './filter.zod'; import { SortNodeSchema, QuerySchema, FullTextSearchSchema, FieldNodeSchema, AggregationNodeSchema, GroupByNodeSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED } from './query.zod'; import type { QueryAST, QueryInput } from './query.zod'; import { PROJECTION_NAMES_NO_FIELD, transportProjectionArray } from './projection-names-a-field'; +import { SEARCH_FIELDS_ACCEPT_SET, searchFieldsArray, searchFieldsString } from './search-fields-names-a-field'; import { retiredKey } from '../shared/retired-key'; import { ExecutionContextSchema } from '../kernel/execution-context.zod'; @@ -958,9 +959,13 @@ export const QueryTransportParamsSchema = lazySchema(() => z.object({ /** OData spelling of `search`. */ $search: z.union([z.string(), FullTextSearchSchema]).nullable().optional() .describe('Transport spelling of `search` (OData `$search`)'), - /** OData spelling of `searchFields`. */ - $searchFields: z.union([z.string(), z.array(z.string())]).nullable().optional() - .describe('Transport spelling of `searchFields` (OData `$searchFields`)'), + /** + * OData spelling of `searchFields`. [#27683] Declares the override the door + * accepts (`./search-fields-names-a-field.ts`): a value naming no field is + * refused, as the canonical `QuerySchema.searchFields` refuses it. + */ + $searchFields: z.union([searchFieldsString(), searchFieldsArray()]).nullable().optional() + .describe(`Transport spelling of \`searchFields\` (OData \`$searchFields\`) — ${SEARCH_FIELDS_ACCEPT_SET}`), /** OData spelling of the response total-count flag. */ $count: TransportCountValueSchema.nullable().optional() .describe('Transport spelling of the response total-count flag (OData `$count`)'), diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index fd0c6c81270..71a0e529697 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -7,6 +7,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; import { transportProjectionArray } from './projection-names-a-field'; +import { SEARCH_FIELDS_ACCEPT_SET, searchFieldsArray } from './search-fields-names-a-field'; /** * QueryAST — Abstract Syntax Tree for data queries. @@ -449,7 +450,10 @@ const QUERY_WINDOW_FUNCTIONS_REMOVED = */ export const FullTextSearchSchema = lazySchema(() => z.object({ query: z.string().describe('Search query text'), - fields: z.array(z.string()).optional().describe('Fields to search in (if not specified, searches all text fields)'), + // [#27683] The search-field override's accept set (`./search-fields-names-a-field.ts`): + // the door refuses a list naming no field rather than searching the defaults. + fields: searchFieldsArray().optional() + .describe(`Fields to search in (if not specified, searches all text fields) — ${SEARCH_FIELDS_ACCEPT_SET}`), fuzzy: z.boolean().optional().default(false).describe('[EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag.'), operator: z.enum(['and', 'or']).optional().default('or').describe('[EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag.'), boost: z.record(z.string(), z.number()).optional().describe('[EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results.'), @@ -545,9 +549,13 @@ const BaseQuerySchema = z.object({ * P1 ("formalize `$searchFields`"); the enforcement site is * `objectql/src/search-filter.ts` `resolveSearchFields`, proven at the * HTTP level by `showcase-search.dogfood.test.ts`. + * + * [#27683] An override naming no field is refused, as the door refuses it + * (`./search-fields-names-a-field.ts`): read as absent it would search the + * object's default fields, a widening of what the caller asked for. */ - searchFields: z.array(z.string()).optional() - .describe('Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1)'), + searchFields: searchFieldsArray().optional() + .describe(`Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — ${SEARCH_FIELDS_ACCEPT_SET}`), /** Order By Clause (Sorting) */ orderBy: z.array(SortNodeSchema).optional().describe('Sorting instructions (ORDER BY)'), diff --git a/packages/spec/src/data/search-fields-names-a-field.test.ts b/packages/spec/src/data/search-fields-names-a-field.test.ts new file mode 100644 index 00000000000..795064e22e2 --- /dev/null +++ b/packages/spec/src/data/search-fields-names-a-field.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27683] The published search-field override declarations — + * `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and + * `FullTextSearchSchema.fields` — refuse a value naming no field, the set the + * protocol door refuses with a 400 since #27484: a blank string, only commas + * and spaces, an empty list, and an empty structured `search.fields`. They used + * to admit all of it, so the published JSON Schema admitted what the door + * refuses. The door-level parity pin (the same probe values against both + * doors) is rest's `data-empty-search-fields-door.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { QuerySchema, FullTextSearchSchema } from './query.zod'; +import { + EngineAggregateOptionsSchema, + EngineCountOptionsSchema, + EngineQueryOptionsSchema, + QueryTransportParamsSchema, + QueryWithTransportSchema, +} from './data-engine.zod'; +import { FindDataRequestSchema } from '../api/protocol.zod'; +import { SEARCH_FIELDS_ACCEPT_SET, SEARCH_FIELDS_NAMES_NO_FIELD } from './search-fields-names-a-field'; + +type Schema = { safeParse(v: unknown): { success: boolean; data?: any; error?: { issues: Array<{ message: string; path: PropertyKey[] }> } } }; + +/** The first issue a schema raises for `input`, or a failed expectation when it parses. */ +function firstIssue(schema: Schema, input: unknown) { + const parsed = schema.safeParse(input); + expect(parsed.success, `refuses ${JSON.stringify(input)}`).toBe(false); + return parsed.error!.issues[0]!; +} + +/** Every spelling of "names no field" the door refuses, string arm first. */ +const REFUSED_STRINGS: ReadonlyArray = [ + ['a blank string', ''], + ['whitespace only', ' '], + ['a comma', ','], + ['commas and spaces only', ' , '], +]; +const REFUSED_LIST: readonly [string, string[]] = ['an empty list', []]; + +const KEPT_STRINGS: ReadonlyArray = [ + ['one name', 'name'], + ['a spaced comma list', ' name , email '], +]; +const KEPT_LISTS: ReadonlyArray = [ + ['one name', ['name']], + ['two names', ['name', 'email']], + // Not "naming no field" to the door either: it is a name the door looks up, + // and refuses as an unknown field when the object is known. + ['a blank member', ['']], +]; + +describe('QueryTransportParamsSchema.$searchFields', () => { + it.each([...REFUSED_STRINGS, REFUSED_LIST])('refuses %s with the accept-set sentence', (_name, value) => { + const issue = firstIssue(QueryTransportParamsSchema, { $searchFields: value }); + expect(issue.path).toEqual(['$searchFields']); + expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD); + }); + + it.each([...KEPT_STRINGS, ...KEPT_LISTS])('keeps %s unchanged', (_name, value) => { + const parsed = QueryTransportParamsSchema.safeParse({ $searchFields: value }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.$searchFields).toEqual(value); + }); + + it('still reads an explicit null as a withdrawal', () => + expect(QueryWithTransportSchema.parse({ object: 'a', $searchFields: null })).toEqual({ object: 'a' })); + + it('lowers a kept comma list onto `searchFields` as the door does', () => + expect(QueryWithTransportSchema.parse({ object: 'a', $searchFields: ' name , email ' })) + .toEqual({ object: 'a', searchFields: ['name', 'email'] })); +}); + +describe('QuerySchema.searchFields', () => { + it('refuses an empty list with the accept-set sentence', () => { + const issue = firstIssue(QuerySchema, { object: 'a', searchFields: [] }); + expect(issue.path).toEqual(['searchFields']); + expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD); + }); + + it.each(KEPT_LISTS)('keeps %s unchanged', (_name, value) => { + const parsed = QuerySchema.safeParse({ object: 'a', searchFields: value }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.searchFields).toEqual(value); + }); +}); + +describe('FullTextSearchSchema.fields', () => { + it('refuses an empty list with the accept-set sentence', () => { + const issue = firstIssue(FullTextSearchSchema, { query: 'x', fields: [] }); + expect(issue.path).toEqual(['fields']); + expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD); + }); + + it.each(KEPT_LISTS)('keeps %s unchanged', (_name, value) => { + const parsed = FullTextSearchSchema.safeParse({ query: 'x', fields: value }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.fields).toEqual(value); + }); + + it('keeps a structured search with no `fields` at all', () => + expect(FullTextSearchSchema.safeParse({ query: 'x' }).success).toBe(true)); +}); + +describe('the transport-aware query slot and the POST /data/:object/query body', () => { + /** Each body-side spelling of the override, as a query bag carrying `value`. */ + const SPELLINGS: ReadonlyArray Record, boolean]> = [ + ['$searchFields', (value) => ({ $searchFields: value }), true], + ['searchFields', (value) => ({ searchFields: value }), false], + ['search.fields', (value) => ({ search: { query: 'x', fields: value } }), false], + ['$search.fields', (value) => ({ $search: { query: 'x', fields: value } }), false], + ]; + + for (const [spelling, bag, takesStrings] of SPELLINGS) { + const refused = takesStrings ? [...REFUSED_STRINGS, REFUSED_LIST] : [REFUSED_LIST]; + it.each(refused)(`refuses \`${spelling}\` as %s, in both slots`, (_name, value) => { + expect(QueryWithTransportSchema.safeParse({ object: 'a', ...bag(value) }).success).toBe(false); + expect(FindDataRequestSchema.safeParse({ object: 'a', query: { object: 'a', ...bag(value) } }).success).toBe(false); + }); + + it(`keeps \`${spelling}\` naming a field`, () => { + expect(QueryWithTransportSchema.safeParse({ object: 'a', ...bag(['name']) }).success).toBe(true); + expect(FindDataRequestSchema.safeParse({ object: 'a', query: { object: 'a', ...bag(['name']) } }).success).toBe(true); + }); + } +}); + +describe('the declarations state the accept set they enforce', () => { + const members: ReadonlyArray = [ + ['QueryTransportParamsSchema.$searchFields', QueryTransportParamsSchema.shape.$searchFields], + ['QuerySchema.searchFields', (QuerySchema as unknown as { shape: Record }).shape.searchFields], + ['FullTextSearchSchema.fields', FullTextSearchSchema.shape.fields], + ]; + it.each(members)('%s carries the shared sentence', (_name, member) => + expect(member?.description).toContain(SEARCH_FIELDS_ACCEPT_SET)); +}); + +describe('stage boundary: the engine-side searchFields declarations are the next stage', () => { + // The in-process engine still reads an empty override as absent, and its own + // declarations still say so; the engine stage moves them together. + it.each([ + ['EngineQueryOptionsSchema', EngineQueryOptionsSchema], + ['EngineAggregateOptionsSchema', EngineAggregateOptionsSchema], + ['EngineCountOptionsSchema', EngineCountOptionsSchema], + ] as const)('%s.searchFields still admits an empty list', (_name, schema) => + expect((schema as Schema).safeParse({ searchFields: [] }).success).toBe(true)); +}); diff --git a/packages/spec/src/data/search-fields-names-a-field.ts b/packages/spec/src/data/search-fields-names-a-field.ts new file mode 100644 index 00000000000..4cb5ab59095 --- /dev/null +++ b/packages/spec/src/data/search-fields-names-a-field.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * The accept set of a search-field override — `searchFields`, its transport + * spelling `$searchFields`, and the structured search's `fields` — as the + * protocol door judges it (`assertSearchFieldsAreSearchable` in + * `@objectstack/metadata-protocol`, reached from `findData` and so from every + * Data API list, query and export door). A comma string is split, trimmed and + * emptied of blank members there, an array is kept as sent, and a value naming + * NO field is refused with a 400: absent means "search the object's default + * search fields", so an empty override 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. The array arm refuses only the empty list, because that is + * all the door refuses as naming no field: a blank member is a name the door + * then looks up, and refuses as an unknown field when the object is known. + * A pattern and a minimum, not refinements, so the published JSON Schema + * states both. + * + * Declared in its own module, outside the `data` barrel, because its readers + * sit on both sides of an import edge: `./query.zod.ts` (`QuerySchema.searchFields` + * and `FullTextSearchSchema.fields`) and `./data-engine.zod.ts` (the transport + * spelling `$searchFields`, which imports `./query.zod.ts`). The projection's + * rule lives beside it in `./projection-names-a-field.ts`. + * + * [#27683] The declarations joined the door, which refuses this set since + * #27484. The engine-side `searchFields` declarations + * (`EngineQueryOptionsSchema` and its aggregate and count siblings) do not + * take it yet: the in-process engine still reads an empty override as absent. + */ +export const SEARCH_FIELDS_NAMES_NO_FIELD = + 'Name at least one field, or leave the key out to search the default search fields'; + +export const SEARCH_FIELDS_ACCEPT_SET = + 'at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused'; + +export const searchFieldsString = () => z.string().regex(/[^\s,]/, SEARCH_FIELDS_NAMES_NO_FIELD); + +export const searchFieldsArray = () => z.array(z.string()).min(1, SEARCH_FIELDS_NAMES_NO_FIELD); From 7b3667ba880cd53cf0dce25b4cd77ef7c70bd33b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:39:01 +0000 Subject: [PATCH 2/2] fix(spec)!: POST /data/:object/query refuses an empty searchFields at the body schema The rest door pin moves its POST rows to the schema door's VALIDATION_FAILED and gains a parity test holding the body schema and the protocol door to one refused set; references regenerated; changeset with its ADR-0087 marker. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk --- .../27683-search-fields-names-a-field.md | 22 ++++++ content/docs/references/api/contract.mdx | 6 +- content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/data-engine.mdx | 26 +++---- content/docs/references/data/query.mdx | 6 +- .../src/data-empty-search-fields-door.test.ts | 73 ++++++++++++++++--- 6 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 .changeset/27683-search-fields-names-a-field.md diff --git a/.changeset/27683-search-fields-names-a-field.md b/.changeset/27683-search-fields-names-a-field.md new file mode 100644 index 00000000000..2c7dfc382ab --- /dev/null +++ b/.changeset/27683-search-fields-names-a-field.md @@ -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 + + + +**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. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 17517625f87..1810e0a84fc 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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) | @@ -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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -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) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 535803ec2cd..c450736f32b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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 \| 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) | @@ -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 \| 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 \| any \| null` | optional | Transport spelling of `where` | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 29b40bcf46a..d6e6d00714a 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -549,7 +549,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -616,7 +616,7 @@ QueryAST-aligned options for DataEngine.count operations | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -744,7 +744,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -760,7 +760,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **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) | @@ -882,7 +882,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **$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`) | +| **$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 \| any \| null` | optional | Transport spelling of `where` | @@ -899,7 +899,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **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) | @@ -918,7 +918,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -939,7 +939,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **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 \| 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) | @@ -960,7 +960,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **$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`) | +| **$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 \| any \| null` | optional | Transport spelling of `where` | @@ -974,7 +974,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -1008,7 +1008,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **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) | @@ -1030,7 +1030,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | **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) | @@ -1049,7 +1049,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index a7572560848..09b6b6c9075 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -84,7 +84,7 @@ const result = AggregationFunction.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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | @@ -132,7 +132,7 @@ Type: `string` | **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) | @@ -151,7 +151,7 @@ Type: `string` | 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` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | diff --git a/packages/rest/src/data-empty-search-fields-door.test.ts b/packages/rest/src/data-empty-search-fields-door.test.ts index 6cb2b06733b..3080aa079be 100644 --- a/packages/rest/src/data-empty-search-fields-door.test.ts +++ b/packages/rest/src/data-empty-search-fields-door.test.ts @@ -19,12 +19,21 @@ * `query-expression-conformance.test.ts`. This file is the door-level half: * the status and `code` each door answers, the spelling it names, that the * driver was never asked, and the controls answering exactly as before. + * + * [#27683] The body's published schema (`FindDataRequestSchema`) now declares + * the override the normalizer enforces, so `POST /data/:object/query` refuses + * an empty `searchFields` / `$searchFields` / `search.fields` where every other + * malformed body is refused: at the schema, `400 VALIDATION_FAILED` naming + * `query.KEY`, as an empty `select` is (`data-empty-projection-count-door.test.ts`). + * The parity test at the end holds the schema door and the protocol door to + * one refused set. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { FindDataRequestSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server'; import { installDefaultOrganization } from './default-organization.testkit.js'; @@ -52,10 +61,17 @@ const REFUSED_GET: ReadonlyArray, strin ['searchFields= blank, unprefixed (the card)', { searchFields: '' }, 'searchFields'], ]; -/** name · the `POST /data/:object/query` body beside `search` · the key the refusal names. */ -const REFUSED_POST: ReadonlyArray, string]> = [ - ['searchFields: []', { search: TERM, searchFields: [] }, 'searchFields'], - ['object-form search.fields: []', { search: { query: TERM, fields: [] } }, 'search'], +/** + * name · the `POST /data/:object/query` body · the `fields[0].field` the body + * schema's refusal names. [#27683] Refused at the schema, `VALIDATION_FAILED`; + * they were the door's `VALIDATION_ERROR` before. + */ +const REFUSED_POST_AT_SCHEMA: ReadonlyArray, string]> = [ + ['searchFields: []', { search: TERM, searchFields: [] }, 'query.searchFields'], + ['$searchFields: ""', { search: TERM, $searchFields: '' }, 'query.$searchFields'], + ['$searchFields: " , "', { search: TERM, $searchFields: ' , ' }, 'query.$searchFields'], + ['$searchFields: []', { search: TERM, $searchFields: [] }, 'query.$searchFields'], + ['object-form search.fields: []', { search: { query: TERM, fields: [] } }, 'query.search.fields'], ]; function createMockServer() { @@ -78,6 +94,7 @@ function makeRes() { describe('[#27484] a searchFields override naming no field on the Data API doors — sqlite', () => { let engine: ObjectQL; let driver: any; + let protocol: ObjectStackProtocolImplementation; const reads = { n: 0 }; let list: (query: Record) => Promise<{ status: number; body: any }>; let query: (body: Record) => Promise<{ status: number; body: any }>; @@ -100,7 +117,7 @@ describe('[#27484] a searchFields override naming no field on the Data API doors driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; } - const protocol = new ObjectStackProtocolImplementation(engine as any); + protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); @@ -147,13 +164,13 @@ describe('[#27484] a searchFields override naming no field on the Data API doors expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); }); - it('POST query: an empty override, either slot, is 400 VALIDATION_ERROR naming the key — no read', async () => { + it("POST query: an empty override, either slot, is the schema door's 400 VALIDATION_FAILED naming the key — no read", async () => { const before = reads.n; - for (const [name, body, param] of REFUSED_POST) { + 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_ERROR'); - expect(res.body.error, name).toContain(`Query parameter '${param}' names no field`); + 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); }); @@ -190,4 +207,42 @@ describe('[#27484] a searchFields override naming no field on the Data API doors expect(exportedNarrowed.status).toBe(200); expect(exportedNarrowed.rows).toEqual([]); }); + + /** + * [#27683] The schema door (`FindDataRequestSchema`, which `POST + * /data/:object/query` parses its body with) and the protocol door + * (`findData`, which every other Data API door reaches without that schema) + * refuse the same set as naming no field, probe for probe. A blank member + * is outside it on both sides: the schema admits it and the door looks it + * up, refusing it as an unknown field. + */ + it('parity: the body schema refuses exactly the values the protocol door refuses as naming no field', async () => { + const STRINGS = ['', ' ', ',', ' , ', ',,', 'label', ' label , note ', 'label,,']; + const LISTS = [[], [''], ['label'], ['label', 'note']]; + const probes: Array<[string, Record]> = [ + ...[...STRINGS, ...LISTS].map((v): [string, Record] => [`$searchFields: ${JSON.stringify(v)}`, { search: TERM, $searchFields: v }]), + ...LISTS.map((v): [string, Record] => [`searchFields: ${JSON.stringify(v)}`, { search: TERM, searchFields: v }]), + ...LISTS.map((v): [string, Record] => [`search.fields: ${JSON.stringify(v)}`, { search: { query: TERM, fields: v } }]), + ]; + const verdicts: string[] = []; + for (const [name, bag] of probes) { + const schemaRefuses = !FindDataRequestSchema.safeParse({ object: OBJECT, query: { object: OBJECT, ...bag } }).success; + // The protocol door's answer: served, or the refusal's code. + let door = 'served'; + try { + await protocol.findData({ object: OBJECT, query: JSON.parse(JSON.stringify(bag)) } as any); + } catch (e: any) { + door = e?.code === 'VALIDATION_ERROR' && /names no field/.test(String(e?.message)) ? 'names no field' : String(e?.code); + } + expect(schemaRefuses, `${name}: schema ${schemaRefuses ? 'refuses' : 'admits'}, door ${door}`).toBe(door === 'names no field'); + verdicts.push(`${name} → ${door}`); + } + // Neither side is vacuous: 8 refused as naming no field, every other probe + // served, except the blank member the door refuses by name. + expect(verdicts.filter((v) => v.endsWith('→ names no field'))).toHaveLength(8); + expect(verdicts.filter((v) => v.endsWith('→ served'))).toHaveLength(9); + expect(verdicts.filter((v) => v.endsWith('→ INVALID_FIELD'))).toEqual([ + '$searchFields: [""] → INVALID_FIELD', 'searchFields: [""] → INVALID_FIELD', 'search.fields: [""] → INVALID_FIELD', + ]); + }); });