Skip to content

Commit b810ddb

Browse files
feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) (#20442)
Fixes #20311 Clause-②: yes Declares the emptiness operator `$empty: boolean` in `@objectstack/spec`. Its description is ruling B's per-type table. The one expansion every compile surface will call is exported beside it. The operator is **staged the way `$like` was**: it is declared, but deliberately absent from `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering still emits `$null`. Ruling-ref: 5861435168 (ruling B on #20311), 5865693155 (ruling A on #20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like 先例分阶段」). ## What changed - **`FieldOperatorsSchema` and `SpecialOperatorSchema`** (the enforced copy and the documentation copy, one shared constant) gain `$empty: z.boolean().optional()`. The description is the ruled table: text-like (`STRING_VALUE_TYPES`) = null or `''`; multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select / radio / lookup / user / file / image with `multiple: true`) = null or `[]`; every other type = null only; `false` is the exact complement; a face with no field declaration judges by value. It also says in plain words that the operator is staged and that no face answers it yet. - **New module `packages/spec/src/data/filter-empty-operator.ts`**, published on the data entry: `expandEmptyOperator(field)` keys on the field DEFINITION (type plus `multiple`) and returns one of the frozen `EMPTY_OPERATOR_ARMS` rows `{ arm, emptyString, emptyList }`. `isEmptyFilterValue(value, expansion?)` is the value-level half: with an expansion it applies the declared row; without one it applies the by-value reading for the formula matcher and `having`. The result is surface-neutral, deliberately not a `FilterCondition`, because `[]` stays refused as an equality comparand (ruling 乙 on #19757, untouched). - **`FILTER_OPERATORS` is unchanged.** Its docblock gains a `$empty` staging paragraph with the measured per-face table below. The flip card is named as the one that adds the operator. `filter-operator-vocabulary.test.ts`' `STAGED_AHEAD_OF_BACKENDS` becomes `['$empty', '$ilike', '$like']`. - **Two reconciliation pins that derive from `FieldOperatorsSchema`'s keys**, kept green the way `$like`'s staging kept them: - the comparand-type face's scalar set (`filter-comparand-type.ts`) gains `$empty`; - the save door's boolean-flag set (`filter-save-door-refusals.ts`) gains `$empty`. A non-boolean `$empty` is then refused at save, as `$null` / `$exists` already are, with the flags' first sentence and an `$empty`-specific prescription. - **Changeset** `@objectstack/spec` `minor`, `Clause-②: yes (widening)`. It says plainly that authoring `$empty` today is refused at query time. ## Why the expansion is its own module, not in `filter.zod.ts` The first version put the functions in `filter.zod.ts`, importing the sets from `field-value.zod.ts`. `gen:skill-refs` then rewrote `skills/objectstack-query/references/_index.md` and `skills/objectstack-api/references/_index.md`: that import pulled `field-value.zod.ts`, `field.zod.ts` and four shared modules into those skills' transitive reference lists. Any `skills/**` path would make this PR Tier H. The two files also meet in the `field.zod` import cycle. So the expansion sits in a sibling module, the precedent being `filter-text-operator-declared-type.ts`, which reads the same sets from the same position. `filter.zod.ts` takes no new import. At the final head `check:skill-refs` is green with zero `skills/**` changes. The description names its type lists literally, and `filter-empty-operator.test.ts` pins each list to the sets the function reads, so the two cannot drift. ## A1: what every compile surface does with a hand-authored `$empty` (measured) Probe: a scratch script run once and not committed. It drove each surface with `{ f: { $empty: true } }`, `{ f: { $empty: false } }` and `{ $and: [{ g: 'x' }, { f: { $empty: true } }] }`, after this change was built. Two controls ran beside it: the declared `{ f: { $null: true } }`, and an undeclared `{ f: { $bogus: true } }`. | surface | `$empty` (all three shapes) | control `$null` | control `$bogus` | |---|---|---|---| | (1) driver-sql `applyFilterCondition`, via `SqlDriver.find` on better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this compiler; not driven separately) | REFUSED `INVALID_FILTER` / 400 | answered `['2']` | REFUSED `INVALID_FILTER` / 400 | | (2) driver-turso `RemoteTransport.buildWhereSQL`, via `find` | REFUSED `INVALID_FILTER` / 400 | compiled `IS NULL` | REFUSED `INVALID_FILTER` / 400 | | (3) service-analytics `compileScopedFilterToSql` | REFUSED `READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) | compiled `IS NULL` | same 500 | | (4) service-analytics `lowerAnalyticsWhere` | passes the condition on unchanged (a lowering, not an executor); the compile right after it, `normalizeAnalyticsFilterTree`, REFUSES `INVALID_FILTER` / 400 | lowered to `notSet` | same 400 | | (5) formula `matchesFilterCondition` | NOT LOUD: answers `false` for every record, with flag `true` and with flag `false` | answered the null row | same silent `false` | | objectql `applyHaving` / `matchesHaving` | REFUSED `INVALID_FILTER` / 400 | answered | REFUSED | | driver-memory `find` and `match` (`checkCondition`) | REFUSED `INVALID_FILTER` / 400 | answered `['2']` | REFUSED | | driver-mongodb `translateFilter` (`translateFieldOperators`) | REFUSED `INVALID_FILTER` / 400 | `{ f: { $eq: null } }` | REFUSED | **Conclusion per surface, for this card: explicitly out of scope; each gets its `$empty` arm from its lane card.** No surface drops the predicate, so nothing widens. Two readings differ from premise A1, "the staging leaves every surface loud": - **formula is not loud.** It answers `false` for any operator it has no arm for: its decided fail-closed posture (#6520), identical for the undeclared `$bogus`, and unchanged by this PR. It denies a write-side `check` rather than widening anything. But its own docblock says a DECLARED operator must not get that silent `false` ("the same defect under a new name"), and `$empty` is now declared. So that claim is stale until formula's lane card lands. `$like` got its formula arm in the PR that declared it; this card is barred from formula by the order and by the amendment's placement of arms in lane cards. Flagged in the report as an open question for the seat. - **read-scope-sql refuses with `READ_SCOPE_COMPILE_FAILED` / 500**, not `INVALID_FILTER` / 400. It is loud and fail-closed, and it does the same for every unknown operator. The description and the changeset say "refuse", not "400". ## A2 to A4 - **A2.** Two in-package pins derive from `FieldOperatorsSchema`'s own keys and saw `$empty`: `filter-comparand-type.test.ts` (judged set) and `filter-save-door-face-parity.test.ts` (`BOOLEAN_SLOTS`). Both are updated, with the source sets they reconcile. `filter-operator-vocabulary.test.ts` records the staging. Suites that enumerate `FILTER_OPERATORS` (`filter-view-operator-parity`, `page-component-filter-record-to-rule-array`, service-analytics' echo coverage) are untouched, because the array is. No test outside `packages/spec` enumerates `FieldOperatorsSchema`'s keys (repo grep: only prose mentions). No gate needed an edit outside `packages/spec`. - **A3, re-measured at base `50e273fd7`:** `STRING_VALUE_TYPES` has the 14 text types; `isMultiValueField` = `MULTI_OPTION_TYPES` or a `MULTI_CAPABLE_TYPES` member with `multiple: true`. The sets are disjoint, a pin asserts it, and `lookup` vs `lookup` with `multiple: true` land on different rows. - **A4:** `isEmptyFilterValue(value)` with no expansion is the declaration-free reading (null, `undefined`, `''`, `[]`). It differs from the declared table only on a non-text column holding `''`, and a pin shows exactly that divergence. ## Pins (`filter-empty-operator.test.ts`) 1. Both copies' description equals the ruled table; each type list it names equals the set the expansion reads. 2. `{ tags: { $empty: true } }` parses at `FieldOperatorsSchema` (kept, not stripped), at `FilterConditionSchema` (any depth) and through the normalized AST. `{ tags: [] }` and `{ tags: { $eq: [] } }` are still refused (issue at the slot; the query face answers `INVALID_FILTER` / 400). A non-boolean `$empty` is refused at the slot and at the save door. 3. `expandEmptyOperator` returns the text, multi-value and null arms for the three kinds, and every `FieldType` lands on its value class's arm. `isEmptyFilterValue` answers each arm, plus the by-value reading. 4. `$empty` is ABSENT from `FILTER_OPERATORS`; a comment names the flip card as the one that adds it. `is_empty` / `isempty` / `is_not_empty` / `isnotempty` still lower to `$null`. ## Verification (final head `32e926db1`) - `packages/spec` full local suite: `vitest run --project local`, 563 files, 16536 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: exit 0 (tsc, scripts typecheck, test typecheck). - **Ablation**, run against the committed tree through `scripts/ablation-replace.mjs`. It added `'$empty'` to `FILTER_OPERATORS`: anchor count 1 to 0, blob `0912c2776e5e` to `74b0e4d23fe0`. The run went red on exactly the two staging pins (`filter-empty-operator` §4 and `filter-operator-vocabulary`), with 2 failed and 23 passed. The file was restored to blob == HEAD with `git diff HEAD` empty. The second ablation (dropping `$empty` from the save-door flag set) was **not run**: the verify-lock queue timed out. - **Consumer suites** (filter / operator files per package, positional vitest filters; spec and each package's upstream closure rebuilt from this branch first). Direction: consumers of `@objectstack/spec`, i.e. downstream. | package | files | tests | |---|---|---| | formula | 16 | 570 passed | | driver-memory | 10 | 392 passed | | driver-mongodb | 6 (+1 skipped) | 219 passed, 38 skipped | | objectql (filter / operator / having) | 16 | 624 passed | | driver-sql | 14 | 331 passed, 4 skipped | | driver-turso | 4 | 170 passed | | metadata-protocol | 4 | 51 passed | | lint | 2 | 76 passed | | service-analytics (filter / operator / read-scope) | 42 | 951 passed | | rest | 6 | 109 passed | | plugin-sharing (criteria / sharing-rule) | 6 | 146 passed | - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 108 commands at this head (a superset of the dispatch list's 72). All were run, and `--ran` reconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET) because they need every package built. Those two are `check:dual-build-cjs-loads` and `check:type-check-debt`, and CI runs them. `check:skill-examples` first exited 3 (client-react unbuilt) and exited 0 after building it. `pnpm --filter @objectstack/spec check:generated`: every artifact current. - **Lint, a declared narrowing:** `eslint --no-inline-config --format json` over the 9 changed TypeScript files reported 9 files, 0 errors, 0 warnings. The population is `eslint.config.mjs`' `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. That config never enables type-aware linting (its own note says so), so this diff cannot move a verdict on any untouched file. The full `pnpm lint` is CI's. ## Patch round: reconciled with #20414 (head `d581aed71`) - **Why.** PR #20414 (#20336) landed as `b28550818`. Its partition pin holds `FieldOperatorsSchema`’s keys equal to its judged positions, the text operators and `$null` / `$exists`. With `$empty` added, that pin went red in every merge group carrying both PRs (audit `5871530372`; the diagnosis is on #20455). This PR is the later lander, so it carries the reconciliation. - **What changed in #20336’s files.** - `$empty` joins the flag operators in the partition; the test title now says "three flag operators". - The module’s "Not judged" docblock sentence and its unjudged-positions item name `$empty`. - One unjudged case row is added. - The door’s verdict logic is unchanged, and no count pin on `NUMBER_COMPARAND_DOOR_CASES` exists. - **Merge.** `origin/main` `b28550818` was merged through `os-regen-merge.sh` (`43801c9ed`) and regenerated in `859d9bd82`. Both PRs’ exports are present, once each, in `api-surface/data.json` and `export-origins/data.json`, and `filter.mdx` carries `main`’s frontmatter and the `$empty` rows. - **Verified at `d581aed71`.** - Passed: - spec `test` (local): 566 files, 16687 passed; - spec `typecheck`: exit 0; - `check:generated`: exit 0; - formula: 592 passed; driver-memory: 392 passed. - Dispatch-gates: 108 derived, 106 exit 0. Two are NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`): both need the whole-repo build. - Spec `test:repo` ran in 9 shards: 8 passed, and one was cut by the local time cap, so it is NOT MEASURED locally. CI’s `Test Core`, which runs `test:repo`, is green on this head. ## Stored sharing rules (ruling B, parameter 3) - objectstack `50e273fd7`: the criteria sharing rules in `examples/` that use emptiness = 0. - objectui `b45d463a9`: none either; the 22 emptiness hits in sharing-rule-related files are builder code and tests, not stored rules. - This PR changes no lowering, so no stored rule changes result. - Production rules are NOT MEASURED; the changeset says so. ## Acceptance notes - PR #20414’s number-comparand partition pin now names `$empty` among the boolean flag operators (see the patch round above). The flip card #20446 must keep it there when `$empty` enters `FILTER_OPERATORS`. - formula's docblock claim ("the silent answer is reachable only for a name the protocol does not declare") goes stale with this declaration. Carrier: formula's lane card, which gives it the `$empty` arm (by value, `isEmptyFilterValue(value)`). - read-scope-sql answers every unknown operator with `READ_SCOPE_COMPILE_FAILED` / 500 rather than the ADR-0112 `INVALID_FILTER` / 400 the other faces use. It is pre-existing, fail-closed and loud. Carrier: service-analytics' lane card. - `packages/spec/src/ui/view-grouping-query.ts` says the empty-group predicate and the view filter's `is_empty` agree on what "empty" means. That stays true while the lowering is `$null`. Carrier: the flip card. - `is_empty` still means null-only on every face until the flip card: the gap ruling B closes is still open for users, by design of the staging. The patch-round section and the first acceptance note were added by the `domain:spec` seat 1 (`session_01B3TqpoQbTAfG7G74GMDWNW`) from the dev’s round report. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e02859 commit b810ddb

16 files changed

Lines changed: 583 additions & 12 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare the `$empty` filter operator — what 「is empty」 means, once, per field type — staged ahead of its executors (#20311)
6+
7+
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).
8+
9+
**⚠️ 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.
10+
11+
**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):
12+
13+
| field type | `$empty: true` matches |
14+
|---|---|
15+
| text-like (`STRING_VALUE_TYPES`: text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) | null or `''` |
16+
| multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with `multiple: true`) | null or `[]` |
17+
| every other type | null only |
18+
19+
`$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.
20+
21+
**The one expansion every face calls**, exported from `@objectstack/spec/data`:
22+
23+
- `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`).
24+
- `isEmptyFilterValue(value, expansion?)` — the value-level half: with an expansion, the declared row; without one, the by-value reading for the declaration-free faces.
25+
26+
**What does not change.**
27+
28+
- 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.
29+
- 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.
30+
- `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.
31+
32+
**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).
33+
34+
**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.

