From 1db8faff906e3745bec0be257eaf91cd395f029f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:43:48 +0000 Subject: [PATCH 1/6] feat(spec): declare the staged $empty filter operator with its per-type expansion Declares `$empty: boolean` in FieldOperatorsSchema and SpecialOperatorSchema, its description carrying the ruled per-type table, plus the exported expandEmptyOperator / isEmptyFilterValue / EMPTY_OPERATOR_ARMS. Staged like $like: absent from FILTER_OPERATORS; the is_empty lowering still emits $null. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../src/data/filter-comparand-type.test.ts | 2 +- .../spec/src/data/filter-comparand-type.ts | 2 +- .../src/data/filter-empty-operator.test.ts | 261 ++++++++++++++++++ .../data/filter-operator-vocabulary.test.ts | 13 +- .../data/filter-save-door-face-parity.test.ts | 4 +- .../src/data/filter-save-door-refusals.ts | 25 +- packages/spec/src/data/filter.zod.ts | 147 +++++++++- 7 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 packages/spec/src/data/filter-empty-operator.test.ts diff --git a/packages/spec/src/data/filter-comparand-type.test.ts b/packages/spec/src/data/filter-comparand-type.test.ts index b1ecae1a61c..84121f28f35 100644 --- a/packages/spec/src/data/filter-comparand-type.test.ts +++ b/packages/spec/src/data/filter-comparand-type.test.ts @@ -73,7 +73,7 @@ describe('the accepted set (#7872 ruling)', () => { const judgedScalar = [ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', - '$like', '$ilike', '$null', '$exists', + '$like', '$ilike', '$null', '$exists', '$empty', ]; const judgedList = ['$in', '$nin', '$between']; expect([...judgedScalar, ...judgedList].sort()).toEqual(declared); diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index 163674e7dc1..27af92752c1 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -184,7 +184,7 @@ const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', '$like', '$ilike', - '$null', '$exists', + '$null', '$exists', '$empty', ]); const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set([ diff --git a/packages/spec/src/data/filter-empty-operator.test.ts b/packages/spec/src/data/filter-empty-operator.test.ts new file mode 100644 index 00000000000..ba2fcd5031b --- /dev/null +++ b/packages/spec/src/data/filter-empty-operator.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20311] `$empty` — the declared emptiness operator, STAGED. + * + * Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per + * field type: text-like = null or `''`; multi-value (multi-select, tags, + * multi-value lookup) = null or `[]`; every other type = null only. Ruling A on + * #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`. + * + * The pins ruling A lists for this card, one `describe` each: + * + * 1. the description string equals the ruled table — and each type list it + * names equals the set the expansion reads, so prose and code cannot drift; + * 2. `{ tags: { $empty: true } }` parses at the schema door, and `{ tags: [] }` + * is still refused (ruling 乙 on #19757 untouched); + * 3. the expansion function 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`. + */ + +import { describe, expect, it } from 'vitest'; + +import { StandardErrorCode } from '../api/errors.zod'; +import { + MULTI_CAPABLE_TYPES, + MULTI_OPTION_TYPES, + STRING_VALUE_TYPES, +} from './field-value.zod'; +import { FieldType } from './field.zod'; +import { + EMPTY_OPERATOR_ARMS, + FILTER_OPERATORS, + FieldOperatorsSchema, + FilterConditionSchema, + NormalizedFilterSchema, + SpecialOperatorSchema, + expandEmptyOperator, + isEmptyFilterValue, + parseFilterAST, +} from './filter.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parsed = { success: boolean; error?: { issues: readonly Issue[] } }; + +/** The one issue at `path` (dot-joined), failing loudly on none or several. */ +function issueAt(result: Parsed, path: string): Issue { + expect(result.success, `expected a refusal at ${path}`).toBe(false); + const issues = (result.error?.issues ?? []).filter((i) => i.path.join('.') === path); + expect(issues, `issues raised: ${JSON.stringify((result.error?.issues ?? []).map((i) => i.path))}`).toHaveLength(1); + return issues[0]!; +} + +/** The thrown refusal of `run`, failing loudly when it does not throw. */ +function refusalOf(run: () => unknown): Error & { code?: string; status?: number } { + try { + run(); + } catch (error) { + return error as Error & { code?: string; status?: number }; + } + throw new Error('expected a refusal, got none'); +} + +/** A slot's `.describe()` text, through `.optional()`. */ +function descriptionOf(shape: Record, key: string): string | undefined { + return (shape[key] as { description?: string } | undefined)?.description; +} + +// --------------------------------------------------------------------------- +// §1 The description IS the ruled table +// --------------------------------------------------------------------------- + +describe('#20311 §1 — the $empty description is the ruled per-type table', () => { + /** The ruled table, verbatim as the operator describes it. */ + const RULED_TABLE = + '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, so every query executor refuses it ' + + '(INVALID_FILTER) until each has its arm; the view operators is_empty / is_not_empty ' + + 'still lower to $null.'; + + 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); + expect(descriptionOf(SpecialOperatorSchema.shape, '$empty')).toBe(RULED_TABLE); + }); + + it('each type list the table names is the set the expansion reads — prose and code cannot drift', () => { + const text = [...STRING_VALUE_TYPES]; + expect(RULED_TABLE).toContain(`text-like types (${text.join(', ')}) = null or '' (the empty string)`); + const inherent = [...MULTI_OPTION_TYPES]; + const capable = [...MULTI_CAPABLE_TYPES]; + expect(RULED_TABLE).toContain( + `multi-value types (${inherent.join(', ')}, and ${capable.slice(0, -1).join(', ')} or ${capable.at(-1)} ` + + 'with multiple: true) = null or [] (the empty list)', + ); + expect(RULED_TABLE).toContain('every other type = null only'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 The schema door: $empty parses; the empty list is still refused +// --------------------------------------------------------------------------- + +describe('#20311 §2 — { tags: { $empty: true } } parses; { tags: [] } is still refused', () => { + it('the enforced operator slot keeps a boolean $empty rather than stripping it', () => { + // An undeclared key on this non-strict object is STRIPPED on parse; a + // declared one survives. `toEqual` holding both values is the difference. + expect(FieldOperatorsSchema.parse({ $empty: true })).toEqual({ $empty: true }); + expect(FieldOperatorsSchema.parse({ $empty: false })).toEqual({ $empty: false }); + expect(SpecialOperatorSchema.parse({ $empty: true })).toEqual({ $empty: true }); + }); + + it('the save door and the normalized AST accept it, at any depth, and keep it', () => { + for (const where of [ + { tags: { $empty: true } }, + { name: { $empty: false } }, + { $or: [{ tags: { $empty: true } }, { name: { $empty: false } }] }, + { $not: { tags: { $empty: true } } }, + ]) { + const parsed = FilterConditionSchema.safeParse(where); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect(parsed.data).toEqual(where); + } + // The normalized AST validates a field condition against the enforced slot. + const ast = NormalizedFilterSchema.safeParse({ $and: [{ tags: { $empty: true } }] }); + expect(ast.success, JSON.stringify(ast.error?.issues)).toBe(true); + expect(ast.data).toEqual({ $and: [{ tags: { $empty: true } }] }); + expect(NormalizedFilterSchema.safeParse({ $and: [{ tags: { $empty: 'yes' } }] }).success).toBe(false); + }); + + it('the empty list stays refused in the equality slot — ruling 乙 on #19757 is untouched', () => { + for (const [where, path] of [ + [{ tags: [] }, 'tags'], + [{ tags: { $eq: [] } }, 'tags.$eq'], + ] as const) { + const issue = issueAt(FilterConditionSchema.safeParse(where), path); + expect(issue.code).toBe('custom'); + expect(issue.message).toMatch(/requires a single comparable value, but received an array/); + } + // …and at the query face, in the ADR-0112 envelope. + const face = refusalOf(() => parseFilterAST({ tags: [] })); + expect(face.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(face.status).toBe(400); + }); + + it('a non-boolean $empty is refused at the slot and at the save door, as $null and $exists are', () => { + expect(issueAt(FieldOperatorsSchema.safeParse({ $empty: 'true' }), '$empty').code).toBe('invalid_type'); + for (const [where, path] of [ + [{ tags: { $empty: 'true' } }, 'tags.$empty'], + [{ tags: { $empty: 1 } }, 'tags.$empty'], + [{ $or: [{ tags: { $empty: null } }] }, '$or.0.tags.$empty'], + ] as const) { + const issue = issueAt(FilterConditionSchema.safeParse(where), path); + expect(issue.code).toBe('custom'); + expect(issue.message).toMatch(/^Operator "\$empty" on field "tags" requires a boolean comparand \(true or false\)\. /); + } + }); +}); + +// --------------------------------------------------------------------------- +// §3 The expansion: three arms for the three field kinds, and the value test +// --------------------------------------------------------------------------- + +describe('#20311 §3 — expandEmptyOperator answers the ruled arm per field definition', () => { + it('returns the text, multi-value and null arms for the three kinds the ruling names', () => { + expect(expandEmptyOperator({ type: 'text' })).toBe(EMPTY_OPERATOR_ARMS.text); + expect(expandEmptyOperator({ type: 'tags' })).toBe(EMPTY_OPERATOR_ARMS.multi_value); + expect(expandEmptyOperator({ type: 'multiselect' })).toBe(EMPTY_OPERATOR_ARMS.multi_value); + // A multi-value lookup is the DEFINITION's `multiple`, not the type. + expect(expandEmptyOperator({ type: 'lookup', multiple: true })).toBe(EMPTY_OPERATOR_ARMS.multi_value); + expect(expandEmptyOperator({ type: 'lookup' })).toBe(EMPTY_OPERATOR_ARMS.null_only); + expect(expandEmptyOperator({ type: 'number' })).toBe(EMPTY_OPERATOR_ARMS.null_only); + }); + + it('each arm says which stored states count as empty beside null', () => { + expect(EMPTY_OPERATOR_ARMS.text).toEqual({ arm: 'text', emptyString: true, emptyList: false }); + expect(EMPTY_OPERATOR_ARMS.multi_value).toEqual({ arm: 'multi_value', emptyString: false, emptyList: true }); + expect(EMPTY_OPERATOR_ARMS.null_only).toEqual({ arm: 'null_only', emptyString: false, emptyList: false }); + expect(Object.isFrozen(EMPTY_OPERATOR_ARMS)).toBe(true); + for (const arm of Object.values(EMPTY_OPERATOR_ARMS)) expect(Object.isFrozen(arm)).toBe(true); + }); + + it('every declarable field type lands on exactly the arm its value class names', () => { + const multiCapable = new Set(MULTI_CAPABLE_TYPES); + for (const type of FieldType.options) { + const single = expandEmptyOperator({ type }).arm; + const expected = MULTI_OPTION_TYPES.has(type) ? 'multi_value' : STRING_VALUE_TYPES.has(type) ? 'text' : 'null_only'; + expect(single, type).toBe(expected); + if (multiCapable.has(type)) expect(expandEmptyOperator({ type, multiple: true }).arm, `${type} multiple`).toBe('multi_value'); + } + // The two value classes are disjoint, so the multi-first order decides nothing today. + for (const type of STRING_VALUE_TYPES) { + expect(MULTI_OPTION_TYPES.has(type) || MULTI_CAPABLE_TYPES.has(type), type).toBe(false); + } + }); + + it('isEmptyFilterValue answers each declared arm, and $empty: false is its exact complement', () => { + const values: ReadonlyArray = [ + ['null', null, true, true, true], + ['undefined (absent)', undefined, true, true, true], + ["''", '', true, false, false], + ['[]', [], false, true, false], + ['a string', 'a', false, false, false], + ['a whitespace string', ' ', false, false, false], + ['a one-member list', ['a'], false, false, false], + ['zero', 0, false, false, false], + ['false', false, false, false, false], + ]; + for (const [label, value, text, multi, nullOnly] of values) { + expect(isEmptyFilterValue(value, EMPTY_OPERATOR_ARMS.text), `text ← ${label}`).toBe(text); + expect(isEmptyFilterValue(value, EMPTY_OPERATOR_ARMS.multi_value), `multi ← ${label}`).toBe(multi); + expect(isEmptyFilterValue(value, EMPTY_OPERATOR_ARMS.null_only), `null ← ${label}`).toBe(nullOnly); + } + }); + + it('without a declaration the value decides: null, \'\' and [] are empty (the formula matcher and having)', () => { + expect([null, undefined, '', []].map((v) => isEmptyFilterValue(v))).toEqual([true, true, true, true]); + expect([' ', 'a', ['a'], 0, false, {}].map((v) => isEmptyFilterValue(v))).toEqual([false, false, false, false, false, false]); + // The one named divergence from the declared table: a non-text column holding ''. + expect(isEmptyFilterValue('')).toBe(true); + expect(isEmptyFilterValue('', expandEmptyOperator({ type: 'number' }))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// §4 The staging: absent from FILTER_OPERATORS, lowering unchanged +// --------------------------------------------------------------------------- + +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'); + 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 } }); + } + for (const op of ['is_not_empty', 'isnotempty']) { + expect(parseFilterAST(['tags', op, true])).toEqual({ tags: { $null: false } }); + } + }); +}); diff --git a/packages/spec/src/data/filter-operator-vocabulary.test.ts b/packages/spec/src/data/filter-operator-vocabulary.test.ts index 4dc0fc2584d..1435bd04407 100644 --- a/packages/spec/src/data/filter-operator-vocabulary.test.ts +++ b/packages/spec/src/data/filter-operator-vocabulary.test.ts @@ -58,8 +58,19 @@ const declaredKeys = () => Object.keys(FieldOperatorsSchema.shape).sort(); * its refusal into a DROPPED predicate (measured for `$icontains` in #5701 — * `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`. */ -const STAGED_AHEAD_OF_BACKENDS = ['$ilike', '$like']; +const STAGED_AHEAD_OF_BACKENDS = ['$empty', '$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 fd96f89a495..ec26ca48f7d 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,9 @@ 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'])); - expect(BOOLEAN_SLOTS.sort()).toEqual(['$exists', '$null']); + // [#20311] `$empty` is declared `z.boolean()` (staged out of FILTER_OPERATORS), + // 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 — // the guard against a battery that silently stopped reaching an arm. const faceJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => faceRefusal({ f: { [op]: c } }))); diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 4ee504ff5bf..3d56a6fc993 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -256,11 +256,18 @@ function comparandShapeRefusalAtSave( } /** - * The two flags `FieldOperatorsSchema` declares `z.boolean()`. A non-boolean one + * The flags `FieldOperatorsSchema` declares `z.boolean()`. A non-boolean one * is refused on every query face under the #5347 / #5369 rulings, in every * 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. */ -const BOOLEAN_FLAG_OPERATORS: ReadonlySet = new Set(['$null', '$exists']); +const BOOLEAN_FLAG_OPERATORS: ReadonlySet = new Set(['$null', '$exists', '$empty']); /** What arrived where a flag's boolean belongs — the analytics door's `describeFlagComparand`. */ function describeFlagComparand(value: unknown): string { @@ -280,8 +287,22 @@ function describeFlagComparand(value: unknown): string { * prescription are the analytics door's, less the location and the history of * what that door used to do. The field is named because this door can see it; * 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. */ function nonBooleanFlagComparandMessage(op: string, field: string, value: unknown): string { + if (op === '$empty') { + return ( + `Operator "${op}" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFlagComparand(value)}. @objectstack/spec FieldOperatorsSchema declares ` + + `${op} as a boolean, and a non-boolean is refused rather than coerced, the rule $null and ` + + `$exists follow on every query face. Write the boolean itself: "${op}": true matches rows ` + + `whose "${field}" is empty, "${op}": false rows whose "${field}" is not empty. The filter ` + + 'was NOT applied.' + ); + } const [whenTrue, whenFalse] = op === '$null' ? ['has no value', 'has a value'] : ['has a value', 'has no value']; return ( `Operator "${op}" on field "${field}" requires a boolean comparand (true or false). ` diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index c27bbb374ee..ff840efc788 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -22,6 +22,10 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da // rather than restated so the `$` dialect and the view vocabulary judge one set. import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand'; import { OPERATOR_PREFIX_KEY_PATTERN, bannedKeyPattern } from '../shared/refinement-projection'; +// [#20311] The value-contract sets the `$empty` expansion reads. Read only +// inside `expandEmptyOperator`'s body, never at module scope: this module and +// `field-value.zod` meet in the `field.zod` import cycle. +import { STRING_VALUE_TYPES, isMultiValueField, type ValueShapeFieldDef } from './field-value.zod'; /** * Unified Query DSL Specification @@ -1537,7 +1541,39 @@ const EXISTS_PREDICATE_DESCRIPTION = + '`{ $eq: null }` (false) on MongoDB.'; /** - * Special check operators for null and existence. + * [#20311] The `describe()` `$empty` carries in both copies — and it IS the + * operator's meaning, not a gloss on it. + * + * Ruling B on #20311 (record 5861435168) set what 「is empty」 means once, per + * field type; ruling A on #20399 (record 5865693155) spelled it as this + * operator, "whose describe IS the per-type table". So this string is the + * table, and `filter-empty-operator.test.ts` pins it to the ruled text and + * pins each type list it names to the set {@link expandEmptyOperator} reads + * (`STRING_VALUE_TYPES`, `MULTI_OPTION_TYPES`, `MULTI_CAPABLE_TYPES` in + * `field-value.zod.ts`), so the prose and the function cannot drift apart. The + * lists are spelled out rather than joined from those sets because this + * module is evaluated inside the `field.zod` ↔ `field-value.zod` import cycle, + * where reading a set at module scope is not safe under `OS_EAGER_SCHEMAS=1`. + * + * The last sentence is 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 every executor refuses + * it today rather than discovering it as a 400 — see {@link FILTER_OPERATORS}. + */ +const EMPTY_PREDICATE_DESCRIPTION = + '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, so every query executor refuses it ' + + '(INVALID_FILTER) until each has its arm; the view operators is_empty / is_not_empty ' + + 'still lower to $null.'; + +/** + * Special check operators for null, existence and emptiness. */ export const SpecialOperatorSchema = lazySchema(() => z.object({ /** Is null check - SQL: IS NULL (true) / IS NOT NULL (false) | MongoDB: field: null */ @@ -1549,8 +1585,107 @@ export const SpecialOperatorSchema = lazySchema(() => z.object({ * `{$ne: null}` / `{$eq: null}` on MongoDB. */ $exists: z.boolean().optional().describe(EXISTS_PREDICATE_DESCRIPTION), + + /** + * [#20311] Field IS EMPTY by its declared type — the per-type table + * {@link EMPTY_PREDICATE_DESCRIPTION} carries, expanded per field by + * {@link expandEmptyOperator}. STAGED: not in {@link FILTER_OPERATORS}. + */ + $empty: z.boolean().optional().describe(EMPTY_PREDICATE_DESCRIPTION), })); +// ============================================================================ +// 3.6 The `$empty` expansion — ONE definition for every face (#20311) +// ============================================================================ + +/** + * [#20311] The three rows of the ruled 「is empty」 table (ruling B on #20311, + * record 5861435168): + * + * - `text` — text-like types (`STRING_VALUE_TYPES`): null or `''`; + * - `multi_value` — a field whose persisted value is a list + * (`isMultiValueField`: multiselect, checkboxes, tags, or a multi-capable + * type with `multiple: true` — a multi-value lookup is a `lookup` or `user` + * with `multiple: true`): null or `[]`; + * - `null_only` — every other type: null only. + */ +export type EmptyOperatorArm = 'text' | 'multi_value' | 'null_only'; + +/** + * [#20311] What `$empty: true` matches on one field, stated surface-neutrally: + * null (no value) always counts as empty, and the two flags say which of the + * two further stored states count too. A compile surface turns this into its + * own predicate — `IS NULL OR col = ''` on the SQL family, a JSON-length test + * for `emptyList`, a value test on a JS face — and `$empty: false` is the exact + * complement of whatever `true` matches. + * + * ⛔ Deliberately NOT a `FilterCondition`: the multi-value row cannot be + * spelled in the lowered vocabulary, because an empty list is refused as an + * equality comparand (ruling 乙 on #19757, record 5793368540, unchanged by this + * operator). That is why the table lives in an operator each surface expands, + * rather than in a lowering that emits fragments. + */ +export interface EmptyOperatorExpansion { + /** Which row of the ruled table the field takes. */ + readonly arm: EmptyOperatorArm; + /** The empty string `''` counts as empty, beside null. */ + readonly emptyString: boolean; + /** The empty list `[]` counts as empty, beside null. */ + readonly emptyList: boolean; +} + +/** + * [#20311] The three expansions, one frozen object per row, so a surface may + * compare by identity or switch on `arm`. + */ +export const EMPTY_OPERATOR_ARMS: Readonly> = Object.freeze({ + text: Object.freeze({ arm: 'text', emptyString: true, emptyList: false }), + multi_value: Object.freeze({ arm: 'multi_value', emptyString: false, emptyList: true }), + null_only: Object.freeze({ arm: 'null_only', emptyString: false, emptyList: false }), +}); + +/** + * [#20311] Expand `$empty` for one field, keyed on its DEFINITION — the type + * and `multiple` — because the multi-value row cannot be read off the type + * alone: a `lookup` is `null_only` and a `lookup` with `multiple: true` is + * `multi_value`. The one function every compile surface calls (ruling A on + * #20399, record 5865693155: "each compile surface expands it by the field's + * declared type through one spec function"), reading the same sets the value + * contract already owns rather than a list of its own. + * + * The multi-value test runs first. The two sets are disjoint today (no + * text-like type is multi-capable), so the order only decides a future + * overlap, and it decides it by the stored SHAPE: a field whose value is a + * list is emptied to `[]`. + */ +export function expandEmptyOperator(field: ValueShapeFieldDef): EmptyOperatorExpansion { + if (isMultiValueField(field)) return EMPTY_OPERATOR_ARMS.multi_value; + if (STRING_VALUE_TYPES.has(field.type)) return EMPTY_OPERATOR_ARMS.text; + return EMPTY_OPERATOR_ARMS.null_only; +} + +/** + * [#20311] Is this stored VALUE empty? The value-level half of the same table, + * for the JS evaluation faces. + * + * - With an `expansion` (from {@link expandEmptyOperator}): the declared row — + * null or `undefined` always, `''` only on the `text` row, `[]` only on the + * `multi_value` row. + * - Without one: the reading ruling A gives the faces that hold NO field + * declaration (`@objectstack/formula`'s matcher, objectql `having` over + * aggregated rows) — null, `undefined`, `''` and `[]` are all empty. It + * differs from the declared table only on a non-text column holding `''`, + * which is a write-door defect rather than a stored state. + * + * `$empty: false` is `!isEmptyFilterValue(…)` with the same arguments. + */ +export function isEmptyFilterValue(value: unknown, expansion?: EmptyOperatorExpansion): boolean { + if (value === null || value === undefined) return true; + if (value === '') return expansion === undefined || expansion.emptyString; + if (Array.isArray(value) && value.length === 0) return expansion === undefined || expansion.emptyList; + return false; +} + // ============================================================================ // Combined Field Operators // ============================================================================ @@ -1620,6 +1755,11 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ // Special $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. + $empty: z.boolean().optional().describe(EMPTY_PREDICATE_DESCRIPTION), })); // ============================================================================ @@ -2640,6 +2780,11 @@ 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`. if (op === 'is_null' || op === 'isnull' || op === 'is_empty' || op === 'isempty') { return { [field]: { $null: true } } as FilterCondition; } From b096c8490c69d473bcdb82ad990011d16f6c3cff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:11:24 +0000 Subject: [PATCH 2/6] feat(spec): record the measured per-face $empty staging table and the changeset Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../20311-empty-filter-operator-staged.md | 34 +++++++++++++ packages/spec/authorable-surface/data.json | 2 + .../src/data/filter-empty-operator.test.ts | 6 +-- packages/spec/src/data/filter.zod.ts | 51 ++++++++++++++++--- 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 .changeset/20311-empty-filter-operator-staged.md diff --git a/.changeset/20311-empty-filter-operator-staged.md b/.changeset/20311-empty-filter-operator-staged.md new file mode 100644 index 00000000000..555c53457f4 --- /dev/null +++ b/.changeset/20311-empty-filter-operator-staged.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare the `$empty` filter operator — what 「is empty」 means, once, per field type — staged ahead of its executors (#20311) + +Clause-②: yes (widening) — a declared operator slot and five exports are added. `FieldOperatorsSchema.parse({ $empty: true })` used to strip the undeclared key and now keeps it. The one refusal that comes with the declared type sits on a key nothing writes (see below). + +**⚠️ Authoring `$empty` today is refused at query time.** The operator is declared but STAGED: it is deliberately absent from `FILTER_OPERATORS`, so no query executor answers it yet. A hand-written `{ "f": { "$empty": true } }` gets `INVALID_FILTER` / 400 from `driver-sql` (and the drivers that inherit its compiler), `driver-turso`'s remote transport, `driver-memory`, `driver-mongodb`, objectql `having` and the analytics `where` compiler; `READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) from the analytics read-scope SQL compiler; and `@objectstack/formula`'s write-side `matchesFilterCondition` answers `false` for every record, its fail-closed posture for an operator it has no arm for. Until each of those faces has its arm, write 「is empty」 with the view operator `is_empty`, which is unchanged. + +**What the operator means.** Its description is the ruled per-type table (ruling B on #20311, spelled as an operator by ruling A on #20399): + +| field type | `$empty: true` matches | +|---|---| +| text-like (`STRING_VALUE_TYPES`: text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) | null or `''` | +| multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with `multiple: true`) | null or `[]` | +| every other type | null only | + +`$empty: false` is the exact complement. A face that holds no field declaration (the formula matcher, objectql `having`) judges by the value: null, `''` and `[]` are empty. + +**The one expansion every face calls**, exported from `@objectstack/spec/data`: + +- `expandEmptyOperator(field)` — keyed on the field DEFINITION (type plus `multiple`), because a `lookup` is `null_only` and a `lookup` with `multiple: true` is `multi_value`. Returns one of the frozen `EMPTY_OPERATOR_ARMS` rows: `{ arm, emptyString, emptyList }` (`EmptyOperatorArm`, `EmptyOperatorExpansion`). +- `isEmptyFilterValue(value, expansion?)` — the value-level half: with an expansion, the declared row; without one, the by-value reading for the declaration-free faces. + +**What does not change.** + +- The `is_empty` / `is_not_empty` view operators still lower to `{ "$null": true | false }`. A later change flips that lowering to `$empty` once every face answers it; no stored filter changes result in this release. +- An empty list is still refused as an equality comparand: `{ "tags": [] }` and `{ "tags": { "$eq": [] } }` keep their refusal. The multi-value row lives in the operator precisely because it cannot be spelled as a lowered equality. +- `FILTER_OPERATORS` is unchanged, so every executor that derives its accepted set from it (`driver-memory`'s gate among them) keeps refusing `$empty` rather than dropping it. + +**One new refusal, on a key nothing writes.** A NON-boolean `$empty` (`"true"`, `1`, `null`) is refused where the declared boolean flags `$null` / `$exists` already are: at the operator slot, and at the save door (`FilterConditionSchema` and the analytics filter carriers that share its slot check), in the flags' own first sentence. `$empty` appears nowhere in this repository or in objectui's `main` before this change (0 occurrences in either). + +**Stored sharing rules** (ruling B's landing measurement): the criteria sharing rules in this repository's examples and objectui's fixtures that use 「is empty」 are 0, and this release changes no lowering, so none changes result. Production sharing rules are NOT MEASURED: they are unreadable from here. diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index eae806a367c..65bf504b267 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -419,6 +419,7 @@ "data/FieldMaskingKeep:keepTail", "data/FieldOperators:$between", "data/FieldOperators:$contains", + "data/FieldOperators:$empty", "data/FieldOperators:$endsWith", "data/FieldOperators:$eq", "data/FieldOperators:$exists", @@ -948,6 +949,7 @@ "data/ShardingConfig:shardingStrategy", "data/SortNode:field", "data/SortNode:order", + "data/SpecialOperator:$empty", "data/SpecialOperator:$exists", "data/SpecialOperator:$null", "data/SqliteConfig:autoMigrate", diff --git a/packages/spec/src/data/filter-empty-operator.test.ts b/packages/spec/src/data/filter-empty-operator.test.ts index ba2fcd5031b..cb704c24c9e 100644 --- a/packages/spec/src/data/filter-empty-operator.test.ts +++ b/packages/spec/src/data/filter-empty-operator.test.ts @@ -85,9 +85,9 @@ describe('#20311 §1 — the $empty description is the ruled per-type table', () + '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, so every query executor refuses it ' - + '(INVALID_FILTER) until each has its arm; the view operators is_empty / is_not_empty ' - + 'still lower to $null.'; + + '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.'; 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); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index ff840efc788..820c01506ad 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1555,10 +1555,12 @@ const EXISTS_PREDICATE_DESCRIPTION = * module is evaluated inside the `field.zod` ↔ `field-value.zod` import cycle, * where reading a set at module scope is not safe under `OS_EAGER_SCHEMAS=1`. * - * The last sentence is load-bearing too: the operator is STAGED (the + * 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 every executor refuses - * it today rather than discovering it as a 400 — see {@link FILTER_OPERATORS}. + * 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}. */ const EMPTY_PREDICATE_DESCRIPTION = 'Is-empty check by the field\'s DECLARED type. `true` matches rows whose field is empty, ' @@ -1568,9 +1570,9 @@ const EMPTY_PREDICATE_DESCRIPTION = + '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, so every query executor refuses it ' - + '(INVALID_FILTER) until each has its arm; the view operators is_empty / is_not_empty ' - + 'still lower to $null.'; + + '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.'; /** * Special check operators for null, existence and emptiness. @@ -3161,6 +3163,43 @@ 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) + * + * 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 {@link expandEmptyOperator} / {@link isEmptyFilterValue} 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 | + * |---|---| + * | `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`. + * * Retired operators (`$regex`, `$options`) are not here either, and never were. * Their prescriptions live in {@link RETIRED_FILTER_OPERATORS}. */ From 3d4f3b4074b75c0e6b4217f9f1431302d6443628 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:21:26 +0000 Subject: [PATCH 3/6] refactor(spec): move the $empty expansion into its own data module filter.zod.ts takes no import from field-value.zod: the two meet in the field.zod import cycle, and filter.zod.ts' import closure is what the published query and api skill reference indexes walk. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../src/data/filter-empty-operator.test.ts | 16 ++- .../spec/src/data/filter-empty-operator.ts | 118 +++++++++++++++++ packages/spec/src/data/filter.zod.ts | 120 ++---------------- packages/spec/src/data/index.ts | 6 + 4 files changed, 149 insertions(+), 111 deletions(-) create mode 100644 packages/spec/src/data/filter-empty-operator.ts diff --git a/packages/spec/src/data/filter-empty-operator.test.ts b/packages/spec/src/data/filter-empty-operator.test.ts index cb704c24c9e..40de5d17e3a 100644 --- a/packages/spec/src/data/filter-empty-operator.test.ts +++ b/packages/spec/src/data/filter-empty-operator.test.ts @@ -18,8 +18,9 @@ * names equals the set the expansion reads, so prose and code cannot drift; * 2. `{ tags: { $empty: true } }` parses at the schema door, and `{ tags: [] }` * is still refused (ruling 乙 on #19757 untouched); - * 3. the expansion function returns the text, multi-value and null arms for - * the three field kinds, and the value predicate answers each arm; + * 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`. */ @@ -33,17 +34,16 @@ import { STRING_VALUE_TYPES, } from './field-value.zod'; import { FieldType } from './field.zod'; +import { EMPTY_OPERATOR_ARMS, expandEmptyOperator, isEmptyFilterValue } from './filter-empty-operator'; import { - EMPTY_OPERATOR_ARMS, FILTER_OPERATORS, FieldOperatorsSchema, FilterConditionSchema, NormalizedFilterSchema, SpecialOperatorSchema, - expandEmptyOperator, - isEmptyFilterValue, parseFilterAST, } from './filter.zod'; +import * as dataBarrel from './index'; type Issue = { code: string; path: PropertyKey[]; message: string }; type Parsed = { success: boolean; error?: { issues: readonly Issue[] } }; @@ -172,6 +172,12 @@ describe('#20311 §2 — { tags: { $empty: true } } parses; { tags: [] } is stil // --------------------------------------------------------------------------- describe('#20311 §3 — expandEmptyOperator answers the ruled arm per field definition', () => { + it('is published on the data entry — the one function every compile surface imports', () => { + expect(dataBarrel.expandEmptyOperator).toBe(expandEmptyOperator); + expect(dataBarrel.isEmptyFilterValue).toBe(isEmptyFilterValue); + expect(dataBarrel.EMPTY_OPERATOR_ARMS).toBe(EMPTY_OPERATOR_ARMS); + }); + it('returns the text, multi-value and null arms for the three kinds the ruling names', () => { expect(expandEmptyOperator({ type: 'text' })).toBe(EMPTY_OPERATOR_ARMS.text); expect(expandEmptyOperator({ type: 'tags' })).toBe(EMPTY_OPERATOR_ARMS.multi_value); diff --git a/packages/spec/src/data/filter-empty-operator.ts b/packages/spec/src/data/filter-empty-operator.ts new file mode 100644 index 00000000000..5a5614d8fc5 --- /dev/null +++ b/packages/spec/src/data/filter-empty-operator.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20311] The `$empty` expansion — ONE definition for every face. + * + * `$empty: boolean` is declared by `FieldOperatorsSchema` and + * `SpecialOperatorSchema` (`./filter.zod.ts`), whose description IS the ruled + * per-type 「is empty」 table (ruling B on #20311, record 5861435168): + * text-like = null or `''`; multi-value (multi-select, tags, multi-value + * lookup) = null or `[]`; every other type = null only. Ruling A on #20399 + * (record 5865693155) spelled it as that operator and ruled that "each compile + * 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. + * + * ## Why this is its own module + * + * It reads the value contract's sets (`STRING_VALUE_TYPES`, + * `isMultiValueField` in `./field-value.zod.ts`) rather than keeping a list of + * its own, so it must import them — and `filter.zod.ts` is deliberately not + * the module that does: the two meet in the `field.zod` import cycle, where a + * module-scope read of a set is not safe under `OS_EAGER_SCHEMAS=1`, and + * `filter.zod.ts`' import closure is what the published query skill's + * reference index walks. The sibling `filter-text-operator-declared-type.ts` + * reads the same sets from the same position for the same reason. + */ + +import { STRING_VALUE_TYPES, isMultiValueField, type ValueShapeFieldDef } from './field-value.zod'; + +/** + * [#20311] The three rows of the ruled 「is empty」 table: + * + * - `text` — text-like types (`STRING_VALUE_TYPES`): null or `''`; + * - `multi_value` — a field whose persisted value is a list + * (`isMultiValueField`: multiselect, checkboxes, tags, or a multi-capable + * type with `multiple: true` — a multi-value lookup is a `lookup` or `user` + * with `multiple: true`): null or `[]`; + * - `null_only` — every other type: null only. + */ +export type EmptyOperatorArm = 'text' | 'multi_value' | 'null_only'; + +/** + * [#20311] What `$empty: true` matches on one field, stated surface-neutrally: + * null (no value) always counts as empty, and the two flags say which of the + * two further stored states count too. A compile surface turns this into its + * own predicate — `IS NULL OR col = ''` on the SQL family, a JSON-length test + * for `emptyList`, a value test on a JS face — and `$empty: false` is the exact + * complement of whatever `true` matches. + * + * ⛔ Deliberately NOT a `FilterCondition`: the multi-value row cannot be + * spelled in the lowered vocabulary, because an empty list is refused as an + * equality comparand (ruling 乙 on #19757, record 5793368540, unchanged by this + * operator). That is why the table lives in an operator each surface expands, + * rather than in a lowering that emits fragments. + */ +export interface EmptyOperatorExpansion { + /** Which row of the ruled table the field takes. */ + readonly arm: EmptyOperatorArm; + /** The empty string `''` counts as empty, beside null. */ + readonly emptyString: boolean; + /** The empty list `[]` counts as empty, beside null. */ + readonly emptyList: boolean; +} + +/** + * [#20311] The three expansions, one frozen object per row, so a surface may + * compare by identity or switch on `arm`. + */ +export const EMPTY_OPERATOR_ARMS: Readonly> = Object.freeze({ + text: Object.freeze({ arm: 'text', emptyString: true, emptyList: false }), + multi_value: Object.freeze({ arm: 'multi_value', emptyString: false, emptyList: true }), + null_only: Object.freeze({ arm: 'null_only', emptyString: false, emptyList: false }), +}); + +/** + * [#20311] Expand `$empty` for one field, keyed on its DEFINITION — the type + * and `multiple` — because the multi-value row cannot be read off the type + * alone: a `lookup` is `null_only` and a `lookup` with `multiple: true` is + * `multi_value`. The one function every compile surface calls, reading the + * sets the value contract already owns rather than a list of its own. + * + * The multi-value test runs first. The two sets are disjoint today (no + * text-like type is multi-capable), so the order only decides a future + * overlap, and it decides it by the stored SHAPE: a field whose value is a + * list is emptied to `[]`. + */ +export function expandEmptyOperator(field: ValueShapeFieldDef): EmptyOperatorExpansion { + if (isMultiValueField(field)) return EMPTY_OPERATOR_ARMS.multi_value; + if (STRING_VALUE_TYPES.has(field.type)) return EMPTY_OPERATOR_ARMS.text; + return EMPTY_OPERATOR_ARMS.null_only; +} + +/** + * [#20311] Is this stored VALUE empty? The value-level half of the same table, + * for the JS evaluation faces. + * + * - With an `expansion` (from {@link expandEmptyOperator}): the declared row — + * null or `undefined` always, `''` only on the `text` row, `[]` only on the + * `multi_value` row. + * - Without one: the reading ruling A gives the faces that hold NO field + * declaration (`@objectstack/formula`'s matcher, objectql `having` over + * aggregated rows) — null, `undefined`, `''` and `[]` are all empty. It + * differs from the declared table only on a non-text column holding `''`, + * which is a write-door defect rather than a stored state. + * + * `$empty: false` is `!isEmptyFilterValue(…)` with the same arguments. + */ +export function isEmptyFilterValue(value: unknown, expansion?: EmptyOperatorExpansion): boolean { + if (value === null || value === undefined) return true; + if (value === '') return expansion === undefined || expansion.emptyString; + if (Array.isArray(value) && value.length === 0) return expansion === undefined || expansion.emptyList; + return false; +} diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 820c01506ad..0912c2776e5 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -22,10 +22,6 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da // rather than restated so the `$` dialect and the view vocabulary judge one set. import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand'; import { OPERATOR_PREFIX_KEY_PATTERN, bannedKeyPattern } from '../shared/refinement-projection'; -// [#20311] The value-contract sets the `$empty` expansion reads. Read only -// inside `expandEmptyOperator`'s body, never at module scope: this module and -// `field-value.zod` meet in the `field.zod` import cycle. -import { STRING_VALUE_TYPES, isMultiValueField, type ValueShapeFieldDef } from './field-value.zod'; /** * Unified Query DSL Specification @@ -1548,12 +1544,14 @@ const EXISTS_PREDICATE_DESCRIPTION = * field type; ruling A on #20399 (record 5865693155) spelled it as this * operator, "whose describe IS the per-type table". So this string is the * table, and `filter-empty-operator.test.ts` pins it to the ruled text and - * pins each type list it names to the set {@link expandEmptyOperator} reads - * (`STRING_VALUE_TYPES`, `MULTI_OPTION_TYPES`, `MULTI_CAPABLE_TYPES` in - * `field-value.zod.ts`), so the prose and the function cannot drift apart. The - * lists are spelled out rather than joined from those sets because this - * module is evaluated inside the `field.zod` ↔ `field-value.zod` import cycle, - * where reading a set at module scope is not safe under `OS_EAGER_SCHEMAS=1`. + * pins each type list it names to the set `expandEmptyOperator` + * (`./filter-empty-operator.ts`) reads — `STRING_VALUE_TYPES`, + * `MULTI_OPTION_TYPES`, `MULTI_CAPABLE_TYPES` in `field-value.zod.ts` — so the + * prose and the function cannot drift apart. The lists are spelled out rather + * than joined from those sets on purpose: this module takes no import from + * `field-value.zod` (the two meet in the `field.zod` import cycle, where a + * 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 先例分阶段」), @@ -1591,103 +1589,12 @@ 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 - * {@link expandEmptyOperator}. STAGED: not in {@link FILTER_OPERATORS}. + * `expandEmptyOperator` (`./filter-empty-operator.ts`). STAGED: not in + * {@link FILTER_OPERATORS}. */ $empty: z.boolean().optional().describe(EMPTY_PREDICATE_DESCRIPTION), })); -// ============================================================================ -// 3.6 The `$empty` expansion — ONE definition for every face (#20311) -// ============================================================================ - -/** - * [#20311] The three rows of the ruled 「is empty」 table (ruling B on #20311, - * record 5861435168): - * - * - `text` — text-like types (`STRING_VALUE_TYPES`): null or `''`; - * - `multi_value` — a field whose persisted value is a list - * (`isMultiValueField`: multiselect, checkboxes, tags, or a multi-capable - * type with `multiple: true` — a multi-value lookup is a `lookup` or `user` - * with `multiple: true`): null or `[]`; - * - `null_only` — every other type: null only. - */ -export type EmptyOperatorArm = 'text' | 'multi_value' | 'null_only'; - -/** - * [#20311] What `$empty: true` matches on one field, stated surface-neutrally: - * null (no value) always counts as empty, and the two flags say which of the - * two further stored states count too. A compile surface turns this into its - * own predicate — `IS NULL OR col = ''` on the SQL family, a JSON-length test - * for `emptyList`, a value test on a JS face — and `$empty: false` is the exact - * complement of whatever `true` matches. - * - * ⛔ Deliberately NOT a `FilterCondition`: the multi-value row cannot be - * spelled in the lowered vocabulary, because an empty list is refused as an - * equality comparand (ruling 乙 on #19757, record 5793368540, unchanged by this - * operator). That is why the table lives in an operator each surface expands, - * rather than in a lowering that emits fragments. - */ -export interface EmptyOperatorExpansion { - /** Which row of the ruled table the field takes. */ - readonly arm: EmptyOperatorArm; - /** The empty string `''` counts as empty, beside null. */ - readonly emptyString: boolean; - /** The empty list `[]` counts as empty, beside null. */ - readonly emptyList: boolean; -} - -/** - * [#20311] The three expansions, one frozen object per row, so a surface may - * compare by identity or switch on `arm`. - */ -export const EMPTY_OPERATOR_ARMS: Readonly> = Object.freeze({ - text: Object.freeze({ arm: 'text', emptyString: true, emptyList: false }), - multi_value: Object.freeze({ arm: 'multi_value', emptyString: false, emptyList: true }), - null_only: Object.freeze({ arm: 'null_only', emptyString: false, emptyList: false }), -}); - -/** - * [#20311] Expand `$empty` for one field, keyed on its DEFINITION — the type - * and `multiple` — because the multi-value row cannot be read off the type - * alone: a `lookup` is `null_only` and a `lookup` with `multiple: true` is - * `multi_value`. The one function every compile surface calls (ruling A on - * #20399, record 5865693155: "each compile surface expands it by the field's - * declared type through one spec function"), reading the same sets the value - * contract already owns rather than a list of its own. - * - * The multi-value test runs first. The two sets are disjoint today (no - * text-like type is multi-capable), so the order only decides a future - * overlap, and it decides it by the stored SHAPE: a field whose value is a - * list is emptied to `[]`. - */ -export function expandEmptyOperator(field: ValueShapeFieldDef): EmptyOperatorExpansion { - if (isMultiValueField(field)) return EMPTY_OPERATOR_ARMS.multi_value; - if (STRING_VALUE_TYPES.has(field.type)) return EMPTY_OPERATOR_ARMS.text; - return EMPTY_OPERATOR_ARMS.null_only; -} - -/** - * [#20311] Is this stored VALUE empty? The value-level half of the same table, - * for the JS evaluation faces. - * - * - With an `expansion` (from {@link expandEmptyOperator}): the declared row — - * null or `undefined` always, `''` only on the `text` row, `[]` only on the - * `multi_value` row. - * - Without one: the reading ruling A gives the faces that hold NO field - * declaration (`@objectstack/formula`'s matcher, objectql `having` over - * aggregated rows) — null, `undefined`, `''` and `[]` are all empty. It - * differs from the declared table only on a non-text column holding `''`, - * which is a write-door defect rather than a stored state. - * - * `$empty: false` is `!isEmptyFilterValue(…)` with the same arguments. - */ -export function isEmptyFilterValue(value: unknown, expansion?: EmptyOperatorExpansion): boolean { - if (value === null || value === undefined) return true; - if (value === '') return expansion === undefined || expansion.emptyString; - if (Array.isArray(value) && value.length === 0) return expansion === undefined || expansion.emptyList; - return false; -} - // ============================================================================ // Combined Field Operators // ============================================================================ @@ -3168,9 +3075,10 @@ export const FilterArraySchema: z.ZodType = z.lazy(() * 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 {@link expandEmptyOperator} / {@link isEmptyFilterValue} as the one - * expansion every face calls — and deliberately ABSENT from this array (the - * maintainer's amendment of ruling A, record 5868169573: 「照 $like 先例分阶段」), + * 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. * diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 8dd5158d5a0..71e142df6cf 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -82,6 +82,12 @@ export * from './filter-comparand-type-conformance'; // door it declares, like `filter-comparand-type`, not as a driver case-set: // drivers sit beneath this door and keep answering FILTER_TEXT_CASES' row. export * from './filter-text-operator-declared-type'; +// [#20311] The `$empty` expansion — the ruled per-type 「is empty」 table read +// off a field DEFINITION (text-like, multi-value, null only) plus the +// value-level predicate for the faces with no declaration. The operator is +// declared in filter.zod.ts and STAGED out of FILTER_OPERATORS; this module is +// what each compile surface calls when it gains its arm. +export * from './filter-empty-operator'; // [#20347] The cross-field COMPARISON CLASS — which two declared columns a // field-to-field comparison (`{ a: { $eq: { $field: 'b' } } }` and its five // sibling operators) may put on either side: six classes over the existing From 32e926db1eabd0765dee8f1a1829489fea4dfae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:27:11 +0000 Subject: [PATCH 4/6] chore(spec): regenerate the api-surface, export-origins and filter reference for $empty Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- content/docs/references/data/filter.mdx | 5 +++++ packages/spec/api-surface/data.json | 5 +++++ packages/spec/export-origins/data.json | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index f89c0e83aef..dba6de709cd 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -119,6 +119,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. | ### Nested Shape: `FieldOperators.$gt` @@ -245,6 +246,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. | ### Nested Shape: `NormalizedFilter.$or[number][string]` @@ -268,6 +270,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. | ### Nested Shape: `NormalizedFilter.$not[string]` @@ -291,6 +294,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. | --- @@ -337,6 +341,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. | --- diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index abf488818c9..29fd50dc64b 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -232,11 +232,14 @@ "DriverVocabularyEntry (interface)", "DroppedFieldsEvent (type)", "DroppedFieldsEventSchema (const)", + "EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED (const)", "ESignatureConfig (type)", "ESignatureConfigParsed (type)", "ESignatureConfigSchema (const)", "EffectiveApiMethods (interface)", + "EmptyOperatorArm (type)", + "EmptyOperatorExpansion (interface)", "EnableLike (interface)", "EngineAggregateOptions (type)", "EngineAggregateOptionsSchema (const)", @@ -767,6 +770,7 @@ "driverSupportsTransactions (function)", "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", + "expandEmptyOperator (function)", "fieldForm (const)", "filterSubtreeProvenanceOf (function)", "foldAsciiCase (function)", @@ -799,6 +803,7 @@ "isCurrentUserDefaultToken (function)", "isDateMacroToken (function)", "isDateRangePresetName (function)", + "isEmptyFilterValue (function)", "isExpressionEnvelopeDefault (function)", "isFileIdToken (function)", "isFilterAST (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 51c0b58ed7a..494dce3fca0 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -227,11 +227,14 @@ "DriverVocabularyEntry": "src/data/driver/config-registry.zod.ts#DriverVocabularyEntry (interface)", "DroppedFieldsEvent": "src/data/data-engine.zod.ts#DroppedFieldsEvent (type)", "DroppedFieldsEventSchema": "src/data/data-engine.zod.ts#DroppedFieldsEventSchema (const)", + "EMPTY_OPERATOR_ARMS": "src/data/filter-empty-operator.ts#EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED": "src/data/data-engine.zod.ts#ENGINE_UPDATE_UPSERT_REMOVED (const)", "ESignatureConfig": "src/data/document.zod.ts#ESignatureConfig (type)", "ESignatureConfigParsed": "src/data/document.zod.ts#ESignatureConfigParsed (type)", "ESignatureConfigSchema": "src/data/document.zod.ts#ESignatureConfigSchema (const)", "EffectiveApiMethods": "src/data/api-derivation.ts#EffectiveApiMethods (interface)", + "EmptyOperatorArm": "src/data/filter-empty-operator.ts#EmptyOperatorArm (type)", + "EmptyOperatorExpansion": "src/data/filter-empty-operator.ts#EmptyOperatorExpansion (interface)", "EnableLike": "src/data/api-derivation.ts#EnableLike (interface)", "EngineAggregateOptions": "src/data/data-engine.zod.ts#EngineAggregateOptions (type)", "EngineAggregateOptionsSchema": "src/data/data-engine.zod.ts#EngineAggregateOptionsSchema (const)", @@ -754,6 +757,7 @@ "driverSupportsTransactions": "src/data/driver.zod.ts#driverSupportsTransactions (function)", "effectiveOperationsArray": "src/data/api-derivation.ts#effectiveOperationsArray (function)", "emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)", + "expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)", "fieldForm": "src/data/field.form.ts#fieldForm (const)", "filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)", "foldAsciiCase": "src/data/filter.zod.ts#foldAsciiCase (function)", @@ -786,6 +790,7 @@ "isCurrentUserDefaultToken": "src/data/default-value-tokens.ts#isCurrentUserDefaultToken (function)", "isDateMacroToken": "src/data/date-macros.zod.ts#isDateMacroToken (function)", "isDateRangePresetName": "src/data/date-range-presets.ts#isDateRangePresetName (function)", + "isEmptyFilterValue": "src/data/filter-empty-operator.ts#isEmptyFilterValue (function)", "isExpressionEnvelopeDefault": "src/data/default-value-shape.ts#isExpressionEnvelopeDefault (function)", "isFileIdToken": "src/data/field-value.zod.ts#isFileIdToken (function)", "isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)", From 859d9bd820f618803edff036b373a5a6bf9494c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:07:26 +0000 Subject: [PATCH 5/6] chore(spec): regenerate api-surface, export-origins and the filter reference over the merged tree Both sides' exports: #20336's number-comparand door and this branch's $empty expansion; filter.mdx carries main's frontmatter and the $empty rows. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- content/docs/references/data/filter.mdx | 3 ++- packages/spec/api-surface/data.json | 27 +++++++++++++++++++++++++ packages/spec/export-origins/data.json | 27 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index dba6de709cd..055932a8e7b 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -1,5 +1,6 @@ --- -title: Filter +title: Filter schema — Data Protocol reference +navTitle: Filter description: "Unified Query DSL Specification. Reference for ComparisonOperator, EqualityOperator, FieldOperators and 9 more: every property with its type and default." --- diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 29fd50dc64b..3607c20a405 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -441,11 +441,21 @@ "MysqlConfig (type)", "MysqlConfigParsed (type)", "MysqlConfigSchema (const)", + "NON_NUMERIC_STRING_FORMS (const)", "NON_TEXT_STORED_VALUE_TYPES (const)", "NOW_DEFAULT_LEGAL_TYPES (const)", + "NUMBER_COMPARAND_DOOR_CASES (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT (const)", + "NUMBER_COMPARAND_DOOR_JUDGED_TYPES (const)", + "NUMBER_COMPARAND_DOOR_LIST_OPERATORS (const)", + "NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS (const)", "NUMERIC_COLUMN_PRECISION (const)", "NUMERIC_COLUMN_REPRESENTATION (const)", "NUMERIC_COLUMN_SCALE (const)", + "NUMERIC_STRING_GRAMMAR_CASES (const)", + "NUMERIC_STRING_PATTERN (const)", "NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", @@ -465,9 +475,21 @@ "NoSQLQueryOptionsSchema (const)", "NoSQLTransactionOptions (type)", "NoSQLTransactionOptionsSchema (const)", + "NonNumericStringForm (type)", "NormalizedFilter (type)", "NormalizedFilterSchema (const)", + "NumberComparandDoorCase (type)", + "NumberComparandDoorDeferredCase (interface)", + "NumberComparandDoorFieldMeta (interface)", + "NumberComparandDoorFixtureField (interface)", + "NumberComparandDoorNarrowsCase (interface)", + "NumberComparandDoorPassesCase (interface)", + "NumberComparandDoorRefusalCase (interface)", + "NumberComparandDoorVerdict (type)", + "NumberComparandRefusalSite (interface)", "NumericColumnRepresentation (type)", + "NumericStringGrammarCase (type)", + "NumericStringReading (type)", "OBJECT_KEY_GUIDANCE (const)", "OWNER_FIELD_DEF (const)", "OWNING_BUSINESS_UNIT_FIELD_DEF (const)", @@ -836,12 +858,16 @@ "missingFieldValues (function)", "nextUtcCalendarDay (function)", "normalizeFilterComparandTypes (function)", + "numberComparandDoorVerdict (function)", + "numberComparandFieldVerdict (function)", + "numberComparandRefusalMessage (function)", "numericColumnFor (function)", "objectForm (const)", "objectTitleCompleteness (function)", "parseAutonumberFormat (function)", "parseDateMacroParam (function)", "parseFilterAST (function)", + "parseNumericString (function)", "passthroughSecretPaths (function)", "percentScaleOf (function)", "placeholderFree (function)", @@ -849,6 +875,7 @@ "platformProvisionsStorage (function)", "provisionPrimary (function)", "readAutonumberCounter (function)", + "readNumericString (function)", "redactDatasourceConfig (function)", "redactUrlCredentialQueryParams (function)", "redactUrlCredentials (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 494dce3fca0..8d1f6e08355 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -431,11 +431,21 @@ "MysqlConfig": "src/data/driver/mysql.zod.ts#MysqlConfig (type)", "MysqlConfigParsed": "src/data/driver/mysql.zod.ts#MysqlConfigParsed (type)", "MysqlConfigSchema": "src/data/driver/mysql.zod.ts#MysqlConfigSchema (const)", + "NON_NUMERIC_STRING_FORMS": "src/data/filter-number-comparand-declared-type.ts#NON_NUMERIC_STRING_FORMS (const)", "NON_TEXT_STORED_VALUE_TYPES": "src/data/field-value.zod.ts#NON_TEXT_STORED_VALUE_TYPES (const)", "NOW_DEFAULT_LEGAL_TYPES": "src/data/default-value-shape.ts#NOW_DEFAULT_LEGAL_TYPES (const)", + "NUMBER_COMPARAND_DOOR_CASES": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_CASES (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_FIXTURE (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS (const)", + "NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT (const)", + "NUMBER_COMPARAND_DOOR_JUDGED_TYPES": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_JUDGED_TYPES (const)", + "NUMBER_COMPARAND_DOOR_LIST_OPERATORS": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_LIST_OPERATORS (const)", + "NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS": "src/data/filter-number-comparand-declared-type.ts#NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS (const)", "NUMERIC_COLUMN_PRECISION": "src/data/numeric-column-representation.ts#NUMERIC_COLUMN_PRECISION (const)", "NUMERIC_COLUMN_REPRESENTATION": "src/data/numeric-column-representation.ts#NUMERIC_COLUMN_REPRESENTATION (const)", "NUMERIC_COLUMN_SCALE": "src/data/numeric-column-representation.ts#NUMERIC_COLUMN_SCALE (const)", + "NUMERIC_STRING_GRAMMAR_CASES": "src/data/filter-number-comparand-declared-type.ts#NUMERIC_STRING_GRAMMAR_CASES (const)", + "NUMERIC_STRING_PATTERN": "src/data/filter-number-comparand-declared-type.ts#NUMERIC_STRING_PATTERN (const)", "NUMERIC_VALUE_TYPES": "src/data/field-value.zod.ts#NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMappingSchema (const)", @@ -455,9 +465,21 @@ "NoSQLQueryOptionsSchema": "src/data/driver-nosql.zod.ts#NoSQLQueryOptionsSchema (const)", "NoSQLTransactionOptions": "src/data/driver-nosql.zod.ts#NoSQLTransactionOptions (type)", "NoSQLTransactionOptionsSchema": "src/data/driver-nosql.zod.ts#NoSQLTransactionOptionsSchema (const)", + "NonNumericStringForm": "src/data/filter-number-comparand-declared-type.ts#NonNumericStringForm (type)", "NormalizedFilter": "src/data/filter.zod.ts#NormalizedFilter (type)", "NormalizedFilterSchema": "src/data/filter.zod.ts#NormalizedFilterSchema (const)", + "NumberComparandDoorCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorCase (type)", + "NumberComparandDoorDeferredCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorDeferredCase (interface)", + "NumberComparandDoorFieldMeta": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorFieldMeta (interface)", + "NumberComparandDoorFixtureField": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorFixtureField (interface)", + "NumberComparandDoorNarrowsCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorNarrowsCase (interface)", + "NumberComparandDoorPassesCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorPassesCase (interface)", + "NumberComparandDoorRefusalCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorRefusalCase (interface)", + "NumberComparandDoorVerdict": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorVerdict (type)", + "NumberComparandRefusalSite": "src/data/filter-number-comparand-declared-type.ts#NumberComparandRefusalSite (interface)", "NumericColumnRepresentation": "src/data/numeric-column-representation.ts#NumericColumnRepresentation (type)", + "NumericStringGrammarCase": "src/data/filter-number-comparand-declared-type.ts#NumericStringGrammarCase (type)", + "NumericStringReading": "src/data/filter-number-comparand-declared-type.ts#NumericStringReading (type)", "OBJECT_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#OBJECT_KEY_GUIDANCE (const)", "OWNER_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNER_FIELD_DEF (const)", "OWNING_BUSINESS_UNIT_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNING_BUSINESS_UNIT_FIELD_DEF (const)", @@ -823,12 +845,16 @@ "missingFieldValues": "src/data/autonumber-format.ts#missingFieldValues (function)", "nextUtcCalendarDay": "src/data/calendar-day.ts#nextUtcCalendarDay (function)", "normalizeFilterComparandTypes": "src/data/filter-comparand-type.ts#normalizeFilterComparandTypes (function)", + "numberComparandDoorVerdict": "src/data/filter-number-comparand-declared-type.ts#numberComparandDoorVerdict (function)", + "numberComparandFieldVerdict": "src/data/filter-number-comparand-declared-type.ts#numberComparandFieldVerdict (function)", + "numberComparandRefusalMessage": "src/data/filter-number-comparand-declared-type.ts#numberComparandRefusalMessage (function)", "numericColumnFor": "src/data/numeric-column-representation.ts#numericColumnFor (function)", "objectForm": "src/data/object.form.ts#objectForm (const)", "objectTitleCompleteness": "src/data/display-name.ts#objectTitleCompleteness (function)", "parseAutonumberFormat": "src/data/autonumber-format.ts#parseAutonumberFormat (function)", "parseDateMacroParam": "src/data/date-macros.zod.ts#parseDateMacroParam (function)", "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", + "parseNumericString": "src/data/filter-number-comparand-declared-type.ts#parseNumericString (function)", "passthroughSecretPaths": "src/data/datasource-credential-redaction.ts#passthroughSecretPaths (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", "placeholderFree": "src/data/driver/common.zod.ts#placeholderFree (function)", @@ -836,6 +862,7 @@ "platformProvisionsStorage": "src/data/injected-system-column-provenance.ts#platformProvisionsStorage (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)", + "readNumericString": "src/data/filter-number-comparand-declared-type.ts#readNumericString (function)", "redactDatasourceConfig": "src/data/datasource-credential-redaction.ts#redactDatasourceConfig (function)", "redactUrlCredentialQueryParams": "src/data/datasource-credential-redaction.ts#redactUrlCredentialQueryParams (function)", "redactUrlCredentials": "src/data/datasource-credential-redaction.ts#redactUrlCredentials (function)", From d581aed71f8b00f1f8df25a5d8f178e5029d1a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:07:48 +0000 Subject: [PATCH 6/6] test(spec): name $empty among the flag operators in the number-comparand door's partition The #20336 pin partitions FieldOperatorsSchema's keys into the judged positions, the text operators and the boolean flags; $empty is a flag (a boolean, not a value of the field), so it joins $null / $exists there, in the module's "Not judged" docblock and as an unjudged case row. The door's verdict logic is unchanged. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../data/filter-number-comparand-declared-type.test.ts | 5 +++-- .../src/data/filter-number-comparand-declared-type.ts | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts index b1500aa64d0..7b5c75d2a61 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts @@ -158,9 +158,10 @@ describe('[#20336] the judged fields', () => { // ── Which positions ────────────────────────────────────────────────────────── describe('[#20336] the judged positions', () => { - it('partition FieldOperatorsSchema\'s keys with the text operators and the two flag operators', () => { + it('partition FieldOperatorsSchema\'s keys with the text operators and the three flag operators', () => { const judged = [...NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS, ...NUMBER_COMPARAND_DOOR_LIST_OPERATORS]; - const partition = [...judged, ...TEXT_FILTER_OPERATORS, '$null', '$exists']; + // [#20311] `$empty` is a boolean flag like `$null` / `$exists`, not a value of the field. + const partition = [...judged, ...TEXT_FILTER_OPERATORS, '$null', '$exists', '$empty']; expect(new Set(partition).size, 'the four parts overlap').toBe(partition.length); expect(sorted(partition)).toEqual(sorted(Object.keys(FieldOperatorsSchema.shape))); }); 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 46845ebd925..af12b15413c 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.ts @@ -118,7 +118,7 @@ * The implicit-equality comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / * `$lte` ({@link NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS}), and every MEMBER of * `$in` / `$nin` / `$between` ({@link NUMBER_COMPARAND_DOOR_LIST_OPERATORS}). - * Not judged: `$null` / `$exists` (a boolean flag, not a value of the field), + * Not judged: `$null` / `$exists` / `$empty` (a boolean flag, not a value of the field), * the text operators (their comparand is a substring or pattern, and over a * numeric field they are refused one door earlier by the text door, #15661), * a `{ $field }` reference (not a literal), and a DOTTED key (a dotted path @@ -739,8 +739,8 @@ const JUDGED_SLOTS: readonly Slot[] = [ * refused string, a narrowed numeric string, and a number (passes). * 3. **The grammar** — every {@link NUMERIC_STRING_GRAMMAR_CASES} row at `$eq` * on `f_number`. - * 4. **The unjudged positions** — `$null`, `$exists` and a `{ $field }` - * reference on `f_number` pass. + * 4. **The unjudged positions** — `$null`, `$exists`, `$empty` and a + * `{ $field }` reference on `f_number` pass. */ export const NUMBER_COMPARAND_DOOR_CASES: readonly NumberComparandDoorCase[] = [ ...NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS.map((field) => @@ -756,6 +756,8 @@ export const NUMBER_COMPARAND_DOOR_CASES: readonly NumberComparandDoorCase[] = [ 'A null test takes a boolean flag, not a value of the field.'), caseFor('unjudged', fixtureField('f_number'), { kind: 'scalar', op: '$exists' }, false, 'An existence test takes a boolean flag, not a value of the field.'), + caseFor('unjudged', fixtureField('f_number'), { kind: 'scalar', op: '$empty' }, true, + 'An emptiness test takes a boolean flag, not a value of the field.'), caseFor('unjudged', fixtureField('f_number'), { kind: 'scalar', op: '$gt' }, { $field: 'f_currency' }, 'A field reference is not a literal.'), ];