Skip to content

Commit fb38607

Browse files
feat(drivers,formula,objectql): the engine's filter faces answer the staged $empty operator (#20444) (#20523)
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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/driver-memory': minor
5+
'@objectstack/driver-mongodb': minor
6+
'@objectstack/formula': minor
7+
'@objectstack/objectql': minor
8+
'@objectstack/spec': minor
9+
---
10+
11+
feat(drivers,formula,objectql): the engine's filter faces answer the staged `$empty` operator (#20444)
12+
13+
Clause-②: yes (widening)
14+
15+
`$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Until now every face in this list refused it (`INVALID_FILTER` / 400), except `matchesFilterCondition`, which answered `false` for every record. **A driver or evaluator called directly now answers it:**
16+
17+
- **By the field's declared type**, through the spec's one expansion (`expandEmptyOperator`): `driver-sql`'s filter compiler (and so `driver-sqlite-wasm` and `driver-turso`'s local transport, which inherit it), `driver-turso`'s remote transport, `driver-memory`'s query path (`find` / `count` / `update` / `delete`) and `driver-mongodb`'s `translateFilter` (its `find`, its aggregate `$match`). The declaration is the one each driver already receives — `initObjects` / `registerObjectMetadata` / `registerExternalObject` on the SQL family, `syncSchema` on the others. On SQL a multi-value field's empty list is tested as stored JSON per dialect (SQLite `json_array_length` behind a `json_valid` guard, PostgreSQL a `jsonb` comparison, MySQL `JSON_LENGTH`), never as an equality comparand.
18+
- **By value** — null, a missing value, `''` and `[]` are empty (`isEmptyFilterValue`) — on the faces that read no field declaration: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`), `driver-memory`'s reference matcher, and `@objectstack/objectql`'s `having` and per-aggregation `filter`. In `having`, a `count` or `sum` holding `0` is not empty.
19+
20+
**Refused, never guessed** (`INVALID_FILTER` / 400): `$empty` on a field whose declaration the driver does not hold (a table built outside its registration, a builtin column such as `id`, a field with no `type`, or `translateFilter` / `RemoteTransport` used standalone without a declaration), a multi-value field on a SQL dialect the driver does not model, and a flag that is not a boolean. `driver-memory`'s analytics (cube) face refuses `$empty` as an operator it cannot compile, as it does `$null`.
21+
22+
New optional API: `translateFilter(where, temporalKind?, valueShape?)` in `@objectstack/driver-mongodb` takes a declared-value-shape resolver (type `ValueShapeResolver`), and `buildAggregationPipeline` a `valueShape` option; `RemoteTransport.setDeclaredValueShapeResolver` in `@objectstack/driver-turso`, which `TursoDriver` wires. `@objectstack/spec`'s shared `FILTER_LOGIC_CASES` table gains seven `$empty` cases: a backend that runs it answers `$empty` or goes red, and its harness must declare the fixture's columns.
23+
24+
`$empty` stays staged: it is not in `FILTER_OPERATORS`, so the engine's front door still refuses it until the flip card adds it, and the view operators `is_empty` / `is_not_empty` still lower to `$null`.

‎packages/drivers/driver-memory/src/filter-refusal.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,13 @@ export const SUPPORTED_FIELD_OPERATORS: ReadonlySet<string> = new Set<string>([
364364
...FILTER_OPERATORS,
365365
'$like',
366366
'$ilike',
367+
// [#20444] The staged emptiness flag, admitted BY HAND for the reason the
368+
// `$like` paragraph above gives, and under its ordering rule: both arms land
369+
// with this entry — the reference matcher judges the stored value
370+
// (`isEmptyFilterValue`, the spec's reading for a face holding no field
371+
// declaration) and the live query path the field's DECLARED row
372+
// (`expandEmptyOperator`, from the declaration `syncSchema` recorded).
373+
'$empty',
367374
]);
368375

369376
/** The vocabulary as it appears in a refusal message, in declaration order. */
@@ -611,6 +618,42 @@ export function nonBooleanNullComparandError(field: string, value: unknown, path
611618
);
612619
}
613620

