diff --git a/.changeset/28059-engine-search-fields-names-a-field.md b/.changeset/28059-engine-search-fields-names-a-field.md new file mode 100644 index 00000000000..ca9b26558e4 --- /dev/null +++ b/.changeset/28059-engine-search-fields-names-a-field.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec)!: `EngineQueryOptionsSchema.searchFields` and its aggregate and count siblings refuse an empty list, as `FullTextSearchSchema.fields` does (#28059) + +Clause-②: no (narrowing) — the engine-side declarations of the search-field override refuse `searchFields: []`, which they admitted before. Nothing is widened and no key is added, removed or renamed. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing of published engine option declarations (`@objectstack/spec/data`), read by codegen, the reference docs and any caller that parses an engine option bag with them. + +- **Before.** `EngineQueryOptionsSchema.searchFields`, `EngineAggregateOptionsSchema.searchFields` and `EngineCountOptionsSchema.searchFields` admitted `[]`, while the structured `search.fields` on the same schemas (it reuses `FullTextSearchSchema`) refused it. One schema said two things about one value. +- **Now.** All three take the shared search-field array arm: a list must hold at least one name, refused with the same sentence (`Name at least one field, or leave the key out to search the default search fields`) at `searchFields`. A list with a blank member is still admitted, as the Data API doors admit it and then refuse the blank name as an unknown field. +- **Unchanged.** The Data API doors already refused an empty override, and in the same release the in-process engine refuses it too (#27732): `engine.find` / `findOne` / `count` / `aggregate` called directly with `searchFields: []` or `search.fields: []` answer `400 VALIDATION_ERROR` with the same sentence. The engine does not parse its options with these schemas, so the declarations now state the refusal it enforces. The TypeScript types are unchanged (`string[] | undefined`). +- **Who sees it.** Code that parses an engine option bag with one of these schemas and sent `searchFields: []`, or a generated client that trusted the wider declaration. + +**Supersedes, in the same release.** Two earlier entries no longer hold for these declarations: the `#27683` entry's "Unchanged" bullet saying the engine-side declarations still admit `[]`, and the `#27484` entry's "Not yet narrowed" bullet, whose remaining member `EngineQueryOptionsSchema.searchFields` (with its aggregate and count siblings) refuses `[]` now. A string `''` was never part of these array declarations; it is the transport spelling `$searchFields`, narrowed by `#27683`. + +**The remedy.** Name at least one field in `searchFields`, or leave it out to search the object's default search fields. diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index d6e6d00714a..a2310cd7c5e 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -486,7 +486,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **timezone** | `string` | optional | | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | -| **searchFields** | `string[]` | optional | | +| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused | ### Nested Shape: `EngineAggregateOptions.context` @@ -571,7 +571,7 @@ QueryAST-aligned options for DataEngine.count operations | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | -| **searchFields** | `string[]` | optional | | +| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused | ### Nested Shape: `EngineCountOptions.context` @@ -697,7 +697,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **top** | `number` | optional | | | **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | -| **searchFields** | `string[]` | optional | | +| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused | | **expand** | `Record` | optional | | | **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 16ad24463fe..2e864dad2a4 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -185,9 +185,13 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema * object's declared/derived searchable set (ADR-0061). Read by the engine's * `$search` → cross-field `$or` expansion and sent by the protocol layer * ever since; it was enforced but undeclared until #4371 (option 2) made - * the engine reject undeclared option keys. + * the engine reject undeclared option keys. [#28059] It takes the accept + * set of `QuerySchema.searchFields` and `FullTextSearchSchema.fields` + * (`./search-fields-names-a-field.ts`): an empty list names no field and is + * refused, so `searchFields: []` and `search: { fields: [] }` get one answer. */ - searchFields: z.array(z.string()).optional(), + searchFields: searchFieldsArray().optional() + .describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`), /** * Recursive relation loading map (expand). @@ -509,9 +513,11 @@ export const EngineAggregateOptionsSchema = lazySchema(() => BaseEngineOptionsSc /** * Fields the `search` expansion may match against — declared exactly as * {@link EngineQueryOptionsSchema}'s `searchFields`: intersected with the - * object's declared/derived searchable set (ADR-0061), never widened. + * object's declared/derived searchable set (ADR-0061), never widened, and + * an empty list refused (#28059). */ - searchFields: z.array(z.string()).optional(), + searchFields: searchFieldsArray().optional() + .describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`), }).describe('QueryAST-aligned options for DataEngine.aggregate operations')); // -------------------------------------------------------------------------- @@ -554,9 +560,11 @@ export const EngineCountOptionsSchema = lazySchema(() => BaseEngineOptionsSchema /** * Fields the `search` expansion may match against — declared exactly as * {@link EngineQueryOptionsSchema}'s `searchFields`: intersected with the - * object's declared/derived searchable set (ADR-0061), never widened. + * object's declared/derived searchable set (ADR-0061), never widened, and + * an empty list refused (#28059). */ - searchFields: z.array(z.string()).optional(), + searchFields: searchFieldsArray().optional() + .describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`), }).describe('QueryAST-aligned options for DataEngine.count operations')); // -------------------------------------------------------------------------- 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 index 795064e22e2..51571f3d8ef 100644 --- a/packages/spec/src/data/search-fields-names-a-field.test.ts +++ b/packages/spec/src/data/search-fields-names-a-field.test.ts @@ -3,7 +3,9 @@ /** * [#27683] The published search-field override declarations — * `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and - * `FullTextSearchSchema.fields` — refuse a value naming no field, the set the + * `FullTextSearchSchema.fields`, and since #28059 the engine-side + * `searchFields` (`EngineQueryOptionsSchema` and its aggregate and count + * siblings) — 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 @@ -133,18 +135,46 @@ describe('the declarations state the accept set they enforce', () => { ['QueryTransportParamsSchema.$searchFields', QueryTransportParamsSchema.shape.$searchFields], ['QuerySchema.searchFields', (QuerySchema as unknown as { shape: Record }).shape.searchFields], ['FullTextSearchSchema.fields', FullTextSearchSchema.shape.fields], + ['EngineQueryOptionsSchema.searchFields', EngineQueryOptionsSchema.shape.searchFields], + ['EngineAggregateOptionsSchema.searchFields', EngineAggregateOptionsSchema.shape.searchFields], + ['EngineCountOptionsSchema.searchFields', EngineCountOptionsSchema.shape.searchFields], ]; 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([ +/** + * [#28059] The engine-side declarations take the same array arm, so the + * declaration never admits what `FullTextSearchSchema.fields` (the structured + * `search` they share) and the engine refuse. + */ +describe('the engine-side searchFields declarations', () => { + const ENGINE_SCHEMAS = [ ['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)); + ] as const; + + it.each(ENGINE_SCHEMAS)('%s.searchFields refuses an empty list with the accept-set sentence', (_name, schema) => { + const issue = firstIssue(schema as Schema, { searchFields: [] }); + expect(issue.path).toEqual(['searchFields']); + expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD); + }); + + it.each(ENGINE_SCHEMAS)('%s.searchFields answers `[]` as its structured `search.fields` does', (_name, schema) => { + const bare = firstIssue(schema as Schema, { searchFields: [] }); + const structured = firstIssue(schema as Schema, { search: { query: 'x', fields: [] } }); + expect(bare.message).toBe(structured.message); + }); + + for (const [name, schema] of ENGINE_SCHEMAS) { + it.each(KEPT_LISTS)(`${name}.searchFields keeps %s unchanged`, (_label, value) => { + const parsed = (schema as Schema).safeParse({ search: 'x', searchFields: value }); + expect(parsed.success).toBe(true); + expect(parsed.data?.searchFields).toEqual(value); + }); + + it(`${name} still admits no searchFields at all`, () => + expect((schema as Schema).safeParse({ search: 'x' }).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 index 4cb5ab59095..02b9b00ddf2 100644 --- a/packages/spec/src/data/search-fields-names-a-field.ts +++ b/packages/spec/src/data/search-fields-names-a-field.ts @@ -27,9 +27,9 @@ import { z } from 'zod'; * 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. + * #27484. [#28059] The engine-side `searchFields` declarations + * (`EngineQueryOptionsSchema` and its aggregate and count siblings) take the + * array arm too, so no published declaration of the override admits `[]`. */ export const SEARCH_FIELDS_NAMES_NO_FIELD = 'Name at least one field, or leave the key out to search the default search fields';