Repository navigation
Commit fb38607
Fixes #20444
Clause-②: yes (widening)
The `domain:engine` lane's arms for the staged `$empty` operator, under
ruling A on #20399 (`5865693155`): 「**One sibling card per
compile-surface lane**, each `Blocked-by:` #20311's spec PR:
`domain:engine` — driver-sql and its heirs, turso `RemoteTransport`,
driver-memory, driver-mongodb, formula, objectql `having`;
`domain:services` — service-analytics' two faces. The two faces with no
field declarations (the formula matcher, objectql `having`) judge by
value, diverging only on a non-text column holding `''` (the write-door
class #20308 closed).」
Every arm calls the spec's one expansion from PR #20442
(`expandEmptyOperator` / `isEmptyFilterValue` in
`@objectstack/spec/data`); no face keeps a copy of the table. The
staging does not move (the maintainer's 「照 $like 先例分阶段」, `5868169573`):
`$empty` is **not** added to `FILTER_OPERATORS`, the `is_empty` /
`is_not_empty` lowering still emits `$null`, and the engine's front door
still refuses the operator. A driver or evaluator called directly now
answers it.
## What each face does now
| face | reads | `$empty: true` | undeclared field |
|---|---|---|---|
| `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm`,
`driver-turso` local, which inherit it) | declared row | null-only: `col
IS NULL`; text: `(col IS NULL OR col = '')`; multi-value: `(col IS NULL
OR L)` | refused |
| `driver-turso` `RemoteTransport.buildWhereSQL` | declared row, via a
resolver `TursoDriver` wires from the same registry | same SQL, SQLite
dialect | refused (also when used standalone with no resolver) |
| `driver-memory` live path (`find` / `count` / `update` / `delete`
through mingo) | declared row | null-only `{ f: { $eq: null } }`; text
`{ f: { $in: [null, ''] } }`; multi-value `{ $or: [{ f: { $eq: null } },
{ f: { $size: 0 } }] }` | refused |
| `driver-mongodb` `translateFilter` (and the aggregate `$match`) |
declared row, via a new optional `valueShape` resolver | the same three
documents | refused (also standalone with no resolver) |
| `driver-memory` reference matcher (`match`) | by value |
`isEmptyFilterValue(value)` | answered by value (it holds no
declarations) |
| `formula` `matchesFilterCondition` | by value |
`isEmptyFilterValue(actual)` | answered by value |
| objectql `having` and per-aggregation `filter` | by value |
`isEmptyFilterValue(value)` | answered by value |
| `driver-memory` analytics (cube) face | — | refused `INVALID_FILTER` /
400 as a declared operator it cannot compile, as it refuses `$null` | —
|
`$empty: false` is the exact complement on every face: `(col IS NOT NULL
AND NOT L)` / a non-null value other than `''` (the not-equal operator
against a bound `''`) / `IS NOT NULL` on SQL, `$nin` / `$nor` / `$ne` on
the document faces, `!isEmptyFilterValue` on the value faces. A
non-boolean flag is refused on every query face (`INVALID_FILTER` / 400,
on each driver's validating walk, so an identity that settles the node
first cannot skip it); formula answers it `false`, its standing posture
for an unevaluable `check`.
`L`, the empty-list test on a multi-value column (a JSON column: TEXT on
SQLite, `json` on PostgreSQL and MySQL):
- SQLite (and libSQL): `(CASE WHEN json_valid(col) THEN json_type(col) =
'array' AND json_array_length(col) = 0 ELSE 0 END)` — a malformed legacy
cell answers FALSE instead of failing the statement; a non-array JSON
value is not an empty list;
- PostgreSQL: `(CAST(col AS jsonb) = CAST('[]' AS jsonb))`;
- MySQL: `(JSON_TYPE(col) = 'ARRAY' AND JSON_LENGTH(col) = 0)`;
- any other knex dialect: the multi-value row is refused (the text and
null-only rows need no dialect).
An empty list is always tested as a stored value, never bound as a `$eq:
[]` comparand (ruling 乙 on #19757 stands). Every SQL predicate is TOTAL
(never UNKNOWN), so `$not` over `$empty` needs no NULL guard: both SQL
compilers' polarity tables gain the row (`operatorIsNullTotal` → true,
`nullValueSatisfiesOperator` → `value === true`).
## PM hypotheses, measured
- **H1 — held, with the sources named.** Measured on base `4a1df1965` by
driving each face directly (a scratch probe, not committed) with `{ f: {
$empty: true } }`, `$empty: false` and `{ $and: [{ g: 'x' }, { f: {
$empty: true } }] }`, beside a `$null` control (answered on every face)
and a `$bogus` control. Refusal sources: driver-sql the emitter's
`default:` arm (`unsupportedFilterOperatorError`); turso remote its own
vocabulary refusal (`unsupportedOperator`); driver-memory live path and
matcher both at the shared shape gate (`assertFilterConditionShape`,
`filter-refusal.ts`); driver-mongodb `translateFieldOperators`'
`default:`; objectql `having` `unknownOperator`. All `INVALID_FILTER` /
400. formula answered `[]` for all three shapes (the silent `false`),
exactly as `$bogus`. After this PR, the same probe answers `['2','3']` /
`['1']` / `['2','3']` on every face that holds the declaration or judges
by value, and refuses on the two standalone entry points given no
declaration.
- **H2 — each declared-type face's declaration.** `driver-sql`: a new
per-table registry `valueShapeFields` (`{ type, multiple }` per field),
filled beside `jsonFields` at `registerManagedObjectMetadata` (so
`initObjects` and `registerObjectMetadata`), `registerExternalObject`,
and the shard alias. turso remote: `registerRemoteFieldMetadata` →
`registerExternalObject` fills the same registry, and `TursoDriver`
hands the transport `setDeclaredValueShapeResolver`. driver-memory and
driver-mongodb: a map filled by `syncSchema` beside the temporal-kind
map. The engine's registry injects the audit / tenant / owner fields
into the object's field map before it is synced (per `registry.ts`' own
docblock; not re-measured end to end here), so those are declared too.
**A field with no declaration (a knex-built table, the builtin `id`, a
field with no `type`) is a refusal, never a row guessed from a value:**
the spec's by-value reading has no SQL form without the type (`amount =
''` is a type error on PostgreSQL). A declared non-member type
(`string`, `object`, `array` from an introspected or test object) takes
the row the spec's expansion gives it, null-only.
- **H3 — SQL arms**, above. Pinned on SQLite locally;
`sql-driver-20444-empty-operator.test.ts` runs on every cell of the live
dialect matrix, so PostgreSQL and MySQL are measured by the `Temporal
Conformance (live PG + MySQL)` job. **Locally NOT MEASURED** on PG /
MySQL: no server is reachable in this container. The MySQL `' '` row
relies on the NO PAD default collation of the job's `mysql:8.0`.
- **H4 — the conformance table.** `FILTER_LOGIC_CASES` gains seven
`$empty` cases on the fixture's nullable column `d` (true, false, both
under `$not`, inside `$or`, inside `$and`, beside `$ne` on the same
field). The fixture stores neither `''` nor `[]`, so on it every row of
the table agrees; the rows pin that every face HAS an arm, that `$not`
over it is total and that it composes. The per-type discrimination is
each face's own suite (below). Census of every consumer that iterates
the table:
- driver-sql `sql-driver-or-filter.test.ts` — built its table through
knex, so the harness now registers the fixture's declaration
(`registerObjectMetadata`);
- driver-sqlite-wasm, driver-turso local and remote, driver-memory live
path and matcher, driver-mongodb live suite — already declared the
fixture (`initObjects` / `syncSchema`), pass unchanged;
- driver-memory analytics face — the harness's rule is "agree or refuse
loudly", and it refuses;
- driver-mongodb `mongodb-filter-logic-translation.test.ts` — calls
`translateFilter` standalone, so it now passes a declaration resolver;
- formula `matches-filter-or-semantics.test.ts` — by value, passes
unchanged;
- spec `filter-verdict.test.ts` — the rows reduce to `clause`, passes
unchanged; lint `validate-empty-combinators.test.ts` reads only the
`#5322` rows;
- service-analytics `read-scope-sql-conformance.test.ts` and
`native-sql-filter-logic-conformance.test.ts` — outside this lane. Since
PR #20498 (merged) both faces answer `$empty`, but only when handed the
field's declaration; each harness now passes a `text` declaration for
the fixture (test-only, no service-analytics source touched), so they
pass the rows rather than partition them. Declared as a deviation below.
- **H5 — `having`'s conclusion.** By value over the aggregated row:
null, a column the row lacks, `''` and `[]` are empty. A numeric
aggregate holding `0` (a `count` over nothing, a `sum` netting to zero)
is **not** empty. A `groupBy` text column holding `''` **is** empty —
the row a declared text field takes too. The per-aggregation `filter`
shares the walker and the reading. Pinned in
`having-empty-operator.test.ts`, including the row-independent refusal
of a non-boolean flag.
- **H6 — formula's docblock.** Its header claimed a DECLARED operator
never gets the silent `false`; that was false from #20311's declaration
until this arm. The header now records that, names the
declared-but-staged set (`$like`, `$ilike`, `$empty`) as answered, and
says the next declared name is owed an arm by the PR that lets an author
write it or by its staging's lane card.
## Tests (head measured: `436a10a3e`)
- New per-face pins, each over a text, a multi-value and a scalar field
with null, `''`, `[]` and value rows, `$empty: false`, nesting under
`$and` / `$or` / `$not`, a sibling operator on the same field, and
refusals asserted by `code` + `status`:
`sql-driver-20444-empty-operator.test.ts` (dialect matrix),
`turso-20444-empty-operator.test.ts` (local and remote held to one row
set, plus `count()`), `memory-20444-empty-operator.test.ts` (live,
matcher, analytics face, and the one pinned cell where the declared row
and the by-value reading part), `mongodb-20444-empty-operator.test.ts`
(emitted documents and their rows; a live-`mongod` half runs when the
opt-in server is available), `matches-filter-empty-operator.test.ts`,
`having-empty-operator.test.ts`.
- Extended: the withheld-refusal seam tests of driver-sql (three new
builders, one needing the `'unknown'` dialect) and of the turso remote
transport (two methods, and the local / remote one-sentence table), and
driver-memory's operator-key clobber sweep (now declares its column and
covers `$empty`).
- Full package suites on the pre-merge head `ea3d95994`, each run
through the verify lock: driver-sql 197 files passed, 1 failed, 11
skipped — the failure was the withheld-refusal seam enumeration, which
the new refusal builders owed rows; they are added in this PR and that
file re-ran green (107 tests); driver-turso 77 files, 2080 passed;
driver-sqlite-wasm 36 files, 665 passed; driver-memory 59 files, 1419
passed; driver-mongodb 29 passed / 5 skipped, 656 passed; formula 42
files, 1227 passed; objectql `--project local` 332 files, 6636 passed;
service-analytics 134 files, 3165 passed.
- On the merged head `436a10a3e`: `typecheck` exit 0 for all seven
packages above (spec's own `typecheck` ran green on the pre-merge head);
the `$empty` suites and every `FILTER_LOGIC_CASES` harness re-run green
(driver-sql 154 passed / 4 skipped, turso 240, sqlite-wasm 37, memory
194, mongodb 65 / 50 skipped, formula 43, objectql 36, service-analytics
72, spec 73).
- **Ablations**, each through `scripts/ablation-replace.mjs` on the
committed tree with a restore trap; every leg restored to blob == HEAD
with `git diff HEAD` empty:
- A1 — driver-sql's text arm drops its `''` limb: 4 red in
`sql-driver-20444-empty-operator.test.ts`; the `FILTER_LOGIC_CASES`
sweep stayed green, which is the measured proof the shared rows do not
discriminate the text row.
- A2 — driver-memory's multi-value lowering written as `$in: [null,
[]]`: 8 red (mingo does not match a stored `[]` that way).
- A3 — formula's arm removed (the silent `false` back): 13 red, 6 in the
new pins and all 7 `$empty` rows of the shared table. The first A3
attempt did not run: its replacement text already occurred in the
anchor, the tool refused the non-rising count, and the file was
restored; it was re-run with a distinct replacement.
- `check:driver-conformance` read before and after: 50 covered cells, 0
DEBT, 0 exempt on both sides.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `436a10a3e` (after merging `origin/main`
with a merge commit) derived 91 commands; all 91 ran, each exit code
recorded before any pipe. `--ran` reconciliation: "91 derived famil(ies)
accounted for — 89 run, 2 NOT-MEASURED". NOT MEASURED:
`check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3
(`PREREQUISITE NOT MET`: they need the whole workspace built). Narrowed
probe instead: the built CJS entry of each changed package loads under
`require` (driver-sql 51 exports, driver-turso 14, driver-memory 23,
driver-mongodb 11, formula 47, objectql 178).
Lint, narrowed: `eslint.config.mjs` lints
`packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no
`parserOptions.project`), so no verdict on an untouched file can move
with this diff. `pnpm exec eslint --no-inline-config --format json` over
the 26 changed `.ts` files: 26 file entries, 0 errors, 0 warnings.
## Deviations
- **service-analytics test files**
(`read-scope-sql-conformance.test.ts`,
`native-sql-filter-logic-conformance.test.ts`) are edited, although the
order bars service-analytics. The edit is test-only: it hands each
harness the fixture's declaration so the new shared rows pass (H4). No
service-analytics source moves.
- **`packages/spec/src/data/filter-logic-conformance.ts`** gains the
seven rows and a header paragraph, a declared cross-lane test-data edit
(the claim names it).
## Acceptance notes (observations, not filed)
- The `FILTER_OPERATORS` TSDoc table in
`packages/spec/src/data/filter.zod.ts` still says no face answers
`$empty` and lists each face as refusing it; `filter-empty-operator.ts`'
header still says nothing in the repository calls the expansion. Both
were already stale after PR #20498 and are staler now. Carrier: the flip
card, which rewrites that paragraph when it adds the operator.
- `@objectstack/formula`'s `matchesFilterCondition` has accepted the
object's declared columns (`options.fields`, type and `multiple`) since
PR #20427, after ruling A was taken. With them it could answer `$empty`
by the declared row, as the read side of the same RLS policy does. This
PR keeps the by-value reading the ruling and the card assign; the two
part only on a stored state the declaration does not predict. Carrier:
none named.
- For the flip card: the engine's front door is the one remaining
refusal on the ObjectQL execute path PR #20498 names. `driver-memory`'s
analytics face refuses `$empty` exactly as it refuses `$null` today, so
the flip moves nothing there.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e1afe8 commit fb38607
27 files changed
Lines changed: 1965 additions & 25 deletions
File tree
- .changeset
- packages
- drivers
- driver-memory/src
- driver-mongodb/src
- driver-sql/src
- driver-turso/src
- formula/src
- objectql/src
- services/service-analytics/src/__tests__
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
367 | 374 | | |
368 | 375 | | |
369 | 376 | | |
| |||
611 | 618 | | |
612 | 619 | | |
613 | 620 | | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
614 | 657 | | |
615 | 658 | | |
616 | 659 | | |
| |||
895 | 938 | | |
896 | 939 | | |
897 | 940 | | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
898 | 948 | | |
899 | 949 | | |
900 | 950 | | |
| |||
Lines changed: 166 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
0 commit comments