621+
/**
622+
* [#20444] A non-boolean `$empty` comparand. The leading sentence is
623+
* `driver-sql`'s `nonBooleanEmptyComparandError`, verbatim — one condition,
624+
* one wording (#5240).
625+
*/
626+
export function nonBooleanEmptyComparandError(field: string, value: unknown, path: string): Error {
627+
return unsupportedFilterError(
628+
`Operator "$empty" on field "${field}" requires a boolean comparand (true or false). ` +
629+
`Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` +
630+
`@objectstack/spec FieldOperatorsSchema declares $empty as a boolean: true asks for the ` +
631+
`empty rows, false for their exact complement.`,
632+
);
633+
}
634+
635+
/**
636+
* [#20444] `$empty` on the live query path, aimed at a field this driver holds
637+
* no declaration for — an object never passed through `syncSchema`, a field
638+
* its schema does not name, or one declared with no `type`.
639+
*
640+
* What counts as empty is the field's DECLARED row of the ruled table, and the
641+
* live path reads it from the declaration rather than from a value, so without
642+
* one there is no answer to give: refused, never guessed. The reference matcher
643+
* (`memory-matcher.ts`) is the face that holds NO declarations at all, and it
644+
* judges the stored value instead — the spec's reading for such a face.
645+
*/
646+
export function undeclaredEmptyOperatorFieldError(field: string, path: string): Error {
647+
return unsupportedFilterError(
648+
`Operator "$empty" on field "${field}" at ${path} targets a field whose declaration this ` +
649+
`driver does not hold (no declared type — the object's schema was never synced, or does not ` +
650+
`declare the field). What counts as empty is the field's DECLARED row of the ruled table — ` +
651+
`null or '' for a text-like type, null or [] for a multi-value field, null only for every ` +
652+
`other type — so the operator is refused rather than guessed. Declare the field, or use ` +
653+
`"$null" for "has no value".`,
654+
);
655+
}
656+
614657
/**
615658
* [#5702] A RETIRED filter operator in a field constraint.
616659
*
@@ -895,6 +938,13 @@ function assertFieldConstraintShape(
895938
if (op === '$null' && typeof spec[op] !== 'boolean') {
896939
throw nonBooleanNullComparandError(field, spec[op], `${path}.$null`);
897940
}
941+
// [#20444] `$empty`'s comparand is a boolean by the same declaration
942+
// (`FieldOperatorsSchema`), refused on this walk for the same reason: both
943+
// faces of this package evaluate `true` / `false` exhaustively, so a third
944+
// value would land on whichever side each arm happens to default to.
945+
if (op === '$empty' && typeof spec[op] !== 'boolean') {
946+
throw nonBooleanEmptyComparandError(field, spec[op], `${path}.$empty`);
947+
}
898948
// [#16810] An ARRAY comparand on a single-value comparison — the operator
899949
// spelling of the implicit-equality position refused at the top of this
900950
// function, and the same cell `@objectstack/spec`'s comparand door leaves
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20444] The staged `$empty` operator on this package's three filter faces.
5+
*
6+
* - **The live query path** (`InMemoryDriver.find` → mingo) holds the field
7+
* declarations `syncSchema` recorded, so it answers by the field's DECLARED
8+
* row of the ruled 「is empty」 table (ruling A on #20399, record 5865693155;
9+
* the spec's `expandEmptyOperator`): text-like = null or `''`; multi-value =
10+
* null or `[]`; every other type = null only; `$empty: false` the exact
11+
* complement. A field it holds no declaration for is REFUSED.
12+
* - **The reference matcher** (`match`) holds no declarations at all, so it
13+
* takes the reading the spec gives such a face — by value
14+
* (`isEmptyFilterValue`): null, a missing key, `''` and `[]` are empty.
15+
* - **The analytics (cube) face** does not lower the flag (nor `$null`), and
16+
* refuses it loudly as a declared operator it cannot compile.
17+
*
18+
* The two value-level faces agree on every value a field's own type can hold;
19+
* the one place they part is a stored state the declaration does not predict,
20+
* and that cell is pinned below so the divergence is a measurement, not a
21+
* surprise.
22+
*/
23+
24+
import { beforeAll, describe, expect, it } from 'vitest';
25+
import type { Cube, FilterCondition } from '@objectstack/spec/data';
26+
import { InMemoryDriver } from './memory-driver.js';
27+
import { match } from './memory-matcher.js';
28+
import { MemoryAnalyticsService } from './memory-analytics.js';
29+
30+
const TABLE = 'os20444_empty';
31+
32+
const FIELDS = {
33+
id: { type: 'text' },
34+
title: { type: 'text' },
35+
tags: { type: 'tags' },
36+
owners: { type: 'lookup', reference: TABLE, multiple: true },
37+
score: { type: 'number' },
38+
};
39+
40+
const ROWS: Array<Record<string, unknown>> = [
41+
{ id: 'r1', title: 'x', tags: ['a'], owners: ['u1'], score: 5 },
42+
{ id: 'r2', title: '', tags: [], owners: [], score: 0 },
43+
{ id: 'r3', title: null, tags: null, owners: null, score: null },
44+
{ id: 'r4', title: ' ', tags: ['a', 'b'], owners: ['u2'], score: -1 },
45+
// Every column MISSING — the other reading of "no value".
46+
{ id: 'r5' },
47+
];
48+
49+
const CASES: Array<{ where: FilterCondition; expected: string[] }> = [
50+
{ where: { title: { $empty: true } }, expected: ['r2', 'r3', 'r5'] },
51+
{ where: { title: { $empty: false } }, expected: ['r1', 'r4'] },
52+
{ where: { tags: { $empty: true } }, expected: ['r2', 'r3', 'r5'] },
53+
{ where: { tags: { $empty: false } }, expected: ['r1', 'r4'] },
54+
{ where: { owners: { $empty: true } }, expected: ['r2', 'r3', 'r5'] },
55+
{ where: { owners: { $empty: false } }, expected: ['r1', 'r4'] },
56+
{ where: { score: { $empty: true } }, expected: ['r3', 'r5'] },
57+
{ where: { score: { $empty: false } }, expected: ['r1', 'r2', 'r4'] },
58+
{ where: { $not: { title: { $empty: true } } }, expected: ['r1', 'r4'] },
59+
{ where: { $not: { tags: { $empty: false } } }, expected: ['r2', 'r3', 'r5'] },
60+
{ where: { $or: [{ score: 5 }, { tags: { $empty: true } }] }, expected: ['r1', 'r2', 'r3', 'r5'] },
61+
{ where: { $and: [{ title: { $empty: false } }, { owners: { $empty: false } }] }, expected: ['r1', 'r4'] },
62+
{ where: { title: { $empty: false, $ne: 'x' } }, expected: ['r4'] },
63+
{ where: { tags: { $empty: true, $ne: null } }, expected: ['r2'] },
64+
];
65+
66+
function refusal(run: () => unknown): Promise<{ code?: string; status?: number } | 'answered'> {
67+
return Promise.resolve()
68+
.then(run)
69+
.then(
70+
() => 'answered' as const,
71+
(err) => ({ code: (err as { code?: string }).code, status: (err as { status?: number }).status }),
72+
);
73+
}
74+
75+
describe('[#20444] InMemoryDriver — $empty on the live path, the reference matcher and the analytics face', () => {
76+
let driver: InMemoryDriver;
77+
const ids = async (where: FilterCondition) =>
78+
((await driver.find(TABLE, { where })) as Array<Record<string, unknown>>).map((r) => String(r.id)).sort();
79+
const reference = (where: FilterCondition) =>
80+
ROWS.filter((r) => match(r, where)).map((r) => String(r.id)).sort();
81+
82+
beforeAll(async () => {
83+
driver = new InMemoryDriver({ persistence: false });
84+
await driver.connect();
85+
await driver.syncSchema(TABLE, { fields: FIELDS });
86+
for (const row of ROWS) await driver.create(TABLE, { ...row });
87+
});
88+
89+
it('the fixture is the five rows', async () => {
90+
expect(await ids({})).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']);
91+
});
92+
93+
for (const c of CASES) {
94+
it(`${JSON.stringify(c.where)} → ${JSON.stringify(c.expected)} on the live path AND the reference matcher`, async () => {
95+
expect(await ids(c.where), 'live').toEqual(c.expected);
96+
expect(reference(c.where), 'reference matcher').toEqual(c.expected);
97+
});
98+
}
99+
100+
it('$empty: false partitions every declared field with $empty: true, on both faces', async () => {
101+
for (const field of ['title', 'tags', 'owners', 'score']) {
102+
const empty = await ids({ [field]: { $empty: true } });
103+
const full = await ids({ [field]: { $empty: false } });
104+
expect([...empty, ...full].sort(), field).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']);
105+
expect(reference({ [field]: { $empty: true } }), field).toEqual(empty);
106+
}
107+
});
108+
109+
it('the live path REFUSES a field it holds no declaration for; the matcher judges the value', async () => {
110+
expect(await refusal(() => driver.find(TABLE, { where: { nope: { $empty: true } } })))
111+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
112+
expect(await refusal(() => driver.find('never_synced', { where: { title: { $empty: true } } })))
113+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
114+
expect(reference({ nope: { $empty: true } })).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']);
115+
});
116+
117+
it('the one cell where the declared row and the by-value reading part: a stored state the type cannot hold', async () => {
118+
// A number column holding '' is a write-door defect, never a value the
119+
// declaration predicts. The declared null-only row does not count it; the
120+
// declaration-free matcher does. Pinned so the divergence is known.
121+
const odd = new InMemoryDriver({ persistence: false });
122+
await odd.connect();
123+
await odd.syncSchema('odd', { fields: { id: { type: 'text' }, score: { type: 'number' } } });
124+
const rows = [{ id: 'blank', score: '' }];
125+
for (const row of rows) await odd.create('odd', { ...row });
126+
const live = ((await odd.find('odd', { where: { score: { $empty: true } } })) as Array<Record<string, unknown>>)
127+
.map((r) => r.id);
128+
expect(live).toEqual([]);
129+
expect(rows.filter((r) => match(r, { score: { $empty: true } })).map((r) => r.id)).toEqual(['blank']);
130+
});
131+
132+
it('a non-boolean flag is refused by both faces, on the shared shape gate', async () => {
133+
expect(await refusal(() => driver.find(TABLE, { where: { title: { $empty: 'yes' as never } } })))
134+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
135+
expect(await refusal(() => match(ROWS[0], { title: { $empty: 1 as never } })))
136+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
137+
// Even where an identity would settle the node before any arm ran.
138+
expect(await refusal(() => match(ROWS[0], { $or: [{}, { title: { $empty: 'no' as never } }] })))
139+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
140+
});
141+
142+
it('the analytics (cube) face refuses $empty as a declared operator it cannot compile — never drops it', async () => {
143+
const cube: Cube = {
144+
name: TABLE,
145+
title: 'empty',
146+
sql: TABLE,
147+
measures: { count: { name: 'count', label: 'Rows', type: 'count', sql: 'id' } },
148+
dimensions: {
149+
id: { name: 'id', label: 'id', type: 'string', sql: 'id' },
150+
title: { name: 'title', label: 'title', type: 'string', sql: 'title' },
151+
},
152+
public: true,
153+
} as Cube;
154+
const service = new MemoryAnalyticsService({ driver, cubes: [cube] });
155+
expect(
156+
await refusal(() =>
157+
service.query({
158+
cube: TABLE,
159+
measures: [`${TABLE}.count`],
160+
dimensions: [`${TABLE}.id`],
161+
where: { title: { $empty: true } },
162+
} as never),
163+
),
164+
).toEqual({ code: 'INVALID_FILTER', status: 400 });
165+
});
166+
});

0 commit comments

Comments
 (0)