From 37b956f0ecb26b1f4eb6b36398c92edfb56b1b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:32:44 +0000 Subject: [PATCH 01/14] feat(spec): WIP measured basis for the $empty flip - not for review The measured diff of the pre-flight: `$empty` joins `FILTER_OPERATORS`, and `is_empty` / `isempty` / `is_not_empty` / `isnotempty` lower to `$empty: true | false` in `AST_OPERATOR_MAP`, the array-sugar lowering and `canonicalAstOperator`. Texts, tables, pins and the changeset are NOT done: the pre-flight measured the diff refusing inputs accepted today (a record write carrying `{ $empty: ... }` as a text value; a lowered `is_empty` on a column the driver holds no declaration for), so the work stopped at the report, as ordered. Tests in this tree still assert the staging and are red on purpose. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/filter.zod.ts | 36 +++++++++++++--------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 0912c2776e5..e39eb346b7f 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -2506,10 +2506,10 @@ const AST_OPERATOR_MAP = { 'is_not_null': '$null', 'isnull': '$null', 'isnotnull': '$null', - 'is_empty': '$null', - 'is_not_empty': '$null', - 'isempty': '$null', - 'isnotempty': '$null', + 'is_empty': '$empty', + 'is_not_empty': '$empty', + 'isempty': '$empty', + 'isnotempty': '$empty', } satisfies Record; /** @@ -2560,15 +2560,10 @@ export function canonicalAstOperator(op: string): string { const lower = String(op).toLowerCase(); // Null predicates carry a DIRECTION that the shared `$null` lowering erases, // so they cannot round-trip through CANONICAL_INFIX — fold them by name. - if (lower === 'is_null' || lower === 'isnull' || lower === 'is_empty' || lower === 'isempty') { - return 'is_null'; - } - if ( - lower === 'is_not_null' || lower === 'isnotnull' - || lower === 'is_not_empty' || lower === 'isnotempty' - ) { - return 'is_not_null'; - } + if (lower === 'is_null' || lower === 'isnull') return 'is_null'; + if (lower === 'is_not_null' || lower === 'isnotnull') return 'is_not_null'; + if (lower === 'is_empty' || lower === 'isempty') return 'is_empty'; + if (lower === 'is_not_empty' || lower === 'isnotempty') return 'is_not_empty'; // `like`/`ilike` used to need a hand-written exemption here: they SHARED the // `$contains` lowering while not being substring matches, so the generic // round-trip below would have folded them onto `contains` and silently @@ -2694,15 +2689,18 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { // would turn every stored 「is empty」 into a refusal. The flip card moves // both this branch and `canonicalAstOperator`'s fold once every face answers // `$empty`. - if (op === 'is_null' || op === 'isnull' || op === 'is_empty' || op === 'isempty') { + if (op === 'is_null' || op === 'isnull') { return { [field]: { $null: true } } as FilterCondition; } - if ( - op === 'is_not_null' || op === 'isnotnull' - || op === 'is_not_empty' || op === 'isnotempty' - ) { + if (op === 'is_not_null' || op === 'isnotnull') { return { [field]: { $null: false } } as FilterCondition; } + if (op === 'is_empty' || op === 'isempty') { + return { [field]: { $empty: true } } as FilterCondition; + } + if (op === 'is_not_empty' || op === 'isnotempty') { + return { [field]: { $empty: false } } as FilterCondition; + } const mapped = astOperatorLowering(op); if (mapped) { @@ -3121,7 +3119,7 @@ export const FILTER_OPERATORS = [ // String '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', // Special - '$null', '$exists', + '$null', '$exists', '$empty', ] as const; /** From 88e295e258d2d89c183a4fa7f102453cf5ca939f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:02:29 +0000 Subject: [PATCH 02/14] feat(spec,drivers,services): $empty joins FILTER_OPERATORS; texts, tables and the QueryAST arm follow (WIP) Retires the staging texts the flip falsifies, adds `$empty` to every operator-enumeration table that goes red, un-partitions the engine number door's `$empty` row, and aligns driver-memory's QueryAST-node `is_empty` case with the canonical fold. Pins, changeset and generated artifacts follow. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../driver-memory/src/filter-refusal.ts | 13 +- .../src/memory-20444-empty-operator.test.ts | 2 +- ...y-analytics-echo-operator-coverage.test.ts | 8 +- .../driver-memory/src/memory-driver.ts | 33 ++++-- .../src/mongodb-20444-empty-operator.test.ts | 2 +- .../sql-driver-20444-empty-operator.test.ts | 8 +- .../sql-driver-compile-refusal-seam.test.ts | 2 +- ...river-json-column-operator-refusal.test.ts | 4 + packages/drivers/driver-sql/src/sql-driver.ts | 11 +- ...ote-transport-compile-refusal-seam.test.ts | 4 +- .../driver-turso/src/remote-transport.ts | 2 +- .../src/turso-20444-empty-operator.test.ts | 2 +- .../src/matches-filter-empty-operator.test.ts | 2 +- packages/formula/src/matches-filter.ts | 13 +- ...umber-comparand-declared-type-door.test.ts | 32 ++--- .../src/having-empty-operator.test.ts | 2 +- packages/objectql/src/having-filter.ts | 4 +- .../objectql-echo-operator-coverage.test.ts | 16 ++- .../__tests__/objectql-icontains-arm.test.ts | 6 + .../service-analytics/src/read-scope-sql.ts | 20 ++-- .../src/strategies/filter-normalizer.ts | 8 +- ...ponent-filter-record-to-rule-array.test.ts | 6 +- packages/spec/src/conversions/registry.ts | 8 +- .../src/data/filter-empty-operator.test.ts | 76 ++++++++---- .../spec/src/data/filter-empty-operator.ts | 11 +- .../spec/src/data/filter-logic-conformance.ts | 10 +- .../filter-number-comparand-declared-type.ts | 6 +- .../data/filter-operator-vocabulary.test.ts | 18 ++- .../data/filter-save-door-face-parity.test.ts | 2 +- .../src/data/filter-save-door-refusals.ts | 16 +-- .../data/filter-view-operator-parity.test.ts | 16 ++- packages/spec/src/data/filter.zod.ts | 111 +++++++++--------- packages/spec/src/ui/view-grouping-query.ts | 10 +- 33 files changed, 289 insertions(+), 195 deletions(-) diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index 9527a8ceda4..dbbdbfa1106 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -364,13 +364,12 @@ export const SUPPORTED_FIELD_OPERATORS: ReadonlySet = new Set([ ...FILTER_OPERATORS, '$like', '$ilike', - // [#20444] The staged emptiness flag, admitted BY HAND for the reason the - // `$like` paragraph above gives, and under its ordering rule: both arms land - // with this entry — the reference matcher judges the stored value - // (`isEmptyFilterValue`, the spec's reading for a face holding no field - // declaration) and the live query path the field's DECLARED row - // (`expandEmptyOperator`, from the declaration `syncSchema` recorded). - '$empty', + // [#20444] `$empty` was admitted here BY HAND, with both its arms (the + // reference matcher by value through `isEmptyFilterValue`, the live query + // path by the field's DECLARED row through `expandEmptyOperator`), while it + // was staged out of `FILTER_OPERATORS`. [#20446] It arrives by DERIVATION + // now, after `$exists` in the spec's order, so the hand entry is gone — the + // `$icontains` direction above, with the arms already in place. ]); /** The vocabulary as it appears in a refusal message, in declaration order. */ diff --git a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts index d409a025717..0c52296c4d6 100644 --- a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts +++ b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on this package's three filter faces. + * [#20444] The `$empty` operator on this package's three filter faces. * * - **The live query path** (`InMemoryDriver.find` → mingo) holds the field * declarations `syncSchema` recorded, so it answers by the field's DECLARED diff --git a/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts b/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts index 92b2d2c1851..6d0b3f36f01 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts @@ -179,8 +179,12 @@ const ACCEPTED_CASES: Record = { $exists: { name: { $exists: true } }, }; -/** The spellings this face REFUSES — the complement, so the two sets are total. */ -const REFUSED_OPERATORS = ['$between', '$startsWith', '$endsWith', '$null'] as const; +/** + * The spellings this face REFUSES — the complement, so the two sets are total. + * [#20446] `$empty` joined `FILTER_OPERATORS`, and this cube face refuses it as + * it refuses `$null`: a declared operator it has no lowering for. + */ +const REFUSED_OPERATORS = ['$between', '$startsWith', '$endsWith', '$null', '$empty'] as const; describe('[#7117] the analytics echo renders the query it describes', () => { let db: any; diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index f4b9c5120d2..d17c3bf0aeb 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -70,9 +70,10 @@ import { * Read straight off {@link SUPPORTED_FIELD_OPERATORS}, which is * `[...FILTER_OPERATORS, '$like', '$ilike']` — the spec's declaration order, * not a hand-copy of it. That matters twice: a nineteenth operator is ranked - * the day it is declared, and the rank of `$exists` (last in the spec's list) - * is what makes this generalisation emit, byte for byte, the documents - * #13195's guard already emits for the one operator it moved. + * the day it is declared, and the rank of `$exists` (after every comparison, + * set and text operator in the spec's list; only `$empty`, since #20446, + * follows it) is what makes this generalisation emit, byte for byte, the + * documents #13195's guard already emits for the one operator it moved. * * An operator absent from the vocabulary cannot reach the assembly — the * `default:` arm throws first — so the `?? Number.MAX_SAFE_INTEGER` fallback is @@ -1438,19 +1439,33 @@ export class InMemoryDriver implements IDataDriver { return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`) } }; case 'endswith': case 'ends_with': return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`) } }; - // Null / empty predicates. These are in `VALID_AST_OPERATORS` and were - // absent here, so every one of them fell to `default: return null` and was + // Null predicates. These are in `VALID_AST_OPERATORS` and were absent + // here, so every one of them fell to `default: return null` and was // dropped — `is_null` narrowed nothing instead of matching null rows. // Alias sets and semantics mirror driver-sql's `whereNull`/`whereNotNull` // arms so both backends accept the same vocabulary. In a document store // `{field: null}` matches null AND missing, and `$ne: null` excludes both, // which is the right analogue of SQL IS [NOT] NULL. #3948. - case 'is_null': case 'isnull': case 'is_empty': case 'isempty': case 'empty': + case 'is_null': case 'isnull': return { [field]: null }; - case 'is_not_null': case 'isnotnull': - case 'is_not_empty': case 'isnotempty': case 'not_empty': case 'notempty': - case 'is_set': case 'set': + case 'is_not_null': case 'isnotnull': case 'is_set': case 'set': return { [field]: { $ne: null } }; + // [#20446] Empty predicates are NOT null predicates any more. + // `canonicalAstOperator` folds `is_empty` / `isempty` onto `is_empty` + // (and the not-pair onto `is_not_empty`) instead of onto `is_null`, and + // `parseFilterAST` lowers them to `$empty` — the field's DECLARED row of + // the 「is empty」 table (text: null or `''`; multi-value: null or `[]`; + // every other type: null). This node path answers them through the SAME + // arm the FilterCondition path uses ({@link emptyOperatorCondition}), so + // one rule gets one answer whichever shape it arrived in, and a field + // this driver holds no declaration for is refused here too. The bare + // `empty` / `not_empty` / `notempty` spellings (no canonical fold; this + // switch's own legacy words) take the same arm rather than a second + // meaning. + case 'is_empty': case 'empty': + return this.emptyOperatorCondition(object, field, true, `filter.${field}.${operator}`); + case 'is_not_empty': case 'not_empty': case 'notempty': + return this.emptyOperatorCondition(object, field, false, `filter.${field}.${operator}`); case 'between': if (Array.isArray(value) && value.length === 2) { // Bare-day max → half-open, inheriting `<=`'s whole-day rule (#4042). diff --git a/packages/drivers/driver-mongodb/src/mongodb-20444-empty-operator.test.ts b/packages/drivers/driver-mongodb/src/mongodb-20444-empty-operator.test.ts index 7671417e8fe..40a9e5e75a1 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-20444-empty-operator.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-20444-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on `translateFilter`, translated by the + * [#20444] The `$empty` operator on `translateFilter`, translated by the * field's DECLARED row of the ruled 「is empty」 table (ruling A on #20399, * record 5865693155; the spec's `expandEmptyOperator`): * diff --git a/packages/drivers/driver-sql/src/sql-driver-20444-empty-operator.test.ts b/packages/drivers/driver-sql/src/sql-driver-20444-empty-operator.test.ts index c63d892e08a..7b7ee11a85d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20444-empty-operator.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20444-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on `driver-sql`'s filter compiler + * [#20444] The `$empty` operator on `driver-sql`'s filter compiler * (`applyFilterCondition`), answered by the field's DECLARED row of the ruled * 「is empty」 table — ruling B on #20311 (record 5861435168), spelled as this * operator by ruling A on #20399 (record 5865693155) — through the spec's one @@ -19,9 +19,9 @@ * construct per dialect — SQLite always, PostgreSQL and MySQL where the * `Temporal Conformance (live PG + MySQL)` job provisions them. * - * `$empty` is staged out of `FILTER_OPERATORS` (「照 $like 先例分阶段」, record - * 5868169573), so the engine's front door still refuses it; the driver is - * driven directly here, which is exactly the caller the arm answers today. + * The driver is driven directly here. Since #20446 `$empty` is in + * `FILTER_OPERATORS` and a stored view rule's `is_empty` lowers to it; that + * path is pinned in `sql-driver-20446-empty-flip.test.ts`. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; diff --git a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts index 1b022cb4f3a..00ea7eff0a5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts @@ -228,7 +228,7 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, SECRET], klass: 'Operator "$exists" in this filter requires a boolean comparand', }, - // ── #20444: the staged `$empty` operator, born in the seam ────────────────── + // ── #20444: the `$empty` operator, born in the seam ───────────────────────── { builder: 'nonBooleanEmptyComparandError', where: () => ({ [POLICY_COL]: { $empty: SECRET } }), diff --git a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts index 346f499a73d..8c96b08d996 100644 --- a/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-json-column-operator-refusal.test.ts @@ -164,6 +164,10 @@ const KEPT: ReadonlyArray = [ ['$icontains', U1], ['$null', false], ['$exists', true], + // [#20446] `$empty` joined `FILTER_OPERATORS`. It asks the question this + // column's declared row answers — a multi-value lookup is null or `[]` when + // empty — so it compiles here like the two presence flags above. + ['$empty', true], ]; describe('[#7398] SqlDriver refuses scalar-comparison operators on JSON/multi-value columns', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6c8223ab802..adf1ec36b0a 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -16171,7 +16171,7 @@ export class SqlDriver implements IDataDriver { } /** - * [#20444] Compile `{ field: { $empty: true | false } }` — the staged + * [#20444] Compile `{ field: { $empty: true | false } }` — the * emptiness operator, answered by the field's DECLARED row of the ruled * 「is empty」 table (ruling B on #20311, record 5861435168; spelled as this * operator by ruling A on #20399, record 5865693155), through the spec's one @@ -16199,10 +16199,11 @@ export class SqlDriver implements IDataDriver { * ({@link undeclaredEmptyOperatorFieldError}), and the multi-value row on a * dialect this driver does not model ({@link emptyListUnsupportedDialectError}). * - * ⚠️ Staged: `$empty` is not in `FILTER_OPERATORS` yet (the maintainer's - * amendment of ruling A, record 5868169573: 「照 $like 先例分阶段」), so the - * engine's front door still refuses it; this arm answers a caller that - * reaches the driver directly, and it is what the flip card turns on. + * [#20446] `$empty` is in `FILTER_OPERATORS`, and the view operators + * `is_empty` / `is_not_empty` lower to it, so a stored view rule reaches this + * arm: the undeclared-field refusal above is what such a rule on the + * built-in `id` (or any column this driver was never told the type of) + * answers, where the old `$null` lowering compiled `IS NULL`. */ private applyEmptyOperator( builder: any, diff --git a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts index 26fa6fcbc91..bee15a56ebb 100644 --- a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts @@ -150,7 +150,7 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, SECRET], klass: 'Operator "$exists" in this filter requires a boolean comparand', }, - // ── #20444: the staged `$empty` operator, born in the seam ────────────────── + // ── #20444: the `$empty` operator, born in the seam ───────────────────────── { builder: 'nonBooleanEmptyComparand', where: () => ({ [POLICY_COL]: { $empty: SECRET } }), @@ -480,7 +480,7 @@ describe('[#20039] TursoDriver LOCAL and REMOTE withhold these classes alike', ( ['undeclared combinator', () => ({ [UNDECLARED_KEY]: 'x' }), UNDECLARED_KEY], // [#20041] Written on both compilers as one sentence from the start. ['U+0000 in a pattern', () => ({ [POLICY_COL]: { $like: `${SECRET}${String.fromCharCode(0x00)}` } }), SECRET], - // [#20444] …and so were the staged `$empty` operator's two refusals. + // [#20444] …and so were the `$empty` operator's two refusals. ['$empty non-boolean flag', () => ({ [POLICY_COL]: { $empty: SECRET } }), SECRET], ['$empty on an undeclared field', () => ({ secret_undeclared_col: { $empty: true } }), 'secret_undeclared_col'], ]; diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 068a2eef16f..7ddbdb71d3a 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -3324,7 +3324,7 @@ export class RemoteTransport { // `$null: true` and `$exists: false` are one question asked twice. clauses.push(`${column} IS ${opValue === false ? 'NULL' : 'NOT NULL'}`); break; - // [#20444] `$empty` — the staged emptiness flag, answered by the + // [#20444] `$empty` — the emptiness flag, answered by the // field's DECLARED row of the ruled table on the plain column (a // presence question like the two above, so no storage form applies). // Refused, as its two siblings are, unless the comparand is boolean. diff --git a/packages/drivers/driver-turso/src/turso-20444-empty-operator.test.ts b/packages/drivers/driver-turso/src/turso-20444-empty-operator.test.ts index f04aad45514..8298e5d4541 100644 --- a/packages/drivers/driver-turso/src/turso-20444-empty-operator.test.ts +++ b/packages/drivers/driver-turso/src/turso-20444-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on BOTH of TursoDriver's faces — the + * [#20444] The `$empty` operator on BOTH of TursoDriver's faces — the * local transport (which inherits `SqlDriver.applyFilterCondition`) and the * remote one (`RemoteTransport.buildWhereSQL`, an independent compiler) — held * to one row set, by the field's DECLARED row of the ruled 「is empty」 table diff --git a/packages/formula/src/matches-filter-empty-operator.test.ts b/packages/formula/src/matches-filter-empty-operator.test.ts index aad3aa708a5..7da8009dd57 100644 --- a/packages/formula/src/matches-filter-empty-operator.test.ts +++ b/packages/formula/src/matches-filter-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on `matchesFilterCondition` — the RLS + * [#20444] The `$empty` operator on `matchesFilterCondition` — the RLS * write-side `check` evaluator. * * This face judges a RECORD, not a declaration, so ruling A on #20399 (record diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index b0191336762..80c6fbcf6d9 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -56,7 +56,8 @@ * below, for every record and both flags: the defect this paragraph names. * It now has its arm in {@link evalOp}, judged by the stored value (this * face's reading, ruling A on #20399). Today the declared-but-staged names - * are `$like`, `$ilike` and `$empty`, and each is answered here; a name the + * are `$like` and `$ilike` (`$empty` left the staging in #20446), and each + * is answered here; a name the * protocol declares NEXT is owed an arm here by the PR that lets an author * write it, or by the lane card its staging names. * @@ -741,8 +742,10 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record): Promise => /** A `formula` case — judged one door earlier, see the header. */ const isFormulaCase = (c: NumberComparandDoorCase): boolean => c.declaredType === 'formula'; -/** The staged `$empty` row — pinned at the door alone, see the header. */ -const isStagedCase = (c: NumberComparandDoorCase): boolean => c.position.endsWith('.$empty'); -const engineDriven = (c: NumberComparandDoorCase): boolean => !isFormulaCase(c) && !isStagedCase(c); +/** The `$empty` row — driven end to end since #20446, see the header. */ +const isEmptyFlagCase = (c: NumberComparandDoorCase): boolean => c.position.endsWith('.$empty'); +const engineDriven = (c: NumberComparandDoorCase): boolean => !isFormulaCase(c); const SEEDED = [ { id: 'r1', f_number: 5 }, @@ -144,11 +144,10 @@ describe('[#20351] the number-comparand declared-type door at the engine collect const PASSES = NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.verdict === 'passes' && engineDriven(c)); const DEFERRED = NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.verdict === 'deferred' && engineDriven(c)); const FORMULA = NUMBER_COMPARAND_DOOR_CASES.filter(isFormulaCase); - const STAGED = NUMBER_COMPARAND_DOOR_CASES.filter(isStagedCase); it('GUARD the case table is partitioned exactly, and every partition that carries a verdict is non-empty', () => { expect(NUMBER_COMPARAND_DOOR_CASES.length).toBe( - REFUSALS.length + NARROWS.length + PASSES.length + DEFERRED.length + FORMULA.length + STAGED.length, + REFUSALS.length + NARROWS.length + PASSES.length + DEFERRED.length + FORMULA.length, ); expect(REFUSALS.length).toBeGreaterThan(0); expect(NARROWS.length).toBeGreaterThan(0); @@ -156,7 +155,7 @@ describe('[#20351] the number-comparand declared-type door at the engine collect expect(FORMULA.length).toBeGreaterThan(0); // The untyped formula is the table's only deferred row, and it is judged one door earlier. expect(DEFERRED).toHaveLength(0); - expect(STAGED.map((c) => c.verdict)).toEqual(['passes']); + expect(PASSES.filter(isEmptyFlagCase)).toHaveLength(1); // Every refused form the grammar names is driven, not just the card's "abc". expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set(NON_NUMERIC_STRING_FORMS)); // Every judged position is driven both ways. @@ -226,12 +225,17 @@ describe('[#20351] the number-comparand declared-type door at the engine collect expect(findNonNumericComparand(schema, { f_formula_untyped: { $gt: 'abc' } })).toBeNull(); }); - it('the staged $empty row is pinned at the DOOR ALONE — the walk neither refuses nor rewrites it', () => { + it('the $empty row is driven end to end — the door neither refuses nor rewrites it, and the driver receives it', async () => { const schema = engine.registry.getObject(OBJECT); - for (const c of STAGED) { + const rows = PASSES.filter(isEmptyFlagCase); + expect(rows).toHaveLength(1); + for (const c of rows) { const filter = c.filter(); expect(findNonNumericComparand(schema, filter), c.name).toBeNull(); expect(narrowNumberComparands(OBJECT, 'find', schema, filter), c.name).toBe(filter); + reads.length = 0; + await expect(engine.find(OBJECT, { where: filter }), c.name).resolves.toBeDefined(); + expect(reads[0]?.ast?.where, c.name).toEqual(filter); } }); diff --git a/packages/objectql/src/having-empty-operator.test.ts b/packages/objectql/src/having-empty-operator.test.ts index b21bf88bd40..57a22a39ac8 100644 --- a/packages/objectql/src/having-empty-operator.test.ts +++ b/packages/objectql/src/having-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20444] The staged `$empty` operator on objectql's `having` — the face that + * [#20444] The `$empty` operator on objectql's `having` — the face that * filters AGGREGATED rows, and the one no shared conformance table drives * (`FILTER_LOGIC_CASES` does not reach the HAVING path), so its conclusion is * stated here, explicitly. diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index 746b76bd294..1f3d8fd9109 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -213,8 +213,8 @@ export function aggregationFilterClause(index: number): FilterClause { // #5499 freeze was lifted for this operator as a sanctioned one-off (maintainer // ruling, 2026-08-08), strictly for semantic parity. // -// [#20444] `$empty` IS here — the staged emptiness flag (declared by -// `FieldOperatorsSchema`, out of `FILTER_OPERATORS` until its flip card), with +// [#20444] `$empty` IS here — the emptiness flag (declared by +// `FieldOperatorsSchema`, in `FILTER_OPERATORS` since #20446), with // its arm in {@link checkCondition} and its comparand gate beside // `$icontains`' — judged BY VALUE, the reading ruling A on #20399 (record // 5865693155) gives this face. See {@link emptyFlagComparandError}. diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts index 05e760c9161..b5d9a7dc16f 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts @@ -31,7 +31,7 @@ * refuses an operator it cannot map (`Unsupported filter operator …`), so the * leaf operators that can ever reach this compiler are exactly what * {@link fieldLeaves} emits for the spec's `FILTER_OPERATORS` — a finite, - * enumerable set. Driving all sixteen authorable spellings through the echo (#6520 added `$icontains`) + * enumerable set. Driving every authorable spelling through the echo (#6520 added `$icontains`, #20446 `$empty`) * turns "the two tables drifted" from something a reader has to notice into a * failing test, which is what #4128 asked for and did not get for this third * compiler. @@ -136,8 +136,19 @@ const OPERATOR_CASES: Record = { $endsWith: { stage: { $endsWith: 'n' } }, $null: { stage: { $null: true } }, $exists: { stage: { $exists: false } }, + // [#20446] `stage` is text, so its row is null or `''`; the fixture's empty + // rows are the two NULLs. + $empty: { stage: { $empty: true } }, }; +/** + * [#20446] The declared value shape `$empty` is answered by — the host's + * `sourceFieldMeta`, as `AnalyticsServicePlugin` relays it. Both strategy + * contexts and the stand-in engine read it. + */ +const DECLARED: Readonly> = { id: { type: 'text' }, stage: { type: 'text' }, amount: { type: 'number' } }; +const declaredValueShape = (_object: string, field: string) => DECLARED[field]; + describe('[#5333] `/analytics/sql` echo — every authorable operator renders a predicate', () => { let db: any; let nativeCtx: StrategyContext; @@ -158,6 +169,7 @@ describe('[#5333] `/analytics/sql` echo — every authorable operator renders a nativeCtx = { getCube: (name: string) => (name === 'deals' ? CUBE : undefined), queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + declaredValueShape, executeRawSql: async (_object: string, sql: string, params: unknown[]) => { const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); stmt.bind(params as any[]); @@ -176,6 +188,7 @@ describe('[#5333] `/analytics/sql` echo — every authorable operator renders a objectqlCtx = { getCube: (name: string) => (name === 'deals' ? CUBE : undefined), queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + declaredValueShape, executeAggregate: async ( _object: string, options: { groupBy?: string[]; filter?: Record }, @@ -183,6 +196,7 @@ describe('[#5333] `/analytics/sql` echo — every authorable operator renders a const { sql, params } = compileScopedFilterToSql( (options.filter ?? {}) as FilterCondition, 'deal', + { declaredValueShape: (field: string) => declaredValueShape('deal', field) }, ); const stmt = db.prepare( `SELECT "id" FROM "deal" AS "deal" WHERE ${sql.length > 0 ? sql : '1 = 1'}`, diff --git a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts index 047ac5bb0f6..b797f2720c5 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts @@ -113,6 +113,10 @@ describe('[#20098] `$icontains` on the ObjectQL strategy, over a real engine', ( executeAggregate, getDatasetScope: () => datasetScope, declaredFieldType: (_object: string, field: string) => FIELDS[field]?.type, + // [#20446] …and the declared value shape, which `$empty` (in + // `FILTER_OPERATORS` since #20446) is answered by on the native face. + declaredValueShape: (_object: string, field: string) => + (FIELDS[field] ? { type: FIELDS[field].type } : undefined), sqlDialect: () => 'sqlite', }) as unknown as StrategyContext; const service = (nativeSql: boolean) => @@ -258,6 +262,8 @@ describe('[#20098] `$icontains` on the ObjectQL strategy, over a real engine', ( $icontains: { name: { $icontains: 'acme' } }, $null: { name: { $null: true } }, $exists: { name: { $exists: true } }, + // [#20446] The text row: `name` holds null (a4) and `''` (a5). + $empty: { name: { $empty: true } }, }; it('the sample table covers every operator `FILTER_OPERATORS` declares', () => { diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 72a8d504b74..630136228c6 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -57,7 +57,7 @@ import { * Supports the operators the RLS layer and common policies emit: implicit * equality, `$eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$between/$contains/$notContains/ * $startsWith/$endsWith/$null/$exists`, and `$and/$or/$not` combinators — plus - * `$icontains` (#6520) and the staged `$empty` (#20445, the last section). + * `$icontains` (#6520) and `$empty` (#20445, the last section). * * ## `''` means TRUE, and that is a value — not "nothing happened" * @@ -610,17 +610,17 @@ import { * when the flag is not a boolean ({@link assertBooleanFlagComparands}, the * gate the two null flags already had). * - * The operator stays STAGED — absent from `FILTER_OPERATORS` until every face - * has its arm (the maintainer's amendment of ruling A: 「照 $like 先例分阶段」) - * — so no in-repo producer emits it in a read scope yet; the CEL lowering's - * `is_empty` still emits `$null`. An in-process `getReadScope` producer can. + * [#20446] The operator is in `FILTER_OPERATORS`, and the view operators + * `is_empty` / `is_not_empty` lower to it, so a read scope built from a stored + * 「is empty」 rule carries it here — as an in-process `getReadScope` producer + * always could. A host that wires no `sourceFieldMeta` cannot name a field's + * declaration, so such a scope is refused on it (fail-closed, in this + * module's envelope) where the old `$null` lowering compiled `IS NULL`. * * The ObjectQL execute face does not meet this compiler: it hands the scope to - * the engine, whose `$empty` arm is the engine lane's (`driver-sql` and its - * heirs, a sibling card of ruling A). Until that arm lands the engine's driver - * refuses the operator there (`INVALID_FILTER` / 400, the operator and field - * withheld from its message), so one scope is refused on that face and - * answered on the other two; nothing is dropped on any of them. + * the engine, whose drivers answer `$empty` by the declared row (#20444) and + * refuse it on a column they hold no declaration for; nothing is dropped on + * any face. * * What `$empty` did NOT change is the envelope of an operator this compiler * has no arm for: still `READ_SCOPE_COMPILE_FAILED` / 500, withheld, per the diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index cfa118e0c18..0718fc83eff 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -436,9 +436,11 @@ * refused by {@link assertBooleanNullFlags} with the two null flags. The leaf * is TOTAL on every consumer, so the `$not` rewrite adds no guard to it. * - * The operator stays STAGED (absent from `FILTER_OPERATORS`, 「照 $like 先例分阶段」): - * the view operators `is_empty` / `is_not_empty` still lower to `$null`, and - * the draft preview does not evaluate `$empty` — it refuses it with its other + * [#20446] The operator is in `FILTER_OPERATORS`, and the view operators + * `is_empty` / `is_not_empty` lower to it (through `parseFilterAST`, the + * array door), so a host that wires no `sourceFieldMeta` refuses a stored + * 「is empty」 rule here where the old `$null` lowering compiled `IS NULL`. The + * draft preview does not evaluate `$empty` — it refuses it with its other * unevaluated operators (`preview-evaluator.ts`). * * Row-result cover: `filter-operator-coverage.test.ts` for the operator diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 3235484d09c..81e8359afe0 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -435,8 +435,10 @@ describe('§4 lossless, measured over the declared vocabularies', () => { const source = op === '$eq' ? { f: v } : { f: { [op]: v } }; expect(parseFilterAST([['f', rule, v]]), `${op} → ${rule}`).toEqual(source); } - // Exactly the two whose meaning lives in the VALUE, not the operator. - expect(declined.sort()).toEqual(['$exists', '$null']); + // Exactly the three whose meaning lives in the VALUE, not the operator + // (`$empty` joined `FILTER_OPERATORS` in #20446: its `true` / `false` is + // `is_empty` / `is_not_empty`). + expect(declined.sort()).toEqual(['$empty', '$exists', '$null']); }); it('every AST operator spelling maps to a rule that lowers exactly as the source did — or is declined', () => { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dbad7cebcf7..a4e6d2d6a92 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10975,9 +10975,11 @@ interface MappedFilterRule { * door runs on `operator` — to a canonical `VIEW_FILTER_OPERATORS` member. * That maps the fourteen comparison, set, range and text operators * (`$gt` → `greater_than`, `$nin` → `not_in`, `$notContains` → `not_contains`, - * …) and declines the two whose meaning lives in their VALUE (`$null`, - * `$exists`); the lowering of every mapped rule back to the same `$` operator - * is pinned per operator against `parseFilterAST` by the test. + * …) and declines the three whose meaning lives in their VALUE (`$null`, + * `$exists`, and — in `FILTER_OPERATORS` since #20446 — `$empty`, whose + * `true` / `false` is `is_empty` / `is_not_empty`); the lowering of every + * mapped rule back to the same `$` operator is pinned per operator against + * `parseFilterAST` by the test. */ function ruleOperatorForFilterOperator(op: string): ViewFilterOperator | undefined { if (!(FILTER_OPERATORS as readonly string[]).includes(op)) return undefined; diff --git a/packages/spec/src/data/filter-empty-operator.test.ts b/packages/spec/src/data/filter-empty-operator.test.ts index 40de5d17e3a..a3f6ea4c517 100644 --- a/packages/spec/src/data/filter-empty-operator.test.ts +++ b/packages/spec/src/data/filter-empty-operator.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20311] `$empty` — the declared emptiness operator, STAGED. + * [#20311] `$empty` — the declared emptiness operator. * * Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per * field type: text-like = null or `''`; multi-value (multi-select, tags, @@ -9,8 +9,10 @@ * #20399 (record 5865693155) spelled it as `$empty: boolean`, "whose describe * IS the per-type table", expanded by each compile surface "through one spec * function". The maintainer's amendment (record 5868169573, 「照 $like 先例分阶段」) - * staged it: declared in `FieldOperatorsSchema`, ABSENT from `FILTER_OPERATORS` - * until every face has its arm, the `is_empty` lowering still `$null`. + * staged it: declared in `FieldOperatorsSchema`, absent from `FILTER_OPERATORS` + * until every face had its arm, the `is_empty` lowering `$null` meanwhile. + * [#20446] ended the staging: `$empty` is in `FILTER_OPERATORS` and the + * `is_empty` / `is_not_empty` lowering emits it (§4). * * The pins ruling A lists for this card, one `describe` each: * @@ -21,8 +23,12 @@ * 3. the expansion function (`./filter-empty-operator.ts`, published on the * data entry) returns the text, multi-value and null arms for the three * field kinds, and the value predicate answers each arm; - * 4. `$empty` is ABSENT from `FILTER_OPERATORS`, and the lowering still emits - * `$null`. + * 4. [#20446, inverted from the staging pin] `$empty` is IN + * `FILTER_OPERATORS`, and every spelling of the view operators `is_empty` / + * `is_not_empty` lowers to it — on the array sugar and in + * `canonicalAstOperator`'s fold. The rows those lowered rules answer are + * each face's own suite (`memory-20446-empty-flip.test.ts`, + * `sql-driver-20446-empty-flip.test.ts`). */ import { describe, expect, it } from 'vitest'; @@ -41,6 +47,7 @@ import { FilterConditionSchema, NormalizedFilterSchema, SpecialOperatorSchema, + canonicalAstOperator, parseFilterAST, } from './filter.zod'; import * as dataBarrel from './index'; @@ -84,10 +91,10 @@ describe('#20311 §1 — the $empty description is the ruled per-type table', () + 'qrcode) = null or \'\' (the empty string); multi-value types (multiselect, checkboxes, ' + 'tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] ' + '(the empty list); every other type = null only. A face that holds no field declaration ' - + 'judges by the value: null, \'\' and [] are empty. STAGED: declared ahead of its ' - + 'backends and absent from FILTER_OPERATORS. Until each face has its arm, the query ' - + 'executors refuse it and the write-side check matcher matches no record; the view ' - + 'operators is_empty / is_not_empty still lower to $null.'; + + 'judges by the value: null, \'\' and [] are empty. A face that answers by the declared ' + + 'type refuses the operator on a column whose declaration it does not hold (the built-in ' + + 'id, for one) rather than guess a row; use $null there for "has no value". The view ' + + 'operators is_empty / is_not_empty lower to this operator.'; it('the enforced copy and the documentation copy carry the same string, and it is the table', () => { expect(descriptionOf(FieldOperatorsSchema.shape, '$empty')).toBe(RULED_TABLE); @@ -239,29 +246,46 @@ describe('#20311 §3 — expandEmptyOperator answers the ruled arm per field def }); // --------------------------------------------------------------------------- -// §4 The staging: absent from FILTER_OPERATORS, lowering unchanged +// §4 The flip: in FILTER_OPERATORS, and the view operators lower to it // --------------------------------------------------------------------------- -describe('#20311 §4 — staged: $empty is ABSENT from FILTER_OPERATORS', () => { - it('is not in FILTER_OPERATORS', () => { - // ⛔ Deliberately absent (the maintainer's amendment, record 5868169573): - // driver-memory's accepted set is built from this array and its matcher's - // `default:` arm lets the row pass, so membership before every face has an - // arm would DROP the predicate and return every row. The FLIP CARD — the - // last card of ruling A's sequence on #20399, `Blocked-by` every lane - // card — is the one that adds `$empty` here, empties it out of - // `STAGED_AHEAD_OF_BACKENDS` (`filter-operator-vocabulary.test.ts`), and - // flips the lowering below. This assertion is the one it inverts. - expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); +describe('#20446 §4 — $empty is IN FILTER_OPERATORS and is_empty / is_not_empty lower to it', () => { + it('is in FILTER_OPERATORS, beside the other value-presence flags', () => { + // Inverted from #20311's staging pin (the maintainer's amendment, record + // 5868169573): every compile face answers `$empty` now (#20444, #20445), + // so the enforcement surface names it. `STAGED_AHEAD_OF_BACKENDS` + // (`filter-operator-vocabulary.test.ts`) no longer does. + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); + expect(FILTER_OPERATORS.slice(-3)).toEqual(['$null', '$exists', '$empty']); expect(Object.keys(FieldOperatorsSchema.shape)).toContain('$empty'); }); - it('the is_empty / is_not_empty lowering still emits $null — the flip is a later card', () => { - for (const op of ['is_empty', 'isempty']) { - expect(parseFilterAST(['tags', op, true])).toEqual({ tags: { $null: true } }); + it('every spelling of is_empty lowers to $empty: true and of is_not_empty to $empty: false, whatever the filler', () => { + for (const op of ['is_empty', 'isempty', 'IS_EMPTY', 'IsEmpty']) { + for (const filler of [true, false, 'x', undefined]) { + expect(parseFilterAST(['tags', op, filler]), `${op} ${String(filler)}`).toEqual({ tags: { $empty: true } }); + } } - for (const op of ['is_not_empty', 'isnotempty']) { - expect(parseFilterAST(['tags', op, true])).toEqual({ tags: { $null: false } }); + for (const op of ['is_not_empty', 'isnotempty', 'IS_NOT_EMPTY']) { + for (const filler of [true, false, 'x', undefined]) { + expect(parseFilterAST(['tags', op, filler]), `${op} ${String(filler)}`).toEqual({ tags: { $empty: false } }); + } } + // Nested under the array sugar's combinators too — a stored view rule set. + expect(parseFilterAST(['or', ['tags', 'is_empty', true], ['name', 'is_not_empty', true]])).toEqual({ + $or: [{ tags: { $empty: true } }, { name: { $empty: false } }], + }); + }); + + it('the null pair keeps its own lowering — is_null is not is_empty', () => { + expect(parseFilterAST(['tags', 'is_null', true])).toEqual({ tags: { $null: true } }); + expect(parseFilterAST(['tags', 'isnotnull', true])).toEqual({ tags: { $null: false } }); + }); + + it('canonicalAstOperator folds the empty pair onto its OWN names, not onto is_null / is_not_null', () => { + for (const op of ['is_empty', 'isempty', 'IS_EMPTY']) expect(canonicalAstOperator(op), op).toBe('is_empty'); + for (const op of ['is_not_empty', 'isnotempty']) expect(canonicalAstOperator(op), op).toBe('is_not_empty'); + for (const op of ['is_null', 'isnull']) expect(canonicalAstOperator(op), op).toBe('is_null'); + for (const op of ['is_not_null', 'isnotnull']) expect(canonicalAstOperator(op), op).toBe('is_not_null'); }); }); diff --git a/packages/spec/src/data/filter-empty-operator.ts b/packages/spec/src/data/filter-empty-operator.ts index 5a5614d8fc5..786dcfd4173 100644 --- a/packages/spec/src/data/filter-empty-operator.ts +++ b/packages/spec/src/data/filter-empty-operator.ts @@ -12,11 +12,12 @@ * surface expands it by the field's declared type through one spec function". * This module is that function, and the value-level predicate beside it. * - * The operator is STAGED (the maintainer's amendment of ruling A, record - * 5868169573, 「照 $like 先例分阶段」): absent from `FILTER_OPERATORS` until - * every face has an arm, and the `is_empty` / `is_not_empty` lowering still - * emits `$null`. Nothing in this repository calls these functions yet; the - * compile-surface lane cards do, one face each. + * The operator was staged (the maintainer's amendment of ruling A, record + * 5868169573, 「照 $like 先例分阶段」) until every compile face called these + * functions — the lane cards #20444 (the engine's drivers, formula and + * `having`) and #20445 (service-analytics). [#20446] It is in + * `FILTER_OPERATORS` now, and the `is_empty` / `is_not_empty` lowering emits + * it. * * ## Why this is its own module * diff --git a/packages/spec/src/data/filter-logic-conformance.ts b/packages/spec/src/data/filter-logic-conformance.ts index fafac0c1c24..d9b8ed96507 100644 --- a/packages/spec/src/data/filter-logic-conformance.ts +++ b/packages/spec/src/data/filter-logic-conformance.ts @@ -60,7 +60,7 @@ * The predicates are deliberately boring: string equality, `$in` / `$nin`, * `$ne`, `$gte` / `$lt` on lexicographic strings, `$notContains` over plain * substrings, the value-presence pair `$null` / `$exists`, and (since #20444) - * the staged emptiness flag `$empty` on the same nullable column. Dates, numeric + * the emptiness flag `$empty` on the same nullable column. Dates, numeric * coercion, `LIKE` escaping and case sensitivity are still out — those * legitimately differ between a SQL engine and a JS matcher, and folding them * in would make the table unpassable rather than more useful. Keep it that way: a case belongs here only if @@ -545,15 +545,15 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ note: 'The direction the ruling called the hardest live harm: a key-presence reading returns NOTHING here, silently emptying every "field is not set" scope. Enrolled beside its twin so a single-direction blind spot cannot rebuild.', }, - // ── The staged emptiness flag `$empty` (#20444) ─────────────────────────── + // ── The emptiness flag `$empty` (#20444) ────────────────────────────────── // // Ruling A on #20399 (record 5865693155): `$empty: boolean`, answered by the // field's declared row of the ruled table on the faces that hold field // declarations and by value on the ones that do not. `d` stores a value or // NULL and never `''` / `[]`, so every row of the table agrees here — see - // this file's header for what that does and does not pin. Staged: the - // engine's front door refuses the operator until its flip card adds it to - // `FILTER_OPERATORS`, so these cases reach each face directly. + // this file's header for what that does and does not pin. These cases reach + // each face directly; since #20446 `$empty` is in `FILTER_OPERATORS` and the + // view operators `is_empty` / `is_not_empty` lower to it. { name: '$empty true selects exactly the no-value rows', filter: { d: { $empty: true } }, diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.ts b/packages/spec/src/data/filter-number-comparand-declared-type.ts index af12b15413c..fbafd7f7c2b 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.ts @@ -58,9 +58,9 @@ * refuses whitespace-padded strings by name, and one grammar serves both * sides. A blank comparand on a number field has no numeric reading: the * write side turns a blank into `null` BEFORE the number arm (#20308), and - * on the read side the emptiness operator lowers to the null test on a - * number field (`is_empty` → `$null`; #20311's ruling B keeps a number field - * on null alone) — neither hands this grammar a blank, so a blank that + * on the read side the emptiness operator is the null test on a number field + * (`is_empty` → `$empty`, whose number row is null alone — #20311's ruling + * B) — neither hands this grammar a blank, so a blank that * reaches it is a mistake, and on PostgreSQL a 500. * - **Refused: `"0x10"`, `"0o17"`, `"0b101"`.** `Number()` reads them; SQLite * stores `'0x10'` as TEXT (measured on #20309); the write side's direction diff --git a/packages/spec/src/data/filter-operator-vocabulary.test.ts b/packages/spec/src/data/filter-operator-vocabulary.test.ts index 1435bd04407..65714f0887c 100644 --- a/packages/spec/src/data/filter-operator-vocabulary.test.ts +++ b/packages/spec/src/data/filter-operator-vocabulary.test.ts @@ -59,18 +59,14 @@ const declaredKeys = () => Object.keys(FieldOperatorsSchema.shape).sort(); * `match()` returned `true` for a non-matching record). Cleared by giving the * remaining faces arms in one PR, the #6520 direction. * - * `$empty` (#20311): declared by `SpecialOperatorSchema` and - * `FieldOperatorsSchema` with the per-type 「is empty」 table as its - * description (ruling B on #20311, record 5861435168; spelled by ruling A on - * #20399, record 5865693155) and answered by NO face yet — every one refuses - * it loudly, which the staging keeps true (the maintainer's amendment, - * record 5868169573: 「照 $like 先例分阶段」). Each compile-surface lane card - * gives its face an arm; the FLIP CARD — the last card of ruling A's - * sequence, `Blocked-by` every lane card — is the one that adds `$empty` to - * `FILTER_OPERATORS`, removes it from this list, and flips the - * `is_empty` / `is_not_empty` lowering from `$null` to `$empty`. + * `$empty` (#20311) was staged here too (the maintainer's amendment, record + * 5868169573: 「照 $like 先例分阶段」) until every compile face had its arm + * (#20444, #20445). #20446 cleared it — the first operator to leave this list + * — by adding it to `FILTER_OPERATORS` and flipping the `is_empty` / + * `is_not_empty` lowering to it in one commit, after measuring that no face + * drops it. */ -const STAGED_AHEAD_OF_BACKENDS = ['$empty', '$ilike', '$like']; +const STAGED_AHEAD_OF_BACKENDS = ['$ilike', '$like']; describe('the declaration surface and the enforcement surface', () => { it('differ by EXACTLY the operators staged ahead of their backends', () => { diff --git a/packages/spec/src/data/filter-save-door-face-parity.test.ts b/packages/spec/src/data/filter-save-door-face-parity.test.ts index ec26ca48f7d..504abb22608 100644 --- a/packages/spec/src/data/filter-save-door-face-parity.test.ts +++ b/packages/spec/src/data/filter-save-door-face-parity.test.ts @@ -210,7 +210,7 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the it('the table is derived, not hand-listed, and covers every arm the faces and the flag rule judge', () => { // The vocabulary is the enforced copy's, so a new operator joins the table. expect(OPERATORS).toEqual(expect.arrayContaining(['$eq', '$ne', '$gt', '$in', '$nin', '$between', '$null', '$exists'])); - // [#20311] `$empty` is declared `z.boolean()` (staged out of FILTER_OPERATORS), + // [#20311] `$empty` is declared `z.boolean()` (in FILTER_OPERATORS since #20446), // so it joins the flag arm by derivation and the door must hold it to a boolean. expect(BOOLEAN_SLOTS.sort()).toEqual(['$empty', '$exists', '$null']); // Every operator the face judges today refuses at least one battery shape — diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 3d56a6fc993..5059d3ccdc1 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -261,11 +261,12 @@ function comparandShapeRefusalAtSave( * position, because the backends read one in opposite directions. * * [#20311] `$empty` is a flag by the same declaration (ruling A on #20399, - * record 5865693155: "`$empty: boolean`"). It is staged — no query face has an - * arm for it yet, and each refuses it whole — so this door holds it to its - * declared type from the day it is declared, the rule each face's arm then - * inherits, rather than letting a `"true"` string be saved into a stored - * filter that no later arm will read the way its author meant. + * record 5865693155: "`$empty: boolean`"). This door has held it to its + * declared type since the day it was declared — before any query face had an + * arm for it — and each face's arm inherited the rule, so a `"true"` string was + * never saved into a stored filter no arm reads the way its author meant. + * [#20446] Every face answers it now, and the view operators `is_empty` / + * `is_not_empty` lower to it. */ const BOOLEAN_FLAG_OPERATORS: ReadonlySet = new Set(['$null', '$exists', '$empty']); @@ -289,8 +290,9 @@ function describeFlagComparand(value: unknown): string { * the issue's own `path` carries the location. * * [#20311] `$empty` keeps the first sentence and the prescription's form; its - * reason cannot be the opposite-directions history (no backend reads it yet), - * so it names the rule it shares with the two null flags instead. + * reason cannot be the opposite-directions history (no backend ever read it + * the other way), so it names the rule it shares with the two null flags + * instead. */ function nonBooleanFlagComparandMessage(op: string, field: string, value: unknown): string { if (op === '$empty') { 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 4065d782046..fe80ff611bf 100644 --- a/packages/spec/src/data/filter-view-operator-parity.test.ts +++ b/packages/spec/src/data/filter-view-operator-parity.test.ts @@ -99,7 +99,7 @@ describe('every view filter operator has an AST lowering', () => { '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$between', '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', - '$null', '$exists', + '$null', '$exists', '$empty', ]); const bad: string[] = []; for (const op of VIEW_FILTER_OPERATORS) { @@ -131,11 +131,15 @@ describe('every view filter operator has an AST lowering', () => { }); it('keeps null-direction keyed on the operator name, not the filler value', () => { - // Clients send a truthy placeholder for both directions. - expect(parseFilterAST(['note', 'is_empty', true])).toEqual({ note: { $null: true } }); - expect(parseFilterAST(['note', 'isempty', true])).toEqual({ note: { $null: true } }); - expect(parseFilterAST(['note', 'is_not_empty', true])).toEqual({ note: { $null: false } }); - expect(parseFilterAST(['note', 'isnotempty', true])).toEqual({ note: { $null: false } }); + // Clients send a truthy placeholder for both directions. [#20446] The empty + // pair lowers to `$empty` (the field's declared 「is empty」 row), the null + // pair to `$null`; the direction still comes from the NAME in both. + expect(parseFilterAST(['note', 'is_empty', true])).toEqual({ note: { $empty: true } }); + expect(parseFilterAST(['note', 'isempty', true])).toEqual({ note: { $empty: true } }); + expect(parseFilterAST(['note', 'is_not_empty', true])).toEqual({ note: { $empty: false } }); + expect(parseFilterAST(['note', 'isnotempty', true])).toEqual({ note: { $empty: false } }); + expect(parseFilterAST(['note', 'is_null', true])).toEqual({ note: { $null: true } }); + expect(parseFilterAST(['note', 'is_not_null', true])).toEqual({ note: { $null: false } }); }); it('still refuses an operator in neither vocabulary', () => { diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index e39eb346b7f..8e19815b848 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1553,12 +1553,15 @@ const EXISTS_PREDICATE_DESCRIPTION = * module-scope read of a set is not safe under `OS_EAGER_SCHEMAS=1`), and the * expansion lives in its own module for the same reason. * - * The last sentences are load-bearing too: the operator is STAGED (the - * maintainer's amendment of ruling A, record 5868169573, 「照 $like 先例分阶段」), - * so an author reading this description is told that no face answers it yet — - * the query executors refuse it, and `@objectstack/formula`'s write-side - * matcher answers its fail-closed `false` — rather than discovering either - * at run time. The measured per-face table is on {@link FILTER_OPERATORS}. + * The last sentences are load-bearing too. [#20446] The staging is over + * (the maintainer's amendment of ruling A, record 5868169573, 「照 $like + * 先例分阶段」, ended with every face holding an arm): the operator is in + * {@link FILTER_OPERATORS} and the view operators `is_empty` / + * `is_not_empty` lower to it. So the description tells an author the one + * place it is refused rather than guessed — a face that answers by the + * declared type, asked about a column it holds no declaration for — and what + * to write there instead. The measured per-face table is on + * {@link FILTER_OPERATORS}. */ const EMPTY_PREDICATE_DESCRIPTION = 'Is-empty check by the field\'s DECLARED type. `true` matches rows whose field is empty, ' @@ -1567,10 +1570,10 @@ const EMPTY_PREDICATE_DESCRIPTION = + 'qrcode) = null or \'\' (the empty string); multi-value types (multiselect, checkboxes, ' + 'tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] ' + '(the empty list); every other type = null only. A face that holds no field declaration ' - + 'judges by the value: null, \'\' and [] are empty. STAGED: declared ahead of its ' - + 'backends and absent from FILTER_OPERATORS. Until each face has its arm, the query ' - + 'executors refuse it and the write-side check matcher matches no record; the view ' - + 'operators is_empty / is_not_empty still lower to $null.'; + + 'judges by the value: null, \'\' and [] are empty. A face that answers by the declared ' + + 'type refuses the operator on a column whose declaration it does not hold (the built-in ' + + 'id, for one) rather than guess a row; use $null there for "has no value". The view ' + + 'operators is_empty / is_not_empty lower to this operator.'; /** * Special check operators for null, existence and emptiness. @@ -1589,8 +1592,9 @@ export const SpecialOperatorSchema = lazySchema(() => z.object({ /** * [#20311] Field IS EMPTY by its declared type — the per-type table * {@link EMPTY_PREDICATE_DESCRIPTION} carries, expanded per field by - * `expandEmptyOperator` (`./filter-empty-operator.ts`). STAGED: not in - * {@link FILTER_OPERATORS}. + * `expandEmptyOperator` (`./filter-empty-operator.ts`). [#20446] In + * {@link FILTER_OPERATORS}; the view operators `is_empty` / `is_not_empty` + * lower to it. */ $empty: z.boolean().optional().describe(EMPTY_PREDICATE_DESCRIPTION), })); @@ -1665,9 +1669,9 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ $null: z.boolean().optional().describe(NULL_PREDICATE_DESCRIPTION), $exists: z.boolean().optional().describe(EXISTS_PREDICATE_DESCRIPTION), // [#20311] Emptiness by the field's declared type — the ruled per-type table - // IS the description. STAGED like `$like` (#7536): declared here and in - // `SpecialOperatorSchema`, deliberately ABSENT from `FILTER_OPERATORS` until - // every face has its arm — see the `$empty` paragraph there. + // IS the description. [#20446] Declared here and in `SpecialOperatorSchema` + // and, now that every face has its arm, enforced by `FILTER_OPERATORS` — + // see the `$empty` paragraph there. $empty: z.boolean().optional().describe(EMPTY_PREDICATE_DESCRIPTION), })); @@ -2558,8 +2562,12 @@ const CANONICAL_INFIX: Record = { export function canonicalAstOperator(op: string): string { const lower = String(op).toLowerCase(); - // Null predicates carry a DIRECTION that the shared `$null` lowering erases, - // so they cannot round-trip through CANONICAL_INFIX — fold them by name. + // Null and empty predicates carry a DIRECTION that their shared `$null` / + // `$empty` lowering erases, so they cannot round-trip through + // CANONICAL_INFIX — fold them by name. [#20446] The empty pair is its OWN + // canonical name now, not `is_null` / `is_not_null`: it lowers to `$empty` + // (the field's declared row of the 「is empty」 table), which is a different + // question from `$null` on a text or multi-value field. if (lower === 'is_null' || lower === 'isnull') return 'is_null'; if (lower === 'is_not_null' || lower === 'isnotnull') return 'is_not_null'; if (lower === 'is_empty' || lower === 'isempty') return 'is_empty'; @@ -2684,11 +2692,13 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { // Null / empty predicates — direction comes from the operator NAME, not the // (filler) value: the ObjectUI client sends a truthy placeholder value for // both `isnull` and `isnotnull`, so keying off `value` would collapse them. - // [#20311] The empty pair still lowers to `$null`, on purpose: its ruled - // spelling `$empty` is staged out of `FILTER_OPERATORS`, so emitting it here - // would turn every stored 「is empty」 into a refusal. The flip card moves - // both this branch and `canonicalAstOperator`'s fold once every face answers - // `$empty`. + // [#20446] The empty pair lowers to `$empty`, its ruled spelling (ruling A + // on #20399, record 5865693155), typelessly: each face expands it by the + // field's DECLARED row (`expandEmptyOperator`), so a stored 「is empty」 on a + // text field also finds `''` and on a multi-value field also finds `[]` + // (ruling B on #20311). It lowered to `$null` while `$empty` was staged out + // of `FILTER_OPERATORS`; the face that holds no declaration for the column + // now refuses it loudly (prescribing `$null`) where `$null` answered. if (op === 'is_null' || op === 'isnull') { return { [field]: { $null: true } } as FilterCondition; } @@ -3068,43 +3078,38 @@ export const FilterArraySchema: z.ZodType = z.lazy(() * refuses. Clearing the staging means arms on the remaining faces in ONE PR, * the #6520 direction — tracked as the follow-up filed on #7536. * - * ## `$empty` is STAGED here too (#20311) + * ## `$empty` JOINED this array in #20446 — its staging is over * * Declared by {@link SpecialOperatorSchema} and {@link FieldOperatorsSchema}, * its description the ruled per-type 「is empty」 table (ruling B on #20311, * record 5861435168; the spelling is ruling A on #20399, record 5865693155), * with `expandEmptyOperator` / `isEmptyFilterValue` - * (`./filter-empty-operator.ts`) as the one expansion every face calls — and - * deliberately ABSENT from this array (the maintainer's amendment of ruling A, - * record 5868169573: 「照 $like 先例分阶段」), - * for the mechanism measured above: membership is what `driver-memory`'s gate - * accepts, and its matcher's `default:` arm lets the row pass. - * - * No face answers it yet. Measured with this declaration built, a hand-authored - * `{ f: { $empty: true } }` (and `false`, and nested under `$and`): - * - * | face | `$empty` today | + * (`./filter-empty-operator.ts`) as the one expansion every face calls. It was + * staged out of this array (the maintainer's amendment of ruling A, record + * 5868169573: 「照 $like 先例分阶段」) for the mechanism measured above, until one + * compile-surface lane card per face (#20444, #20445) had given every face its + * arm. #20446 added it here — measured first, with `$empty` in this array and + * no other change, on every face: no face drops the predicate — and flipped the + * `is_empty` / `is_not_empty` lowering from `$null` to `$empty` in the same + * commit. + * + * | face | `$empty` | * |---|---| - * | `driver-sql` — measured on it; `driver-sqlite-wasm` and `driver-turso`'s local transport inherit its compiler | REFUSES — `INVALID_FILTER` / 400 | - * | `driver-turso` remote transport | REFUSES — `INVALID_FILTER` / 400 | - * | `driver-memory` — query path and reference matcher | REFUSES — `INVALID_FILTER` / 400 | - * | `driver-mongodb` | REFUSES — `INVALID_FILTER` / 400 | - * | objectql `having` | REFUSES — `INVALID_FILTER` / 400 | - * | `service-analytics` — the `where` lowering passes it on, the compile after it | REFUSES — `INVALID_FILTER` / 400 | - * | `service-analytics` — the read-scope SQL compiler | REFUSES, fail-closed — `READ_SCOPE_COMPILE_FAILED` / 500 | - * | `@objectstack/formula` `matchesFilterCondition` | answers `false` for every record, flag `true` or `false` — its decided fail-closed posture for an operator it has no arm for, the same answer an undeclared name gets | - * - * Nothing DROPS it, which is what the staging exists to guarantee. The formula - * row is the one that is not loud, and it is not a widening: that face judges - * a write-side `check`, where `false` denies the write. It is still the thing - * its own docblock calls "the same defect under a new name" for a DECLARED - * operator, so its arm is owed by its lane card like every other face's. - * - * Clearing the staging is the FLIP CARD's — the last card of ruling A's - * sequence, after one compile-surface lane card per face has given that face - * its arm: it adds `$empty` here, empties it out of - * `filter-operator-vocabulary.test.ts`' `STAGED_AHEAD_OF_BACKENDS`, and flips - * the `is_empty` / `is_not_empty` lowering from `$null` to `$empty`. + * | `driver-sql` (and `driver-sqlite-wasm`, `driver-turso`'s local transport) | the field's DECLARED row, from `initObjects` / `registerObjectMetadata` / `registerExternalObject` | + * | `driver-turso` remote transport | the declared row, through the resolver `TursoDriver` wires | + * | `driver-memory` query path, `driver-mongodb` | the declared row, from `syncSchema` | + * | `service-analytics` — `where` and read-scope SQL | the declared row, from the host's `sourceFieldMeta` | + * | `driver-memory` reference matcher, objectql `having`, `@objectstack/formula` `matchesFilterCondition` | by VALUE — null, `''` and `[]` are empty (they hold no declarations) | + * | `driver-memory` analytics (cube) face | REFUSES — `INVALID_FILTER` / 400, as it refuses `$null` | + * + * A declared-row face asked about a column it holds NO declaration for refuses + * the operator loudly (`INVALID_FILTER` / 400; `READ_SCOPE_COMPILE_FAILED` / + * 500 on a read scope) and prescribes `$null`, rather than guessing a row. The + * flip made that refusal reachable from a stored view rule, where `$null` + * answered before: the built-in `id`, a federated object on a driver with no + * `registerExternalObject` (the boot reports it unbound), an analytics host + * built without `sourceFieldMeta`, and a multi-value column over a SQL dialect + * `driver-sql` does not model. The changeset declares it as a narrowing. * * Retired operators (`$regex`, `$options`) are not here either, and never were. * Their prescriptions live in {@link RETIRED_FILTER_OPERATORS}. diff --git a/packages/spec/src/ui/view-grouping-query.ts b/packages/spec/src/ui/view-grouping-query.ts index 3923dc3c150..a3e7f5ba3f7 100644 --- a/packages/spec/src/ui/view-grouping-query.ts +++ b/packages/spec/src/ui/view-grouping-query.ts @@ -542,8 +542,14 @@ export function compileListViewGroupQuery( * for the empty group, whose header key is `null`: the `$null` predicate is * the AST's own spelling for absence (`data/filter.zod.ts`, lowered to * `IS NULL` on the SQL family) and the one the view filter dialect's - * `is_empty` / `is_null` lower to (`parseFilterAST`), so a group predicate - * and a view filter agree on what "empty" means. + * `is_null` lowers to (`parseFilterAST`), so the empty group selects exactly + * the rows its header counted. It is deliberately NOT `$empty`, which the + * view filter's `is_empty` lowers to since #20446: a group key is ONE stored + * value, so the header row counts `''` (a text field) under a group of its + * own, apart from the `null` group, and a `[]` cell is not a scalar key at + * all. `$empty` would open the `null` group onto the `''` group's rows too. + * So a group predicate and a view filter's `is_null` agree on "no value"; + * `is_empty` asks the wider, per-type question. * * `groupKey` must carry a PREFIX of the nesting order — every level from the * outermost down to the group being opened, and no level past it — so the From bc601d0ebb71b582f5a3eba34447c1e591470628 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:12:26 +0000 Subject: [PATCH 03/14] =?UTF-8?q?test(drivers,objectql,analytics):=20pin?= =?UTF-8?q?=20the=20$empty=20flip=20=E2=80=94=20ruled=20rows,=20N1=20and?= =?UTF-8?q?=20N2=20refusals=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/memory-20446-empty-flip.test.ts | 147 ++++++++++++++ .../src/memory-filter-ast-vocabulary.test.ts | 9 + .../src/mongodb-operator-key-clobber.test.ts | 11 +- .../src/sql-driver-20446-empty-flip.test.ts | 179 ++++++++++++++++++ .../operator-object-write-value.test.ts | 58 ++++++ packages/services/service-analytics/README.md | 18 +- .../__tests__/where-empty-flip-host.test.ts | 99 ++++++++++ 7 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-20446-empty-flip.test.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-20446-empty-flip.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts diff --git a/packages/drivers/driver-memory/src/memory-20446-empty-flip.test.ts b/packages/drivers/driver-memory/src/memory-20446-empty-flip.test.ts new file mode 100644 index 00000000000..d4de8b4ccd7 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20446-empty-flip.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20446] The flip, on this driver's live query path: a STORED view rule's + * 「is empty」 / 「is not empty」 reaches the driver as `$empty` (the spec's + * `parseFilterAST` — the one lowering every door runs — emits it since #20446; + * it emitted `$null` before) and is answered by the field's DECLARED row. + * + * The rules are lowered here by `parseFilterAST`, exactly as the engine's + * array door and the protocol's `?filter=` door lower them, and handed to + * `find` — so what is pinned is the stored rule, not the operator. + * + * - Ruling A on #20399 (record 5865693155): a stored rule's 「is empty」 on a + * multi-value field returns the rows holding `[]` or null, and refuses + * nothing. + * - Ruling B on #20311 (record 5861435168): a text field holding `''` is + * empty; `is_not_empty` is the exact complement. + * - N2, the narrowing the changeset declares: where this driver holds no + * declaration for the column, the rule is REFUSED, loudly, with the `$null` + * prescription — where the old `$null` lowering answered. Two compositions + * reach it here: the built-in `id` (no object declares it), and a federated + * (ADR-0015) object, which the engine never hands this driver's `syncSchema` + * because it has no `registerExternalObject` (objectql `plugin.ts`'s boot + * loop; the boot reports such an object as NOT bound). + * - The QueryAST-node spelling (`{ type: 'comparison', operator: 'is_empty' }`) + * answers through the same arm as the lowered rule, not the old `$null`. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; + +const TABLE = 'os20446_task'; +/** A federated object: never synced here, the way the engine's boot skips it on this driver. */ +const FEDERATED = 'os20446_ext_task'; + +/** What the engine's registry hands `syncSchema` — `id` is NOT a declared field there. */ +const FIELDS = { + title: { type: 'text' }, + tags: { type: 'tags' }, + amount: { type: 'number' }, +}; + +const ROWS: Array> = [ + { id: 'r1', title: null, tags: null, amount: null }, + { id: 'r2', title: '', tags: [], amount: 1 }, + { id: 'r3', title: 'a', tags: ['x'], amount: 2 }, +]; +const ALL = ['r1', 'r2', 'r3']; + +type Refusal = { code?: string; status?: number; message: string }; + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + return 'answered'; + } catch (err) { + const e = err as { code?: string; status?: number; message?: string }; + return { code: e.code, status: e.status, message: String(e.message) }; + } +} + +describe('[#20446] InMemoryDriver — a stored 「is empty」 rule, lowered to $empty, on the live path', () => { + let driver: InMemoryDriver; + const rows = async (object: string, where: unknown) => + ((await driver.find(object, { where } as never)) as Array>).map((r) => String(r.id)).sort(); + /** A stored rule, lowered the way every door lowers it. */ + const rule = (field: string, op: string) => parseFilterAST([field, op, true]) as FilterCondition; + + beforeAll(async () => { + driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + await driver.syncSchema(TABLE, { fields: FIELDS }); + for (const row of ROWS) await driver.create(TABLE, { ...row }); + for (const row of ROWS) await driver.create(FEDERATED, { ...row }); + }); + + it('the lowering this file drives is the flipped one', () => { + expect(rule('tags', 'is_empty')).toEqual({ tags: { $empty: true } }); + expect(rule('tags', 'is_not_empty')).toEqual({ tags: { $empty: false } }); + }); + + it('ruling A: a stored 「is empty」 on a multi-value field returns the [] and null rows, and refuses nothing', async () => { + for (const op of ['is_empty', 'isempty']) { + expect(await rows(TABLE, rule('tags', op)), op).toEqual(['r1', 'r2']); + } + }); + + it('ruling B: a stored 「is empty」 on a text field finds \'\' as well as null', async () => { + expect(await rows(TABLE, rule('title', 'is_empty'))).toEqual(['r1', 'r2']); + // A scalar field keeps the null-only row. + expect(await rows(TABLE, rule('amount', 'is_empty'))).toEqual(['r1']); + }); + + it('is_not_empty is the exact complement of is_empty on every declared field', async () => { + for (const field of Object.keys(FIELDS)) { + const empty = await rows(TABLE, rule(field, 'is_empty')); + const full = await rows(TABLE, rule(field, 'is_not_empty')); + expect(empty.filter((id) => full.includes(id)), `${field}: overlap`).toEqual([]); + expect([...empty, ...full].sort(), `${field}: union`).toEqual(ALL); + } + expect(await rows(TABLE, rule('tags', 'is_not_empty'))).toEqual(['r3']); + expect(await rows(TABLE, rule('title', 'is_not_empty'))).toEqual(['r3']); + }); + + it('N2: a stored rule on the built-in `id` is REFUSED with the $null prescription, both directions', async () => { + for (const op of ['is_empty', 'is_not_empty']) { + const got = await refusalOf(() => rows(TABLE, rule('id', op))); + expect(got, op).not.toBe('answered'); + const r = got as Refusal; + expect({ code: r.code, status: r.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(r.message, op).toContain('Operator "$empty" on field "id"'); + expect(r.message, op).toContain('use "$null" for "has no value"'); + } + }); + + it('N2: a federated object this driver was never handed is REFUSED on its author-declared columns', async () => { + // The composition: the engine binds a federated object through + // `registerExternalObject`, and skips it on a driver without one. + expect((driver as unknown as { registerExternalObject?: unknown }).registerExternalObject).toBeUndefined(); + for (const field of Object.keys(FIELDS)) { + const got = await refusalOf(() => rows(FEDERATED, rule(field, 'is_empty'))); + expect(got, field).not.toBe('answered'); + const r = got as Refusal; + expect({ code: r.code, status: r.status }, field).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(r.message, field).toContain('the object\'s schema was never synced'); + expect(r.message, field).toContain('use "$null" for "has no value"'); + } + }); + + it('the QueryAST-node spelling answers through the same arm as the lowered rule', async () => { + const node = (field: string, operator: string) => ({ type: 'comparison', field, operator, value: true }); + for (const [field, op, lowered] of [ + ['tags', 'is_empty', 'is_empty'], + ['tags', 'isempty', 'is_empty'], + ['title', 'is_empty', 'is_empty'], + ['tags', 'is_not_empty', 'is_not_empty'], + ['title', 'isnotempty', 'is_not_empty'], + ] as const) { + expect(await rows(TABLE, node(field, op)), `${field} ${op}`).toEqual(await rows(TABLE, rule(field, lowered))); + } + expect(await rows(TABLE, node('tags', 'is_empty'))).toEqual(['r1', 'r2']); + const refused = await refusalOf(() => rows(TABLE, node('id', 'is_empty'))); + expect(refused).not.toBe('answered'); + expect((refused as Refusal).code).toBe('INVALID_FILTER'); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts b/packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts index 1e0f84fe300..b4c8c45db5b 100644 --- a/packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts +++ b/packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts @@ -43,6 +43,15 @@ describe('InMemoryDriver filter vocabulary ↔ VALID_AST_OPERATORS', () => { beforeEach(async () => { driver = new InMemoryDriver({ persistence: false }); await driver.connect(); + // [#20446] The table is DECLARED, as every object the engine serves is + // (`syncSchema` at boot). `is_empty` / `is_not_empty` lower to `$empty` + // now, which this driver answers by the field's declared row and refuses + // on a column it was never told the type of — so an undeclared probe table + // would measure that refusal (pinned in `memory-20446-empty-flip.test.ts`), + // not the expressibility this file is about. + await driver.syncSchema(TABLE, { + fields: { name: { type: 'text' }, score: { type: 'number' }, note: { type: 'text' } }, + }); await driver.create(TABLE, { id: '1', name: 'alpha', score: 10, note: null }); await driver.create(TABLE, { id: '2', name: 'beta', score: 20, note: 'set' }); }); diff --git a/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts b/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts index 740cfffc085..62d641a8785 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts @@ -47,8 +47,14 @@ import { FILTER_OPERATORS } from '@objectstack/spec/data'; import { translateFilter } from './mongodb-filter.js'; +/** + * [#20446] Every probed field is declared `text`, so `$empty` — in + * `FILTER_OPERATORS` since #20446 — translates its text row rather than being + * refused for want of a declaration. No other operator reads the resolver. + */ +const TEXT_SHAPE = { type: 'text' } as const; const doc = (where: unknown): Record => - translateFilter(where as never) as Record; + translateFilter(where as never, undefined, () => TEXT_SHAPE) as Record; /** Both key orders of one two-operator field constraint. */ function bothOrders( @@ -115,6 +121,8 @@ describe('[#13524] the ENUMERATION — which lowered key each declared operator ['$null', false, ['$ne']], ['$exists', true, ['$ne']], ['$exists', false, ['$eq']], + ['$empty', true, ['$in']], // the text row: null or '' + ['$empty', false, ['$nin']], ]; it('the probe table covers the declared vocabulary exactly', () => { @@ -237,6 +245,7 @@ describe('[#13524] the sweep — every declared pair, both orders, nothing dropp $icontains: '07-15', $null: false, $exists: true, + $empty: false, }); it('the comparand table covers the declared vocabulary exactly', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-20446-empty-flip.test.ts b/packages/drivers/driver-sql/src/sql-driver-20446-empty-flip.test.ts new file mode 100644 index 00000000000..d6acf070ab4 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20446-empty-flip.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20446] The flip, on `driver-sql`: a STORED view rule's 「is empty」 / + * 「is not empty」 reaches the compiler as `$empty` (the spec's `parseFilterAST` + * — the one lowering every door runs — emits it since #20446; it emitted `$null` + * before) and is answered by the field's DECLARED row. + * + * - Ruling A on #20399 (record 5865693155): a stored rule's 「is empty」 on a + * multi-value field returns the rows holding `[]` or null, and refuses + * nothing. + * - Ruling B on #20311 (record 5861435168): a text field holding `''` is + * empty; `is_not_empty` is the exact complement. + * - A federated (ADR-0015) object is answered too: this driver implements + * `registerExternalObject`, which records the declarations the engine hands + * it — the corrected premise of ruling 5881556735 on #20446. + * - N2, the narrowing the changeset declares, in its two `driver-sql` shapes: + * the built-in `id` (no object declares it), and a multi-value column over a + * knex client this driver does not model (mssql here — its client builds + * without a server, so the statement is compiled, not run). Each is REFUSED, + * loudly, with the `$null` prescription, where the old `$null` lowering + * compiled `IS NULL`. + * + * SQLite only: the per-dialect JSON constructs are `sql-driver-20444-empty- + * operator.test.ts`'s matrix; this file pins the lowering's reach. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Knex } from 'knex'; +import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; +import { SqlDriver } from './sql-driver.js'; + +const TABLE = 'os20446_task'; + +/** What the engine's registry hands the driver — `id` is NOT a declared field there. */ +const FIELDS = { + title: { type: 'text' }, + tags: { type: 'tags' }, + amount: { type: 'number' }, +}; + +const ROWS: Array> = [ + { id: 'r1', title: null, tags: null, amount: null }, + { id: 'r2', title: '', tags: [], amount: 1 }, + { id: 'r3', title: 'a', tags: ['x'], amount: 2 }, +]; +const ALL = ['r1', 'r2', 'r3']; + +/** A stored rule, lowered the way every door lowers it. */ +const rule = (field: string, op: string) => parseFilterAST([field, op, true]) as FilterCondition; + +type Refusal = { code?: string; status?: number; message: string }; + +async function refusalOf(run: () => unknown): Promise { + try { + await run(); + return 'answered'; + } catch (err) { + const e = err as { code?: string; status?: number; message?: string }; + return { code: e.code, status: e.status, message: String(e.message) }; + } +} + +function expectPrescribedRefusal(got: Refusal | 'answered', label: string): void { + expect(got, label).not.toBe('answered'); + const r = got as Refusal; + expect({ code: r.code, status: r.status }, label).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(r.message, label).toContain('Operator "$empty"'); + expect(r.message, label).toContain('"$null"'); +} + +describe('[#20446] SqlDriver (SQLite) — a stored 「is empty」 rule, lowered to $empty', () => { + let driver: SqlDriver; + const rows = async (where: FilterCondition) => + ((await driver.find(TABLE, { where })) as Array>).map((r) => String(r.id)).sort(); + + beforeAll(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.initObjects([{ name: TABLE, fields: FIELDS } as never]); + for (const row of ROWS) await driver.create(TABLE, { ...row }); + }); + afterAll(async () => { + await driver?.disconnect?.(); + }); + + it('the lowering this file drives is the flipped one', () => { + expect(rule('tags', 'is_empty')).toEqual({ tags: { $empty: true } }); + expect(rule('tags', 'isnotempty')).toEqual({ tags: { $empty: false } }); + }); + + it('ruling A: a stored 「is empty」 on a multi-value field returns the [] and null rows, and refuses nothing', async () => { + for (const op of ['is_empty', 'isempty']) { + expect(await rows(rule('tags', op)), op).toEqual(['r1', 'r2']); + } + }); + + it('ruling B: a stored 「is empty」 on a text field finds \'\' as well as null; a scalar keeps null only', async () => { + expect(await rows(rule('title', 'is_empty'))).toEqual(['r1', 'r2']); + expect(await rows(rule('amount', 'is_empty'))).toEqual(['r1']); + }); + + it('is_not_empty is the exact complement of is_empty on every declared field', async () => { + for (const field of Object.keys(FIELDS)) { + const empty = await rows(rule(field, 'is_empty')); + const full = await rows(rule(field, 'is_not_empty')); + expect(empty.filter((id) => full.includes(id)), `${field}: overlap`).toEqual([]); + expect([...empty, ...full].sort(), `${field}: union`).toEqual(ALL); + } + expect(await rows(rule('tags', 'is_not_empty'))).toEqual(['r3']); + }); + + it('N2: a stored rule on the built-in `id` is REFUSED with the $null prescription, both directions', async () => { + for (const op of ['is_empty', 'is_not_empty']) { + expectPrescribedRefusal(await refusalOf(() => rows(rule('id', op))), op); + } + }); +}); + +describe('[#20446] SqlDriver — a federated object bound by registerExternalObject is answered', () => { + const file = join(tmpdir(), `os-20446-fed-${process.pid}-${Date.now()}.db`); + let ext: SqlDriver; + + beforeAll(async () => { + const fixture = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); + await fixture.initObjects([{ name: 'remote_task', fields: FIELDS } as never]); + for (const row of ROWS) await fixture.create('remote_task', { ...row }); + await fixture.disconnect?.(); + ext = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: file }, + useNullAsDefault: true, + schemaMode: 'external', + } as never); + // What the engine's boot does for an `external` object on a driver that implements federation. + ext.registerExternalObject({ name: 'os20446_ext_task', fields: FIELDS, external: { remoteName: 'remote_task' } } as never); + }); + afterAll(async () => { + await ext?.disconnect?.(); + rmSync(file, { force: true }); + }); + + it('every author-declared column answers the stored rule by its declared row', async () => { + const rows = async (where: FilterCondition) => + ((await ext.find('os20446_ext_task', { where })) as Array>).map((r) => String(r.id)).sort(); + expect(await rows(rule('tags', 'is_empty'))).toEqual(['r1', 'r2']); + expect(await rows(rule('title', 'is_empty'))).toEqual(['r1', 'r2']); + expect(await rows(rule('amount', 'is_empty'))).toEqual(['r1']); + expect(await rows(rule('tags', 'is_not_empty'))).toEqual(['r3']); + }); +}); + +describe('[#20446] SqlDriver — a multi-value column over a knex client this driver does not model', () => { + /** Compiles the WHERE without a server — the `[#6518]` probe shape. */ + class CompilerProbeDriver extends SqlDriver { + compileWhere(where: FilterCondition): string { + const builder: Knex.QueryBuilder = this.getKnex()(TABLE); + this.applyFilters(builder, where); + return builder.toString(); + } + } + + it('N2: the lowered rule is REFUSED with the $null prescription on the multi-value column only', async () => { + // mssql is not a platform datasource driver; its knex client builds without + // a server, and this driver reads it as the `'unknown'` dialect. + const d = new CompilerProbeDriver({ client: 'mssql', connection: {} } as never); + expect(d.dialectName).toBe('unknown'); + d.registerObjectMetadata([{ name: TABLE, fields: FIELDS } as never]); + for (const op of ['is_empty', 'is_not_empty']) { + expectPrescribedRefusal(await refusalOf(() => d.compileWhere(rule('tags', op))), `tags ${op}`); + } + // The text and null-only rows need no dialect construct, and `$null` never did. + expect(d.compileWhere(rule('title', 'is_empty'))).toMatch(/is null or/i); + expect(d.compileWhere(rule('amount', 'is_empty'))).toMatch(/is null/i); + expect(d.compileWhere({ tags: { $null: true } })).toMatch(/is null/i); + }); +}); diff --git a/packages/objectql/src/validation/operator-object-write-value.test.ts b/packages/objectql/src/validation/operator-object-write-value.test.ts index bf48b5b8166..715a3bfe71d 100644 --- a/packages/objectql/src/validation/operator-object-write-value.test.ts +++ b/packages/objectql/src/validation/operator-object-write-value.test.ts @@ -279,3 +279,61 @@ describe('#5922 — write path end to end: no dirty row reaches the driver', () expect(rows.get('rec_1')).toMatchObject({ title: 'renamed', n: 7 }); }); }); + +/** + * [#20446] N1 — the narrowing the changeset declares on the write door. + * + * `$empty` joined `FILTER_OPERATORS`, so it is in `ALL_OPERATORS` and therefore + * in this rule's derived key set — refused as a VALUE the day it joined, which is + * the #5922 rule working as written. Before #20446 `{ title: { $empty: true } }` + * was not recognised as a filter: a text field stored it (driver-memory kept the + * object, driver-sql the string `{"$empty":true}`). The prescription is the + * rule's own: write the value itself; a filter belongs in the query `where`. + */ +describe('#20446 N1 — a `{ $empty: … }` written as a field value is refused as a filter', () => { + it('validateRecord names $empty and prescribes the where clause, on insert and update', () => { + for (const mode of ['insert', 'update'] as const) { + for (const flag of [true, false]) { + const errs = errorsOf({ title: { $empty: flag } }, mode); + expect(errs, `${mode} ${flag}`).toHaveLength(1); + expect(errs[0].field).toBe('title'); + expect(errs[0].code).toBe('invalid_type'); + expect(errs[0].message).toContain('$empty is a filter operator, not a value'); + expect(errs[0].message).toContain("a filter belongs in the query 'where', not in the write payload"); + } + } + }); + + it('the engine refuses it end to end in the VALIDATION_FAILED envelope, and the driver is never written', async () => { + const engine = new ObjectQL(); + const writes: unknown[] = []; + engine.registerDriver({ + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async find() { return []; }, async findOne() { return null; }, + async create(_o: string, data: Record) { writes.push(data); return { ...data, id: 'x' }; }, + async update(_o: string, id: string, data: Record) { writes.push(data); return { ...data, id }; }, + async updateMany(_o: unknown, _a: unknown, data: unknown) { writes.push(data); return 0; }, + async delete() { return true; }, async count() { return 0; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + } as any, true); + await engine.init(); + engine.registry.registerObject({ + name: 'empty_task', label: 'Task', + fields: { title: { name: 'title', label: 'Title', type: 'text' } }, + } as any); + for (const run of [ + () => engine.insert('empty_task', { title: { $empty: true } } as any), + () => engine.update('empty_task', { title: { $empty: false } } as any, { multi: true } as any), + ]) { + const err = await run().then(() => null, (e: unknown) => e as ValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(err!.code).toBe('VALIDATION_FAILED'); + expect(err!.fields.map((f) => [f.field, f.code])).toEqual([['title', 'invalid_type']]); + expect(err!.message).toContain("a filter belongs in the query 'where'"); + } + expect(writes).toEqual([]); + }); +}); diff --git a/packages/services/service-analytics/README.md b/packages/services/service-analytics/README.md index 3a59f339765..365a7984053 100644 --- a/packages/services/service-analytics/README.md +++ b/packages/services/service-analytics/README.md @@ -182,9 +182,25 @@ import { AnalyticsService, CubeRegistry } from '@objectstack/service-analytics'; const registry = new CubeRegistry(); registry.registerAll([ordersCube]); -const service = new AnalyticsService({ cubes: [ordersCube] }); +const service = new AnalyticsService({ + cubes: [ordersCube], + // The declared type (and `multiple`) of a source object's field — what + // `AnalyticsServicePlugin` relays from the engine's registry. + sourceFieldMeta: (object, field) => { + const f = engine.getObject(object)?.fields?.[field]; + return f ? { type: f.type, multiple: f.multiple === true } : undefined; + }, +}); ``` +`sourceFieldMeta` is how the service knows a field's declared type. The view +operators `is_empty` / `is_not_empty` — the `$empty` operator — need it: what +counts as empty depends on the type (null or `''` for a text-like field, null or +`[]` for a multi-value field, null only for every other type). A host built +without it refuses them with `INVALID_FILTER` (a read scope, with +`READ_SCOPE_COMPILE_FAILED`) rather than guess; pass `sourceFieldMeta`, or filter +with `is_null` / `is_not_null` there. + ## License Apache-2.0. See [LICENSING.md](../../../LICENSING.md). diff --git a/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts b/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts new file mode 100644 index 00000000000..1190559742e --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20446] N2 on an analytics host: a stored 「is empty」 rule reaches + * `AnalyticsService` as `$empty` (the spec's `parseFilterAST` lowers the view + * operators `is_empty` / `is_not_empty` to it since #20446; it lowered them to + * `$null` before), and `$empty` is answered by the field's DECLARED type, which + * the host supplies through `sourceFieldMeta`. + * + * - A host constructed directly WITHOUT `sourceFieldMeta` (the shape the + * package README showed before #20446) cannot name the type, so it REFUSES + * the rule — loudly, `INVALID_FILTER` / 400, prescribing `$null` — and runs + * no SQL. It compiled `IS NULL` under the old lowering: this is the + * narrowing the changeset declares. + * - The same host WITH `sourceFieldMeta` (the shape `AnalyticsServicePlugin` + * builds, and the README now shows) answers the declared row. + * - `is_null` is untouched on both. + */ + +import { describe, it, expect } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; + +import { AnalyticsService } from '../analytics-service.js'; + +const CUBE: Cube = { + name: 'orders', + title: 'Orders', + sql: 'orders', + public: true, + measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + dimensions: { + status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + }, +} as unknown as Cube; + +const q = (where: unknown): AnalyticsQuery => + ({ cube: 'orders', measures: ['count'], dimensions: ['status'], timezone: 'UTC', where }) as AnalyticsQuery; + +function host(withFieldMeta: boolean) { + const executed: string[] = []; + const service = new AnalyticsService({ + cubes: [CUBE], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object: string, sql: string) => { + executed.push(sql); + return []; + }, + ...(withFieldMeta + ? { sourceFieldMeta: (object: string, field: string) => (object === 'orders' && field === 'status' ? { type: 'text' } : undefined) } + : {}), + }); + return { service, executed }; +} + +type Refusal = { code?: string; status?: number; message: string }; +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + return 'answered'; + } catch (err) { + const e = err as { code?: string; status?: number; message?: string }; + return { code: e.code, status: e.status, message: String(e.message) }; + } +} + +describe('[#20446] N2 — a stored 「is empty」 rule on an analytics host', () => { + it('a host built WITHOUT sourceFieldMeta refuses it with the $null prescription, and runs no SQL', async () => { + const { service, executed } = host(false); + for (const op of ['is_empty', 'is_not_empty']) { + for (const run of [() => service.generateSql(q(['status', op, true])), () => service.query(q(['status', op, true]))]) { + const got = await refusalOf(run); + expect(got, op).not.toBe('answered'); + const r = got as Refusal; + expect({ code: r.code, status: r.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(r.message, op).toContain('Operator "$empty" is answered by the field\'s DECLARED type'); + expect(r.message, op).toContain('no field metadata is wired'); + expect(r.message, op).toContain('use "$null" for "has no value"'); + } + } + expect(executed).toEqual([]); + }); + + it('the same host WITH sourceFieldMeta answers the declared text row', async () => { + const { service } = host(true); + const empty = await service.generateSql(q(['status', 'is_empty', true])); + expect(empty.sql).toMatch(/WHERE \(status IS NULL OR status = \$1\)/); + expect(empty.params).toContain(''); + const full = await service.generateSql(q(['status', 'is_not_empty', true])); + expect(full.sql).toMatch(/WHERE \(status IS NOT NULL AND status <> \$1\)/); + }); + + it('is_null is untouched on both hosts', async () => { + for (const withFieldMeta of [false, true]) { + const { sql } = await host(withFieldMeta).service.generateSql(q(['status', 'is_null', true])); + expect(sql, String(withFieldMeta)).toMatch(/WHERE status IS NULL/); + } + }); +}); From 640656d9df9a97325f42ba9594327bee32496ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:14:05 +0000 Subject: [PATCH 04/14] test(driver-mongodb): record $empty's by-key contest in the clobber enumeration (WIP) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/mongodb-operator-key-clobber.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts b/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts index 62d641a8785..005edfb63a2 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-operator-key-clobber.test.ts @@ -157,6 +157,13 @@ describe('[#13524] the ENUMERATION — which lowered key each declared operator $lt: ['$between', '$lt', '$lte'], $lte: ['$between', '$lte'], $regex: ['$contains', '$endsWith', '$icontains', '$startsWith'], + // [#20446] `$empty` (in `FILTER_OPERATORS` since #20446) writes `$in` / + // `$nin` on its text row, so it contests those two keys BY KEY — but the + // translator never puts it in the shared slot: it is always a document of + // its own, AND-ed beside the field's other operators, so no pairing can + // clobber it (the sweep below holds every pair to that). + $in: ['$empty', '$in'], + $nin: ['$empty', '$nin'], }); // `$not` is written by `$notContains` and by nothing else — the card's // third named member, measured NOT reachable. From 38a8d9f7e32dc94167250b0583cbbeec88b47c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:19:51 +0000 Subject: [PATCH 05/14] docs(spec): regenerate the filter reference for the $empty describe (check:generated --fix) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/data/filter.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 055932a8e7b..068f9d790a5 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -120,7 +120,7 @@ const result = ComparisonOperatorSchema.parse(data); | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | -| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record; the view operators is_empty / is_not_empty still lower to $null. | +| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | ### Nested Shape: `FieldOperators.$gt` @@ -247,7 +247,7 @@ Type: `[FilterArray](#filterarray)[]` | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | -| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record; the view operators is_empty / is_not_empty still lower to $null. | +| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | ### Nested Shape: `NormalizedFilter.$or[number][string]` @@ -271,7 +271,7 @@ Type: `[FilterArray](#filterarray)[]` | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | -| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record; the view operators is_empty / is_not_empty still lower to $null. | +| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | ### Nested Shape: `NormalizedFilter.$not[string]` @@ -295,7 +295,7 @@ Type: `[FilterArray](#filterarray)[]` | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | -| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record; the view operators is_empty / is_not_empty still lower to $null. | +| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | --- @@ -342,7 +342,7 @@ Type: `[FilterArray](#filterarray)[]` | :--- | :--- | :--- | :--- | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | -| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record; the view operators is_empty / is_not_empty still lower to $null. | +| **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | --- From 26496a58d7bfd251459cceaa604bc9b4b6858d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:51:14 +0000 Subject: [PATCH 06/14] =?UTF-8?q?chore(changeset):=20$empty=20joins=20FILT?= =?UTF-8?q?ER=5FOPERATORS=20=E2=80=94=20Clause-=E2=91=A1=20yes=20(narrowin?= =?UTF-8?q?g)=20(WIP,=20marker=20pending=20the=20gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20446-empty-joins-filter-operators.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/20446-empty-joins-filter-operators.md diff --git a/.changeset/20446-empty-joins-filter-operators.md b/.changeset/20446-empty-joins-filter-operators.md new file mode 100644 index 00000000000..72f76383d56 --- /dev/null +++ b/.changeset/20446-empty-joins-filter-operators.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-memory': minor +'@objectstack/service-analytics': patch +--- + +feat(spec)!: `$empty` joins `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` lower to it (#20446) + +A stored 「is empty」 / 「is not empty」 — `['field', 'is_empty', …]`, `isempty`, `is_not_empty`, `isnotempty`, in a view rule, a sharing rule or any filter array — now lowers to `{ field: { $empty: true | false } }` instead of `$null`. `$empty` is answered by the field's DECLARED type: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. So an 「is empty」 rule on a text field now also finds `''`, and on a multi-value field also finds `[]`, which the `$null` lowering missed. `is_not_empty` is its exact complement. `$empty` is in `FILTER_OPERATORS` (and `ALL_OPERATORS`) now, and `canonicalAstOperator` folds the empty pair onto `is_empty` / `is_not_empty` rather than onto `is_null` / `is_not_null`. On `@objectstack/driver-memory`, a QueryAST comparison node (`{ type: 'comparison', operator: 'is_empty' }`) is answered by the same declared-type arm. + +**BREAKING**: two things accepted before are refused now, each loudly and with its fix. + +- **A `{ $empty: … }` object written as a field value** (a `where` pasted into an insert or update payload) is refused with `VALIDATION_FAILED` (`invalid_type`, "$empty is a filter operator, not a value"). Before, a text-like field stored it as data. + FROM `update('task', { title: { $empty: true } })` → TO write the value itself (`{ title: '' }`, `{ title: null }`); a filter belongs in `where`. +- **`is_empty` / `is_not_empty` where no face holds the column's declared type** is refused with `INVALID_FILTER` / 400 (`READ_SCOPE_COMPILE_FAILED` / 500 on an analytics read scope) and the prescription `$null`. The `$null` lowering answered these. The compositions: + - the built-in `id`, which no object declares. FROM `['id', 'is_empty', true]` → TO `['id', 'is_null', true]` / `is_not_null`; + - a federated (external) object on a driver that does not implement `registerExternalObject` (driver-memory, driver-mongodb). The boot already reports such an object as NOT bound to its remote table, naming it, and its reads answered from a table named after the object. FROM `is_empty` on such an object → TO bind it on a driver that implements federation (driver-sql and its heirs, driver-turso); + - an `AnalyticsService` constructed without `sourceFieldMeta`. FROM such a host → TO pass `sourceFieldMeta` (the package README shows it), or filter with `is_null` / `is_not_null`; + - a multi-value column on a SQL dialect `driver-sql` does not model (a knex client other than SQLite, PostgreSQL or MySQL). FROM `['tags', 'is_empty', true]` there → TO `['tags', 'is_null', true]` / `is_not_null`. + +Stored sharing rules and views that use 「is empty」 are not rewritten; they are re-read under the new meaning. Production rules that use 「is empty」 on a text or multi-value field were not measured; each finds more rows (the `''` / `[]` ones) from this release. + +Clause-②: yes (narrowing) From e6b76fdd8650be0ae02e0624fb8a631c1c50a213 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:52:14 +0000 Subject: [PATCH 07/14] feat(spec): ADR-0087 D3 entry for the is_empty lowering flip; changeset carries its registration marker Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20446-empty-joins-filter-operators.md | 2 + ...ilter-is-empty-lowers-to-empty-operator.ts | 56 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 52 +++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts diff --git a/.changeset/20446-empty-joins-filter-operators.md b/.changeset/20446-empty-joins-filter-operators.md index 72f76383d56..2ce639a3fd2 100644 --- a/.changeset/20446-empty-joins-filter-operators.md +++ b/.changeset/20446-empty-joins-filter-operators.md @@ -21,3 +21,5 @@ A stored 「is empty」 / 「is not empty」 — `['field', 'is_empty', …]`, ` Stored sharing rules and views that use 「is empty」 are not rewritten; they are re-read under the new meaning. Production rules that use 「is empty」 on a text or multi-value field were not measured; each finds more rows (the `''` / `[]` ones) from this release. Clause-②: yes (narrowing) + + diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts new file mode 100644 index 00000000000..b717f8b4545 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20446 — the last step of ruling A on #20399: `$empty` joins FILTER_OPERATORS +// and the view operators is_empty / is_not_empty lower to it instead of `$null`. +// Nothing stored is rewritten; a stored 「is empty」 rule is re-read under the new +// meaning, which widens on text and multi-value fields and refuses where no face +// holds the column's declared type. The changeset declares Clause-② yes +// (narrowing); this entry is where the prescription reaches os migrate meta, +// the upgrade guide and spec-changes.json. +export const entry: SemanticMigration = { + id: 'filter-is-empty-lowers-to-empty-operator', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — the view operators is_empty / isempty / is_not_empty / isnotempty ' + + '(a ViewFilterRule, a sharing rule, any filter array), and a $empty object written as a ' + + 'record field value', + replacement: + 'Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: ' + + 'true } } and is_not_empty to { field: { $empty: false } }, answered by the field\'s declared ' + + 'type — a text-like field is empty when it is null or the empty string, a multi-value field ' + + 'when it is null or the empty list, every other type only when it is null. Where no face holds ' + + 'the column\'s declared type the rule is refused: on the built-in id, write is_null / ' + + 'is_not_null; on a federated object whose driver does not implement external-object ' + + 'registration (driver-memory, driver-mongodb), bind it on a driver that implements federation ' + + '(the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass ' + + 'sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect ' + + 'driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty ' + + 'object as a field value writes the value itself instead; a filter belongs in where', + reason: + 'Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per field type, and ' + + 'ruling A on #20399 (record 5865693155) spelled it as the $empty operator, expanded by each ' + + 'compile face from the field\'s declaration. It was staged out of FILTER_OPERATORS (the ' + + 'maintainer\'s amendment, record 5868169573) until every face had its arm (#20444, #20445); ' + + '#20446 added it and flipped the lowering in one change, after measuring that no face drops ' + + 'it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value ' + + 'field finds more rows: the ones holding the empty string or the empty list, which the $null ' + + 'lowering missed while the three builders already showed them as empty. And the rule is ' + + 'refused, loudly and with the $null prescription, where the face that answers it holds no ' + + 'declaration for the column — the four compositions the replacement names — where the $null ' + + 'lowering compiled IS NULL. The same change made the write door refuse a $empty object as a ' + + 'field value, because that door refuses every filter operator the protocol enforces as a ' + + 'value (#5922): before, a text-like field stored it. Metadata AT REST is deliberately NOT ' + + 'rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its ' + + 'new meaning is the ruled one. ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' + + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' + + 'selects the rows holding the empty string or the empty list. On the built-in id, rewrite it ' + + 'to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an ' + + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails with ' + + 'INVALID_FILTER instead of answering — bind the object on a federation-capable driver, or pass ' + + 'sourceFieldMeta. No insert or update payload carries a $empty object as a field value.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 317051cc49b..5f0a092c0dd 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10419,6 +10419,58 @@ const step18: MigrationStep = { + 'what the view is supposed to show rather than assuming the old result set was correct. ' + 'Both refusals now arrive at the authoring path.', }, + // #20446 — the last step of ruling A on #20399: `$empty` joins FILTER_OPERATORS + // and the view operators is_empty / is_not_empty lower to it instead of `$null`. + // Nothing stored is rewritten; a stored 「is empty」 rule is re-read under the new + // meaning, which widens on text and multi-value fields and refuses where no face + // holds the column's declared type. The changeset declares Clause-② yes + // (narrowing); this entry is where the prescription reaches os migrate meta, + // the upgrade guide and spec-changes.json. + { + id: 'filter-is-empty-lowers-to-empty-operator', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — the view operators is_empty / isempty / is_not_empty / isnotempty ' + + '(a ViewFilterRule, a sharing rule, any filter array), and a $empty object written as a ' + + 'record field value', + replacement: + 'Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: ' + + 'true } } and is_not_empty to { field: { $empty: false } }, answered by the field\'s declared ' + + 'type — a text-like field is empty when it is null or the empty string, a multi-value field ' + + 'when it is null or the empty list, every other type only when it is null. Where no face holds ' + + 'the column\'s declared type the rule is refused: on the built-in id, write is_null / ' + + 'is_not_null; on a federated object whose driver does not implement external-object ' + + 'registration (driver-memory, driver-mongodb), bind it on a driver that implements federation ' + + '(the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass ' + + 'sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect ' + + 'driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty ' + + 'object as a field value writes the value itself instead; a filter belongs in where', + reason: + 'Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per field type, and ' + + 'ruling A on #20399 (record 5865693155) spelled it as the $empty operator, expanded by each ' + + 'compile face from the field\'s declaration. It was staged out of FILTER_OPERATORS (the ' + + 'maintainer\'s amendment, record 5868169573) until every face had its arm (#20444, #20445); ' + + '#20446 added it and flipped the lowering in one change, after measuring that no face drops ' + + 'it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value ' + + 'field finds more rows: the ones holding the empty string or the empty list, which the $null ' + + 'lowering missed while the three builders already showed them as empty. And the rule is ' + + 'refused, loudly and with the $null prescription, where the face that answers it holds no ' + + 'declaration for the column — the four compositions the replacement names — where the $null ' + + 'lowering compiled IS NULL. The same change made the write door refuse a $empty object as a ' + + 'field value, because that door refuses every filter operator the protocol enforces as a ' + + 'value (#5922): before, a text-like field stored it. Metadata AT REST is deliberately NOT ' + + 'rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its ' + + 'new meaning is the ruled one. ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' + + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' + + 'selects the rows holding the empty string or the empty list. On the built-in id, rewrite it ' + + 'to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an ' + + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails with ' + + 'INVALID_FILTER instead of answering — bind the object on a federation-capable driver, or pass ' + + 'sourceFieldMeta. No insert or update payload carries a $empty object as a field value.', + }, // Ruling A on #19886, item 1: the $ne slot's half of the question // filter-equality-array-comparand-refused answered for equality. One entry for // both named positions — the shared comparand-shape face every query crosses, From 9186457db947527fd4912adf290b52918c7c6120 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:05:42 +0000 Subject: [PATCH 08/14] test(driver-sql): the null-operators harness declares its knex-built table, as the engine does Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../driver-sql/src/sql-driver-null-operators.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts b/packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts index 4f78932cdc7..393f7e6d36c 100644 --- a/packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-null-operators.test.ts @@ -35,6 +35,17 @@ describe('SqlDriver — null / empty operators (#2704)', () => { t.string('assignee').nullable(); }); + // [#20446] The table is built through knex, outside the driver's + // registration, so its declaration is registered beside it — as the engine + // does for every object it serves. `is_empty` / `is_not_empty` lower to + // `$empty` now, which this driver answers by the column's DECLARED row and + // refuses on a column it was never told the type of; `assignee` is text, + // so its row is null or `''`, and this fixture stores no `''`, so the rows + // below are the IS NULL / IS NOT NULL rows they always were. + driver.registerObjectMetadata([ + { name: 'tasks', fields: { title: { type: 'text' }, assignee: { type: 'text' } } } as any, + ]); + await k('tasks').insert([ { id: '1', title: 'A', assignee: 'alice' }, { id: '2', title: 'B', assignee: null }, From e3a3980779f7046fcad217abab54853be5a7feb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:07:39 +0000 Subject: [PATCH 09/14] fix(spec): the $empty D3 entry's surface carries no clause separator Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18.filter-is-empty-lowers-to-empty-operator.ts | 9 +++++---- packages/spec/src/migrations/registry.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts index b717f8b4545..3484ae5df6b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts @@ -12,11 +12,12 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'filter-is-empty-lowers-to-empty-operator', // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. + // span already, and a nested backtick would close it. No ' / ' either: the + // schema build reads that separator as a boundary between registered clauses. surface: - 'data.FilterCondition — the view operators is_empty / isempty / is_not_empty / isnotempty ' - + '(a ViewFilterRule, a sharing rule, any filter array), and a $empty object written as a ' - + 'record field value', + 'data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty ' + + 'and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty ' + + 'object written as a record field value', replacement: 'Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: ' + 'true } } and is_not_empty to { field: { $empty: false } }, answered by the field\'s declared ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5f0a092c0dd..e062392194c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10429,11 +10429,12 @@ const step18: MigrationStep = { { id: 'filter-is-empty-lowers-to-empty-operator', // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. + // span already, and a nested backtick would close it. No ' / ' either: the + // schema build reads that separator as a boundary between registered clauses. surface: - 'data.FilterCondition — the view operators is_empty / isempty / is_not_empty / isnotempty ' - + '(a ViewFilterRule, a sharing rule, any filter array), and a $empty object written as a ' - + 'record field value', + 'data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty ' + + 'and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty ' + + 'object written as a record field value', replacement: 'Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: ' + 'true } } and is_not_empty to { field: { $empty: false } }, answered by the field\'s declared ' From 7bcef4085c901df46f2d419bfae9b3984de807ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:14:33 +0000 Subject: [PATCH 10/14] docs(driver-memory): keep the rank note's citation line as it was Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/drivers/driver-memory/src/memory-driver.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index d17c3bf0aeb..d8cf37b9c5a 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -71,9 +71,9 @@ import { * `[...FILTER_OPERATORS, '$like', '$ilike']` — the spec's declaration order, * not a hand-copy of it. That matters twice: a nineteenth operator is ranked * the day it is declared, and the rank of `$exists` (after every comparison, - * set and text operator in the spec's list; only `$empty`, since #20446, - * follows it) is what makes this generalisation emit, byte for byte, the - * documents #13195's guard already emits for the one operator it moved. + * set and text operator in the spec's list; only `$empty` follows it since + * #20446) is what makes this generalisation emit, byte for byte, the documents + * #13195's guard already emits for the one operator it moved. * * An operator absent from the vocabulary cannot reach the assembly — the * `default:` arm throws first — so the `?? Number.MAX_SAFE_INTEGER` fallback is From b4087e563e9c76231e40c08cbddfd07bbae77ff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:37:00 +0000 Subject: [PATCH 11/14] fix(spec): the filter-is-empty-lowers-to-empty-operator guidance says what was decided, not which card The migrate-meta guidance pin (packages/cli/test/migrate-meta-engine-guidance.test.ts) covers the filter- family: no author-shown field of a covered entry may cite a tracker id. This entry's reason cited six, plus three comment record ids. Each sentence now states the decision in words; ADR ids stay. A cut: the reason is one line shorter. The registry is regenerated by gen:migration-registry; spec-changes.json and the upgrade guide carry no protocol-18 entry yet, and their generators wrote no change. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...ilter-is-empty-lowers-to-empty-operator.ts | 29 +++++++++---------- packages/spec/src/migrations/registry.ts | 29 +++++++++---------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts index 3484ae5df6b..9200b7466f9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts @@ -31,21 +31,20 @@ export const entry: SemanticMigration = { + 'driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty ' + 'object as a field value writes the value itself instead; a filter belongs in where', reason: - 'Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per field type, and ' - + 'ruling A on #20399 (record 5865693155) spelled it as the $empty operator, expanded by each ' - + 'compile face from the field\'s declaration. It was staged out of FILTER_OPERATORS (the ' - + 'maintainer\'s amendment, record 5868169573) until every face had its arm (#20444, #20445); ' - + '#20446 added it and flipped the lowering in one change, after measuring that no face drops ' - + 'it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value ' - + 'field finds more rows: the ones holding the empty string or the empty list, which the $null ' - + 'lowering missed while the three builders already showed them as empty. And the rule is ' - + 'refused, loudly and with the $null prescription, where the face that answers it holds no ' - + 'declaration for the column — the four compositions the replacement names — where the $null ' - + 'lowering compiled IS NULL. The same change made the write door refuse a $empty object as a ' - + 'field value, because that door refuses every filter operator the protocol enforces as a ' - + 'value (#5922): before, a text-like field stored it. Metadata AT REST is deliberately NOT ' - + 'rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its ' - + 'new meaning is the ruled one. ADR-0087 / ADR-0112.', + 'One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty ' + + 'operator, which each compile face expands from the field\'s declaration. It was staged out ' + + 'of FILTER_OPERATORS until every face answered it, then added in the same change that flipped ' + + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' + + 'empty string or the empty list, which the $null lowering missed while the three builders ' + + 'already showed them as empty. And the rule is refused, loudly and with the $null ' + + 'prescription, where the face that answers it holds no declaration for the column — the four ' + + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + + 'change made the write door refuse a $empty object as a field value, because that door ' + + 'refuses every filter operator the protocol enforces as a value: before, a text-like field ' + + 'stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ' + + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f97b0535cfa..f285b631ca1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10877,21 +10877,20 @@ const step18: MigrationStep = { + 'driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty ' + 'object as a field value writes the value itself instead; a filter belongs in where', reason: - 'Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per field type, and ' - + 'ruling A on #20399 (record 5865693155) spelled it as the $empty operator, expanded by each ' - + 'compile face from the field\'s declaration. It was staged out of FILTER_OPERATORS (the ' - + 'maintainer\'s amendment, record 5868169573) until every face had its arm (#20444, #20445); ' - + '#20446 added it and flipped the lowering in one change, after measuring that no face drops ' - + 'it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value ' - + 'field finds more rows: the ones holding the empty string or the empty list, which the $null ' - + 'lowering missed while the three builders already showed them as empty. And the rule is ' - + 'refused, loudly and with the $null prescription, where the face that answers it holds no ' - + 'declaration for the column — the four compositions the replacement names — where the $null ' - + 'lowering compiled IS NULL. The same change made the write door refuse a $empty object as a ' - + 'field value, because that door refuses every filter operator the protocol enforces as a ' - + 'value (#5922): before, a text-like field stored it. Metadata AT REST is deliberately NOT ' - + 'rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its ' - + 'new meaning is the ruled one. ADR-0087 / ADR-0112.', + 'One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty ' + + 'operator, which each compile face expands from the field\'s declaration. It was staged out ' + + 'of FILTER_OPERATORS until every face answered it, then added in the same change that flipped ' + + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' + + 'empty string or the empty list, which the $null lowering missed while the three builders ' + + 'already showed them as empty. And the rule is refused, loudly and with the $null ' + + 'prescription, where the face that answers it holds no declaration for the column — the four ' + + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + + 'change made the write door refuse a $empty object as a field value, because that door ' + + 'refuses every filter operator the protocol enforces as a value: before, a text-like field ' + + 'stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ' + + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' From 395c53182a5e42e68b262ebbe3150a630e02b2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:28:40 +0000 Subject: [PATCH 12/14] test(service-analytics): the empty-flip host cube names its members by record key alone main retired the inner `name` on cube measures and dimensions (the record key is the member's name), and spec's tree-scoped absence pin (cube-member-inner-name-retirement.test.ts) refuses any authoring of it inside its radius. This PR's where-empty-flip-host.test.ts, written before that retirement landed and merged in with it, still spelled `name` on its one measure and one dimension. Both are dropped; the host test's three pins are unchanged and still pass. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/__tests__/where-empty-flip-host.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts b/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts index 1190559742e..72f2068fdda 100644 --- a/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-empty-flip-host.test.ts @@ -28,9 +28,9 @@ const CUBE: Cube = { title: 'Orders', sql: 'orders', public: true, - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, }, } as unknown as Cube; From a1402a37ba803e0d2bb0de12b29109f70db6b641 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:07:31 +0000 Subject: [PATCH 13/14] fix(spec): cut three over-claims from the filter-is-empty migration guidance and one from its changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier review's wording flags, folded in as cuts: - acceptanceCriteria: "it now also selects the rows holding the empty string or the empty list" read as if is_not_empty gained those rows; it loses them. - acceptanceCriteria: "fails with INVALID_FILTER" — the analytics read scope fails with a different code. - reason: "while the three builders already showed them as empty" is cut. - changeset: "and the prescription `$null`" — the read-scope envelope's message does not spell it; the FROM → TO lines carry the remedy. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/20446-empty-joins-filter-operators.md | 2 +- .../18.filter-is-empty-lowers-to-empty-operator.ts | 12 ++++++------ packages/spec/src/migrations/registry.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/20446-empty-joins-filter-operators.md b/.changeset/20446-empty-joins-filter-operators.md index 2ce639a3fd2..3f4ec047967 100644 --- a/.changeset/20446-empty-joins-filter-operators.md +++ b/.changeset/20446-empty-joins-filter-operators.md @@ -12,7 +12,7 @@ A stored 「is empty」 / 「is not empty」 — `['field', 'is_empty', …]`, ` - **A `{ $empty: … }` object written as a field value** (a `where` pasted into an insert or update payload) is refused with `VALIDATION_FAILED` (`invalid_type`, "$empty is a filter operator, not a value"). Before, a text-like field stored it as data. FROM `update('task', { title: { $empty: true } })` → TO write the value itself (`{ title: '' }`, `{ title: null }`); a filter belongs in `where`. -- **`is_empty` / `is_not_empty` where no face holds the column's declared type** is refused with `INVALID_FILTER` / 400 (`READ_SCOPE_COMPILE_FAILED` / 500 on an analytics read scope) and the prescription `$null`. The `$null` lowering answered these. The compositions: +- **`is_empty` / `is_not_empty` where no face holds the column's declared type** is refused with `INVALID_FILTER` / 400 (`READ_SCOPE_COMPILE_FAILED` / 500 on an analytics read scope). The `$null` lowering answered these. The compositions: - the built-in `id`, which no object declares. FROM `['id', 'is_empty', true]` → TO `['id', 'is_null', true]` / `is_not_null`; - a federated (external) object on a driver that does not implement `registerExternalObject` (driver-memory, driver-mongodb). The boot already reports such an object as NOT bound to its remote table, naming it, and its reads answered from a table named after the object. FROM `is_empty` on such an object → TO bind it on a driver that implements federation (driver-sql and its heirs, driver-turso); - an `AnalyticsService` constructed without `sourceFieldMeta`. FROM such a host → TO pass `sourceFieldMeta` (the package README shows it), or filter with `is_null` / `is_not_null`; diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts index 9200b7466f9..96d6cf62270 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts @@ -36,8 +36,8 @@ export const entry: SemanticMigration = { + 'of FILTER_OPERATORS until every face answered it, then added in the same change that flipped ' + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' - + 'empty string or the empty list, which the $null lowering missed while the three builders ' - + 'already showed them as empty. And the rule is refused, loudly and with the $null ' + + 'empty string or the empty list, which the $null lowering missed. ' + + 'And the rule is refused, loudly and with the $null ' + 'prescription, where the face that answers it holds no declaration for the column — the four ' + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + 'change made the write door refuse a $empty object as a field value, because that door ' @@ -47,10 +47,10 @@ export const entry: SemanticMigration = { + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' - + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' - + 'selects the rows holding the empty string or the empty list. On the built-in id, rewrite it ' + + 'or multi-value field, re-check what the view or rule is supposed to select. ' + + 'On the built-in id, rewrite it ' + 'to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an ' - + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails with ' - + 'INVALID_FILTER instead of answering — bind the object on a federation-capable driver, or pass ' + + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails ' + + 'instead of answering — bind the object on a federation-capable driver, or pass ' + 'sourceFieldMeta. No insert or update payload carries a $empty object as a field value.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f285b631ca1..2bcf85c98ec 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10882,8 +10882,8 @@ const step18: MigrationStep = { + 'of FILTER_OPERATORS until every face answered it, then added in the same change that flipped ' + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' - + 'empty string or the empty list, which the $null lowering missed while the three builders ' - + 'already showed them as empty. And the rule is refused, loudly and with the $null ' + + 'empty string or the empty list, which the $null lowering missed. ' + + 'And the rule is refused, loudly and with the $null ' + 'prescription, where the face that answers it holds no declaration for the column — the four ' + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + 'change made the write door refuse a $empty object as a field value, because that door ' @@ -10893,11 +10893,11 @@ const step18: MigrationStep = { + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' - + 'or multi-value field, re-check what the view or rule is supposed to select: it now also ' - + 'selects the rows holding the empty string or the empty list. On the built-in id, rewrite it ' + + 'or multi-value field, re-check what the view or rule is supposed to select. ' + + 'On the built-in id, rewrite it ' + 'to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an ' - + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails with ' - + 'INVALID_FILTER instead of answering — bind the object on a federation-capable driver, or pass ' + + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails ' + + 'instead of answering — bind the object on a federation-capable driver, or pass ' + 'sourceFieldMeta. No insert or update payload carries a $empty object as a field value.', }, // Ruling A on #19886, item 1: the $ne slot's half of the question From c96e1feacafd896e4b646dcb5e3b2c0480a01c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:31:14 +0000 Subject: [PATCH 14/14] fix(spec): the filter-is-empty migration reason no longer claims the refusal prescribes $null ", loudly and with the $null prescription," is cut from the entry's reason: the analytics read-scope refusal's message does not spell $null, the same reason the changeset bullet lost that claim. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../semantic/18.filter-is-empty-lowers-to-empty-operator.ts | 4 ++-- packages/spec/src/migrations/registry.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts index 96d6cf62270..6e5652dcceb 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-is-empty-lowers-to-empty-operator.ts @@ -37,8 +37,8 @@ export const entry: SemanticMigration = { + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' + 'empty string or the empty list, which the $null lowering missed. ' - + 'And the rule is refused, loudly and with the $null ' - + 'prescription, where the face that answers it holds no declaration for the column — the four ' + + 'And the rule is refused ' + + 'where the face that answers it holds no declaration for the column — the four ' + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + 'change made the write door refuse a $empty object as a field value, because that door ' + 'refuses every filter operator the protocol enforces as a value: before, a text-like field ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2bcf85c98ec..dbf5717eb98 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10883,8 +10883,8 @@ const step18: MigrationStep = { + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' + 'empty string or the empty list, which the $null lowering missed. ' - + 'And the rule is refused, loudly and with the $null ' - + 'prescription, where the face that answers it holds no declaration for the column — the four ' + + 'And the rule is refused ' + + 'where the face that answers it holds no declaration for the column — the four ' + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' + 'change made the write door refuse a $empty object as a field value, because that door ' + 'refuses every filter operator the protocol enforces as a value: before, a text-like field '