diff --git a/.changeset/29546-declared-search-column-no-clause-comment.md b/.changeset/29546-declared-search-column-no-clause-comment.md new file mode 100644 index 00000000000..03ecc8eafff --- /dev/null +++ b/.changeset/29546-declared-search-column-no-clause-comment.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the `resolveSearchFieldResolution` docblock says a declared search column whose type can never hold a string is admitted to the set but contributes no clause + +Clause-②: no +Metadata-protocol: none + +The declared-branch text on `resolveSearchFieldResolution` (`@objectstack/spec`, shipped in its `.d.ts`) still said a `json` column declared in `searchableFields` is "a scan that CAN match". The `$search` expansion in `@objectstack/objectql` gives such a column no clause: its declared type can never hold a string, which is the text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`). The docblock now says so: + +- a declared `json` column (or any numeric, boolean, date or time, or structured-JSON column) is still admitted to the set, but contributes no clause; +- a set made only of such columns answers no rows, never an error; +- a declared single-valued `lookup` column still matches by substring over the stored foreign key; +- only an entry that names no column at all is dropped, as before. + +Comment-only: no behaviour, schema or accepted value moves. diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index ffd2c22cf12..874df33a8b7 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -309,13 +309,18 @@ describe('a virtual field declared in searchableFields — not admitted, since n it('CONTROL — the declared branch still bypasses the auto-default exclusions', () => { // The dividing line is STORAGE, not search quality. `lookup` and `json` are - // auto-EXCLUDED types, and declaring them is still the author's choice: the - // engine runs a `$contains` over the stored foreign key / the stored JSON - // text. Narrow and rarely useful, but a scan that CAN match — so it must not - // be swept up by the virtual filter. This control is what keeps #6674 from - // silently becoming "the declared branch is type-filtered after all", which - // would reject metadata the runtime accepts (ADR-0072 D1, and the #4830 - // changeset's explicit carve-out). + // auto-EXCLUDED types, and declaring them is still the author's choice, so + // both are ADMITTED here. What each then contributes is the expansion's call + // (`expandSearchToFilter`, `@objectstack/objectql`): a single-valued + // `lookup` becomes an `$icontains` over the stored foreign key, a narrow scan + // that can match; + // [#29394] `json` can never hold a string (the text-operator door's + // `door-refusal` verdict), so it contributes NO clause, and a set made only + // of such columns answers no rows. Neither is swept up by the virtual + // filter, which drops only an entry with no column at all. This control is + // what keeps #6674 from silently becoming "the declared branch is + // type-filtered after all", which would reject metadata the runtime accepts + // (ADR-0072 D1, and the #4830 changeset's explicit carve-out). expect( resolveSearchFieldResolution({ fields, diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index d45c69a63f3..cf5226ff43a 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -89,15 +89,15 @@ export const SEARCH_AUTO_EXCLUDED_TYPES: ReadonlySet = new Set([ ]); /** * [#6674] Field types with NO STORED COLUMN — the value is computed on read, so - * a `$contains` scan has nothing to look at. Exactly `formula` today. + * a substring scan has nothing to look at. Exactly `formula` today. * * This is a STORAGE fact, not a taste judgment, and that is what separates it * from the three vocabularies above. `SEARCH_AUTO_EXCLUDED_TYPES` says "the * auto-default does not GUESS this type" — an author may still declare a `json` - * or `lookup` column in `searchableFields` and the engine executes it (a - * `$contains` over the stored JSON text, over the stored foreign key), which is - * a choice that can match. A `formula` entry cannot: the drivers materialize no - * column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`, + * or `lookup` column in `searchableFields` and it is admitted, because a stored + * column exists (what each then contributes to a search is set out on + * {@link resolveSearchFieldResolution}). A `formula` entry is not: the drivers + * materialize no column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`, * `driver-turso/src/remote-transport.ts` "Virtual — no column"), and the engine * excludes it from the projection it sends down (`objectql/src/engine.ts` * `buildFormulaPlan`) because the driver would fail on the column name. @@ -279,15 +279,26 @@ function autoDefaultFields(fields: Record, displayField * 2. [#6674] SCANNABILITY — an entry naming a VIRTUAL field * ({@link isVirtualSearchField}) is dropped. The entry names a real field, * passes existence, and can still never match, because the field has no - * stored column for `$contains` to scan. + * stored column for a substring scan to read. * * Filter 2 is deliberately NOT a type allow-list on the declared branch. The * declaration remains the author's explicit choice and still bypasses the - * auto-default's exclusions: a `json` or `lookup` column declared here is - * executed by the engine as a `$contains` over the stored JSON text / stored - * foreign key — narrow, rarely useful, but a scan that CAN match. Only "there - * is no column at all" is refused, which is why the vocabulary is a storage - * fact rather than a search-quality one. + * auto-default's exclusions, so a `lookup` or `json` column declared here is + * ADMITTED to the set. What an admitted column then contributes to a search is + * the expansion's call (`expandSearchToFilter`, `@objectstack/objectql`), not + * this function's: + * + * - a single-valued `lookup` column becomes an `$icontains` over the stored + * foreign key — narrow, rarely useful, but a scan that can match; + * - [#29394] a column whose DECLARED type can never hold a string — the + * text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`, + * `./filter-text-operator-declared-type`: numeric, boolean, calendar-date, + * instant, clock-time and structured-JSON types such as `json`) — + * contributes NO clause. A set made only of such columns answers no rows: + * never an error, never every row. + * + * Only "there is no column at all" is refused, which is why the vocabulary is + * a storage fact rather than a search-quality one. * * Why the drop is not the whole fix. Dropping alone would only make the * declaration silently NARROWER — the failure mode #4254 exists to close on