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
18 changes: 18 additions & 0 deletions .changeset/olive-pugs-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
---

Correct the `$search` expansion description: it compiles to `$icontains`, not `$contains`

The `DriverCapabilities.fullTextSearch` tombstone prescription in `driver.zod.ts` said
`$search` is compiled into "an `$or` of `$contains` predicates". It has compiled to
`$icontains` since #7641 — textual search is case-insensitive by ruling, and `$contains`
is contractually case-**sensitive** (#4706 Q2 = A). The prescription now says so, and the
four generated driver reference rows it feeds regenerate with it.

The same false sentence is corrected on the three hand-written pages that carried it
(`protocol/objectql/query-syntax.mdx`, `data-modeling/queries.mdx`,
`data-modeling/schema-design.mdx`), including a callout that told readers the
case-insensitivity question "remains a separate open question" when #7641 closed it.

Documentation only — no accept/reject, emitted-shape or runtime behaviour change.
4 changes: 2 additions & 2 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ The structured form is equivalent and carries the experimental knobs below — `

<Callout type="warn">
Only `query` and `fields` are implemented. The engine expands `search` into a driver-agnostic
`$and`-of-`$or`-of-`$contains` filter (ADR-0061) — `fuzzy`, `operator`, `boost`, `minScore`,
`$and`-of-`$or`-of-`$icontains` filter (ADR-0061) — `fuzzy`, `operator`, `boost`, `minScore`,
`language`, and `highlight` are accepted by `QuerySchema` but read nowhere in
`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect; since #4286 their
`.describe()` markers say so. Multiple search terms are always AND-ed regardless of
Expand Down Expand Up @@ -530,7 +530,7 @@ The answer is a **mirror field**: copy the related record's title into a stored
field on this object and declare *that* field searchable. A task list searched by
project name gets a `project_name` text column on `task`, maintained on write and
listed in `task.searchableFields`. It has to be a **stored** field — a `formula`
field is virtual, so no driver has a column for `$contains` to scan. Cross-object
field is virtual, so no driver has a column for `$icontains` to scan. Cross-object
search paths are rejected by design, not pending. Full recipe (the hooks that keep
the mirror fresh, and the lint wording) in [Schema Design → Searching by a related
record's title](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value).
Expand Down
8 changes: 4 additions & 4 deletions content/docs/data-modeling/schema-design.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,15 @@ To let users search a task list by project name:
}
```

`?search=apollo` now expands to `name $contains 'apollo' OR project_name
$contains 'apollo'` — one single-table scan, on every driver, with no traversal.
`?search=apollo` now expands to `name $icontains 'apollo' OR project_name
$icontains 'apollo'` — one single-table scan, on every driver, with no traversal.
If the object declares no `searchableFields` at all, a `text` mirror is picked up
by the auto-default anyway; declare the set explicitly when you want to pin it.

<Callout type="warn">
**The mirror must be a stored field — a `formula` field does not work.** A
`formula` field is *virtual*: no driver materializes a column for it, so a
`$contains` predicate against one has nothing to scan (the SQL driver would emit
`formula` field is *virtual*: no driver materializes a column for it, so an
`$icontains` predicate against one has nothing to scan (the SQL driver would emit
a `WHERE` over a column that does not exist). A CEL formula also only reads this
record's own fields (`record.<field>`), so it cannot fetch the related title in
the first place.
Expand Down
28 changes: 17 additions & 11 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -969,9 +969,9 @@ disclose every other tenant's values for that column.
### Full-Text Search

The `search` parameter does **not** reach a full-text index. The engine expands it into
an `$or` of `$contains` predicates across the object's server-resolved searchable fields
an `$or` of `$icontains` predicates across the object's server-resolved searchable fields
(ADR-0061) and deletes `search` from the AST before the driver sees it — every driver
already runs `$or`/`$contains`, so no driver support is needed (which is also why the
already runs `$or`/`$icontains`, so no driver support is needed (which is also why the
old `supports.fullTextSearch` capability bit had no reader and was retired in 17.0.0,
#4634).

Expand Down Expand Up @@ -1006,13 +1006,15 @@ because the engine-side intersection alone used to drop the unknown name and fal
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep
the tolerant intersection. Multiple whitespace-separated terms are AND-ed and
fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not
from the expansion: it emits a plain `$contains`, which is **case-sensitive** by the
rule in [Case Sensitivity](#case-sensitivity) above. Note what that means for search —
a user typing `acme` does not find `ACME Corp`. Only `select` / `status` option
*labels* are matched case-insensitively by the expansion itself.
from the expansion: it emits `$icontains`, which ignores **ASCII case** by the
rule in [Case Sensitivity](#case-sensitivity) above. So a user typing `acme` does
find `ACME Corp` — search is case-insensitive by ruling
([#7641](https://github.com/objectstack-ai/objectstack/issues/7641)). `select` /
`status` option *labels* are matched case-insensitively too, by a separate
label→value mapping in the expansion rather than by the operator.

<Callout type="info">
**Measured today: every driver matches that rule.** The `$contains` alignment
**Measured today: both operators mean one thing on every driver.** The `$contains` alignment
landed in three steps —
[#6518](https://github.com/objectstack-ai/objectstack/issues/6518) made `SqlDriver`
case-exact per dialect (`GLOB` on the SQLite dialects, `LIKE` unchanged on
Expand All @@ -1024,8 +1026,12 @@ a user typing `acme` does not find `ACME Corp`. Only `select` / `status` option
the divergence this callout warned about is closed, and
`FILTER_TEXT_CASES` holds all five drivers to it. Whether the expansion should emit
`$icontains` instead of `$contains` — i.e. whether search is case-insensitive by
definition — remains a separate open question, and one that can now actually be
answered, since both operators mean one thing everywhere.
definition — is **settled, not open**:
[#7641](https://github.com/objectstack-ai/objectstack/issues/7641) ruled that it should,
and `expandSearchToFilter` has compiled `$search` to `$icontains` ever since.
`$contains` stays case-**sensitive** by contract
([#4706](https://github.com/objectstack-ai/objectstack/issues/4706) Q2 = A) — the two
are different operators, not two spellings of one.
</Callout>
`fuzzy`, `boost`, `operator`, `minScore`, `language`, and `highlight` carry
`[EXPERIMENTAL — not enforced]` markers (#4286): the schema accepts them, the
Expand All @@ -1050,8 +1056,8 @@ The declarative answer is a **mirror field**: copy the related record's title
into a stored field on this object and declare *that* field searchable — a task
list searched by project name carries a `project_name` text column on `task`,
maintained on write and listed in `task.searchableFields`, so the expansion stays
a single-table `$or` of `$contains`. The mirror must be **stored**: a `formula`
field is virtual, no driver materializes a column for it, and a `$contains`
a single-table `$or` of `$icontains`. The mirror must be **stored**: a `formula`
field is virtual, no driver materializes a column for it, and an `$icontains`
against one has nothing to scan. Cross-object search paths are rejected by
design, not pending — see [Schema Design → Searching by a related record's
title](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-nosql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ const result = AggregationPipelineSchema.parse(data);
| **querySubqueries** | `never` | optional | [REMOVED] `DriverCapabilities.querySubqueries` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. ObjectQL never plans subqueries through a driver, so there was nothing for the bit to switch on. Delete the key. |
| **queryCTE** | `never` | optional | [REMOVED] `DriverCapabilities.queryCTE` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. ObjectQL never plans Common Table Expressions through a driver, so there was nothing for the bit to switch on. Delete the key. |
| **joins** | `never` | optional | [REMOVED] `DriverCapabilities.joins` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Related data is resolved by the engine (lookup expansion over `find()`), not by driver-side JOIN planning — no code consulted the bit. Delete the key. |
| **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. |
| **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$icontains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. The operator is `$icontains`, NOT `$contains`: textual search is case-insensitive by ruling (#7641). Delete the key. |
| **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. |
| **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. |
| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const result = DataTypeMappingSchema.parse(data);
| **querySubqueries** | `never` | optional | [REMOVED] `DriverCapabilities.querySubqueries` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. ObjectQL never plans subqueries through a driver, so there was nothing for the bit to switch on. Delete the key. |
| **queryCTE** | `never` | optional | [REMOVED] `DriverCapabilities.queryCTE` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. ObjectQL never plans Common Table Expressions through a driver, so there was nothing for the bit to switch on. Delete the key. |
| **joins** | `never` | optional | [REMOVED] `DriverCapabilities.joins` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Related data is resolved by the engine (lookup expansion over `find()`), not by driver-side JOIN planning — no code consulted the bit. Delete the key. |
| **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. |
| **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$icontains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. The operator is `$icontains`, NOT `$contains`: textual search is case-insensitive by ruling (#7641). Delete the key. |
| **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. |
| **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. |
| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. |
Expand Down
Loading
Loading