‎content/docs/references/data/filter.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const result = ComparisonOperatorSchema.parse(data);
120120
| **$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. |
121121
| **$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. |
122122
| **$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. |
123+
| **$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. |
123124

124125
### Nested Shape: `FieldOperators.$gt`
125126

@@ -246,6 +247,7 @@ Type: `[FilterArray](#filterarray)[]`
246247
| **$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. |
247248
| **$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. |
248249
| **$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. |
250+
| **$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. |
249251

250252
### Nested Shape: `NormalizedFilter.$or[number][string]`
251253

@@ -269,6 +271,7 @@ Type: `[FilterArray](#filterarray)[]`
269271
| **$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. |
270272
| **$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. |
271273
| **$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. |
274+
| **$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. |
272275

273276
### Nested Shape: `NormalizedFilter.$not[string]`
274277

@@ -292,6 +295,7 @@ Type: `[FilterArray](#filterarray)[]`
292295
| **$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. |
293296
| **$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. |
294297
| **$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. |
298+
| **$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. |
295299

296300

297301
---
@@ -338,6 +342,7 @@ Type: `[FilterArray](#filterarray)[]`
338342
| :--- | :--- | :--- | :--- |
339343
| **$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. |
340344
| **$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. |
345+
| **$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. |
341346

342347

