From d570c9623df6ddd8c5a69f84071c2fe67d765e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 06:31:29 +0000 Subject: [PATCH 1/4] feat(spec): icontains joins the view and infix filter vocabularies (#8934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling (Option A on #8934): the case-insensitive contains every driver executes as $icontains (#5702/#6520) becomes authorable from all three filter dialects instead of exactly one. - ui/view.zod.ts: VIEW_FILTER_OPERATORS gains 'icontains'; no alias rows — the alias table bridges spellings already in stored metadata, and a new canonical operator has none. - data/filter.zod.ts: AST_OPERATOR_MAP gains 'icontains' -> '$icontains' (extending the #7536 boundary prose: escaped substring, never a raw pattern, never aliased onto ilike/$ilike in either direction); matching CANONICAL_INFIX row so canonicalAstOperator round-trips generically. - filter-view-operator-parity.test.ts: #8934 block — lowering, %-literal comparand, no ilike/contains collapse, no negative form. - filter-text-conformance.ts: one case computed THROUGH the lowering, so a future icontains->$ilike fold goes red on every backend running the table. - No not_icontains: the $ dialect has no $notIcontains; the authoring vocabularies mirror the executed set. - content/docs/references regenerated via check:generated --fix (gen:docs was the one proved-stale artifact; authorable-surface key set unchanged). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq --- .changeset/icontains-dialect-parity.md | 30 +++++++++ content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/page.mdx | 2 +- content/docs/references/ui/view.mdx | 11 +-- .../spec/src/data/filter-text-conformance.ts | 14 +++- .../data/filter-view-operator-parity.test.ts | 67 +++++++++++++++++++ packages/spec/src/data/filter.zod.ts | 14 +++- packages/spec/src/ui/view.zod.ts | 19 +++++- 8 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 .changeset/icontains-dialect-parity.md diff --git a/.changeset/icontains-dialect-parity.md b/.changeset/icontains-dialect-parity.md new file mode 100644 index 0000000000..3e768cd71a --- /dev/null +++ b/.changeset/icontains-dialect-parity.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `icontains` joins the view and infix filter vocabularies, closing the dialect gap on the capability every driver executes (#8934) + +`$icontains` has been executable on every driver and evaluation face since +#5702/#6520, yet it was authorable from exactly one of the three filter +dialects — the MongoDB-style `FieldOperatorsSchema`. Maintainer ruling +(Option A on #8934): the two remaining vocabularies gain the canonical +spelling. + +- `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) gains `icontains`, so a + `ViewFilterRule` can declare a case-insensitive contains. No alias rows: + the alias table bridges spellings already living in stored metadata, and a + new canonical operator has none. +- `AST_OPERATOR_MAP` (`data/filter.zod.ts`) gains `icontains` → `$icontains`, + so `isFilterAST` accepts the infix spelling and `parseFilterAST` lowers it + to the operator the drivers already run. `canonicalAstOperator` round-trips + it through the generic path (`CANONICAL_INFIX` row added). +- Boundary preserved, per the ruling: `icontains`/`$icontains` (LIKE-escaped + substring — a comparand `%` is a LITERAL) and `ilike`/`$ilike` (raw LIKE + pattern) are NOT aliases of each other in either vocabulary, and there is no + `not_icontains` — the `$` dialect has no `$notIcontains`, and the authoring + vocabularies mirror the executed set rather than widening it. +- The parity suite (`filter-view-operator-parity.test.ts`) and + `FILTER_TEXT_CASES` extend accordingly, including a conformance case that + lowers the infix spelling and pins `%`-literalness on every backend that + runs the table. The comparand-type door already judged `$icontains` + (a `FieldOperatorsSchema` key since #5701) — no change needed there. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 00a38cd5a2..409c336f4a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -660,7 +660,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 702a4f3798..8274b98450 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -50,7 +50,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index dd7ca205f1..faa7a9d475 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -230,7 +230,7 @@ Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on a | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the field definition (`referenceTo`). | @@ -469,7 +469,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -558,7 +558,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -845,7 +845,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | +| **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. | ### Allowed Values: `ViewFilterRule.operator` @@ -854,6 +854,7 @@ View filter rule * `not_equals` * `contains` * `not_contains` +* `icontains` * `starts_with` * `ends_with` * `greater_than` @@ -1063,7 +1064,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | diff --git a/packages/spec/src/data/filter-text-conformance.ts b/packages/spec/src/data/filter-text-conformance.ts index 3657837ca9..1dabb9bf25 100644 --- a/packages/spec/src/data/filter-text-conformance.ts +++ b/packages/spec/src/data/filter-text-conformance.ts @@ -95,7 +95,7 @@ * @see https://github.com/objectstack-ai/objectstack/issues/6682 (the $contains family — mongodb and memory both landed) */ -import type { FilterCondition } from './filter.zod'; +import { parseFilterAST, type FilterCondition } from './filter.zod'; /** * A row in the conformance fixture. One text column is enough: every case here @@ -231,6 +231,18 @@ export const FILTER_TEXT_CASES: readonly FilterTextCase[] = [ expected: ['5'], note: 'An unescaped comparand compiles to LIKE \'%100%%\', which also matches row 6 (100X match).', }, + { + name: 'icontains (the infix/view spelling, #8934) lowers to $icontains — % stays a LITERAL through that door too', + // Computed THROUGH the lowering on purpose: today this case is byte-equal + // at runtime to the one above, and that is the point. If the infix spelling + // is ever folded onto `$ilike` instead (the boundary #8934 rules out), the + // raw-pattern reading of `100%` also matches row 6 (`100X match`) and this + // case goes red on every backend that runs this table — the fold is caught + // where it executes, not only in the spec's own suite. + filter: parseFilterAST(['name', 'icontains', '100%']) as FilterCondition, + expected: ['5'], + note: 'The three authoring dialects declare ONE capability (#8934): the infix door must reach the same escaped-substring operator the $ dialect names directly, never the raw-pattern $ilike.', + }, { name: '$icontains treats _ as a literal character, not a single-character wildcard', filter: { name: { $icontains: 'a_b' } }, diff --git a/packages/spec/src/data/filter-view-operator-parity.test.ts b/packages/spec/src/data/filter-view-operator-parity.test.ts index 79d96df64b..4065d78204 100644 --- a/packages/spec/src/data/filter-view-operator-parity.test.ts +++ b/packages/spec/src/data/filter-view-operator-parity.test.ts @@ -29,6 +29,7 @@ import { describe, it, expect } from 'vitest'; import { VALID_AST_OPERATORS, + canonicalAstOperator, isFilterAST, parseFilterAST, } from './filter.zod'; @@ -97,6 +98,7 @@ describe('every view filter operator has an AST lowering', () => { const KNOWN = new Set([ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$between', '$contains', '$notContains', '$startsWith', '$endsWith', + '$icontains', '$null', '$exists', ]); const bad: string[] = []; @@ -141,3 +143,68 @@ describe('every view filter operator has an AST lowering', () => { expect(isFilterAST(['some_field', 'sounds_like', 'x'])).toBe(false); }); }); + +describe('[#8934] icontains joins both remaining vocabularies', () => { + // `$icontains` was executable on every driver and evaluation face + // (#5702/#6520) while being AUTHORABLE from exactly one of the three filter + // dialects. This block pins the closing of that gap: the view vocabulary and + // the infix vocabulary both carry `icontains`, and it lowers to the operator + // the drivers already run. + + it('is a canonical view operator and a valid AST spelling', () => { + expect(VIEW_FILTER_OPERATORS).toContain('icontains'); + expect(VALID_AST_OPERATORS.has('icontains')).toBe(true); + }); + + it('lowers to $icontains — the operator every face executes', () => { + expect(parseFilterAST(['name', 'icontains', 'acme'])).toEqual({ + name: { $icontains: 'acme' }, + }); + }); + + it('folds the operator case-insensitively, like every other spelling', () => { + expect(parseFilterAST(['name', 'ICONTAINS', 'acme'])).toEqual({ + name: { $icontains: 'acme' }, + }); + }); + + it('keeps a % comparand LITERAL — icontains is escaped substring, not a pattern', () => { + // The ruled boundary (#8934, restating #7536's): `$icontains` LIKE-escapes + // its comparand, so the author's `%` matches a literal percent sign. Were + // `icontains` ever folded onto `$ilike`, this same comparand would become a + // raw pattern ("100" then anything) — a different query nobody wrote. + expect(parseFilterAST(['name', 'icontains', '100%'])).toEqual({ + name: { $icontains: '100%' }, + }); + }); + + it('does NOT alias onto ilike in either direction', () => { + const icontains = parseFilterAST(['name', 'icontains', '100%']); + const ilike = parseFilterAST(['name', 'ilike', '100%']); + expect(icontains).toEqual({ name: { $icontains: '100%' } }); + expect(ilike).toEqual({ name: { $ilike: '100%' } }); + expect(icontains).not.toEqual(ilike); + }); + + it('does NOT collapse onto contains — case sensitivity is contract (#5701)', () => { + const icontains = parseFilterAST(['name', 'icontains', 'Acme']); + const contains = parseFilterAST(['name', 'contains', 'Acme']); + expect(icontains).not.toEqual(contains); + }); + + it('canonicalises to itself through the generic round-trip', () => { + expect(canonicalAstOperator('icontains')).toBe('icontains'); + expect(canonicalAstOperator('ICONTAINS')).toBe('icontains'); + expect(canonicalAstOperator('icontains')).not.toBe(canonicalAstOperator('ilike')); + expect(canonicalAstOperator('icontains')).not.toBe(canonicalAstOperator('contains')); + }); + + it('has no negative form — the $ dialect has no $notIcontains', () => { + // Ruled out of this card by name: a `not_icontains` would WIDEN the + // executed surface rather than mirror it. Separate card if ever wanted. + expect(VALID_AST_OPERATORS.has('not_icontains')).toBe(false); + expect(VALID_AST_OPERATORS.has('noticontains')).toBe(false); + expect(isFilterAST(['name', 'not_icontains', 'x'])).toBe(false); + expect((VIEW_FILTER_OPERATORS as readonly string[]).includes('not_icontains')).toBe(false); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index bc4c625fc5..eb9827f103 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1620,6 +1620,18 @@ const AST_OPERATOR_MAP = { // vocabulary here with its lowering, per the #3948 single-table rule.) 'like': '$like', 'ilike': '$ilike', + // [#8934] `icontains` gives the infix (and, through the parity contract, the + // view) vocabulary the case-insensitive CONTAINS every driver already + // executes as `$icontains` (#5702/#6520) — before this row, the capability + // was authorable from exactly one of the three dialects. It lowers to its + // OWN operator for the reason the [#7536] note above records one entry up: + // `$icontains` LIKE-escapes the comparand and wraps it in `%…%` (a caller's + // `%` is a LITERAL character), while `$ilike` passes a raw pattern. That is + // a semantic boundary, not two spellings of one thing — `icontains` must + // never be folded onto `ilike`/`$ilike` in either vocabulary, nor the + // reverse. No `noticontains`: the `$` dialect has no `$notIcontains`, and + // this table mirrors the executed set rather than widening it. + 'icontains': '$icontains', 'startswith': '$startsWith', 'starts_with': '$startsWith', 'endswith': '$endsWith', @@ -1673,7 +1685,7 @@ export const VALID_AST_OPERATORS = new Set(Object.keys(AST_OPERATOR_MAP)); */ const CANONICAL_INFIX: Record = { '$eq': '=', '$ne': '!=', '$gt': '>', '$gte': '>=', '$lt': '<', '$lte': '<=', - '$in': 'in', '$nin': 'nin', '$contains': 'contains', + '$in': 'in', '$nin': 'nin', '$contains': 'contains', '$icontains': 'icontains', '$notContains': 'not_contains', '$startsWith': 'starts_with', '$endsWith': 'ends_with', '$between': 'between', '$like': 'like', '$ilike': 'ilike', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 05d8d8c05d..c99c5b9f20 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -174,13 +174,23 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', * no `value`. `before` / `after` are the date-friendly spellings of * `less_than` / `greater_than`; `between` expects a two-element `value` array. * + * `icontains` (#8934) is the case-insensitive twin of `contains`: an ASCII-only + * fold on both sides (#4706 Q1 = A — `café` does NOT match `CAFÉ`), comparand + * LITERAL (`%`, `_` and regex metacharacters are ordinary characters). It + * lowers to `$icontains`, which LIKE-escapes the comparand — it is NOT a + * spelling of `ilike`/`$ilike`, which takes a raw LIKE pattern, and the two + * must never be aliased onto each other (the #7536 boundary, restated on + * `AST_OPERATOR_MAP` in `data/filter.zod.ts`). There is deliberately no + * negative form: the `$` dialect has no `$notIcontains`, and this vocabulary + * mirrors the executed set rather than widening it. + * * Note: relative-date operators (`this_quarter`, `last_7_days`, …) are NOT * filter-rule operators — they are date-range presets and live on dashboard * date-range config (`DashboardFilterSchema.defaultRange`), not here. */ export const VIEW_FILTER_OPERATORS = [ 'equals', 'not_equals', - 'contains', 'not_contains', + 'contains', 'not_contains', 'icontains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'greater_than_or_equal', 'less_than_or_equal', @@ -230,6 +240,13 @@ export const VIEW_FILTER_PAIR_VALUE_OPERATORS = [ * downstream consumer sees exactly one vocabulary — one strict contract, not * N dialects. Deprecated: new producers MUST emit the canonical forms; these * aliases are a migration bridge and may be dropped in a future major. + * + * `icontains` (#8934) deliberately has NO rows here: this table bridges + * spellings that already live in stored metadata, and a canonical operator + * born after the table has none — inventing "synonyms" for it would widen the + * authoring surface rather than bridge a legacy one. Single-token canonicals + * (`contains`, `in`, `between`) carry no camelCase/squashed folds for the same + * reason: the folds exist per measured legacy spelling, not per operator. */ export const VIEW_FILTER_OPERATOR_ALIASES: Record = { eq: 'equals', From ab81100e7cd067f9a06dd4a725ca07f4058962e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 06:46:48 +0000 Subject: [PATCH 2/4] test(driver-memory): analytics-face expressible subset is twelve cases after #8934 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new FILTER_TEXT_CASES entry (infix icontains, %-literal) lands as $icontains post-lowering, so it joins the analytics face's expressible subset automatically — the count pin moves 11 -> 12, exactly the mechanism the selection note promises. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq --- .../src/memory-filter-text-conformance.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts index 98cce970cf..a8bc3a2565 100644 --- a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts @@ -247,8 +247,12 @@ describe('[#6682] the analytics face answers the same text rules', () => { .every((ops) => Object.keys(ops).every((op) => EXPRESSIBLE.includes(op))), ); - it('covers the whole expressible subset — eleven cases, not an accidental one', () => { - expect(analyticsCases.length).toBe(11); + it('covers the whole expressible subset — twelve cases, not an accidental one', () => { + // Twelve since #8934: the infix `icontains` spelling's `%`-literal case is + // computed through `parseFilterAST` and lands as `$icontains`, so it joins + // this face's expressible subset automatically — exactly the mechanism the + // selection note above promises. + expect(analyticsCases.length).toBe(12); }); for (const c of analyticsCases) { From 37653e3779165e188761ba4abda10aa20290832c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:06:59 +0000 Subject: [PATCH 3/4] merge origin/main (os-regen artifacts taken from main; regeneration follows) --- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/page.mdx | 2 +- content/docs/references/ui/view.mdx | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 409c336f4a..00a38cd5a2 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -660,7 +660,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 8274b98450..702a4f3798 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -50,7 +50,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index faa7a9d475..dd7ca205f1 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -230,7 +230,7 @@ Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on a | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the field definition (`referenceTo`). | @@ -469,7 +469,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -558,7 +558,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -845,7 +845,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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 | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| '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. | ### Allowed Values: `ViewFilterRule.operator` @@ -854,7 +854,6 @@ View filter rule * `not_equals` * `contains` * `not_contains` -* `icontains` * `starts_with` * `ends_with` * `greater_than` @@ -1064,7 +1063,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | From 7a1265266519e9113f751ab3249f0e8fa3fd9157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:12:24 +0000 Subject: [PATCH 4/4] chore(spec): regenerate content/docs/references on the merged tree (#8934) os-regen relay: merge of origin/main (6468df1) committed first with main's side of the generated artifacts; gen:docs re-run on the merged tree restores the icontains rows into main's current references. gen:openapi re-run after (the gen:schema-cleanup sister trap). check:generated: all 13 up to date. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq --- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/page.mdx | 2 +- content/docs/references/ui/view.mdx | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 00a38cd5a2..409c336f4a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -660,7 +660,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 702a4f3798..8274b98450 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -50,7 +50,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index dd7ca205f1..faa7a9d475 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -230,7 +230,7 @@ Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on a | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the field definition (`referenceTo`). | @@ -469,7 +469,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -558,7 +558,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -845,7 +845,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | +| **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. | ### Allowed Values: `ViewFilterRule.operator` @@ -854,6 +854,7 @@ View filter rule * `not_equals` * `contains` * `not_contains` +* `icontains` * `starts_with` * `ends_with` * `greater_than` @@ -1063,7 +1064,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab |