Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/icontains-dialect-parity.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>; 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 |
Expand Down
11 changes: 6 additions & 5 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`). |


Expand DownExpand Up@@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand DownExpand Up@@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand DownExpand Up@@ -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`
Expand All@@ -854,6 +854,7 @@ View filter rule
* `not_equals`
* `contains`
* `not_contains`
* `icontains`
* `starts_with`
* `ends_with`
* `greater_than`
Expand DownExpand Up@@ -1063,7 +1064,7 @@ Tab configuration for multi-tab view interface
| **label** | `string \| Record<string, string>` | 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 |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/src/data/filter-text-conformance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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' } },
Expand Down
67 changes: 67 additions & 0 deletions packages/spec/src/data/filter-view-operator-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@
import { describe, it, expect } from 'vitest';
import {
VALID_AST_OPERATORS,
canonicalAstOperator,
isFilterAST,
parseFilterAST,
} from './filter.zod';
Expand DownExpand Up@@ -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[] = [];
Expand DownExpand Up@@ -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);
});
});
14 changes: 13 additions & 1 deletion packages/spec/src/data/filter.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -1673,7 +1685,7 @@ export const VALID_AST_OPERATORS = new Set(Object.keys(AST_OPERATOR_MAP));
*/
const CANONICAL_INFIX: Record<string, string> = {
'$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',
Expand Down
Loading
Loading