diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md new file mode 100644 index 00000000000..c4a3316ccca --- /dev/null +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: the filter doors refuse the three shapes they already declared refused — a scalar operator's array, an `icontains` comparand the conformance table rejects, and an ungated `defaultFilters` (#19514) + +**BREAKING** — three accept-set narrowings on published authoring surfaces, each pulling the door back to what this package already declared somewhere an author's parse never reached. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying any of these shapes keeps loading and keeps rendering exactly as it does today; what changes is that RE-SAVING it is refused, at the key that carries the mistake. The hand-migration prescriptions are registered under protocol major 18 as `view-filter-rule-scalar-operator-array-refused`, `filter-icontains-comparand-refused-at-parse` and `object-grid-default-filters-rule-array`. + +Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differences are the protocol's to close」. + +## 1. A scalar operator carrying an ARRAY is refused + +`ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so from the day the coupling landed until this change the entire scalar class was declared and not judged. + +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches at this release do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): + +| backend | what it does with the lowered `{ tags: ['a'] }` | +|:--|:--| +| the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | +| `driver-memory` | **REFUSES** — the same shape in the same envelope | +| `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | + +How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. + +**None reads the array as the scalar the operator declares.** `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. + +Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. + +## 2. The `icontains` comparands the platform's own table declares refused + +`@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. All five driver packages run both rows in their own suites, and the drivers re-run for this change — `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter` — each refuse both comparands with `INVALID_FILTER` / 400; the formula matcher does not refuse them, it answers `false` for every row. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. + +The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. + +One asymmetry between the two vocabularies, and it is a fact about them rather than an extra rule: a view rule's `value` is optional, so an **absent** comparand is left unjudged there; the `$` dialect has no absent, so an explicit `undefined` in a comparand slot is the refused non-string shape. + +## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration + +The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` pin `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: + +- the **record form** and the **AST tuple array** are lowered and **applied** as declared; +- a **bare string** or a **number** is **dropped** without a word, so the grid sends no filter and lists its rows unfiltered; +- a **list of malformed rules** is **refused** — on the wire with 400 `INVALID_FILTER`, or by the client before any request for the value shapes it judges itself. + +⛔ **Narrowed, not retired.** Refusing the key outright is a removal of an accepted shape and needs its own ruling. The deprecation already stated in the description is unchanged: prefer `filter`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ field: 'tags', operator: 'equals', value: ['a'] }` | `{ field: 'tags', operator: 'equals', value: 'a' }` — or `operator: 'in'` if membership was meant | +| `{ field: 'name', operator: 'icontains', value: '' }` | delete the condition — every value contains the empty substring | +| `{ field: 'name', operator: 'icontains', value: 42 }` | `value: '42'`, if a substring match on those two characters was really meant | +| `{ name: { $icontains: '' } }` | delete the condition | +| `{ name: { $icontains: 42 } }` | `{ name: { $icontains: '42' } }` | +| `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | +| `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | + +What the rewrite changes on the page differs by row, so read them apart: + +- **The scalar-operator array.** How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. +- **The two `icontains` comparands.** At this release each of the five driver packages answers both with a 400 and the formula matcher excludes every row; how earlier releases answered them is NOT MEASURED. +- **`defaultFilters`.** The record form and the AST tuple array were applied as declared in the pinned objectui, so for them the rewrite is a spelling change. A bare string or a number was dropped, so that grid has been listing its rows unfiltered — decide which rows it should show before writing the rule. Beside a non-empty `filter`, deleting `defaultFilters` is the whole migration; beside `filter: []` the grid reads `defaultFilters`, so move its rules onto `filter` rather than deleting them. + +The one to read closest is a one-element array: its two corrected spellings — `value: 'won'` on `equals`, and `operator: 'in'` with `value: ['won']` — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows. + +## Who is affected, measured + +Nothing in this repository authored any of the three shapes. Two fixtures pinned the old accept set and were re-judged rather than rewritten by rote: one asserted that a scalar-operator array parses (it pinned the reading paragraph 1 reverses), and one parsed an ObjectQL AST tuple array on `defaultFilters` to prove the key is HONOURED — that subject survives, on the rule array, with the tuple array's refusal pinned beside it. The full `@objectstack/spec` suite is green, and `check:api-surface` reports no export moved: no symbol is added, removed or renamed by this change. + +Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves, and no new published vocabulary is introduced (the two operator sets the refusals name are the ones already exported, and the two the checks needed for themselves are deliberately module-private). Every one of the three accept sets narrows back to what this package had already declared: a published `.describe()` for the first, a published conformance table for the second, and the sibling key's own declaration for the third. + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7a051b848b3..9219cdc48b9 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -189,7 +189,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementNumberProps.aria` @@ -231,7 +231,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementRecordPickerProps.sort[number]` @@ -327,7 +327,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectCalendarProps.sort[number]` @@ -463,7 +463,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectGanttProps.sort[number]` @@ -523,7 +523,7 @@ Sort field and direction pair | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | +| **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | @@ -563,7 +563,17 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Nested Shape: `ObjectGridProps.defaultFilters[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectGridProps.sort[number]` @@ -637,7 +647,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectKanbanProps.navigation` @@ -707,7 +717,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectMapProps.sort[number]` @@ -791,7 +801,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- @@ -838,7 +848,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectTimelineProps.sort[number]` @@ -914,7 +924,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectTreeProps.tree` @@ -1405,7 +1415,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `RecordRelatedListProps.add` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 9f929e20688..98811cc478c 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -41,7 +41,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementDataSource.sort[number]` @@ -114,7 +114,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `InterfacePageConfig.appearance` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b48d558ff29..374244f857f 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -317,7 +317,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- @@ -896,7 +896,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ListView.userFilters` @@ -1308,7 +1308,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectListView.selection` @@ -2065,7 +2065,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Allowed Values: `ViewFilterRule.operator` @@ -2515,7 +2515,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- diff --git a/packages/spec/src/data/filter-icontains-parse-door.test.ts b/packages/spec/src/data/filter-icontains-parse-door.test.ts new file mode 100644 index 00000000000..c8f417d472a --- /dev/null +++ b/packages/spec/src/data/filter-icontains-parse-door.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19514] The two comparands `FILTER_TEXT_CASES` declares REFUSED for the + * case-insensitive contains operator are refused AT PARSE, on both authoring + * vocabularies. + * + * `filter-text-conformance.test.ts` proves the table is internally honest and + * `filter-text-comparand.test.ts` proves the published predicate answers it. + * Neither of those reaches a SCHEMA. The platform declares the two refusals as + * data, and each of the five driver packages refuses both comparands at query + * time (the formula matcher excludes every row instead); yet until this round + * both authoring doors admitted the document — `FilterConditionSchema` and + * `ViewFilterRuleSchema` both said `success: true` for an empty comparand and + * for a numeric one. That is the declared-not-enforced shape ADR-0049 closes. + * + * ## What these pins are for, and what would make them worthless + * + * §1 and §2 pin each door in BOTH directions — the newly-refused comparand + * REFUSES, and the comparand that must keep working ACCEPTS. A one-directional + * pin cannot tell a working arm from a door that refuses everything, and an + * inert door passes every ACCEPT-only suite ever written. Each `REFUSE` block + * therefore also carries an ENVELOPE control: a nearby input refused for an + * unrelated reason, proving the door is reachable and that the refusal being + * read is the one under test. + * + * §3 is the pin the derivation is FOR: the doors are driven from + * `FILTER_TEXT_CASES` itself, so a row added to the table arrives here without + * an edit, and a door that stopped following the table goes red naming the row + * it dropped. A transcribed list here would pass while the doors drifted, which + * is exactly the failure the predicate was lifted into this package to end. + */ + +import { describe, expect, it } from 'vitest'; + +import { + FILTER_TEXT_CASES, + type FilterTextCase, + type FilterTextRejectionCase, +} from './filter-text-conformance'; +import { FilterConditionSchema } from './filter.zod'; +import { ViewFilterRuleSchema } from '../ui/view.zod'; + +/** The operator under test, in the two spellings that can ARRIVE. */ +const DOLLAR_SPELLING = '$icontains'; +const INFIX_SPELLING = 'icontains'; + +const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase => + 'expectRejection' in c && c.expectRejection === true; + +/** Every (field, operator, comparand) triple a case's filter carries. */ +function comparands(c: FilterTextCase): Array<{ field: string; operator: string; target: unknown }> { + const out: Array<{ field: string; operator: string; target: unknown }> = []; + for (const [field, ops] of Object.entries(c.filter as Record>)) { + if (typeof ops !== 'object' || ops === null) continue; + for (const [operator, target] of Object.entries(ops)) out.push({ field, operator, target }); + } + return out; +} + +/** The rows the table declares refused FOR THIS OPERATOR, in its own dialect. */ +const TABLE_ROWS = FILTER_TEXT_CASES.filter( + (c): c is FilterTextRejectionCase => + isRejection(c) && comparands(c).some(({ operator }) => operator === DOLLAR_SPELLING), +); + +/** The issue raised at a given path, or a failure naming what was raised instead. */ +function issueAt( + result: { success: boolean; error?: { issues: readonly { path: PropertyKey[]; message: string }[] } }, + path: string, +) { + expect(result.success).toBe(false); + const issues = (result.error?.issues ?? []).filter((i) => i.path.join('.') === path); + expect(issues.map((i) => i.message).join(' | ')).not.toBe(''); + expect(issues).toHaveLength(1); + return issues[0]!; +} + +// --------------------------------------------------------------------------- +// §1 the `$` dialect door — FilterConditionSchema +// --------------------------------------------------------------------------- + +describe('#19514 §1 — the $ dialect refuses what the table declares refused', () => { + it.each([ + ['the EMPTY comparand', '', 'EMPTY STRING'], + ['a NUMBER comparand', 42, 'not a string'], + ['a BOOLEAN comparand', true, 'not a string'], + ['a NULL comparand', null, 'not a string'], + ])('refuses %s', (_label, target, marker) => { + const issue = issueAt( + FilterConditionSchema.safeParse({ name: { [DOLLAR_SPELLING]: target } }), + `name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain(marker); + // The refusal names the spelling that ARRIVED, and the declared code. + expect(issue.message).toContain(`on operator '${DOLLAR_SPELLING}'`); + expect(issue.message).toContain('INVALID_FILTER'); + }); + + it('refuses inside a $and member, at the member own path', () => { + const issue = issueAt( + FilterConditionSchema.safeParse({ $and: [{ name: { [DOLLAR_SPELLING]: '' } }] }), + `$and.0.name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain('EMPTY STRING'); + }); + + it('refuses inside a nested relation, at the nested path', () => { + const issue = issueAt( + FilterConditionSchema.safeParse({ owner: { profile: { name: { [DOLLAR_SPELLING]: 42 } } } }), + `owner.profile.name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain('not a string'); + }); + + it('ENVELOPE CONTROL — this door refuses an unrelated thing, so it is reachable', () => { + // A bare date-range preset in an ordering comparand (#8793). If this reads + // ACCEPT the door is not running at all and every REFUSE above is a phantom. + const issue = issueAt( + FilterConditionSchema.safeParse({ created: { $gt: 'last_7_days' } }), + 'created.$gt', + ); + expect(issue.message).toContain('PRESET'); + // ⚠️ NOT asserted by the absence of `INVALID_FILTER`: the preset refusal + // names that code too, in its own prose about the engine. What separates + // the two refusals is the comparand door's own wording. + expect(issue.message).not.toContain('EMPTY STRING'); + expect(issue.message).not.toContain('not a string'); + }); + + it.each([ + ['a non-empty string comparand', { name: { [DOLLAR_SPELLING]: 'acme' } }], + ['a single space, which IS a substring', { name: { [DOLLAR_SPELLING]: ' ' } }], + ['the case-SENSITIVE sibling, empty', { name: { $contains: '' } }], + ['the case-SENSITIVE sibling, numeric', { name: { $contains: 42 } }], + ['$startsWith, empty — no row in the table', { name: { $startsWith: '' } }], + ['$endsWith, numeric — no row in the table', { name: { $endsWith: 42 } }], + ['$ilike, empty — a different operator family', { name: { $ilike: '' } }], + ['an ordinary equality condition', { name: 'acme' }], + ['the AND identity', { $and: [] }], + ])('NEGATIVE CONTROL — still accepts %s', (_label, filter) => { + expect(FilterConditionSchema.safeParse(filter).success).toBe(true); + }); + + it('⛔ the sibling operators are NOT widened by analogy — the table decides that', () => { + // Stated as its own assertion because "we did not do X" is invisible in a + // suite otherwise, and widening by analogy is the failure this scope note + // exists to prevent. + for (const op of ['$contains', '$startsWith', '$endsWith', '$like', '$ilike']) { + expect(FilterConditionSchema.safeParse({ name: { [op]: '' } }).success, op).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// §2 the infix/view dialect door — ViewFilterRuleSchema +// --------------------------------------------------------------------------- + +describe('#19514 §2 — the view vocabulary refuses the same two comparands', () => { + const rule = (value?: unknown) => + ViewFilterRuleSchema.safeParse( + value === undefined + ? { field: 'name', operator: INFIX_SPELLING } + : { field: 'name', operator: INFIX_SPELLING, value }, + ); + + it.each([ + ['the EMPTY comparand', '', 'EMPTY STRING'], + ['a NUMBER comparand', 42, 'not a string'], + ['a BOOLEAN comparand', true, 'not a string'], + ['a NULL comparand', null, 'not a string'], + ])('refuses %s', (_label, value, marker) => { + const issue = issueAt(rule(value), 'value'); + expect(issue.message).toContain(marker); + // Names the spelling a VIEW author can actually write… + expect(issue.message).toContain(`on operator '${INFIX_SPELLING}'`); + // …and names the wire operator in its own tail, which is the face's job: + // the contract half deliberately does not substitute a `$` key for what + // arrived, because a view author cannot write one. + expect(issue.message).toContain(`"${DOLLAR_SPELLING}"`); + expect(issue.message).toContain('400 INVALID_FILTER'); + }); + + it('carries no internal tracker id — the reader of this string cannot open one', () => { + expect(issueAt(rule(''), 'value').message).not.toMatch(/(? { + // No `field`. If this reads ACCEPT the door is inert. + const result = ViewFilterRuleSchema.safeParse({ operator: INFIX_SPELLING, value: '' }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + expect(result.error.issues.some((i) => i.path.join('.') === 'field')).toBe(true); + }); + + it.each([ + ['a non-empty string comparand', 'acme'], + ['a single space, which IS a substring', ' '], + ['the digit-string an author who meant 42 writes', '42'], + ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { + expect(rule(value).success).toBe(true); + }); + + it('ABSENCE is left unjudged — this vocabulary HAS an absent, and no row is about it', () => { + // `isRefusedTextComparand(undefined)` answers TRUE, and its docblock hands + // the carve-out to callers with an "absent". `value` is optional on every + // view rule, so an omitted comparand is not a comparand the table judged. + expect(rule().success).toBe(true); + }); + + it('an ARRAY is the SHAPE arm defect, reported once and with the shape wording', () => { + // One defect, one issue. The author has to fix the shape first, so that is + // what the message must be about. + const issue = issueAt(rule(['a']), 'value'); + expect(issue.message).toContain('requires a SCALAR value'); + expect(issue.message).not.toContain('EMPTY STRING'); + expect(issue.message).not.toContain('not a string'); + }); + + it('⛔ the sibling operators are NOT widened by analogy here either', () => { + for (const operator of ['contains', 'not_contains', 'starts_with', 'ends_with']) { + const emptyOne = ViewFilterRuleSchema.safeParse({ field: 'name', operator, value: '' }); + const numericOne = ViewFilterRuleSchema.safeParse({ field: 'name', operator, value: 42 }); + expect(emptyOne.success, `${operator} + empty`).toBe(true); + expect(numericOne.success, `${operator} + number`).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// §3 both doors are DRIVEN BY the table, not by a copy of it +// --------------------------------------------------------------------------- + +describe('#19514 §3 — a row added to FILTER_TEXT_CASES reaches both doors', () => { + it('the table still carries rows for this operator, so this suite is not vacuous', () => { + expect(TABLE_ROWS.length).toBeGreaterThan(0); + }); + + it.each(TABLE_ROWS.map((c) => [c.name, c] as const))( + '%s — the $ dialect door refuses the row own filter', + (_name, row) => { + const result = FilterConditionSchema.safeParse(row.filter); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + // The row declares what the refusal must NAME; the door's message is the + // published reason text, which carries the same tokens. + const joined = result.error.issues.map((i) => i.message).join(' '); + for (const token of row.mustMention) expect(joined, token).toContain(token); + expect(joined).toContain(row.code); + }, + ); + + it.each(TABLE_ROWS.map((c) => [c.name, c] as const))( + '%s — the view door refuses the same comparand under the infix spelling', + (_name, row) => { + const { field, target } = comparands(row).find(({ operator }) => operator === DOLLAR_SPELLING)!; + const result = ViewFilterRuleSchema.safeParse({ field, operator: INFIX_SPELLING, value: target }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const joined = result.error.issues.map((i) => i.message).join(' '); + // `mustMention` is spelled in the `$` dialect because the rows' filters + // are. The view face names the `$` twin in its TAIL, so every token the + // row requires is present here too — via a different sentence, on purpose. + for (const token of row.mustMention) expect(joined, token).toContain(token); + expect(joined).toContain(row.code); + }, + ); + + it('the ROWS-verdict cases of the table are all still ACCEPTED by the $ door', () => { + // The other direction of the same derivation: every case the table expects + // to be EVALUATED must pass the authoring door, or the door is refusing the + // platform's own conformance corpus. This is the assertion that goes red if + // a future arm over-reaches. + const rows = FILTER_TEXT_CASES.filter((c) => !isRejection(c)); + expect(rows.length).toBeGreaterThan(0); + for (const c of rows) { + expect(FilterConditionSchema.safeParse(c.filter).success, c.name).toBe(true); + } + }); + + it('the RETIRED-operator rejections are a different door and are NOT answered here', () => { + // `$regex` / `$options` rows are refused because the OPERATOR is retired and + // their comparands are ordinary non-empty strings. The comparand door must + // stay silent about them, or it reports the wrong repair. + const retired = FILTER_TEXT_CASES.filter( + (c) => isRejection(c) && !comparands(c).some(({ operator }) => operator === DOLLAR_SPELLING), + ); + expect(retired.length).toBeGreaterThan(0); + for (const c of retired) { + const result = FilterConditionSchema.safeParse(c.filter); + const joined = result.success ? '' : result.error.issues.map((i) => i.message).join(' '); + expect(joined, c.name).not.toContain('EMPTY STRING'); + expect(joined, c.name).not.toContain('not a string'); + } + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 4dd3e7836ec..c8e69de2093 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -4,6 +4,11 @@ import { z } from 'zod'; import { assertListComparandShapes } from './filter-comparand-shape'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './date-range-presets'; +// [#19514] The text-comparand door this package publishes for the +// case-insensitive contains operator: the discrimination `FILTER_TEXT_CASES`' +// two REJECTION rows are about, and the reason text that answers them. Called +// rather than restated so the `$` dialect and the view vocabulary judge one set. +import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand'; import { OPERATOR_PREFIX_KEY_PATTERN, bannedKeyPattern } from '../shared/refinement-projection'; /** @@ -1527,6 +1532,13 @@ const PRESET_JUDGED_ORDERING_OPS: ReadonlySet = new Set(['$gt', '$gte', /** Bounded-depth guard — mirrors the engine door's own limit. */ const PRESET_WALK_MAX_DEPTH = 32; +/** + * [#19514] The `$`-dialect operator `FILTER_TEXT_CASES` writes comparand + * REJECTION rows for — the spelling the published rows' `mustMention` carries, + * named once so the judged key and the refused key cannot drift apart. + */ +const FILTER_TEXT_COMPARAND_OPERATOR = '$icontains'; + /** Plain filter STRUCTURE, as the engine door classifies it (a `Date` is a comparand). */ function isPlainFilterNode(value: unknown): value is Record { return ( @@ -1538,8 +1550,10 @@ function isPlainFilterNode(value: unknown): value is Record { } /** - * [#8793] Walk one condition node and report every bare preset name sitting in - * an ordering-comparand position. + * Walk one condition node and report every comparand this authoring door + * refuses — the bare date-range PRESET names in an ordering position (#8793), + * and the `$icontains` comparands the platform's own conformance table declares + * refused (#19514). * * Descends non-`$` keys only (operator specs and nested relations): the * `$and` / `$or` / `$not` members are re-parsed by {@link FilterConditionSchema} @@ -1547,8 +1561,40 @@ function isPlainFilterNode(value: unknown): value is Record { * descending here as well would double-report. Unrecognised `$` keys are * skipped WITHOUT descending, the engine door's own conservatism: a hole, not * a false refusal, is the right failure direction. - */ -function checkBarePresetOrderingComparands( + * + * ## Why the second arm rides this walk instead of a second refinement + * + * They ask the same question of the same positions — "what is sitting in this + * operator's comparand slot?" — and they share every boundary the walk draws: + * the depth bound, the `$`-key conservatism, and the rule that combinator + * members are judged by their own pass. A second `superRefine` would duplicate + * the descent, and the two copies would answer differently the first time one + * of those boundaries moved. One walk, one set of boundaries, `n` arms. + * + * ## The `$icontains` arm answers the TABLE, and does not read it here + * + * `FILTER_TEXT_CASES` declares two REJECTION rows for this operator (an EMPTY + * comparand and a NON-STRING one, `code: 'INVALID_FILTER'`, + * `mustMention: ['$icontains']`) and `filter-text-comparand.ts` publishes the + * discrimination those rows are about. This arm CALLS that predicate. A local + * `typeof !== 'string' || === ''` would be a second spelling of a rule the table + * owns, drifting apart the first time a row moved — which is the exact failure + * that lifted the predicate into this package. A row added to the table reaches + * this door with no edit here. + * + * ⚠️ **No absence carve-out, and that is the DIALECT's fact, not an extra row.** + * `isRefusedTextComparand` answers `true` for `undefined` and its docblock hands + * the carve-out to "a vocabulary with an 'absent' the `$` dialect does not + * have". This is that dialect: `{ name: { $icontains: undefined } }` is not an + * omitted comparand, it is `undefined` written into a comparand slot — the + * shape `FILTER_COMPARAND_TYPE_CASES` calls the mongo silent-edit worst cell. + * The view vocabulary, which DOES have an absent, carves it out on its own side. + * + * ⛔ `$contains` / + * `$startsWith` / `$endsWith` / `$like` / `$ilike` keep the + * answer they give today; widening by analogy is the table's decision. + */ +function checkFilterConditionComparands( node: unknown, ctx: z.RefinementCtx, path: (string | number)[] = [], @@ -1564,10 +1610,19 @@ function checkBarePresetOrderingComparands( if (!hasOperatorKeys) { // Nested relation / deep equality — the schema does not re-parse these, // so the walk descends itself. - checkBarePresetOrderingComparands(value, ctx, [...path, key], depth + 1); + checkFilterConditionComparands(value, ctx, [...path, key], depth + 1); continue; } for (const [op, comparand] of Object.entries(value)) { + if (op === FILTER_TEXT_COMPARAND_OPERATOR && isRefusedTextComparand(comparand)) { + ctx.addIssue({ + code: 'custom', + path: [...path, key, op], + message: + `The ${textComparandRefusalReason(key, op, comparand)}.`, + }); + continue; + } if (PRESET_JUDGED_ORDERING_OPS.has(op) && isDateRangePresetName(comparand)) { ctx.addIssue({ code: 'custom', @@ -1709,12 +1764,16 @@ export const FilterConditionSchema: z.ZodType $or: z.array(FilterConditionSchema).optional(), $not: FilterConditionSchema.optional(), }) - // [#8793] Bare date-range preset names are refused from ordering comparands - // at the authoring door — see the § 3.35 block above for the ruling, the - // measured defect, and the ordering-only boundary. The refinement judges - // this node's own field entries; `$and` / `$or` / `$not` members re-enter - // the schema and are judged by their own pass with nested issue paths. - ).superRefine((node, ctx) => checkBarePresetOrderingComparands(node, ctx)) + // Two comparand refusals ride one walk — see its docblock for why. [#8793] + // Bare date-range preset names are refused from ordering comparands (the + // § 3.35 block above carries the ruling, the measured defect and the + // ordering-only boundary); [#19514] `$icontains` comparands are refused on + // the two shapes `FILTER_TEXT_CASES` already declares refused, so the door + // stops admitting the document its own conformance table says will 400. The + // refinement judges this node's own field entries; `$and` / `$or` / `$not` + // members re-enter the schema and are judged by their own pass with nested + // issue paths. + ).superRefine((node, ctx) => checkFilterConditionComparands(node, ctx)) ); // ============================================================================ diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts new file mode 100644 index 00000000000..30bc1af1278 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// One entry for two doors on purpose: the two vocabularies spell one operator +// and the rows being answered are one pair. Splitting it would put half the +// prescription in front of an author who wrote the other spelling. +export const entry: SemanticMigration = { + id: 'filter-icontains-comparand-refused-at-parse', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect ' + + 'key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, ' + + 'dashboard and analytics filters) and the infix spelling icontains on ' + + 'ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the ' + + 'EMPTY STRING or is not a string at all', + replacement: + 'a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate ' + + 'that constrains nothing, so the repair is to DROP the condition rather than to write ' + + 'something in it. A comparand that was a number, boolean or null is written as the ' + + 'string it was meant to match: value 42 becomes value "42" only if a substring match on ' + + 'the two characters is really what was meant, and if it is not, the operator was the ' + + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + + 'and this rule says nothing about it', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' + + 'driver packages run both rows in their own suites, and the drivers re-run for this ' + + 'change (driver-sql on SQLite, driver-memory, driver-mongodb\'s translateFilter) each ' + + 'refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse ' + + 'them, it answers false for every row. Nothing applied them at PARSE on either ' + + 'vocabulary, so the ' + + 'protocol declared the refusal and then admitted the document that would hit it — the ' + + 'declared-not-enforced shape ADR-0049 exists to close. ' + + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + + 'the published predicate isRefusedTextComparand and the published reason text ' + + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + + '$contains, $startsWith, ' + + '$endsWith, $like and $ilike keep the answer they give today, ' + + 'because widening by analogy is the table\'s decision and not a door\'s. ' + + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' + + 'slot is the refused non-string shape — the same reading the comparand-type door ' + + 'already takes of that cell. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. ' + + 'An empty comparand has no lossless replacement (dropping a condition changes which ' + + 'rows a view returns, which is the author\'s decision) and a non-string one has no ' + + 'honest coercion (the platform refuses to answer a query nobody wrote). The read path ' + + 'does not re-validate stored rows, so a stored filter keeps loading; what changes is ' + + 'that RE-SAVING it is refused, with the reason text three shipped consumer faces ' + + 'already show at query time. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored filters for the case-insensitive contains operator in either ' + + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + + 'and the repair is to delete it, and a non-string one means either a missing pair of ' + + 'quotes or the wrong operator. At this release neither comparand returns rows: each of ' + + 'the five driver packages answers both with INVALID_FILTER at query time, and the ' + + 'formula matcher answers false for every row. How earlier releases answered them was ' + + 'NOT measured, so re-check ' + + 'what the view is supposed to show rather than assuming the old result set was correct. ' + + 'Both refusals now arrive at the authoring path.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts new file mode 100644 index 00000000000..75644d1dc92 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The key the one-filter-orthography convergence did not name. Its sibling +// entry element-data-source-and-object-block-filter-rule-array says so in as +// many words — 「object-grid.defaultFilters is a different key and is not named +// by the ruling this entry records」 — so this is the entry that names it. +export const entry: SemanticMigration = { + id: 'object-grid-default-filters-rule-array', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' + + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' + + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' + + 'rules alike', + replacement: + 'the same ViewFilterRule array form its sibling filter takes — ' + + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' + + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' + + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' + + 'operator into the rule, becoming ' + + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' + + '[["owner_id", "=", "{current_user_id}"]] becomes ' + + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' + + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' + + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' + + 'is read only when filter is absent, and its own description has prescribed filter all ' + + 'along', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + + 'in the SAME role as filter — the key\'s own description says it is read only when ' + + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + + 'refusal that sink can give was reachable from a document the protocol had just ' + + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' + + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' + + 'said nothing about what the grid would then do with the value, and in the objectui ' + + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' + + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' + + 'tuple array were lowered and applied as declared; a bare string or a number was ' + + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' + + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' + + 'client before any request for the value shapes it judges itself. ' + + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + + 'own ruling; the deprecation already stated in the description is unchanged and still ' + + 'says to prefer filter. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, ' + + 'for the reason its sibling gives at length: a SemanticMigration converts nothing by ' + + 'its own type, the stored-row pass replays D2 conversions only, and the read path does ' + + 'not re-validate stored rows — so a stored page carrying the record form keeps loading ' + + 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at ' + + 'the defaultFilters path, with the same conversion table the filter door gives, ' + + 'computed from the author\'s own keys. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Every object-grid node in your pages either omits defaultFilters or carries a ' + + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + + 'the conversion table and a worked rewrite built from the keys that were written, and ' + + 'an AST tuple array is refused one level in, at the first element. What to re-check ' + + 'depends on the shape that was there, as the objectui version this release pins treats ' + + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' + + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' + + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' + + 'show before writing the rule that selects them. A list of malformed rules was refused ' + + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' + + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' + + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' + + 'rules onto filter rather than deleting them.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts new file mode 100644 index 00000000000..e90a8b819e8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The scalar half of the coupling #6227 declared and did not judge. Recorded +// here rather than amended onto `view-filter-rule-value-shaped-by-operator` +// because that entry's own prose states the OPPOSITE reading as accepted, and +// an upgrade guide that quietly rewrites a shipped prescription leaves the +// reader who followed it with no trace of why their metadata now fails. +export const entry: SemanticMigration = { + id: 'view-filter-rule-scalar-operator-array-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' + + 'every carrier of ViewFilterRuleSchema', + replacement: + 'one scalar — a string, number, boolean or null. A rule written ' + + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' + + 'of a list becomes operator: "in" with the array unchanged. The list operators (in / ' + + 'not_in) and the range operator (between) are untouched and still take their arrays. ' + + 'The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched ' + + 'too: they take their direction from the operator NAME and their value position is ' + + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + + 'still an omitted value', + reason: + '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' + + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' + + 'The value key\'s own published description has declared this rule since #6227 — ' + + '「every other operator takes a scalar」 — and the refinement that implements the ' + + 'coupling returned early for every operator that is neither a list operator nor ' + + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'not judged. ' + + '⚠️ This REVERSES a reading recorded in the sibling entry ' + + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + + 'array as deliberately accepted because it 「lowers to a deep-equality comparand' + + '」. The backends a lowered view rule reaches ' + + 'at this release do not agree, so each is named rather than generalised. The SQL ' + + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + + 'of the six accepted comparand types the platform declares in ' + + 'ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld ' + + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' + + 'the implicit-equality position). ' + + 'driver-mongodb ANSWERS: its translateFilter passes ' + + 'the array through unchanged and the engine\'s shared comparand doors ' + + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' + + 'stored array equals the value or holds the value as one of its elements, and a row ' + + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + + 'driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'lowered node beside a scalar and an $in control; ' + + 'MySQL and a live Turso server were NOT measured. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + + 'two on equals has no honest single value, and picking the first is a different ' + + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + + 'loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ' + + 'ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + + 'then decide per rule which of the two things it meant: one value, or membership. ' + + 'os validate reports each one by path with the operator, the received shape ' + + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + + 'Either way, re-check what the view is ' + + 'supposed to show rather than assuming the old result set was correct. A one-element ' + + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + + 'set cannot tell you which the metadata meant, and only the author knows.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 213a29821f6..78c7a1f75fe 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8546,6 +8546,70 @@ const step18: MigrationStep = { + 'authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry\'s transition ' + 'and are worth sweeping in the same pass.', }, + // One entry for two doors on purpose: the two vocabularies spell one operator + // and the rows being answered are one pair. Splitting it would put half the + // prescription in front of an author who wrote the other spelling. + { + id: 'filter-icontains-comparand-refused-at-parse', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect ' + + 'key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, ' + + 'dashboard and analytics filters) and the infix spelling icontains on ' + + 'ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the ' + + 'EMPTY STRING or is not a string at all', + replacement: + 'a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate ' + + 'that constrains nothing, so the repair is to DROP the condition rather than to write ' + + 'something in it. A comparand that was a number, boolean or null is written as the ' + + 'string it was meant to match: value 42 becomes value "42" only if a substring match on ' + + 'the two characters is really what was meant, and if it is not, the operator was the ' + + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + + 'and this rule says nothing about it', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' + + 'driver packages run both rows in their own suites, and the drivers re-run for this ' + + 'change (driver-sql on SQLite, driver-memory, driver-mongodb\'s translateFilter) each ' + + 'refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse ' + + 'them, it answers false for every row. Nothing applied them at PARSE on either ' + + 'vocabulary, so the ' + + 'protocol declared the refusal and then admitted the document that would hit it — the ' + + 'declared-not-enforced shape ADR-0049 exists to close. ' + + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + + 'the published predicate isRefusedTextComparand and the published reason text ' + + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + + '$contains, $startsWith, ' + + '$endsWith, $like and $ilike keep the answer they give today, ' + + 'because widening by analogy is the table\'s decision and not a door\'s. ' + + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' + + 'slot is the refused non-string shape — the same reading the comparand-type door ' + + 'already takes of that cell. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. ' + + 'An empty comparand has no lossless replacement (dropping a condition changes which ' + + 'rows a view returns, which is the author\'s decision) and a non-string one has no ' + + 'honest coercion (the platform refuses to answer a query nobody wrote). The read path ' + + 'does not re-validate stored rows, so a stored filter keeps loading; what changes is ' + + 'that RE-SAVING it is refused, with the reason text three shipped consumer faces ' + + 'already show at query time. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored filters for the case-insensitive contains operator in either ' + + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + + 'and the repair is to delete it, and a non-string one means either a missing pair of ' + + 'quotes or the wrong operator. At this release neither comparand returns rows: each of ' + + 'the five driver packages answers both with INVALID_FILTER at query time, and the ' + + 'formula matcher answers false for every row. How earlier releases answered them was ' + + 'NOT measured, so re-check ' + + 'what the view is supposed to show rather than assuming the old result set was correct. ' + + 'Both refusals now arrive at the authoring path.', + }, { id: 'filter-preset-ordering-comparand-refused', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -10120,6 +10184,75 @@ const step18: MigrationStep = { + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes ' + 'objectui#6207.', }, + // The key the one-filter-orthography convergence did not name. Its sibling + // entry element-data-source-and-object-block-filter-rule-array says so in as + // many words — 「object-grid.defaultFilters is a different key and is not named + // by the ruling this entry records」 — so this is the entry that names it. + { + id: 'object-grid-default-filters-rule-array', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' + + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' + + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' + + 'rules alike', + replacement: + 'the same ViewFilterRule array form its sibling filter takes — ' + + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' + + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' + + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' + + 'operator into the rule, becoming ' + + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' + + '[["owner_id", "=", "{current_user_id}"]] becomes ' + + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' + + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' + + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' + + 'is read only when filter is absent, and its own description has prescribed filter all ' + + 'along', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + + 'in the SAME role as filter — the key\'s own description says it is read only when ' + + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + + 'refusal that sink can give was reachable from a document the protocol had just ' + + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' + + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' + + 'said nothing about what the grid would then do with the value, and in the objectui ' + + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' + + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' + + 'tuple array were lowered and applied as declared; a bare string or a number was ' + + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' + + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' + + 'client before any request for the value shapes it judges itself. ' + + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + + 'own ruling; the deprecation already stated in the description is unchanged and still ' + + 'says to prefer filter. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, ' + + 'for the reason its sibling gives at length: a SemanticMigration converts nothing by ' + + 'its own type, the stored-row pass replays D2 conversions only, and the read path does ' + + 'not re-validate stored rows — so a stored page carrying the record form keeps loading ' + + 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at ' + + 'the defaultFilters path, with the same conversion table the filter door gives, ' + + 'computed from the author\'s own keys. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Every object-grid node in your pages either omits defaultFilters or carries a ' + + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + + 'the conversion table and a worked rewrite built from the keys that were written, and ' + + 'an AST tuple array is refused one level in, at the first element. What to re-check ' + + 'depends on the shape that was there, as the objectui version this release pins treats ' + + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' + + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' + + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' + + 'show before writing the rule that selects them. A list of malformed rules was refused ' + + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' + + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' + + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' + + 'rules onto filter rather than deleting them.', + }, { id: 'object-index-unknown-keys-refused', surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', @@ -12931,6 +13064,80 @@ const step18: MigrationStep = { + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' + 'rail.', }, + // The scalar half of the coupling #6227 declared and did not judge. Recorded + // here rather than amended onto `view-filter-rule-value-shaped-by-operator` + // because that entry's own prose states the OPPOSITE reading as accepted, and + // an upgrade guide that quietly rewrites a shipped prescription leaves the + // reader who followed it with no trace of why their metadata now fails. + { + id: 'view-filter-rule-scalar-operator-array-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' + + 'every carrier of ViewFilterRuleSchema', + replacement: + 'one scalar — a string, number, boolean or null. A rule written ' + + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' + + 'of a list becomes operator: "in" with the array unchanged. The list operators (in / ' + + 'not_in) and the range operator (between) are untouched and still take their arrays. ' + + 'The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched ' + + 'too: they take their direction from the operator NAME and their value position is ' + + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + + 'still an omitted value', + reason: + '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' + + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' + + 'The value key\'s own published description has declared this rule since #6227 — ' + + '「every other operator takes a scalar」 — and the refinement that implements the ' + + 'coupling returned early for every operator that is neither a list operator nor ' + + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'not judged. ' + + '⚠️ This REVERSES a reading recorded in the sibling entry ' + + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + + 'array as deliberately accepted because it 「lowers to a deep-equality comparand' + + '」. The backends a lowered view rule reaches ' + + 'at this release do not agree, so each is named rather than generalised. The SQL ' + + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + + 'of the six accepted comparand types the platform declares in ' + + 'ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld ' + + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' + + 'the implicit-equality position). ' + + 'driver-mongodb ANSWERS: its translateFilter passes ' + + 'the array through unchanged and the engine\'s shared comparand doors ' + + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' + + 'stored array equals the value or holds the value as one of its elements, and a row ' + + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + + 'driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'lowered node beside a scalar and an $in control; ' + + 'MySQL and a live Turso server were NOT measured. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + + 'two on equals has no honest single value, and picking the first is a different ' + + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + + 'loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ' + + 'ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + + 'then decide per rule which of the two things it meant: one value, or membership. ' + + 'os validate reports each one by path with the operator, the received shape ' + + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + + 'Either way, re-check what the view is ' + + 'supposed to show rather than assuming the old result set was correct. A one-element ' + + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + + 'set cannot tell you which the metadata meant, and only the author knows.', + }, { id: 'wait-node-event-config-required', surface: diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts new file mode 100644 index 00000000000..8f70f077601 --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19514] `object-grid`'s `defaultFilters` carries the SAME declaration as its + * `filter` sibling. + * + * The key is described as "read only when `filter` is absent" — the same value + * in the same role — and objectui's `ObjectGrid` reads it through the same + * lowering sink. `filter` converged on the `ViewFilterRule` array with the rest + * of its family; this key was not named by that ruling and kept the + * pre-convergence `z.unknown()`, so the block had one declared door and one + * undeclared door onto one seam: a bare string, a number, a MongoDB-style + * record, an ObjectQL AST tuple array and a list of malformed rules all parsed + * here. At the objectui `.objectui-sha` pin `87af769e9a` the lowering + * treats them three ways, per shape: the record form and the tuple array are + * lowered and APPLIED as declared; a bare string or a number is DROPPED, so the + * grid sends no filter and lists its rows unfiltered; and a list of malformed + * rules is REFUSED, on the wire or by the client before any request. + * + * These pins hold the two keys EQUAL rather than transcribing a list of shapes + * — the equality is the rule, and a list would go stale the next time `filter` + * moves. Both directions are pinned at each key: the newly-refused shapes + * REFUSE and the shape that must keep working ACCEPTS, because an ACCEPT-only + * suite passes just as well against a door that has been narrowed to refuse + * everything. + * + * ⛔ Narrowed, NOT retired. Refusing the key outright is a REMOVAL of an + * accepted shape and needs its own ruling; the deprecation already stated in + * the description is unchanged. The pin below says so in the one way a test + * can: a well-formed `defaultFilters` still parses. + */ + +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; + +const GRID = ComponentPropsMap['object-grid']; + +/** A valid grid node with one key under test swapped in. */ +const node = (extra: Record) => ({ objectName: 'account', ...extra }); + +/** The rule array both keys take. */ +const RULES = [{ field: 'status', operator: 'equals', value: 'active' }] as const; + +/** The shapes the `z.unknown()` door used to receipt as valid. */ +const REFUSED_SHAPES: readonly (readonly [string, unknown])[] = [ + ['a bare string', 'status = active'], + ['a number', 42], + ['a boolean', true], + ['the MongoDB-style record form', { status: 'active' }], + ['an operator-object record form', { amount: { $gt: 100 } }], + ['an ObjectQL AST tuple array', [['owner_id', '=', '{current_user_id}']]], + ['an array of malformed rules', [{ nonsense: true }]], +]; + +describe('#19514 — defaultFilters refuses what filter refuses', () => { + it.each(REFUSED_SHAPES.map(([label, value]) => [label, value] as const))( + 'refuses %s at the defaultFilters path', + (_label, value) => { + const result = GRID.safeParse(node({ defaultFilters: value })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const under = result.error.issues.filter((i) => String(i.path[0]) === 'defaultFilters'); + expect(under.length).toBeGreaterThan(0); + }, + ); + + it('the two keys agree shape for shape — the rule, not a transcribed list', () => { + // If `filter` is narrowed or widened again, this is what holds the fallback + // to it. A list of literals here would silently stop tracking. + for (const [label, value] of REFUSED_SHAPES) { + const onFilter = GRID.safeParse(node({ filter: value })).success; + const onFallback = GRID.safeParse(node({ defaultFilters: value })).success; + expect(onFallback, `${label}: defaultFilters`).toBe(onFilter); + } + expect(GRID.safeParse(node({ filter: RULES })).success).toBe(true); + expect(GRID.safeParse(node({ defaultFilters: RULES })).success).toBe(true); + }); + + it('the record form gets the conversion table, naming the key that was written', () => { + const result = GRID.safeParse(node({ defaultFilters: { status: 'active' } })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const issue = result.error.issues.find((i) => i.path.join('.') === 'defaultFilters')!; + expect(issue.message).toContain('`defaultFilters`'); + expect(issue.message).toContain('[{ field, operator, value }, ...]'); + // The rewrite is computed from the author's own keys, as at every sibling door. + expect(issue.message).toContain("[{ field: 'status', operator: 'equals', value: 'active' }]"); + expect(issue.message).toContain('migration `object-grid-default-filters-rule-array`'); + }); + + it.each([ + ['the rule array', RULES], + ['an empty rule array', []], + ['a multi-rule array', [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'stage', operator: 'in', value: ['won', 'lost'] }, + ]], + ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { + expect(GRID.safeParse(node({ defaultFilters: value })).success).toBe(true); + }); + + it('NEGATIVE CONTROL — absence is still absence, and the key is still optional', () => { + const result = GRID.safeParse(node({})); + expect(result.success).toBe(true); + if (!result.success) throw new Error('unreachable'); + expect('defaultFilters' in (result.data as Record)).toBe(false); + }); + + it('NEGATIVE CONTROL — both keys together still parse, as the fallback contract allows', () => { + // The key is read only when `filter` is absent; authoring both has never + // been an error and this narrowing does not make it one. + expect(GRID.safeParse(node({ filter: RULES, defaultFilters: RULES })).success).toBe(true); + }); + + it('ENVELOPE CONTROL — the door refuses an unrelated thing, so it is reachable', () => { + // The card's own discriminating control. If this reads ACCEPT, the block is + // not being parsed at all and every REFUSE above is a phantom. + const result = GRID.safeParse({ objectName: 42 }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + expect(result.error.issues.some((i) => i.path.join('.') === 'objectName')).toBe(true); + }); + + it('the element-level issues of an array author are still their own', () => { + // The fall-through `ruleArrayFilterError` protects: a blanket message at the + // key would overwrite the diagnosis an array author actually needs. + const result = GRID.safeParse(node({ defaultFilters: [{ field: 'status', operator: 'nope', value: 'a' }] })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const under = result.error.issues.filter((i) => i.path.join('.').startsWith('defaultFilters.')); + expect(under.length).toBeGreaterThan(0); + for (const issue of under) expect(issue.message).not.toContain('migration `'); + expect(result.error.issues.filter((i) => i.path.join('.') === 'defaultFilters')).toHaveLength(0); + }); + + it('the rule-level narrowings reach THIS key too — one schema, every carrier', () => { + // The scalar arm and the icontains comparand door are `ViewFilterRuleSchema`'s, + // so they arrive here by construction. Pinned because "the fallback is the + // same declaration" is the whole claim of this file. + expect(GRID.safeParse(node({ + defaultFilters: [{ field: 'tags', operator: 'equals', value: ['a'] }], + })).success).toBe(false); + expect(GRID.safeParse(node({ + defaultFilters: [{ field: 'name', operator: 'icontains', value: '' }], + })).success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 5279794ce70..6dfaabe9adf 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3186,11 +3186,31 @@ describe('#7751 — object-* block props schemas', () => { // ObjectGrid.tsx reads it and lowers it to `$filter` when `filter` is // absent (the routed finding on #7751 verified the read point). Only the // plural `filters` has zero read points. + // + // [#19514] The VALUE this pin carries moved, and the pin's subject did not. + // The key is still honoured and still parses; what changed is that it now + // carries `filter`'s own declaration — the same value in the same role, + // read through the same lowering sink — instead of `z.unknown()`. The AST + // tuple array this pin used to spell is one the pinned objectui grid + // APPLIES (`toFilterNode` passes it through and `parseFilterAST` accepts + // it), so its refusal here is a spelling change for the author, not the + // repair of a filter that failed. Its refusal is pinned below, and in full + // at `component-object-grid-default-filters.pin.test.ts`. + const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; const parsed = ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + defaultFilters: rules, + }); + expect(parsed.defaultFilters).toEqual(rules); + }); + + it('`defaultFilters` refuses the AST tuple array the `z.unknown()` door used to receipt (#19514)', () => { + const r = ComponentPropsMap['object-grid'].safeParse({ objectName: 'showcase_task', defaultFilters: [['status', '=', 'open']], }); - expect(parsed.defaultFilters).toEqual([['status', '=', 'open']]); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => String(i.path[0]) === 'defaultFilters')).toBe(true); }); // #11805 — the grid's legacy single-sort fallback, retired by maintainer diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f770c554d8b..e2671e71cf3 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2714,8 +2714,45 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ }), }).optional() .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), - defaultFilters: z.unknown().optional() - .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), + /** + * [#19514] The legacy base-filter fallback — the SAME value in the SAME role + * as `filter` above, so it carries the same declaration. + * + * Its own description has said "read only when `filter` is absent" since the + * key entered this map (#7751), which is a statement that the two keys hold + * one kind of value: objectui's `ObjectGrid` reads this one through the same + * lowering sink it reads `filter` through, so every refusal that sink can + * give is reachable from a document that passed the protocol. While `filter` + * was narrowed to the rule array and this stayed `z.unknown()`, the block had + * a declared door and an undeclared one onto the same seam — a bare string, a + * number, a MongoDB-style record and an ObjectQL AST tuple array all parsed + * here, and the author's receipt said nothing about what the grid would do + * with them. At the objectui `.objectui-sha` pin `87af769e9a` + * (`ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record + * form and the tuple array are lowered and APPLIED as declared; a bare string + * or a number is DROPPED, so the grid sends no filter and lists its rows + * unfiltered; and a list of malformed rules is REFUSED — on the wire with + * 400 `INVALID_FILTER`, or by the client before any request for the value + * shapes it judges itself. + * + * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this + * could have taken and it is a REMOVAL of an accepted shape, which needs its + * own ruling. The deprecation stated in the description stands + * exactly where it stood — prefer `filter` — and is unchanged by this. + * + * The `{ error }` map is `filter`'s, deliberately: an author who wrote the + * record form here needs the same conversion table, computed from their own + * keys, and a second hand-written sentence at this door is the drift + * `ruleArrayFilterError` exists to prevent. Its `surface` names which key was + * written, because the message's own subject is `filter`. + */ + defaultFilters: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-grid` (you wrote it on the `defaultFilters` fallback, which takes the same form)', + migration: 'object-grid-default-filters-rule-array', + }), + }).optional() + .describe('Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array`'), /** * Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`, * the one sort orthography every DECLARED `sort` door on this platform diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index c8dd7a4b5b8..fb6575afc23 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -7,11 +7,22 @@ * `{ field: 'stage', operator: 'not_in', value: 'won' }` published cleanly and * then answered a named 400 `INVALID_FILTER` at query time (#5869 / PR #6209 * closed that runtime half). The author was gone by then. These pins assert the - * publish-time half now refuses the same three shapes the runtime refuses — + * publish-time half now refuses the same shapes the runtime refuses — * `$in`/`$nin` must be arrays, `$between` must be a 2-array — and, just as * importantly, that it refuses NOTHING ELSE (#5685: a schema stricter than the * runtime is the wrong side). * + * [#19514] The SCALAR arm joined them, and it moved three pins in this file from + * the accepted side to the refused side. It is not an extension of #6227's + * reasoning but a correction of one of its readings: an array on a scalar + * operator was recorded here as accepted because it 「lowers to a + * deep-equality comparand」, and re-measurement + * found the opposite. The pins below carry both + * directions of that arm, and the carve-outs + * (an absent value, the four valueless operators) keep their + * own pins, because the #5685 side of this file is what stops a narrowing from + * running on past the query path. + * * Every rejection pin asserts the issue PATH and the message's leading sentence, * not merely that a throw happened: a bare `.toThrow()` cannot tell "refused for * the right reason at the right key" from "refused because the value union @@ -29,6 +40,34 @@ import { /** Parse helper — the authored object form, exactly as a view carries it. */ const parse = (rule: Record) => ViewFilterRuleSchema.safeParse(rule); +/** + * The operators whose value position is discarded downstream — they take their + * direction from the operator NAME. Transcribed here rather than imported + * because the schema keeps its copy PRIVATE on purpose (publishing it would + * enlarge the package's public face for a question only the refinement asks), + * and the sweep below is what holds the two lists equal: an operator dropped + * from the schema's set reddens the array half of the sweep, and one added to + * it reddens the carve-out pin. + */ +const VALUELESS_OPERATORS = ['is_empty', 'is_not_empty', 'is_null', 'is_not_null'] as const; + +/** + * Every operator this check judges as taking a SCALAR — the canonical + * vocabulary minus the list set, the range set and the four above. DERIVED, so + * an operator added to the enum arrives in both directions of the sweep instead + * of quietly skipping it. + */ +const UNSHAPED_VALUE_OPERATORS: readonly string[] = VIEW_FILTER_OPERATORS.filter( + (operator) => + !( + [ + ...VIEW_FILTER_LIST_VALUE_OPERATORS, + ...VIEW_FILTER_PAIR_VALUE_OPERATORS, + ...VALUELESS_OPERATORS, + ] as readonly string[] + ).includes(operator), +); + /** The single `value`-path issue a shape refusal must produce. */ function valueIssue(result: ReturnType) { expect(result.success).toBe(false); @@ -135,18 +174,26 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the ['not_in + empty array', { field: 'f', operator: 'not_in', value: [] }], ['between + pair', { field: 'f', operator: 'between', value: [1, 2] }], ['between + ISO date pair', { field: 'd', operator: 'between', value: ['2024-01-01', '2024-12-31'] }], - // A scalar operator carrying an array lowers to a deep-equality comparand. - ['equals + array', { field: 'f', operator: 'equals', value: ['a', 'b'] }], - ['not_equals + array', { field: 'f', operator: 'not_equals', value: ['a'] }], - // A string operator carrying a number: no backend refuses it. + // A string operator carrying a number: none of the backends a lowered + // view rule reaches refuses it (`driver-sql` on SQLite and `driver-memory` + // answer it, `driver-mongodb` compiles it). + // (`icontains` is the one exception and it is the TABLE's row, + // not an analogy — see the comparand pins below.) ['contains + number', { field: 'f', operator: 'contains', value: 5 }], ['starts_with + number', { field: 'f', operator: 'starts_with', value: 5 }], // Ordering operators take a scalar of any declared type (#5685 widened these). ['greater_than + ISO string', { field: 'd', operator: 'greater_than', value: '2026-01-01' }], ['before + string', { field: 'd', operator: 'before', value: '2026-01-01' }], ['after + string', { field: 'd', operator: 'after', value: '2026-01-01' }], - // Ordering operators carrying an array are not this check's business either. - ['greater_than + array', { field: 'f', operator: 'greater_than', value: [1, 2] }], + // A scalar operator with NO value: `value` is optional and absence is not a + // shape. The scalar arm must not turn an omitted comparand into a refusal. + ['equals + omitted', { field: 'f', operator: 'equals' }], + ['greater_than + omitted', { field: 'd', operator: 'greater_than' }], + // Every scalar type the declared union carries still parses on a scalar operator. + ['equals + null', { field: 'f', operator: 'equals', value: null }], + ['equals + boolean', { field: 'f', operator: 'equals', value: false }], + ['equals + empty string', { field: 'f', operator: 'equals', value: '' }], + ['equals + zero', { field: 'f', operator: 'equals', value: 0 }], // Alias spellings with a CONFORMING value keep parsing. ['nin alias + array', { field: 'f', operator: 'nin', value: ['a'] }], ['notIn alias + array', { field: 'f', operator: 'notIn', value: ['a'] }], @@ -169,16 +216,79 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the }, ); - it('leaves every operator outside the two shaped vocabularies unjudged', () => { - const shaped = new Set([ - ...VIEW_FILTER_LIST_VALUE_OPERATORS, - ...VIEW_FILTER_PAIR_VALUE_OPERATORS, - ]); - for (const operator of VIEW_FILTER_OPERATORS) { - if (shaped.has(operator)) continue; - // Both a scalar and an array parse for every unshaped operator. - expect(parse({ field: 'f', operator, value: 'x' }).success).toBe(true); - expect(parse({ field: 'f', operator, value: ['x'] }).success).toBe(true); + it('takes a SCALAR on every operator outside the list, range and valueless sets', () => { + // [#19514] The sweep that used to read "both a scalar and an array parse" + // for these operators. The scalar half is unchanged and is the half this + // assertion protects: a narrowing that made the common shape refuse would + // fail here before it reached a consumer. The array half moved and has its + // own two-directional sweep below. + for (const operator of UNSHAPED_VALUE_OPERATORS) { + expect(parse({ field: 'f', operator, value: 'x' }).success, operator).toBe(true); + } + }); +}); + +describe('#19514 — the scalar arm, in both directions', () => { + /** The three pins this arm MOVED, named as such so the reversal is legible. */ + it.each([ + ['equals + array', { field: 'f', operator: 'equals', value: ['a', 'b'] }, 'equals'], + ['not_equals + array', { field: 'f', operator: 'not_equals', value: ['a'] }, 'not_equals'], + ['greater_than + array', { field: 'f', operator: 'greater_than', value: [1, 2] }, 'greater_than'], + ])('refuses %s — recorded as ACCEPTED at #6227, reversed on measurement', (_label, rule, operator) => { + const issue = valueIssue(parse(rule as Record)); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['value']); + expect(issue.message).toContain(`Operator "${operator}" on field "f" requires a SCALAR value.`); + // Not the list wording and not the range wording — three arms, three + // diagnoses, and an author must be able to tell which one fired. + expect(issue.message).not.toContain('requires an ARRAY of values'); + expect(issue.message).not.toContain('requires a [min, max] value array'); + // The refusal carries what to DO. + expect(issue.message).toContain('to compare against one value'); + expect(issue.message).toContain('or use "in" to test membership of the list'); + }); + + it('prescribes the author OWN first member, not a canned example', () => { + const issue = valueIssue(parse({ field: 'stage', operator: 'equals', value: ['won', 'lost'] })); + expect(issue.message).toContain('Received an array of 2 (["won","lost"])'); + expect(issue.message).toContain('write "won" to compare against one value'); + }); + + it('has something to say about an EMPTY array too, where there is no first member', () => { + const issue = valueIssue(parse({ field: 'stage', operator: 'equals', value: [] })); + expect(issue.message).toContain('Received an array of 0 ([])'); + expect(issue.message).toContain('write the value to compare against'); + }); + + it('names the two vocabularies from the exported sets, not from a transcription', () => { + const issue = valueIssue(parse({ field: 'f', operator: 'equals', value: ['a'] })); + for (const operator of VIEW_FILTER_LIST_VALUE_OPERATORS) expect(issue.message).toContain(`"${operator}"`); + for (const operator of VIEW_FILTER_PAIR_VALUE_OPERATORS) expect(issue.message).toContain(`"${operator}"`); + }); + + it('carries no internal tracker id — the reader of this string cannot open one', () => { + const issue = valueIssue(parse({ field: 'f', operator: 'equals', value: ['a'] })); + expect(issue.message).not.toMatch(/(? { + // The two-directional sweep. A one-directional one would stay green if the + // arm refused everything, which is the failure an inert door looks like. + expect(UNSHAPED_VALUE_OPERATORS.length).toBeGreaterThan(0); + for (const operator of UNSHAPED_VALUE_OPERATORS) { + expect(parse({ field: 'f', operator, value: ['x'] }).success, `${operator} + array`).toBe(false); + expect(parse({ field: 'f', operator, value: 'x' }).success, `${operator} + scalar`).toBe(true); + } + }); + + it('leaves the four VALUELESS operators alone, array included', () => { + // The carve-out that keeps this narrowing from running past the query path: + // `convertComparison` maps these to `{ $null: true|false }` and discards the + // value position, and the ObjectUI client sends a truthy PLACEHOLDER there. + for (const operator of VALUELESS_OPERATORS) { + expect(parse({ field: 'f', operator, value: ['x'] }).success, operator).toBe(true); + expect(parse({ field: 'f', operator, value: 'x' }).success, operator).toBe(true); + expect(parse({ field: 'f', operator }).success, operator).toBe(true); } }); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2ecea023f2a..1339953de4d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -70,6 +70,12 @@ import { retiredKey } from '../shared/retired-key'; // docblock for the measurement and for the two routes that were not taken. import { MAX_RENDERABLE_SCALE, SCALE_UPPER_BOUND_MESSAGE } from '../shared/scale-ceiling'; import { FieldType, SelectOptionSchema } from '../data/field.zod'; +// [#19514] The text-comparand door the Filter Protocol publishes for the +// case-insensitive contains operator — the discrimination `FILTER_TEXT_CASES`' +// two REJECTION rows are about, and the reason text that answers them. Imported +// rather than restated so this vocabulary and the `$` dialect cannot drift into +// judging two different sets; the module imports nothing, so no cycle. +import { isRefusedTextComparand, textComparandRefusalReason } from '../data/filter-text-comparand'; import { BulkActionDefSchema } from './bulk-action.zod'; /** @@ -517,6 +523,34 @@ function previewFilterValue(value: unknown): string { return text.length > 40 ? `${text.slice(0, 39)}…` : text; } +/** + * The operators that take their direction from their NAME and ignore `value`. + * + * Deliberately NOT exported, where {@link VIEW_FILTER_LIST_VALUE_OPERATORS} and + * {@link VIEW_FILTER_PAIR_VALUE_OPERATORS} are. Those two exist because a + * PRODUCER has to ask the question the schema asks — `@object-ui`'s filter + * builder decides `isMultiOperator` and would otherwise keep its own list. This + * set answers the opposite question ("may I skip the value check?"), which only + * the check below asks; publishing it would enlarge the package's public face to + * carry a fact nothing outside this file needs. The vocabulary itself is + * declared once, in {@link VIEW_FILTER_OPERATORS}, and this is a subset of it. + */ +const VIEW_FILTER_VALUELESS_OPERATORS = [ + 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', +] as const satisfies readonly ViewFilterOperator[]; + +/** + * The one operator this vocabulary spells for which `FILTER_TEXT_CASES` writes + * comparand REJECTION rows — the infix twin of the `$` dialect's `$icontains` + * (the `AST_OPERATOR_MAP` row that made them one capability, not two spellings). + * + * A named constant rather than a literal at the comparison, so the coupling + * between "the operator this door judges" and "the spelling the refusal names" + * is one declaration. Private for the same reason + * {@link VIEW_FILTER_VALUELESS_OPERATORS} is. + */ +const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOperator; + /** * [#6227] `value` must have the shape the rule's OPERATOR can execute. * @@ -534,33 +568,91 @@ function previewFilterValue(value: unknown): string { * module docblock names this schema as the reachable authoring source of the * defect. * - * ## Why this mirrors the runtime gate EXACTLY, and refuses to go further - * - * The checks below are `assertListComparandShapes`' three constraints, one for - * one: `$in`/`$nin` must be an array, `$between` must be a 2-array. Nothing else - * is judged here, deliberately — #5685 already ruled on the opposite error, where - * `FieldOperatorsSchema` declared `$gt` as `number | Date | FieldReference` while - * every first-party producer put an ISO STRING there; the schema was ruled the - * wrong side and widened to match the runtime. A publish-time gate refusing more - * than the query path refuses would re-create that mismatch pointing the other - * way, and would reject stored metadata that executes correctly today. - * Specifically NOT refused, because the runtime does not refuse them: + * ## Why this mirrors the query path's refusals, and refuses to go further + * + * The first two checks below are `assertListComparandShapes`' constraints, one + * for one: `$in`/`$nin` must be an array, `$between` must be a 2-array. The + * third — a SCALAR operator handed an array — is `driver-sql`'s + * `assertCompilableComparand` scalar arm. + * Nothing beyond those is judged, deliberately — #5685 already ruled on the + * opposite error, where `FieldOperatorsSchema` declared `$gt` as + * `number | Date | FieldReference` while every first-party producer put an ISO + * STRING there; the schema was ruled the wrong side and widened to match the + * runtime. A publish-time gate refusing + * more than the query path refuses would re-create that mismatch pointing the + * other way, and would reject stored metadata that executes correctly today. + * + * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said + * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality + * comparand, which every backend answers". **The backends a lowered view rule + * reaches at this change do not agree** — so + * each is named rather than generalised, in the present tense: + * + * - **The SQL family REFUSES** — `driver-sql`, the `driver-turso` / + * `driver-sqlite-wasm` drivers built on it, and turso's `RemoteTransport`. The + * lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` + * loop, which calls `assertCompilableComparand(column, '=', value)`; `'='` is + * in that file's `SCALAR_COMPARAND_OPERATORS`, `isBindableComparand(['a'])` is + * `false` (an array is none of the six accepted comparand types — + * `isAcceptedFilterComparand`, `filter-comparand-type.ts`), and the comparand + * is refused with the withheld `INVALID_FILTER` / 400 envelope. + * - **`driver-memory` REFUSES** the same shape in the same envelope — `match()` + * runs `assertFilterConditionShape`, whose implicit-equality arm throws on an + * array (`filter-refusal.ts`). That refusal first shipped in + * `@objectstack/driver-memory@17.4.0`; published 17.3.0 returned the row + * stored as `['a']` (run in this change's review; which other rows it + * selected is NOT MEASURED). + * - **`driver-mongodb` ANSWERS** — `translateFilter({ tags: ['a'] })` emits + * `{"tags":["a"]}` unchanged (the array falls to the implicit-equality arm), + * and the engine's shared comparand doors — `normalizeFilterComparandTypes` + * and `assertListComparandShapes` — both pass the shape, so the server + * applies MongoDB's equality rule for an array operand: a row matches when + * its stored array EQUALS `['a']` or HOLDS `['a']` as an element, and a row + * storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, + * `['a','b']`, `['b','a']`, `[['a'],'x']`, `[['a']]` and `'b'`, selects + * `['a']`, `[['a'],'x']` and `[['a']]`). ⚠️ Read at the driver's compile + * face, at those engine doors and through `mingo`; a live `mongod` cell is + * NOT MEASURED. + * + * Method: `driver-sql` on SQLite, `driver-memory`, + * `driver-mongodb`'s `translateFilter` and `mingo` were each run on the + * lowered `{ tags: ['a'] }` beside a scalar and an `$in` control; + * MySQL, a live Turso server and + * a live `mongod` are NOT MEASURED, and so is whether any SQL-family release + * before this change answered the shape. + * + * So **none reads the array as the SCALAR the operator + * declares**: `driver-mongodb` returns + * rows for a different predicate — array equality, and only on an array-valued + * field (live `mongod` NOT MEASURED). The ORIGINAL reading was therefore the one that widened the accept + * set past the query path; this arm pulls it back to what `value`'s own + * `.describe()` has declared since #6227 — 「every other operator takes a + * scalar」. + * Direction set by objectui#9050's ruling C′ (「the differences are the + * protocol's to close」); prescription registered as the ADR-0087 entry + * `view-filter-rule-scalar-operator-array-refused`. + * + * Specifically NOT refused, because the query path does not refuse them: * * - **`in: []` / `not_in: []`.** An empty list is a legitimate declared predicate * — "matches nothing" / "matches everything" — and the runtime gate says so in * as many words. Arity is not this check's business for membership; only "is it * a list at all". - * - **A scalar operator carrying an array** (`equals: ['a','b']`). `equals` - * lowers to a bare `{ field: value }` deep-equality comparand - * (`convertComparison`), which every backend answers. * - **A string operator carrying a number** (`contains: 5`). Lowers to - * `$contains: 5`; no backend refuses it. - * - **A unary operator carrying a value** (`is_empty: ''`). The null predicates - * take their direction from the operator NAME — `convertComparison` maps them - * to `{ $null: true|false }` and ignores the value position entirely — and the - * ObjectUI client deliberately sends a truthy PLACEHOLDER value for both - * `isnull` and `isnotnull`. Refusing it would break a live first-party producer - * to enforce nothing. + * `$contains: 5`; none of the backends above refuses it (`driver-sql` + * on SQLite and `driver-memory` answer it, `driver-mongodb` compiles it to a + * `$regex`). `icontains` is the ONE + * exception and it is not an analogy — `FILTER_TEXT_CASES` declares that + * comparand refused as data, and {@link checkViewFilterRuleTextComparand} + * below answers those rows and only those rows. + * - **A unary operator carrying a value** (`is_empty: ''`, and `is_empty: []`). + * The null predicates take their direction from the operator NAME — + * `convertComparison` maps them to `{ $null: true|false }` and ignores the + * value position entirely — and the ObjectUI client deliberately sends a + * truthy PLACEHOLDER value for both `isnull` and `isnotnull`. Refusing it would + * break a live first-party producer to enforce nothing, which is why the scalar + * arm skips them explicitly rather than by accident. `value`'s own + * `.describe()` carves them out in the same words. * * ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed) * @@ -630,16 +722,100 @@ function checkViewFilterRuleValueShape( return; } - if (!isPair) return; - if (Array.isArray(value) && value.length === 2) return; + if (isPair) { + if (Array.isArray(value) && value.length === 2) return; + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `Operator "${operator}" on field "${field}" requires a [min, max] value array. ` + + `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). ` + + `A range needs exactly two bounds, in order. This is refused at authoring time ` + + `because the query path refuses it too (400 INVALID_FILTER).`, + }); + return; + } + + // Everything left takes a SCALAR — `value`'s own `.describe()` has said so + // since #6227 and nothing judged it, so the whole class rode through. The two + // carve-outs are the ones the query path itself makes: an ABSENT value (the + // key is optional, and a unary operator need not carry one) and the valueless + // operators, whose `value` position is discarded by `convertComparison`. + if (value === undefined) return; + if ((VIEW_FILTER_VALUELESS_OPERATORS as readonly string[]).includes(operator)) return; + if (!Array.isArray(value)) return; ctx.addIssue({ code: 'custom', path: ['value'], message: - `Operator "${operator}" on field "${field}" requires a [min, max] value array. ` + `Operator "${operator}" on field "${field}" requires a SCALAR value. ` + `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). ` - + `A range needs exactly two bounds, in order. This is refused at authoring time ` - + `because the query path refuses it too (400 INVALID_FILTER).`, + + `Only "${VIEW_FILTER_LIST_VALUE_OPERATORS.join('" / "')}" take a list and only ` + + `"${VIEW_FILTER_PAIR_VALUE_OPERATORS.join('" / "')}" takes a [min, max] range — write ` + + `${value.length > 0 ? previewFilterValue(value[0]) : 'the value to compare against'} ` + + `to compare against one value, or use "${VIEW_FILTER_LIST_VALUE_OPERATORS[0]}" to test ` + + `membership of the list.`, + }); +} + +/** + * [#19514] `icontains` takes the comparand `FILTER_TEXT_CASES` declares. + * + * ## The half this answers, and the half it leaves alone + * + * `@objectstack/spec/data` publishes two REJECTION rows for the + * case-insensitive contains operator — an EMPTY comparand and a NON-STRING one, + * each `code: 'INVALID_FILTER'`, `mustMention: ['$icontains']` — and publishes + * the discrimination those rows are about as {@link isRefusedTextComparand}, + * with the author-facing half as {@link textComparandRefusalReason}. Three + * objectui faces already seat that reason in their own envelopes. + * + * ⭐ This arm CALLS the predicate rather than restating it. A hand-written + * `typeof value !== 'string' || value === ''` here would be a second spelling of + * a rule the table owns, and the two would drift the first time a row moved — + * which is the failure the predicate was lifted into spec to end. A row added to + * the table therefore reaches this door without an edit here. + * + * ## Three carve-outs, each the caller's own and none of them a new row + * + * 1. **ABSENCE.** `isRefusedTextComparand(undefined)` answers `true`, and its + * docblock says in as many words that a vocabulary with an "absent" must test + * for it BEFORE asking. A view rule is exactly that vocabulary — `value` is + * optional on every rule — so an omitted comparand is left to whatever judges + * absence, and the table says nothing about it. + * 2. **ARRAYS** are {@link checkViewFilterRuleValueShape}'s business. One defect, + * one issue: an author who wrote `icontains: ['a']` is told about the SHAPE, + * which is what they have to fix first. + * 3. **THE SIBLING OPERATORS.** `contains` / `starts_with` / `ends_with` + * keep the answer they give today. Widening by + * analogy is the table's decision to make, never this door's. + * + * ## The `$` twin is named here, not in the contract half + * + * `textComparandRefusalReason` names the spelling that ARRIVED — `icontains` + * from this vocabulary — and deliberately does not substitute the `$` dialect's + * `$icontains` for it, because a view author cannot write that key. Naming the + * wire operator is the FACE's job, and this tail does it: that is where the + * refusal the author will hit at query time is spelled, and it is what the + * published rows' `mustMention` is written in. + */ +function checkViewFilterRuleTextComparand( + rule: { field?: unknown; operator?: unknown; value?: unknown }, + ctx: z.RefinementCtx, +): void { + if (rule.operator !== VIEW_FILTER_TEXT_COMPARAND_OPERATOR) return; + const value = rule.value; + if (value === undefined) return; + if (Array.isArray(value)) return; + if (!isRefusedTextComparand(value)) return; + const field = typeof rule.field === 'string' ? rule.field : ''; + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `The ${textComparandRefusalReason(field, VIEW_FILTER_TEXT_COMPARAND_OPERATOR, value)}. ` + + `This rule lowers to the wire operator "$icontains", where the query path refuses the ` + + `same comparand (400 INVALID_FILTER).`, }); } @@ -712,16 +888,28 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ * Filter value (optional for unary operators like is_empty, is_null). * * The accepted SHAPE is coupled to `operator` by - * {@link checkViewFilterRuleValueShape} (#6227). + * {@link checkViewFilterRuleValueShape} (#6227, scalar arm #19514), and the + * `icontains` COMPARAND by {@link checkViewFilterRuleTextComparand} (#19514). */ value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) .optional().describe( 'Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an ' + 'array (any length, including []), `between` takes exactly [min, max], every other ' + 'operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / ' - + 'is_not_null) take their direction from the operator name and ignore this key.', + + 'is_not_null) take their direction from the operator name and ignore this key. ' + + 'One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY ' + + 'STRING, the comparand the Filter Protocol conformance table declares for it — an ' + + 'empty comparand constrains nothing and a non-string one would answer a query nobody ' + + 'wrote, and both are refused at the query path too.', ), -}).superRefine(checkViewFilterRuleValueShape).describe('View filter rule')); +}).superRefine((rule, ctx) => { + // ONE refinement calling two checks, rather than two chained `.superRefine`s: + // zod runs every check in the chain even after one has added an issue, so a + // chain would report an `icontains` array TWICE — once for its shape and once + // for its comparand. The order is the order an author fixes them in. + checkViewFilterRuleValueShape(rule, ctx); + checkViewFilterRuleTextComparand(rule, ctx); +}).describe('View filter rule')); export type ViewFilterRule = z.input; /** Post-parse shape of {@link ViewFilterRule} — defaults applied, transforms run (ADR-0122). */