343348
---

‎packages/spec/api-surface/data.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,14 @@
232232
"DriverVocabularyEntry (interface)",
233233
"DroppedFieldsEvent (type)",
234234
"DroppedFieldsEventSchema (const)",
235+
"EMPTY_OPERATOR_ARMS (const)",
235236
"ENGINE_UPDATE_UPSERT_REMOVED (const)",
236237
"ESignatureConfig (type)",
237238
"ESignatureConfigParsed (type)",
238239
"ESignatureConfigSchema (const)",
239240
"EffectiveApiMethods (interface)",
241+
"EmptyOperatorArm (type)",
242+
"EmptyOperatorExpansion (interface)",
240243
"EnableLike (interface)",
241244
"EngineAggregateOptions (type)",
242245
"EngineAggregateOptionsSchema (const)",
@@ -789,6 +792,7 @@
789792
"driverSupportsTransactions (function)",
790793
"effectiveOperationsArray (function)",
791794
"emptyGroupValueFor (function)",
795+
"expandEmptyOperator (function)",
792796
"fieldForm (const)",
793797
"filterSubtreeProvenanceOf (function)",
794798
"foldAsciiCase (function)",
@@ -821,6 +825,7 @@
821825
"isCurrentUserDefaultToken (function)",
822826
"isDateMacroToken (function)",
823827
"isDateRangePresetName (function)",
828+
"isEmptyFilterValue (function)",
824829
"isExpressionEnvelopeDefault (function)",
825830
"isFileIdToken (function)",
826831
"isFilterAST (function)",

‎packages/spec/authorable-surface/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@
419419
"data/FieldMaskingKeep:keepTail",
420420
"data/FieldOperators:$between",
421421
"data/FieldOperators:$contains",
422+
"data/FieldOperators:$empty",
422423
"data/FieldOperators:$endsWith",
423424
"data/FieldOperators:$eq",
424425
"data/FieldOperators:$exists",
@@ -948,6 +949,7 @@
948949
"data/ShardingConfig:shardingStrategy",
949950
"data/SortNode:field",
950951
"data/SortNode:order",
952+
"data/SpecialOperator:$empty",
951953
"data/SpecialOperator:$exists",
952954
"data/SpecialOperator:$null",
953955
"data/SqliteConfig:autoMigrate",

‎packages/spec/export-origins/data.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,14 @@
227227
"DriverVocabularyEntry": "src/data/driver/config-registry.zod.ts#DriverVocabularyEntry (interface)",
228228
"DroppedFieldsEvent": "src/data/data-engine.zod.ts#DroppedFieldsEvent (type)",
229229
"DroppedFieldsEventSchema": "src/data/data-engine.zod.ts#DroppedFieldsEventSchema (const)",
230+
"EMPTY_OPERATOR_ARMS": "src/data/filter-empty-operator.ts#EMPTY_OPERATOR_ARMS (const)",
230231
"ENGINE_UPDATE_UPSERT_REMOVED": "src/data/data-engine.zod.ts#ENGINE_UPDATE_UPSERT_REMOVED (const)",
231232
"ESignatureConfig": "src/data/document.zod.ts#ESignatureConfig (type)",
232233
"ESignatureConfigParsed": "src/data/document.zod.ts#ESignatureConfigParsed (type)",
233234
"ESignatureConfigSchema": "src/data/document.zod.ts#ESignatureConfigSchema (const)",
234235
"EffectiveApiMethods": "src/data/api-derivation.ts#EffectiveApiMethods (interface)",
236+
"EmptyOperatorArm": "src/data/filter-empty-operator.ts#EmptyOperatorArm (type)",
237+
"EmptyOperatorExpansion": "src/data/filter-empty-operator.ts#EmptyOperatorExpansion (interface)",
235238
"EnableLike": "src/data/api-derivation.ts#EnableLike (interface)",
236239
"EngineAggregateOptions": "src/data/data-engine.zod.ts#EngineAggregateOptions (type)",
237240
"EngineAggregateOptionsSchema": "src/data/data-engine.zod.ts#EngineAggregateOptionsSchema (const)",
@@ -776,6 +779,7 @@
776779
"driverSupportsTransactions": "src/data/driver.zod.ts#driverSupportsTransactions (function)",
777780
"effectiveOperationsArray": "src/data/api-derivation.ts#effectiveOperationsArray (function)",
778781
"emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)",
782+
"expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)",
779783
"fieldForm": "src/data/field.form.ts#fieldForm (const)",
780784
"filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)",
781785
"foldAsciiCase": "src/data/filter.zod.ts#foldAsciiCase (function)",
@@ -808,6 +812,7 @@
808812
"isCurrentUserDefaultToken": "src/data/default-value-tokens.ts#isCurrentUserDefaultToken (function)",
809813
"isDateMacroToken": "src/data/date-macros.zod.ts#isDateMacroToken (function)",
810814
"isDateRangePresetName": "src/data/date-range-presets.ts#isDateRangePresetName (function)",
815+
"isEmptyFilterValue": "src/data/filter-empty-operator.ts#isEmptyFilterValue (function)",
811816
"isExpressionEnvelopeDefault": "src/data/default-value-shape.ts#isExpressionEnvelopeDefault (function)",
812817
"isFileIdToken": "src/data/field-value.zod.ts#isFileIdToken (function)",
813818
"isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)",

‎packages/spec/src/data/filter-comparand-type.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('the accepted set (#7872 ruling)', () => {
7373
const judgedScalar = [
7474
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
7575
'$contains', '$notContains', '$startsWith', '$endsWith', '$icontains',
76-
'$like', '$ilike', '$null', '$exists',
76+
'$like', '$ilike', '$null', '$exists', '$empty',
7777
];
7878
const judgedList = ['$in', '$nin', '$between'];
7979
expect([...judgedScalar, ...judgedList].sort()).toEqual(declared);

‎packages/spec/src/data/filter-comparand-type.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ const SCALAR_COMPARAND_OPERATORS: ReadonlySet<string> = new Set([
184184
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
185185
'$contains', '$notContains', '$startsWith', '$endsWith', '$icontains',
186186
'$like', '$ilike',
187-
'$null', '$exists',
187+
'$null', '$exists', '$empty',
188188
]);
189189

190190
const LIST_COMPARAND_OPERATORS: ReadonlySet<string> = new Set([

0 commit comments

Comments
 (0)