You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
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):
| 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.
Copy file name to clipboardExpand all lines: content/docs/references/data/filter.mdx
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -120,6 +120,7 @@ const result = ComparisonOperatorSchema.parse(data);
120
120
|**$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. |
121
121
|**$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. |
122
122
|**$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. |
|**$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. |
247
248
|**$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. |
248
249
|**$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. |
|**$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. |
270
272
|**$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. |
271
273
|**$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. |
|**$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. |
293
296
|**$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. |
294
297
|**$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. |
|**$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. |
340
344
|**$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. |
0 commit comments