diff --git a/.changeset/20822-driver-memory-face-copies.md b/.changeset/20822-driver-memory-face-copies.md new file mode 100644 index 00000000000..c9c01d972a3 --- /dev/null +++ b/.changeset/20822-driver-memory-face-copies.md @@ -0,0 +1,10 @@ +--- +'@objectstack/driver-memory': patch +--- + +refactor(driver-memory): the cube face's own whole-day bound and the in-memory reference matcher are deleted; no answer a caller gets moves (#5930 step 4, #20822) + +Clause-②: no + +- **`MemoryAnalyticsService` (the cube face).** Its `where` door has run the shared `lowerFilterCondition` (`@objectstack/spec/data`) since #5930 step 3, on every column. A bare-day `$lte` therefore reaches the `lte` row already lowered: as `$lt` the next day, or as `$null: false` on `9999-12-31`. The row's own copy of that rule is deleted, and the `lte` row now compiles the comparison it is handed on both exits. The rows `query()` returns and the SQL `generateSql()` echoes are unchanged. An explicit `dateRange` end still widens a bare day through its own window arm (ADR-0053 D-D1 item 8). +- **The reference matcher (`memory-matcher.ts`, `match()`) is retired** (ruling D6 on #5930). No production code called it and the package never exported it: the published `dist` exports are the same 33 names before and after. `InMemoryDriver` keeps `getValueByPath`, the one helper it imported from that module. The matcher's tests now assert the live query path (`InMemoryDriver.find`), the shared filter shape gate, or the spec predicate the matcher evaluated. diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index dbbdbfa1106..9e651137cbe 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -4,13 +4,14 @@ * The filter refusals this driver raises, in ONE place — and, since #5324/#5328, * the ONE walk that decides which shapes are refused at all. * - * All THREE of this package's filter surfaces refuse the same shapes with the - * same wire envelope: the live query path (`memory-driver.ts` → mingo), the - * reference matcher (`memory-matcher.ts`, the record-at-a-time evaluator the - * conformance suites hold against `driver-sql` and `@objectstack/formula`), and - * since #5345 the analytics/cube face (`memory-analytics.ts`). They were - * independent code paths with independent notions of what a filter may be, which - * is exactly how #5240's divergence survived unnoticed in-package. + * All of this package's filter surfaces refuse the same shapes with the same + * wire envelope: the live query path (`memory-driver.ts` → mingo) and since + * #5345 the analytics/cube face (`memory-analytics.ts`) — and, until #5930 + * step 4 retired it (ruling D6: no production caller), the reference matcher + * (`memory-matcher.ts`, a record-at-a-time evaluator whose tests now hold the + * live path and this gate). They were independent code paths with independent + * notions of what a filter may be, which is exactly how #5240's divergence + * survived unnoticed in-package. * * #5240 gave the first two faces one refusal by writing the same check twice. * That was still two implementations of one rule, and the shapes #5324/#5328 @@ -319,8 +320,8 @@ export function emptyFieldConstraintError(field: string, path: string): Error { * RLS read scope is a permission bypass rather than a degraded filter (#3948). * * So the arms and the word list HAD to land in one PR, and #6520 did that: - * `memory-matcher.ts` and `memory-driver.ts` both carry a `$icontains` case, and - * `memory-analytics.ts` lowers it too. Re-verified by deleting the matcher's arm + * `memory-matcher.ts` (retired since) and `memory-driver.ts` both carried a + * `$icontains` case, and `memory-analytics.ts` lowers it too. Re-verified by deleting the matcher's arm * on the #6520 branch — with the name admitted, the reference matcher answered * EVERY row, which is the measurement, not a prediction. * @@ -350,22 +351,22 @@ export function emptyFieldConstraintError(field: string, path: string): Error { * PARSES must survive to a matched row here. * * The ordering rule from the `$icontains` paragraph applies unchanged and was - * followed: both arms (`memory-driver.ts`'s query path and `memory-matcher.ts`) - * landed in the same commit as this widening. A name added here with no arm + * followed: both arms (`memory-driver.ts`'s query path and the since-retired + * `memory-matcher.ts`) landed in the same commit as this widening. A name added here with no arm * behind it is the #5701 measurement — gate stops refusing, matcher has no * case, predicate silently DROPPED, every row matches. * * Everything else is refused. That includes the mingo operators this driver used * to hand through by accident (`$elemMatch`, `$size`, `$type`, `$mod`, `$where`, - * `$expr`, field-level `$not`) — none of them is in the Filter Protocol, none is - * implemented by the matcher, and `driver-sql` refuses every one. + * `$expr`, field-level `$not`) — none of them is in the Filter Protocol, none was + * implemented by the retired matcher, and `driver-sql` refuses every one. */ export const SUPPORTED_FIELD_OPERATORS: ReadonlySet = new Set([ ...FILTER_OPERATORS, '$like', '$ilike', // [#20444] `$empty` was admitted here BY HAND, with both its arms (the - // reference matcher by value through `isEmptyFilterValue`, the live query + // since-retired reference matcher by value through `isEmptyFilterValue`, the live query // path by the field's DECLARED row through `expandEmptyOperator`), while it // was staged out of `FILTER_OPERATORS`. [#20446] It arrives by DERIVATION // now, after `$exists` in the spec's order, so the hand entry is gone — the @@ -409,10 +410,10 @@ export interface FilterFaceCapabilities { } /** - * [#5345] The default: the whole vocabulary this driver's query path and - * reference matcher evaluate. Passing no capabilities means "this face compiles - * everything the driver does", which is true of both of them and keeps every - * pre-#5345 call site behaving byte-for-byte as before. + * [#5345] The default: the whole vocabulary this driver's query path evaluates + * (as its reference matcher did, until retired). Passing no capabilities means + * "this face compiles everything the driver does", which is true of the query + * path and keeps every pre-#5345 call site behaving byte-for-byte as before. */ export const DRIVER_FILTER_CAPABILITIES: FilterFaceCapabilities = Object.freeze({ face: 'this driver', @@ -638,9 +639,10 @@ export function nonBooleanEmptyComparandError(field: string, value: unknown, pat * * What counts as empty is the field's DECLARED row of the ruled table, and the * live path reads it from the declaration rather than from a value, so without - * one there is no answer to give: refused, never guessed. The reference matcher - * (`memory-matcher.ts`) is the face that holds NO declarations at all, and it - * judges the stored value instead — the spec's reading for such a face. + * one there is no answer to give: refused, never guessed. A face that holds NO + * declarations at all judges the stored value instead — the spec's reading for + * such a face (`@objectstack/formula`'s, and this package's reference matcher's + * until it was retired). */ export function undeclaredEmptyOperatorFieldError(field: string, path: string): Error { return unsupportedFilterError( @@ -804,7 +806,7 @@ export function filterNodeExpectedError(value: unknown, path: string): Error { } /** - * [#5324 / #5328] The ONE shape gate, walked before either face evaluates. + * [#5324 / #5328] The ONE shape gate, walked before any face evaluates. * * ## Why up front, and why exhaustive * @@ -845,8 +847,8 @@ export function filterNodeExpectedError(value: unknown, path: string): Error { * Shape is universal; CAPABILITY is per-face. `capabilities` narrows what this * particular caller can lower — see {@link FilterFaceCapabilities} — and the * walk refuses the difference. It defaults to - * {@link DRIVER_FILTER_CAPABILITIES}, i.e. everything, so the query path and the - * matcher are unaffected. + * {@link DRIVER_FILTER_CAPABILITIES}, i.e. everything, so the query path (and + * the matcher, while it lived) is unaffected. * * The capability check is made BEFORE the shape checks at the same key, and * deliberately: on a face that cannot compile `$or` at all, reporting that its diff --git a/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts b/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts index b96e96797f8..314d4400717 100644 --- a/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts +++ b/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts @@ -20,12 +20,15 @@ * - the `$`-spelling on the live query path (`assertFieldConstraintShape`); * - the QueryAST `comparison` spelling (`convertConditionToMongo`'s `like` / * `ilike` arm), which has its own dangling-escape refusal; - * - the reference matcher `match()`, through the same shape gate. + * - the shape gate itself (`assertFilterConditionShape`, `filter-refusal.ts`), + * called directly. It was the reference matcher's door; [#5930 step 4, ruling + * D6] that matcher is retired, and its refusal assertions are held on the + * gate it ran, which the live query path runs too. */ import { describe, it, expect, beforeEach } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; interface WireBearingError extends Error { code?: string; @@ -81,13 +84,14 @@ describe('[#20041] driver-memory — a $like / $ilike pattern holding U+0000 is return err; }; - const matcherRefusalOf = (where: unknown): WireBearingError => { + /** The shared shape gate, called directly — the door the retired reference matcher ran. */ + const gateRefusalOf = (where: unknown): WireBearingError => { try { - match({ v: 'ab' }, where); + assertFilterConditionShape(where, 'filter'); } catch (e) { return e as WireBearingError; } - throw new Error(`expected the matcher to refuse ${JSON.stringify(where)}, but it answered`); + throw new Error(`expected the shape gate to refuse ${JSON.stringify(where)}, but it passed`); }; const expectEnvelope = (err: WireBearingError, located: string) => { @@ -120,8 +124,8 @@ describe('[#20041] driver-memory — a $like / $ilike pattern holding U+0000 is ); }); - it(`${op} ${shown}: the reference matcher refuses it rather than answering`, () => { - expectEnvelope(matcherRefusalOf({ v: { [op]: pattern } }), `Operator "${op}" on field "v" at filter.v.${op}`); + it(`${op} ${shown}: the shape gate refuses it rather than passing it on (the retired matcher's door)`, () => { + expectEnvelope(gateRefusalOf({ v: { [op]: pattern } }), `Operator "${op}" on field "v" at filter.v.${op}`); }); } @@ -130,7 +134,7 @@ describe('[#20041] driver-memory — a $like / $ilike pattern holding U+0000 is for (const err of [ await refusalOf({ v: { $like: pattern } }), await refusalOf({ type: 'comparison', field: 'v', operator: 'like', value: pattern }), - matcherRefusalOf({ v: { $like: pattern } }), + gateRefusalOf({ v: { $like: pattern } }), ]) { expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); @@ -141,12 +145,14 @@ describe('[#20041] driver-memory — a $like / $ilike pattern holding U+0000 is }); for (const c of CONTROLS) { - it(`control: ${JSON.stringify(c.where)} (no U+0000) answers the same rows on the query path and the matcher`, async () => { + // [#5930 step 4] The retired matcher's half of this control asserted the + // same `expected` rows — `@objectstack/formula`'s answer, the oracle both + // halves were held to — so the control keeps that oracle on the live path. + it(`control: ${JSON.stringify(c.where)} (no U+0000) answers formula's rows on the query path`, async () => { const queried = (await driver.find(TABLE, { where: c.where as never })) .map((r: Record) => String(r.label)) .sort(); - const matched = ROWS.filter((r) => match(r, c.where)).map((r) => r.label).sort(); - expect({ queried, matched }).toEqual({ queried: [...c.expected], matched: [...c.expected] }); + expect(queried).toEqual([...c.expected]); }); } }); diff --git a/packages/drivers/driver-memory/src/memory-20143-like-code-point.test.ts b/packages/drivers/driver-memory/src/memory-20143-like-code-point.test.ts index 2f6889fe7b4..67792b6ab67 100644 --- a/packages/drivers/driver-memory/src/memory-20143-like-code-point.test.ts +++ b/packages/drivers/driver-memory/src/memory-20143-like-code-point.test.ts @@ -18,11 +18,16 @@ * * Each door is asserted against the literal code-point rows, not merely * against the others: parity alone is satisfied by breaking all three alike. + * + * [#5930 step 4, ruling D6] The reference matcher is retired. Its door held + * nothing of its own for this operator — it evaluated the spec's + * `matchesLikePattern` — so its third of each assertion is held on that shared + * predicate directly, the one `@objectstack/formula` evaluates too. */ import { describe, it, expect, beforeAll } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { matchesLikePattern } from '@objectstack/spec/data'; const GRIN = String.fromCodePoint(0x1f600); const SCRIPT_A = String.fromCodePoint(0x1d49c); @@ -71,14 +76,17 @@ describe('[#20143] driver-memory — `_` is one code point on every door', () => }); for (const [op, pattern, expected] of FAMILY) { - it(`${op} ${JSON.stringify(pattern)} answers the code-point rows on all three doors`, async () => { + it(`${op} ${JSON.stringify(pattern)} answers the code-point rows on every door`, async () => { const where = { v: { [op]: pattern } }; const ast = { type: 'comparison', field: 'v', operator: op === '$ilike' ? 'ilike' : 'like', value: pattern }; expect({ dollarQuery: labels(await driver.find(TABLE, { where: where as never })), astQuery: labels(await driver.find(TABLE, { where: ast as never })), - matcher: Object.entries(ROWS).filter(([label, v]) => match({ label, v }, where)).map(([l]) => l).sort(), - }).toEqual({ dollarQuery: [...expected], astQuery: [...expected], matcher: [...expected] }); + sharedPredicate: Object.entries(ROWS) + .filter(([, v]) => matchesLikePattern(v, pattern, op === '$ilike')) + .map(([l]) => l) + .sort(), + }).toEqual({ dollarQuery: [...expected], astQuery: [...expected], sharedPredicate: [...expected] }); }); } }); diff --git a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts index f2b19318eff..875db96ff07 100644 --- a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts +++ b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts @@ -9,9 +9,13 @@ * the spec's `expandEmptyOperator`): text-like = null or `''`; multi-value = * null or `[]`; every other type = null only; `$empty: false` the exact * complement. A field it holds no declaration for is REFUSED. - * - **The reference matcher** (`match`) holds no declarations at all, so it - * takes the reading the spec gives such a face — by value - * (`isEmptyFilterValue`): null, a missing key, `''` and `[]` are empty. + * - **The by-value reading** — what a face holding no declarations at all + * answers (`isEmptyFilterValue`): null, a missing key, `''` and `[]` are + * empty. [#5930 step 4, ruling D6] This package's reference matcher was such + * a face and is retired; its half of each cell is held here on the spec's + * `isEmptyFilterValue` (the one function it evaluated), and the combinator + * cells on `@objectstack/formula`'s `matches-filter-empty-operator.test.ts`, + * the by-value face that ships (same five rows, same answers). * - **The analytics (cube) face** does not lower the flag (nor `$null`), and * refuses it loudly as a declared operator it cannot compile. * @@ -23,8 +27,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import { isEmptyFilterValue } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; const TABLE = 'os20444_empty'; @@ -72,12 +77,24 @@ function refusal(run: () => unknown): Promise<{ code?: string; status?: number } ); } -describe('[#20444] InMemoryDriver — $empty on the live path, the reference matcher and the analytics face', () => { +/** + * The by-value reading of ONE leaf `{ field: { $empty: flag } }` — the spec's + * `isEmptyFilterValue`, which is all the retired reference matcher evaluated + * for this operator. `null` when the filter is not a single `$empty` leaf. + */ +function byValue(rows: Array>, where: FilterCondition): string[] | null { + const keys = Object.keys(where); + if (keys.length !== 1 || keys[0].startsWith('$')) return null; + const spec = (where as Record)[keys[0]]; + if (!spec || typeof spec !== 'object' || Object.keys(spec).length !== 1 || !('$empty' in spec)) return null; + const flag = (spec as { $empty: unknown }).$empty === true; + return rows.filter((r) => isEmptyFilterValue(r[keys[0]]) === flag).map((r) => String(r.id)).sort(); +} + +describe('[#20444] InMemoryDriver — $empty on the live path, the by-value reading and the analytics face', () => { let driver: InMemoryDriver; const ids = async (where: FilterCondition) => ((await driver.find(TABLE, { where })) as Array>).map((r) => String(r.id)).sort(); - const reference = (where: FilterCondition) => - ROWS.filter((r) => match(r, where)).map((r) => String(r.id)).sort(); beforeAll(async () => { driver = new InMemoryDriver({ persistence: false }); @@ -91,33 +108,37 @@ describe('[#20444] InMemoryDriver — $empty on the live path, the reference mat }); for (const c of CASES) { - it(`${JSON.stringify(c.where)} → ${JSON.stringify(c.expected)} on the live path AND the reference matcher`, async () => { + it(`${JSON.stringify(c.where)} → ${JSON.stringify(c.expected)} on the live path AND by value`, async () => { expect(await ids(c.where), 'live').toEqual(c.expected); - expect(reference(c.where), 'reference matcher').toEqual(c.expected); + // A single leaf is judged by value here; a combinator cell's by-value half + // is `formula`'s (see the module note). + const leaf = byValue(ROWS, c.where); + if (leaf !== null) expect(leaf, 'by value').toEqual(c.expected); }); } - it('$empty: false partitions every declared field with $empty: true, on both faces', async () => { + it('$empty: false partitions every declared field with $empty: true, live and by value', async () => { for (const field of ['title', 'tags', 'owners', 'score']) { const empty = await ids({ [field]: { $empty: true } }); const full = await ids({ [field]: { $empty: false } }); expect([...empty, ...full].sort(), field).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']); - expect(reference({ [field]: { $empty: true } }), field).toEqual(empty); + expect(byValue(ROWS, { [field]: { $empty: true } }), field).toEqual(empty); } }); - it('the live path REFUSES a field it holds no declaration for; the matcher judges the value', async () => { + it('the live path REFUSES a field it holds no declaration for; the by-value reading judges the value', async () => { expect(await refusal(() => driver.find(TABLE, { where: { nope: { $empty: true } } }))) .toEqual({ code: 'INVALID_FILTER', status: 400 }); expect(await refusal(() => driver.find('never_synced', { where: { title: { $empty: true } } }))) .toEqual({ code: 'INVALID_FILTER', status: 400 }); - expect(reference({ nope: { $empty: true } })).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']); + expect(byValue(ROWS, { nope: { $empty: true } })).toEqual(['r1', 'r2', 'r3', 'r4', 'r5']); }); it('the one cell where the declared row and the by-value reading part: a stored state the type cannot hold', async () => { // A number column holding '' is a write-door defect, never a value the // declaration predicts. The declared null-only row does not count it; the - // declaration-free matcher does. Pinned so the divergence is known. + // by-value reading a declaration-free face gives (the retired matcher's, + // and `formula`'s) does. Pinned so the divergence is known. const odd = new InMemoryDriver({ persistence: false }); await odd.connect(); await odd.syncSchema('odd', { fields: { id: { type: 'text' }, score: { type: 'number' } } }); @@ -126,16 +147,19 @@ describe('[#20444] InMemoryDriver — $empty on the live path, the reference mat const live = ((await odd.find('odd', { where: { score: { $empty: true } } })) as Array>) .map((r) => r.id); expect(live).toEqual([]); - expect(rows.filter((r) => match(r, { score: { $empty: true } })).map((r) => r.id)).toEqual(['blank']); + expect(byValue(rows, { score: { $empty: true } })).toEqual(['blank']); }); - it('a non-boolean flag is refused by both faces, on the shared shape gate', async () => { + it('a non-boolean flag is refused on the live path and by the shared shape gate itself', async () => { expect(await refusal(() => driver.find(TABLE, { where: { title: { $empty: 'yes' as never } } }))) .toEqual({ code: 'INVALID_FILTER', status: 400 }); - expect(await refusal(() => match(ROWS[0], { title: { $empty: 1 as never } }))) + // The gate the retired reference matcher ran, called directly. + expect(await refusal(() => assertFilterConditionShape({ title: { $empty: 1 } }, 'filter'))) .toEqual({ code: 'INVALID_FILTER', status: 400 }); // Even where an identity would settle the node before any arm ran. - expect(await refusal(() => match(ROWS[0], { $or: [{}, { title: { $empty: 'no' as never } }] }))) + expect(await refusal(() => assertFilterConditionShape({ $or: [{}, { title: { $empty: 'no' } }] }, 'filter'))) + .toEqual({ code: 'INVALID_FILTER', status: 400 }); + expect(await refusal(() => driver.find(TABLE, { where: { $or: [{}, { title: { $empty: 'no' as never } }] } }))) .toEqual({ code: 'INVALID_FILTER', status: 400 }); }); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index a653beb9356..d31fed174d9 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -254,65 +254,10 @@ interface MongoPredicateInput { * Unicode range and would answer `CAFÉ` for `café`. */ readonly asciiSubstring: (value: unknown) => RegExp; - /** - * [#20661] One value put into the storage form of the field this entry - * constrains — the SAME conversion `comparands` came out of - * ({@link MemoryAnalyticsService.storageFormFor}), handed over for the one - * value that is not an authored operand: a bound the builder DERIVES. - * See {@link lteUpperBound}. - */ - readonly storageForm: (value: unknown) => unknown; } type MongoPredicateBuilder = (input: MongoPredicateInput) => Record; -/** - * [#20661] What an `lte` bound compiles to, decided ONCE for both exits — the - * mingo `$match` ({@link CUBE_OPERATOR_TO_MONGO_PREDICATE}) and the SQL echo - * ({@link CUBE_OPERATOR_TO_SQL_PREDICATE}) only render it, so the rows a chart - * is drawn from and the statement shown beside it cannot disagree on it. - * - * - `before` — a bare `YYYY-MM-DD` means the WHOLE day (#4042; the SQL twin is - * #3777): the bound is the next day's midnight, exclusive. - * - `unbounded` — the same on `9999-12-31`, which has no next day (#20600): - * every value is inside the bound, so what is left to ask is a value. - * - `through` — anything else keeps instant semantics, inclusive, compared - * against the authored comparand in its storage form. - * - * ## ⛔ The order is the fix: widen the AUTHORED string, then convert the bound - * - * ADR-0053 D-E3: the calendar-day rewrite is a *calendar* operation and runs - * on the bare-day STRING first; only the resulting bound is converted to the - * storage form. Both rows used to ask {@link nextUtcCalendarDay} about - * `comparands[0]`, which is ALREADY in storage form — on a declared `datetime` - * field that is the instant `2026-07-28T00:00:00.000Z`, which the helper - * correctly refuses to widen, so `$lte: '2026-07-28'` compiled an inclusive - * bound at that midnight and dropped the rest of the day, while `find()` on the - * same filter (which widens `val` and converts `nextDay`, `memory-driver.ts`) - * kept it. On an undeclared field the storage form IS the authored string, - * which is why only the declared case was wrong. ⛔ Never teach - * `nextUtcCalendarDay` to widen an instant instead: it refuses one because an - * instant already says where it stops. - * - * `comparand` is the authored value's storage form, passed in rather than - * recomputed: it already exists, and the `through` arm is exactly it. - */ -type LteUpperBound = - | { readonly kind: 'before'; readonly bound: unknown } - | { readonly kind: 'unbounded' } - | { readonly kind: 'through'; readonly bound: unknown }; - -function lteUpperBound( - authored: unknown, - comparand: unknown, - storageForm: (value: unknown) => unknown, -): LteUpperBound { - const nextDay = nextUtcCalendarDay(authored); - if (isUnboundedAbove(nextDay)) return { kind: 'unbounded' }; - if (nextDay != null) return { kind: 'before', bound: storageForm(nextDay) }; - return { kind: 'through', bound: comparand }; -} - /** * [#5374] How each cube operator becomes a mingo field predicate — the whole * `{$op: …}` object, not the name of an operator. @@ -369,19 +314,14 @@ const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly ({ $gt: comparands[0] }), gte: ({ comparands }) => ({ $gte: comparands[0] }), lt: ({ comparands }) => ({ $lt: comparands[0] }), - // A bare-day `lte` bound means "through that whole day" (#4042; the SQL twin - // is #3777): compile half-open so timestamp values on the final day stay in. - // Order-equivalent to `$lte` for plain `YYYY-MM-DD` values. - // [#20600] On the last supported day there is no next day: every value is - // inside the bound, so what `lte` still asks is a value (the `set` row below). - // [#20661] Decided from the AUTHORED value by {@link lteUpperBound}, which - // the SQL twin shares — widening `comparands[0]` read an instant on a - // declared `datetime` field and never widened at all. - lte: ({ raw, comparands, storageForm }) => { - const upper = lteUpperBound(raw[0], comparands[0], storageForm); - if (upper.kind === 'unbounded') return { $ne: null }; - return upper.kind === 'before' ? { $lt: upper.bound } : { $lte: upper.bound }; - }, + // [ADR-0053 D-D1, amended — #5930 step 4] The comparison as handed, like its + // three neighbours. The whole-day upper bound (#4042), its last supported day + // (#20600) and D-E3's order — widen the AUTHORED day, then convert the bound + // (#20661) — are the shared lowering's, run once at this face's door + // ({@link MemoryAnalyticsService.normalizeFilters}): a bare-day `$lte` reaches + // this table as `lt` a calendar string (converted like any comparand) or as + // `isNull: false`, never as `lte`. + lte: ({ comparands }) => ({ $lte: comparands[0] }), // The list operators take the WHOLE list. An empty one is a real predicate — // `$in: []` selects nothing, `$nin: []` selects everything — and saying so // here is what retires the call site's `values.length > 0` guard, under which @@ -440,11 +380,6 @@ interface SqlPredicateInput { * See {@link globSubstringPattern} for why GLOB and not LIKE. */ readonly globSubstring: (value: unknown) => string; - /** - * [#20661] The storage-form conversion `comparands` came out of, for a bound - * the builder derives — the twin of {@link MongoPredicateInput.storageForm}. - */ - readonly storageForm: (value: unknown) => unknown; } type SqlPredicateBuilder = (input: SqlPredicateInput) => string; @@ -578,21 +513,12 @@ const CUBE_OPERATOR_TO_SQL_PREDICATE: Readonly `${column} > ${literal(comparands[0])}`, gte: ({ column, comparands, literal }) => `${column} >= ${literal(comparands[0])}`, lt: ({ column, comparands, literal }) => `${column} < ${literal(comparands[0])}`, - // Half-open on a bare-day bound, exactly as the mingo row above is (#4042; the - // SQL twin is #3777). `<= '2026-01-02'` drops that day's timestamped rows, - // which is measurable as an echo one row NARROWER than the chart it describes. - // [#20600] …and `IS NOT NULL` on the last supported day, as the mingo row - // above answers `$ne: null` there. - // [#20661] The same {@link lteUpperBound} decision the mingo row renders, so - // on a declared `datetime` field the echo reads `< '2026-07-29T00:00:00.000Z'` - // where it used to read `<= '2026-07-28T00:00:00.000Z'`. - lte: ({ column, raw, comparands, storageForm, literal }) => { - const upper = lteUpperBound(raw[0], comparands[0], storageForm); - if (upper.kind === 'unbounded') return `${column} IS NOT NULL`; - return upper.kind === 'before' - ? `${column} < ${literal(upper.bound)}` - : `${column} <= ${literal(upper.bound)}`; - }, + // [ADR-0053 D-D1, amended — #5930 step 4] The comparison as handed, exactly + // as the mingo row above: the echo renders the lowered filter the rows were + // drawn from, so a bare-day `$lte` echoes as the `lt` / `isNull` row it was + // lowered to — `< '2026-07-29T00:00:00.000Z'` on a declared `datetime` field + // (#20661), `IS NOT NULL` on the last supported day (#20600). + lte: ({ column, comparands, literal }) => `${column} <= ${literal(comparands[0])}`, // The list operators take the WHOLE list, and an EMPTY one is a real // predicate on this side too — `$in: []` selects nothing, `$nin: []` // everything. Saying so here is what retires the WHERE builder's @@ -1589,7 +1515,6 @@ export class MemoryAnalyticsService implements IAnalyticsService { const predicate = this.mongoPredicateBuilder(filter.operator)({ comparands: filter.values.map(storageForm), raw: filter.values, - storageForm, substring: (value) => this.driver.filterSubstringPattern(value), // [#6520] `$icontains`' fold, from the spec's shared definition rather // than from the driver's Unicode-folding `filterSubstringPattern`. @@ -1659,7 +1584,6 @@ export class MemoryAnalyticsService implements IAnalyticsService { column: fieldPath, comparands: entry.values.map(storageForm), raw: entry.values, - storageForm, literal: (value) => this.toSqlLiteral(value), globSubstring: (value) => this.toSqlLiteral(globSubstringPattern(value)), })); @@ -1680,8 +1604,8 @@ export class MemoryAnalyticsService implements IAnalyticsService { * → flattened into one cube-style entry per (field, operator) pair. * * [#5345] Everything outside {@link ANALYTICS_FILTER_CAPABILITIES} is REFUSED - * with `INVALID_FILTER` / 400, by the same walk the query path and the - * reference matcher use. It used to be dropped, and the direction of that drop + * with `INVALID_FILTER` / 400, by the same walk the query path uses (as the + * reference matcher did, until retired). It used to be dropped, and the direction of that drop * is what made it a defect rather than a limitation: fewer predicates means * MORE rows, so a widget filtered on `{$or: [...]}` aggregated the whole table * and looked like a working widget. `$not` made it a permission bug on top — @@ -1710,10 +1634,13 @@ export class MemoryAnalyticsService implements IAnalyticsService { * tokens on this face, so it reads the comparands as authored (item 3). * It applies type-blind (item 7): the declared temporal kinds live inside * the driver and reach this face only as a storage-form conversion, and - * type-blind is the reading this face's own bound copy - * (`lteUpperBound`) and the driver's `find()` already give, so it moves no - * answer. The lowering emits calendar strings; each exit converts them to - * the field's storage form as it converts any comparand (item 6). + * type-blind is the reading this face's own bound copy gave until #5930 + * step 4 deleted it, so it moved no answer. This door is now the ONLY + * place this face applies the whole-day bound to a `where` — on every + * column, since no `isDatetimeColumn` reader is passed — and neither + * exit's `lte` row widens anything. (A `dateRange` window is not a + * `where`: its explicit end keeps its own arm, the amendment's item 8.) The lowering emits calendar strings; each exit converts + * them to the field's storage form as it converts any comparand (item 6). * 3. **This face's own vocabulary gate**, on the LOWERED condition — what the * face actually compiles, as a driver behind the engine seam judges the * lowered filter it receives. So a `$between` reaches the gate as the two @@ -1851,10 +1778,10 @@ export class MemoryAnalyticsService implements IAnalyticsService { * [#5373] The conversion that puts a value into the storage form of the field * one lowered entry is compared against — the ONE place either exit converts * a value, so the two exits cannot drift apart. Each exit maps the entry's - * comparands through it, and hands the same function to its predicate builder - * for the one value a builder derives rather than receives: the whole-day - * bound of an `lte` (#20661, {@link lteUpperBound}), which has to be widened - * from the AUTHORED day before it is converted (ADR-0053 D-E3). + * comparands through it. No builder derives a value any more: the whole-day + * bound of an `lte` (#20661) is widened from the AUTHORED day by the shared + * lowering at {@link MemoryAnalyticsService.normalizeFilters}, before either + * exit converts it (ADR-0053 D-E3, structural since #5930 step 4). * * The only conversion left is the temporal one (#4047): a `datetime` column * holds canonical UTC ISO text, so a `Date` comparand has to become that text diff --git a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts index d1acccd782c..ed04a5b1696 100644 --- a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts @@ -9,7 +9,7 @@ * #5324 offered both directions and deliberately declined to choose. The * evidence chooses: `$not` is a DECLARED combinator (`LOGICAL_OPERATORS` in * `@objectstack/spec/data`, alongside `$and`/`$or`), `driver-sql` compiles it, - * `driver-mongodb` translates it, `memory-matcher` evaluates it, and + * `driver-mongodb` translates it, `formula` evaluates it, and * `FILTER_LOGIC_CASES` — the standard every backend is held to — contains a case * that requires it. Refusing it would have made this driver the only backend * that cannot run a spec-declared operator, and would have left the conformance @@ -36,15 +36,22 @@ * rewrote `driver-sql`'s SQL to match it, because SQL's `NOT (col = x)` is * UNKNOWN for a NULL column and a `WHERE` drops the row. `$nor` is total by * construction and lands on the same answer — asserted below against the exact - * fixture and expectations `memory-matcher-not-null-safe.test.ts` pins, so the - * live path is held to the ruling rather than merely to "it no longer throws". + * fixture and expectations `sql-driver-not-null-safe.test.ts` and `formula`'s + * `matches-filter-not-null-safe.test.ts` pin, so the live path is held to the + * ruling rather than merely to "it no longer throws". + * + * [#5930 step 4, ruling D6] This package's reference matcher is retired. Its + * #5146 pin (`memory-matcher-not-null-safe.test.ts`) held that record-at-a-time + * face to the same fixture and the same ids; every one of its cells is asserted + * here now, on the live path and on both readings of "no value", and the + * live-vs-reference comparisons below became literal expectations — the + * answers both faces gave when the matcher was retired. */ import { describe, it, expect, beforeAll } from 'vitest'; import type { FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; /** Fields present but null — how a SQL NULL round-trips into a record. */ const NULLED = [ @@ -91,17 +98,15 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => { }; /** - * Both readings of "no value" must give the same answer, and the reference - * matcher must give it too — the same contract - * `memory-matcher-not-null-safe.test.ts` states for its own face, now binding - * on the path that actually serves queries. + * Both readings of "no value" must give the same answer — the contract the + * retired reference matcher's #5146 pin stated for its own face, binding on + * the path that actually serves queries. Every caller asserts the literal ids + * as well, so the two readings cannot pass by being wrong together. */ const matched = async (where: unknown): Promise => { const fromNulled = await idsFrom(nulled, where); const fromMissing = await idsFrom(missing, where); expect(fromMissing, 'a null field and an absent field must match alike').toEqual(fromNulled); - const reference = NULLED.filter((r) => match(r, where)).map((r) => r.id); - expect(fromNulled, 'the live query path and the reference matcher must agree').toEqual(reference); return fromNulled; }; @@ -247,55 +252,60 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => { * used to disprove is finally true of the whole package AND of the other * document-shaped backend. * - * ⛔ What the row still shows, and why the pin stays: this package answers + * ⛔ What the row still showed, and why the pin stays: this package answered * with two faces. "driver-memory reads has-value" was true of the reference * matcher and FALSE of the live query path users actually reach, for the - * three months between #5962 and #13195. Asserting the two columns against - * each other — rather than each against a literal — is what makes a future - * one-sided edit fail here. + * three months between #5962 and #13195. + * + * [#5930 step 4, ruling D6] The reference matcher is retired, so the + * live-vs-reference columns below are asserted as the literal answer both + * faces gave — which is the column each note already named correct — on the + * live path, and on BOTH readings of "no value" where the matcher's own #5146 + * pin (`memory-matcher-not-null-safe.test.ts`) asserted both. */ - describe('[#5299] the settled cells, live vs reference — $nin / $notContains converged (#13166), $exists converged (#13195)', () => { - const liveVsReference = async (where: unknown) => ({ - live: await idsFrom(nulled, where), - reference: NULLED.filter((r) => match(r, where)).map((r) => r.id), - }); - - it('$exists on a present-but-null field: the two faces now AGREE (#13195)', async () => { + describe('[#5299] the settled cells — $nin / $notContains converged (#13166), $exists converged (#13195)', () => { + it('$exists on a present-but-null field reads HAS-VALUE (#13195)', async () => { // Was `live: ['1','2','3','4']` — mingo said "the key is there" while the - // matcher said "no value". The REFERENCE column is unchanged, and it is - // the column this note already named correct. - expect(await liveVsReference({ stage: { $exists: true } })).toEqual({ - live: ['1', '2'], - reference: ['1', '2'], - }); + // matcher said "no value". The matcher's column was the ruling's. + expect(await idsFrom(nulled, { stage: { $exists: true } })).toEqual(['1', '2']); }); - it('$exists: false on a present-but-null field: the two faces agree there too (#13195)', async () => { + it('$exists: false on a present-but-null field returns the no-value rows (#13195)', async () => { // The direction the old pin never recorded, and the worse one: the live // path returned NOTHING for the query asking for the rows with no value. - expect(await liveVsReference({ stage: { $exists: false } })).toEqual({ - live: ['3', '4'], - reference: ['3', '4'], - }); + expect(await idsFrom(nulled, { stage: { $exists: false } })).toEqual(['3', '4']); }); - it('$nin on an ABSENT field: the two faces now AGREE (#13166)', async () => { + it('$nin on an ABSENT field includes the no-value rows (#13166)', async () => { // Was `reference: ['2']` — the matcher's `value === undefined` guard - // short-circuited before the `$nin` arm ran. The LIVE column is unchanged, - // and it is the column this note already named correct. - const live = await idsFrom(missing, { stage: { $nin: ['won'] } }); - const reference = MISSING.filter((r) => match(r, { stage: { $nin: ['won'] } })).map((r) => r.id); - expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2', '3', '4'] }); + // short-circuited before the `$nin` arm ran. The LIVE column was the + // affirmed include answer. + expect(await idsFrom(missing, { stage: { $nin: ['won'] } })).toEqual(['2', '3', '4']); }); - it('$notContains on a null field: the two faces now AGREE (#13166)', async () => { + it('$notContains on a null field: a value-less field satisfies it, so its $not excludes it (#13166)', async () => { // Was `reference: ['1', '3', '4']` — `typeof null !== 'string'` failed the - // TYPE test, so the negation readmitted the null rows. The LIVE column is - // unchanged here too. - expect(await liveVsReference({ $not: { stage: { $notContains: 'w' } } })).toEqual({ - live: ['1'], - reference: ['1'], - }); + // TYPE test, so the negation readmitted the null rows. The LIVE column was + // the affirmed answer; both readings of "no value" give it. + expect(await matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']); + }); + + // ── Moved from `memory-matcher-not-null-safe.test.ts` (#5930 step 4) ────── + + it('$not of $nin: an ABSENT field and a null one are treated ALIKE (#13166)', async () => { + // The matcher's pin asserted both columns separately, because the whole + // content of the cell is that the two readings agree. + expect(await idsFrom(nulled, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); + expect(await idsFrom(missing, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); + expect(await matched({ $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); + }); + + it('$not of $exists: a present-but-null field counts as NOT existing, on both readings (#13195)', async () => { + expect(await idsFrom(nulled, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); + expect(await idsFrom(missing, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); + expect(await idsFrom(nulled, { stage: { $exists: true } })).toEqual(['1', '2']); + expect(await idsFrom(nulled, { stage: { $exists: true } })) + .toEqual(await idsFrom(nulled, { stage: { $null: false } })); }); }); }); diff --git a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts index 9a14facc55e..f15fb707a3e 100644 --- a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts @@ -13,12 +13,12 @@ * that SQL on sql.js, `driver-mongodb` translates and executes it, and * `service-analytics` lowers it into its read-scope SQL. * - * `driver-memory` ran it through `memory-matcher` ONLY - * (`memory-matcher-or-semantics.test.ts`). That file is not a driver test: the - * driver does not call `match()` — it imports exactly one symbol from that - * module, `getValueByPath`, and filters with mingo instead. So this backend's - * half of the conformance table was measured against a REFERENCE implementation - * while the half users actually run was never executed against the standard once. + * `driver-memory` ran it through its reference matcher ONLY (`match()` in + * `memory-matcher.ts`, since retired). That was not a driver test: the driver + * never called `match()` — it imported exactly one symbol from that module, + * `getValueByPath`, and filters with mingo instead. So this backend's half of + * the conformance table was measured against a REFERENCE implementation while + * the half users actually run was never executed against the standard once. * * The cost was not hypothetical. The table's `$not ANDs with its sibling keys * inside a branch` case was green here for as long as it has existed, while the @@ -29,10 +29,15 @@ * is the "declared ≠ enforced" shape Prime Directive #10 names. * * So the gap is closed the way the other three backends close it — by running - * the table through the thing that serves queries. `memory-matcher-or-semantics` - * stays: the matcher is still the reference evaluator, and holding BOTH faces to - * the same table is what makes "this package has two filter surfaces" a - * statement someone can check. + * the table through the thing that serves queries. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED. Its copy of this table (`memory-matcher-or-semantics.test.ts`) + * asserted the same case names against the same `expected` column this file + * asserts on the live path, so its assertions live here now, one `it` per case; + * the table's `expected` column is the oracle both were held to, and + * `@objectstack/formula`, `driver-sql`, `driver-mongodb` and the analytics read + * scope run it too. * * # The third face (#5345) * @@ -97,7 +102,6 @@ import type { Cube, FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService, ANALYTICS_FILTER_CAPABILITIES } from './memory-analytics.js'; -import { match } from './memory-matcher.js'; const TABLE = 'conformance'; @@ -165,21 +169,21 @@ describe('[#5324] InMemoryDriver.find — filter logic conformance (the LIVE que }); /** - * The two faces, on the same table, in one assertion. + * The whole table, in one assertion. * - * `memory-matcher-or-semantics.test.ts` already holds the matcher to these - * cases and this file holds the driver to them, so both being green already - * implies agreement. Asserting it directly is still worth one test: it is the - * invariant #5240 established for this package ("a backend whose two halves - * disagree about what a filter MEANS is exactly the divergence the ruling - * closes"), and stated here it survives either suite being edited. + * This was "both filter faces answer the whole table identically" — the live + * path against the reference matcher, the invariant #5240 established for this + * package ("a backend whose two halves disagree about what a filter MEANS is + * exactly the divergence the ruling closes"). [#5930 step 4, ruling D6] The + * matcher is retired, so one face is left and the oracle it was compared to is + * the one both were already held to: the table's own `expected` column. Kept + * as ONE assertion over every case, for the reason it was written — stated + * here it survives the per-case loop above being edited. */ - it('both filter faces answer the whole table identically', async () => { - for (const c of FILTER_LOGIC_CASES) { - const live = await ids(c.filter); - const reference = FILTER_LOGIC_ROWS.filter((r) => match(r, c.filter)).map((r) => r.id); - expect(live, `${c.name}: the live query path and the reference matcher disagree`).toEqual(reference); - } + it('the live query path answers the whole table as the spec states it', async () => { + const live: Record = {}; + for (const c of FILTER_LOGIC_CASES) live[c.name] = await ids(c.filter); + expect(live).toEqual(Object.fromEntries(FILTER_LOGIC_CASES.map((c) => [c.name, [...c.expected]]))); }); }); diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 7efbcc6109f..a4c3db7e0e8 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -21,7 +21,6 @@ import { assertObjectsNotTenantScoped, assertCallNotTenantScoped, } from './memory-tenancy-guard.js'; -import { getValueByPath } from './memory-matcher.js'; import { assertFilterConditionShape, filterArrayReachedDriverError, @@ -131,9 +130,10 @@ interface LoweredWrite { * key). Taken key → the write becomes its own `$and` branch on the same field, * where both constraints survive. That is exactly the guard #13195 landed for * `$exists` alone, generalised to every writer rather than restated per - * operator — the reference matcher (`memory-matcher.ts`), which loops the - * operators and therefore CANNOT express this defect, is the oracle both - * agree with. + * operator — the reference matcher (`memory-matcher.ts`, retired since #5930 + * step 4), which looped the operators and therefore COULD NOT express this + * defect, was the oracle; `memory-operator-key-clobber.test.ts` keeps its + * answers as literals. * * ## Why rank, and not author order * @@ -263,9 +263,10 @@ export interface InMemoryDriverConfig { * * - `projectFields` skips `undefined` values, so the same stored row answered * `'status' in row === false` under a projection and `true` without one; - * - the matcher reads it as absent — measured, `{ status: { $exists: true } }` - * excludes it and `{ status: { $null: true } }` includes it, exactly as for - * a row that never carried the key at all. + * - the reference matcher (retired since #5930 step 4) read it as absent — + * measured, `{ status: { $exists: true } }` excluded it and + * `{ status: { $null: true } }` included it, exactly as for a row that + * never carried the key at all. * * So the returned row was the only surface still claiming the key was present. * @@ -300,6 +301,18 @@ function withoutUndefinedOwnKeys>(record: T): T { return (out as T) ?? record; } +/** + * Read a nested property by dot-notation (`"user.name"`). + * + * [#5930 step 4] Lived in `memory-matcher.ts` and was the ONE symbol this + * driver imported from it; moved here, byte-for-byte, when ruling D6 retired + * that reference matcher (no production caller). + */ +function getValueByPath(obj: any, path: string): any { + if (!path.includes('.')) return obj[path]; + return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj); +} + /** * Snapshot for in-memory transactions. */ @@ -1353,9 +1366,10 @@ export class InMemoryDriver implements IDataDriver { return { [op]: conditions }; } // MongoDB/FilterCondition format: { field: value } or { field: { $op: value } } - // [#5324/#5328] Shape first, then translate — the SAME gate the reference - // matcher runs (`filter-refusal.ts`), so the two faces cannot answer one - // filter differently again. It must run before `normalizeFilterCondition` + // [#5324/#5328] Shape first, then translate — the ONE gate every face of + // this package runs (`filter-refusal.ts`; the since-retired reference + // matcher ran it too), so no two faces can answer one filter differently + // again. It must run before `normalizeFilterCondition` // and not inside it: the translator recurses per key and would therefore // refuse or not refuse depending on where in the tree it gave up. assertFilterConditionShape(filters, 'filter'); @@ -1529,7 +1543,7 @@ export class InMemoryDriver implements IDataDriver { } if (key === '$not') { // [#5324] The whole point of the issue. `$not` is a declared combinator - // (spec `LOGICAL_OPERATORS`), `driver-sql` compiles it, `memory-matcher` + // (spec `LOGICAL_OPERATORS`), `driver-sql` compiles it, `formula` // evaluates it, and `cel-to-filter` EMITS it — a CEL `!expr` in an RLS // read scope lowers to `{ $not: {…} }`. Passing it through unchanged // meant mingo received a document-level `$not`, which MongoDB does not @@ -1540,9 +1554,9 @@ export class InMemoryDriver implements IDataDriver { // is what `driver-mongodb` rewrites to for the identical reason (#4405). // It is also NULL-safe by construction, which is the semantics #5146 // ruled canonical: a row whose field is null or missing does not satisfy - // the inner condition, so `$nor` admits it — the same answer this - // package's matcher and `@objectstack/formula` give, and the one - // driver-sql was rewritten to match. + // the inner condition, so `$nor` admits it — the same answer + // `@objectstack/formula` gives (and this package's reference matcher + // gave, until retired), and the one driver-sql was rewritten to match. // // At most one `$not` per node (it is one object key), so this never // overwrites a sibling `$nor`, and an input `$nor` cannot reach here — @@ -1555,8 +1569,8 @@ export class InMemoryDriver implements IDataDriver { // Field-level: value may be primitive (implicit eq) or operator object if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp)) { // A field spec with no `$` keys is a nested-object COMPARAND, not an - // operator map — mingo compares it structurally, `driver-mongodb` says - // so explicitly, and the matcher deep-equals it. Handing it to the + // operator map — mingo compares it structurally, and `driver-mongodb` + // says so explicitly. Handing it to the // operator translator would read its field names as operators. if (!Object.keys(value).some((k) => k.startsWith('$'))) { result[key] = value; diff --git a/packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts b/packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts index 1e12184fbca..9e25ae26ab9 100644 --- a/packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts +++ b/packages/drivers/driver-memory/src/memory-empty-field-constraint.test.ts @@ -18,19 +18,24 @@ * on ordinary data. Right answer for the wrong reason, and a DIFFERENT filter * from the FALSE everyone believed was being computed. * - `memory-matcher.match` (the reference matcher the cross-backend conformance - * suites hold against driver-sql and formula) fell through to + * suites held against driver-sql and formula) fell through to * `JSON.stringify(value) === JSON.stringify(condition)` — structural equality * against `{}` — and answered `false` incidentally. * * Neither was a ruling, and driver-sql read the same shape as TRUE inside a * combinator. #5240 ruled REFUSE; a backend whose two halves disagree about * what a filter MEANS is exactly the divergence the ruling closes, so both - * halves are pinned here. + * halves were pinned here. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is retired. Its refusal was never its own: it ran `assertFilterConditionShape`, + * the same gate `InMemoryDriver.find` runs, so its half of this file is held on + * that gate directly (the second describe) and its row answers on the live path. */ import { describe, it, expect, beforeEach } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; import type { FilterCondition } from '@objectstack/spec/data'; interface WireBearingError extends Error { @@ -74,9 +79,6 @@ describe('[#5240] InMemoryDriver (live mingo path) refuses a zero-operator field return rows.map((r: any) => String(r.id)).sort(); }; - /** The same filter through the OTHER face, so an answer can be compared. */ - const matchedIds = (filter: unknown): string[] => ROWS.filter((r) => match(r, filter)).map((r) => r.id); - const refusalOf = async (where: unknown): Promise => { try { await ids(where); @@ -127,29 +129,30 @@ describe('[#5240] InMemoryDriver (live mingo path) refuses a zero-operator field // such operator. Now that the path compiles `$not` to `$nor`, the identity // is the answer it was always supposed to be, and it agrees with // driver-sql (#5134: NOT of a TRUE group is FALSE → the FALSE constant) - // and with this package's own matcher. + // and with the answer this package's retired reference matcher gave. expect(await ids({ $not: {} })).toEqual([]); - expect(matchedIds({ $not: {} })).toEqual([]); }); }); }); -describe('[#5240] memory-matcher (reference matcher) refuses the same shape', () => { - const matched = (filter: unknown): string[] => - ROWS.filter((r) => match(r, filter)).map((r) => r.id); - - const refusalOf = (filter: unknown): WireBearingError => { +/** + * [#5930 step 4] What `describe('[#5240] memory-matcher (reference matcher) + * refuses the same shape')` asserted, moved off the retired matcher: its + * refusals onto the gate it ran, its answers onto the live query path. + */ +describe('[#5240] the shared shape gate refuses the same shape, whatever the rows', () => { + const gateRefusalOf = (filter: unknown): WireBearingError => { try { - matched(filter); + assertFilterConditionShape(filter, 'filter'); } catch (e) { return e as WireBearingError; } - throw new Error('expected the matcher to refuse this filter, but it answered'); + throw new Error('expected the shape gate to refuse this filter, but it passed'); }; for (const [name, filter, position] of POSITIONS) { it(`${name} → INVALID_FILTER naming ${position}`, () => { - const err = refusalOf(filter); + const err = gateRefusalOf(filter); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(err.message).toContain(position); @@ -157,31 +160,54 @@ describe('[#5240] memory-matcher (reference matcher) refuses the same shape', () }); } - it('the refusal does not depend on the RECORD being tested', () => { - // The evaluator short-circuits (`every`/`some`, and a node returns on its - // first failing key), so a gate inside evaluation would fire for some rows - // and not others. The walk runs before evaluation, so every row refuses. - for (const row of ROWS) { - expect(() => match(row, { stage: 'nothing-matches-this', owner: {} })).toThrow(/zero operators/); - expect(() => match(row, { $or: [{ stage: 'won' }, { owner: {} }] })).toThrow(/zero operators/); + it('the refusal does not depend on the RECORD being tested', async () => { + // A record-at-a-time evaluator short-circuits (`every`/`some`, and a node + // returns on its first failing key), so a gate inside evaluation would fire + // for some rows and not others. The walk runs before any row is read, so a + // table holding any one row — or none — refuses alike. + for (const rows of [...ROWS.map((row) => [row]), []]) { + const driver = new InMemoryDriver(); + for (const row of rows) await driver.create('deal', { ...row }); + for (const where of [ + { stage: 'nothing-matches-this', owner: {} }, + { $or: [{ stage: 'won' }, { owner: {} }] }, + ]) { + await expect(driver.find('deal', { where: where as FilterCondition }), JSON.stringify(rows)) + .rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400, message: expect.stringMatching(/zero operators/) }); + } } }); describe('evaluation is otherwise byte-identical', () => { - it('ordinary filters answer exactly as before', () => { - expect(matched({ stage: 'won' })).toEqual(['1']); - expect(matched({ amount: { $gt: 15 } })).toEqual(['2', '3']); - expect(matched({ $or: [{ stage: 'won' }, { owner: 'u2' }] })).toEqual(['1', '2']); - expect(matched({ $and: [{ owner: 'u1' }, { amount: { $gt: 15 } }] })).toEqual(['3']); - expect(matched({ $not: { stage: 'won' } })).toEqual(['2', '3']); - expect(matched({})).toEqual(['1', '2', '3']); + let driver: InMemoryDriver; + beforeEach(async () => { + driver = new InMemoryDriver(); + for (const row of ROWS) await driver.create('deal', { ...row }); + }); + const liveIds = async (where: unknown): Promise => + ((await driver.find('deal', { where: where as FilterCondition })) as Array>) + .map((r) => String(r.id)) + .sort(); + + it('ordinary filters answer exactly as before', async () => { + expect(await liveIds({ stage: 'won' })).toEqual(['1']); + expect(await liveIds({ amount: { $gt: 15 } })).toEqual(['2', '3']); + expect(await liveIds({ $or: [{ stage: 'won' }, { owner: 'u2' }] })).toEqual(['1', '2']); + expect(await liveIds({ $and: [{ owner: 'u1' }, { amount: { $gt: 15 } }] })).toEqual(['3']); + expect(await liveIds({ $not: { stage: 'won' } })).toEqual(['2', '3']); + expect(await liveIds({})).toEqual(['1', '2', '3']); }); - it('a nested object comparison (a NON-empty plain object) still compares structurally', () => { + it('a nested object comparison (a NON-empty plain object) still compares structurally', async () => { // The arm `{ field: {} }` used to fall into. It keeps its behaviour for // every shape that actually carries keys. - expect(match({ meta: { a: 1 } }, { meta: { a: 1 } })).toBe(true); - expect(match({ meta: { a: 2 } }, { meta: { a: 1 } })).toBe(false); + const nested = new InMemoryDriver(); + await nested.create('doc', { id: 'one', meta: { a: 1 } }); + await nested.create('doc', { id: 'two', meta: { a: 2 } }); + const found = (await nested.find('doc', { where: { meta: { a: 1 } } as unknown as FilterCondition })) as Array< + Record + >; + expect(found.map((r) => r.id)).toEqual(['one']); }); }); }); diff --git a/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts b/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts index 711ecbb4103..3cbfae9dafe 100644 --- a/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts +++ b/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts @@ -9,8 +9,13 @@ * `$exists` means "the field has a value" (`!= null`), never key-presence: * #5298 leg 3 / #5369, landed in PR #5962. It is settled and shipped on the * surfaces that ruling named — `@objectstack/formula`'s `matchesFilterCondition` - * and this package's reference matcher (`memory-matcher.ts`, pinned by - * `memory-matcher-not-null-safe.test.ts`). + * and this package's reference matcher (`memory-matcher.ts`, since retired). + * + * [#5930 step 4, ruling D6] That matcher had no production caller and is + * RETIRED. It was this file's second exit and the ORACLE for its composed cells; + * each of its assertions is kept as the literal answer it gave — which is the + * ruling's answer, the one every other exit is asserted against here — so the + * three exits that remain are held to it rather than to each other alone. * * ## What this file is — and what it WAS * @@ -76,7 +81,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; /** `name` present but NULL — how a SQL NULL round-trips into a record. */ @@ -108,10 +112,6 @@ async function liveIds(rows: Array>, where: unknown): Pr } } -/** Exit 2 — the REFERENCE matcher, the exit #5962 already aligned. */ -const matcherIds = (rows: Array>, where: unknown): string[] => - sorted(rows.filter((r) => match(r, where)).map((r) => String(r.id))); - const CUBE = { name: 'deals', title: 'Deals', @@ -149,27 +149,26 @@ describe('[#13195] `$exists` on a row with NO VALUE — the two readings, the fo describe('the key-absent reading: the CONTROL — it answered the ruling before and must not move', () => { it('`$exists: true` excludes the no-key row everywhere', async () => { expect(await liveIds(MISSING, { name: { $exists: true } })).toEqual(['1', '2']); - expect(matcherIds(MISSING, { name: { $exists: true } })).toEqual(['1', '2']); expect((await analytics(MISSING, { name: { $exists: true } })).executed).toEqual(['1', '2']); }); it('`$exists: false` returns the no-key row everywhere', async () => { expect(await liveIds(MISSING, { name: { $exists: false } })).toEqual(['3']); - expect(matcherIds(MISSING, { name: { $exists: false } })).toEqual(['3']); expect((await analytics(MISSING, { name: { $exists: false } })).executed).toEqual(['3']); }); it('`$not` around it agrees too, on the exits that accept `$not`', async () => { expect(await liveIds(MISSING, { $not: { name: { $exists: true } } })).toEqual(['3']); - expect(matcherIds(MISSING, { $not: { name: { $exists: true } } })).toEqual(['3']); }); }); describe('the `name: null` reading: every exit now answers the ruling (#13195, ruled 2026-08-30)', () => { - it('the reference matcher reads HAS-VALUE — the ruling, shipped by #5962', () => { - expect(matcherIds(NULLED, { name: { $exists: true } })).toEqual(['1', '2']); - expect(matcherIds(NULLED, { name: { $exists: false } })).toEqual(['3']); - expect(matcherIds(NULLED, { $not: { name: { $exists: true } } })).toEqual(['3']); + it('the ruling, shipped by #5962 on the retired reference matcher, holds on the live path in all three spellings', async () => { + // These three lines were the matcher's; its answers were the ruling's, so + // they stay as literals, now binding the face that serves queries. + expect(await liveIds(NULLED, { name: { $exists: true } })).toEqual(['1', '2']); + expect(await liveIds(NULLED, { name: { $exists: false } })).toEqual(['3']); + expect(await liveIds(NULLED, { $not: { name: { $exists: true } } })).toEqual(['3']); }); it('the live mingo path reads HAS-VALUE: `$exists: true` drops the null row', async () => { @@ -198,7 +197,8 @@ describe('[#13195] `$exists` on a row with NO VALUE — the two readings, the fo const direct = await liveIds(NULLED, { name: { $exists: false } }); const negated = await liveIds(NULLED, { $not: { name: { $exists: true } } }); expect(direct).toEqual(negated); - expect(direct).toEqual(matcherIds(NULLED, { name: { $exists: false } })); + // The retired reference matcher's answer, the ruling's. + expect(direct).toEqual(['3']); // And on the other reading of "no value", where they already agreed. const directMissing = await liveIds(MISSING, { name: { $exists: false } }); @@ -225,8 +225,12 @@ describe('[#13195] `$exists` on a row with NO VALUE — the two readings, the fo const f = await analytics(NULLED, { name: { $exists: false } }); expect(t.sql).toContain('name IS NOT NULL'); expect(f.sql).toContain('name IS NULL'); - expect(t.executed).toEqual(matcherIds(NULLED, { name: { $exists: true } })); - expect(f.executed).toEqual(matcherIds(NULLED, { name: { $exists: false } })); + // Held against the retired reference matcher's answers (the ruling's), and + // against the live path, so this face cannot drift from either. + expect(t.executed).toEqual(['1', '2']); + expect(f.executed).toEqual(['3']); + expect(t.executed).toEqual(await liveIds(NULLED, { name: { $exists: true } })); + expect(f.executed).toEqual(await liveIds(NULLED, { name: { $exists: false } })); }); }); @@ -250,7 +254,6 @@ describe('[#13195] `$exists` on a row with NO VALUE — the two readings, the fo it('a predicate that should narrow still narrows on every exit', async () => { expect(await liveIds(NULLED, { name: { $eq: 'beta' } })).toEqual(['2']); - expect(matcherIds(NULLED, { name: { $eq: 'beta' } })).toEqual(['2']); expect((await analytics(NULLED, { name: { $eq: 'beta' } })).executed).toEqual(['2']); }); }); @@ -276,22 +279,26 @@ describe('[#13195] `$exists` on a row with NO VALUE — the two readings, the fo * Four of those six cells AGREED with the reference matcher on `origin/main` * before the alignment, so shipping the merge unguarded would have traded a * fixed single-operator cell for a broken composed one — the one-driver, - * two-faces shape this card exists to remove. The reference matcher is the - * ORACLE here, exactly as it is for the single-operator cells above. + * two-faces shape this card exists to remove. The reference matcher was the + * ORACLE here, exactly as it was for the single-operator cells above. + * + * [#5930 step 4] With the matcher retired, the oracle is its answers, written + * out: measured on it at `9905e61ca2` for both fixtures, and identical on the + * two readings of "no value" for every cell. */ describe('composed constraints — the lowering must not clobber a sibling operator', () => { - const COMPOSED: Array<[string, unknown]> = [ - ['$exists:true beside $ne', { name: { $exists: true, $ne: 'beta' } }], - ['$ne beside $exists:true (keys swapped)', { name: { $ne: 'beta', $exists: true } }], - ['$exists:false beside $eq', { name: { $exists: false, $eq: 'alpha-one' } }], - ['$exists:true beside $eq', { name: { $exists: true, $eq: 'alpha-one' } }], - ['$exists:true beside $contains', { name: { $exists: true, $contains: 'alpha' } }], + const COMPOSED: Array<[string, unknown, string[]]> = [ + ['$exists:true beside $ne', { name: { $exists: true, $ne: 'beta' } }, ['1']], + ['$ne beside $exists:true (keys swapped)', { name: { $ne: 'beta', $exists: true } }, ['1']], + ['$exists:false beside $eq', { name: { $exists: false, $eq: 'alpha-one' } }, []], + ['$exists:true beside $eq', { name: { $exists: true, $eq: 'alpha-one' } }, ['1']], + ['$exists:true beside $contains', { name: { $exists: true, $contains: 'alpha' } }, ['1']], ]; - for (const [label, where] of COMPOSED) { - it(`${label}: the live path answers what the reference matcher answers`, async () => { + for (const [label, where, oracle] of COMPOSED) { + it(`${label}: the live path answers what the reference matcher answered`, async () => { for (const rows of [NULLED, MISSING]) { - expect(await liveIds(rows, where), label).toEqual(matcherIds(rows, where)); + expect(await liveIds(rows, where), label).toEqual(oracle); } }); } diff --git a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts index c0ca4aa85b6..bbe6da42ede 100644 --- a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts @@ -38,10 +38,18 @@ * Because this package's recurring defect is not "a face is wrong", it is "the * faces disagree" — #5374, #5324/#5328, #5347, and #6682 itself. A per-face * file lets one arm rot without the others noticing, so each case below runs - * through the live query path and the reference matcher and demands one answer; - * the analytics face runs the subset its cube vocabulary can express - * (`contains` / `notContains` / `icontains` — it has no `startsWith` / - * `endsWith` row in `MONGO_TO_CUBE_OPERATOR`). + * through the live query path and demands the table's answer; the analytics + * face runs the subset its cube vocabulary can express (`contains` / + * `notContains` / `icontains` — it has no `startsWith` / `endsWith` row in + * `MONGO_TO_CUBE_OPERATOR`). + * + * [#5930 step 4, ruling D6] Every case used to run through the reference + * matcher too. The matcher had no production caller and is retired: its row + * cases asserted the same `expected` column the query-path cases assert (and + * `@objectstack/formula` and every other backend answer the same table), its + * refusals were raised by `assertFilterConditionShape` — asserted on that gate + * directly below — and its one private cell (#14079, a valued non-string row) + * is asserted on the live path. * * ## Pre-fix measurement, recorded before the diff existed * @@ -59,7 +67,7 @@ import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data'; import type { FilterTextCase, Cube } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; const TABLE = 'text_rows'; @@ -78,10 +86,6 @@ async function seed(): Promise { const queryIds = async (driver: InMemoryDriver, filter: unknown): Promise => (await driver.find(TABLE, { where: filter as any })).map((r: any) => String(r.id)).sort(byId); -/** Ids the REFERENCE MATCHER returns, ascending. */ -const matcherIds = (filter: unknown): string[] => - ROWS.filter((r) => match(r, filter)).map((r) => r.id).sort(byId); - const isRejection = (c: FilterTextCase): c is Extract => c.expectRejection === true; @@ -108,29 +112,21 @@ describe('[#6682] InMemoryDriver — text-operator conformance, the query path', } }); -describe('[#6682] the reference matcher answers the same table', () => { - for (const c of rowCases) { - it(c.name, () => { - expect(matcherIds(c.filter), c.note ?? c.name).toEqual([...c.expected]); - }); - } -}); - -describe('[#5374] the two general-purpose faces agree, case by case', () => { +describe('[#5374] the general-purpose face answers the whole table', () => { let driver: InMemoryDriver; beforeEach(async () => { driver = await seed(); }); /** - * Not redundant with the two blocks above, and this is the row that would - * have been red for the whole life of the defect: a package can satisfy a - * table face-by-face at two different times and still be the thing #6682 was - * filed about in between. Stated as an equality between the faces so it fails - * on the DIVERGENCE rather than on the table. + * This was "every case returns the same ids through find() and match()" — an + * equality between the two general-purpose faces, the row that would have + * been red for the whole life of #6682. [#5930 step 4] One face is left, so + * the equality is kept against the oracle both faces were held to: the whole + * table in ONE assertion, which survives the per-case loop being edited. */ - it('every case returns the same ids through find() and match()', async () => { - for (const c of rowCases) { - expect(await queryIds(driver, c.filter), c.name).toEqual(matcherIds(c.filter)); - } + it('every case returns the table\'s ids through find(), in one sweep', async () => { + const got: Record = {}; + for (const c of rowCases) got[c.name] = await queryIds(driver, c.filter); + expect(got).toEqual(Object.fromEntries(rowCases.map((c) => [c.name, [...c.expected]]))); }); /** @@ -152,13 +148,12 @@ describe('[#5374] the two general-purpose faces agree, case by case', () => { for (const c of rowCases) { if (c.name === WHOLE_SET) continue; expect((await queryIds(driver, c.filter)).length, c.name).toBeLessThan(ROWS.length); - expect(matcherIds(c.filter).length, c.name).toBeLessThan(ROWS.length); } }); }); /** - * The refusals, on both general-purpose faces. + * The refusals, on the query path and on the shared shape gate it runs. * * `code` AND `status`, never a bare `toThrow()` (ADR-0112): a rejection test * that only asserts "something threw" carries one bit where the defect has two, @@ -168,7 +163,7 @@ describe('[#5374] the two general-purpose faces agree, case by case', () => { * because a refusal that does not name the replacement sends the author to the * docs, which is what `RETIRED_FILTER_OPERATORS` exists to prevent. */ -describe('[#6682] refusals, in the ADR-0112 envelope, on both faces', () => { +describe('[#6682] refusals, in the ADR-0112 envelope, on the query path and the shape gate', () => { let driver: InMemoryDriver; beforeEach(async () => { driver = await seed(); }); @@ -190,8 +185,9 @@ describe('[#6682] refusals, in the ADR-0112 envelope, on both faces', () => { for (const fragment of c.mustMention) expect(err.message).toContain(fragment); }); - it(`reference matcher: ${c.name}`, async () => { - const err = await thrownBy(() => match(ROWS[0], c.filter)); + // [#5930 step 4] The retired reference matcher's refusal was this gate's. + it(`shape gate: ${c.name}`, async () => { + const err = await thrownBy(() => assertFilterConditionShape(c.filter, 'filter')); expect(err, c.name).toBeInstanceOf(Error); expect(err.code).toBe(c.code); expect(err.status).toBe(400); @@ -302,13 +298,13 @@ describe('[#6682] the analytics face answers the same text rules', () => { * carry `$like` rows — a driver's enrolment is the whole table (rule 2 of its * header) and `driver-mongodb` refuses those two operators — so the pair is * pinned per face that answers it. Same shape as the table's `score` rows: - * the positive pattern matches NOTHING, its `$not` matches EVERYTHING, on - * both faces, and the two faces agree before either is checked against the - * answer. Under coercion `'%5%'` would match seven rows and `'%0'` every row + * the positive pattern matches NOTHING, its `$not` matches EVERYTHING. (The + * retired reference matcher was asserted to agree first; it did, with these + * same literal answers.) Under coercion `'%5%'` would match seven rows and `'%0'` every row * on a REAL column (`5` renders `'5.0'`), which is the wrong answer the * assertion keeps out. */ -describe('[#14079] $like / $ilike over a stored non-string value, on both faces', () => { +describe('[#14079] $like / $ilike over a stored non-string value, on the query path', () => { let driver: InMemoryDriver; beforeEach(async () => { driver = await seed(); }); @@ -323,22 +319,22 @@ describe('[#14079] $like / $ilike over a stored non-string value, on both faces' for (const [name, filter, expected] of CASES) { it(name, async () => { - const fromQuery = await queryIds(driver, filter); - const fromMatcher = matcherIds(filter); - expect(fromMatcher, 'the reference matcher disagrees with the query path').toEqual(fromQuery); - expect(fromQuery).toEqual(expected); + expect(await queryIds(driver, filter)).toEqual(expected); }); } - it('the reference matcher answers BOTH polarities for a valued non-string row — the #14079 cell itself', () => { - // The measured defect: `{ n: 5 }` failed `$contains: '5'` AND - // `$notContains: '5'`. A type test in place of the predicate says NO to an - // operator and to its negation; the predicate says NO to one and YES to - // the other. - const row = { id: 'x', n: 5 }; - expect(match(row, { n: { $contains: '5' } })).toBe(false); - expect(match(row, { n: { $notContains: '5' } })).toBe(true); - expect(match({ id: 'y', n: 0 }, { n: { $notContains: '0' } })).toBe(true); - expect(match({ id: 'z', n: true }, { n: { $notContains: 'true' } })).toBe(true); + it('BOTH polarities are answered for a valued non-string row — the #14079 cell itself', async () => { + // The measured defect, on the retired reference matcher: `{ n: 5 }` failed + // `$contains: '5'` AND `$notContains: '5'`. A type test in place of the + // predicate says NO to an operator and to its negation; the predicate says + // NO to one and YES to the other. [#5930 step 4] Held on the live path now. + const cell = new InMemoryDriver(); + for (const row of [{ id: 'x', n: 5 }, { id: 'y', n: 0 }, { id: 'z', n: true }]) await cell.create('cell', row); + const ids = async (where: unknown): Promise => + ((await cell.find('cell', { where: where as any })) as any[]).map((r) => String(r.id)).sort(byId); + expect(await ids({ n: { $contains: '5' } })).toEqual([]); + expect(await ids({ n: { $notContains: '5' } })).toEqual(['x', 'y', 'z']); + expect(await ids({ n: { $notContains: '0' } })).toEqual(['x', 'y', 'z']); + expect(await ids({ n: { $notContains: 'true' } })).toEqual(['x', 'y', 'z']); }); }); diff --git a/packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts b/packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts index 02b510a63ae..0cb2eef10e9 100644 --- a/packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts +++ b/packages/drivers/driver-memory/src/memory-filter-vocabulary-refusal.test.ts @@ -31,17 +31,22 @@ * * # Both faces * - * Each case is asserted through `InMemoryDriver.find` (mingo) AND through - * `match` (the reference matcher). They are one gate now, and this is the test - * that says so — a regression that re-forks them fails here rather than being - * discovered by a conformance table that only runs one of them (#5240). + * Each case was asserted through `InMemoryDriver.find` (mingo) AND through + * `match` (the reference matcher). They were one gate, and this was the test + * that said so — a regression that re-forked them failed here rather than being + * discovered by a conformance table that only ran one of them (#5240). + * + * [#5930 step 4, ruling D6] The reference matcher is retired. Its half of every + * case was the gate's refusal (`assertFilterConditionShape`), so each case now + * asserts the live path's refusal and the SAME message from that gate called + * directly — the one-gate invariant, kept. */ import { describe, it, expect, beforeEach } from 'vitest'; import type { FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; interface WireBearingError extends Error { code?: string; @@ -97,14 +102,17 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans throw new Error('expected the live query path to refuse this filter, but it answered'); }; - /** The refusal raised by the REFERENCE matcher, for the same filter. */ - const matcherRefusal = (where: unknown): WireBearingError => { + /** + * The refusal raised by the shared shape gate, for the same filter — the gate + * the retired reference matcher ran, called directly. + */ + const gateRefusal = (where: unknown): WireBearingError => { try { - ROWS.filter((r) => match(r, where)); + assertFilterConditionShape(where, 'filter'); } catch (e) { return e as WireBearingError; } - throw new Error('expected the reference matcher to refuse this filter, but it answered'); + throw new Error('expected the shape gate to refuse this filter, but it passed'); }; /** Every refusal in this package carries the same wire identity (#4436). */ @@ -147,7 +155,7 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans expectEnvelope(live); expect(live.message).toContain(DRIVER_SQL_WORDING.unknownOperator(op, 'stage')); - const reference = matcherRefusal(where); + const reference = gateRefusal(where); expectEnvelope(reference); expect(reference.message).toBe(live.message); }); @@ -186,7 +194,7 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans // Both faces, one sentence — the #5324 invariant, which is exactly what a // retirement must not be allowed to fork: this driver's matcher is the one // surface in the repo that really evaluated `$regex`. - const reference = matcherRefusal(where); + const reference = gateRefusal(where); expectEnvelope(reference); expect(reference.message).toBe(live.message); }); @@ -207,7 +215,7 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans const live = await liveRefusal(where); expectEnvelope(live); expect(live.message).toContain(`Unsupported filter combinator "${key}"`); - expect(matcherRefusal(where).message).toBe(live.message); + expect(gateRefusal(where).message).toBe(live.message); } }); @@ -236,7 +244,7 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans expectEnvelope(live); expect(live.message).toContain(DRIVER_SQL_WORDING.malformedBetween('score')); - const reference = matcherRefusal(where); + const reference = gateRefusal(where); expectEnvelope(reference); expect(reference.message).toBe(live.message); }); @@ -247,12 +255,11 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans // NO rows from `find` and EVERY row from `match`. One package, one filter, // two contradictory silent answers, neither of them a range. const where = { score: { $between: 5 } }; - expect((await liveRefusal(where)).message).toBe(matcherRefusal(where).message); + expect((await liveRefusal(where)).message).toBe(gateRefusal(where).message); }); it('a well-formed $between is untouched, including the calendar-day rewrite it feeds', async () => { expect(await ids({ score: { $between: [10, 20] } })).toEqual(['1', '2']); - expect(ROWS.filter((r) => match(r, { score: { $between: [10, 20] } })).map((r) => r.id)).toEqual(['1', '2']); }); // ── malformed combinator operands, the same shape one position over ──────── @@ -271,7 +278,7 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans const live = await liveRefusal(where); expectEnvelope(live); expect(live.message).toMatch(pattern); - expect(matcherRefusal(where).message).toBe(live.message); + expect(gateRefusal(where).message).toBe(live.message); } }); @@ -314,13 +321,21 @@ describe('[#5324/#5328] a filter this driver cannot evaluate is refused, not ans } }); - it('the refusal does not depend on the RECORD being tested', () => { - // Same argument as #5240's: both faces short-circuit, so a gate inside + it('the refusal does not depend on the RECORD being tested', async () => { + // Same argument as #5240's: an evaluator short-circuits, so a gate inside // evaluation would refuse for some rows and answer for others — a filter - // that is valid or invalid by luck of the data. The walk runs first. - for (const row of ROWS) { - expect(() => match(row, { stage: 'nothing-matches-this', score: { $between: 5 } })).toThrow(/\[min, max\]/); - expect(() => match(row, { $or: [{ stage: 'won' }, { owner: { $sounds_like: 'u1' } }] })).toThrow(/Unsupported filter operator/); + // that is valid or invalid by luck of the data. The walk runs first, so a + // table holding any one row — or none — refuses alike. + for (const rows of [...ROWS.map((row) => [row]), []]) { + const one = new InMemoryDriver({ persistence: false }); + for (const row of rows) await one.create('deal', { ...row }); + const label = JSON.stringify(rows); + await expect(one.find('deal', { where: { stage: 'nothing-matches-this', score: { $between: 5 } } as never }), label) + .rejects.toThrow(/\[min, max\]/); + await expect( + one.find('deal', { where: { $or: [{ stage: 'won' }, { owner: { $sounds_like: 'u1' } }] } as never }), + label, + ).rejects.toThrow(/Unsupported filter operator/); } }); }); diff --git a/packages/drivers/driver-memory/src/memory-icontains.test.ts b/packages/drivers/driver-memory/src/memory-icontains.test.ts index 981ba09199c..abcd4b6e834 100644 --- a/packages/drivers/driver-memory/src/memory-icontains.test.ts +++ b/packages/drivers/driver-memory/src/memory-icontains.test.ts @@ -21,6 +21,12 @@ * case below therefore runs the same filter through the live query path, the * reference matcher and the analytics face and demands ONE answer. * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is retired. Its `$icontains` arm held nothing of its own — it evaluated the + * spec's `asciiCaseInsensitiveContains`, the fold `@objectstack/formula` shares — + * so its half of each case is asserted on that shared predicate directly, and + * its refusal on the shape gate it ran (`assertFilterConditionShape`). + * * ## Why this file drives the ROWS and spells its own cases * * `check-driver-conformance.mjs` judges coverage by whether a package names the @@ -39,9 +45,9 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { FILTER_TEXT_ROWS } from '@objectstack/spec/data'; +import { FILTER_TEXT_ROWS, asciiCaseInsensitiveContains } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; const TABLE = 'text_rows'; @@ -60,9 +66,17 @@ const queryIds = async (driver: InMemoryDriver, where: unknown): Promise String(r.id)) .sort((a, b) => a.localeCompare(b)); -/** Ids the REFERENCE MATCHER returns, ascending. */ -const matcherIds = (where: unknown): string[] => - ROWS.filter((r) => match(r, where)).map((r) => r.id).sort((a, b) => a.localeCompare(b)); +/** + * Ids the SHARED fold answers for a `{ name: { $icontains: needle } }` case, + * ascending — the spec's `asciiCaseInsensitiveContains`, which is what the + * retired reference matcher evaluated for this operator. + */ +const sharedFoldIds = (where: unknown): string[] => { + const needle = (where as { name: { $icontains: string } }).name.$icontains; + return ROWS.filter((r) => typeof r.name === 'string' && asciiCaseInsensitiveContains(r.name, needle)) + .map((r) => r.id) + .sort((a, b) => a.localeCompare(b)); +}; describe('[#6520] $icontains — the accept table, on every face', () => { let driver: InMemoryDriver; @@ -93,14 +107,14 @@ describe('[#6520] $icontains — the accept table, on every face', () => { expect(await queryIds(driver, where)).toEqual(expected); }); - it(`reference matcher: ${label}`, () => { - expect(matcherIds(where)).toEqual(expected); + it(`shared fold: ${label}`, () => { + expect(sharedFoldIds(where)).toEqual(expected); }); } - it('the two faces agree on every case — the divergence class #5374 closed', async () => { + it('the query path and the shared fold agree on every case — the divergence class #5374 closed', async () => { for (const [label, where] of CASES) { - expect(await queryIds(driver, where), label).toEqual(matcherIds(where)); + expect(await queryIds(driver, where), label).toEqual(sharedFoldIds(where)); } }); @@ -112,7 +126,7 @@ describe('[#6520] $icontains — the accept table, on every face', () => { it('never answers every row — a dropped predicate WIDENS', async () => { for (const [label, where] of CASES) { expect((await queryIds(driver, where)).length, label).toBeLessThan(ROWS.length); - expect(matcherIds(where).length, label).toBeLessThan(ROWS.length); + expect(sharedFoldIds(where).length, label).toBeLessThan(ROWS.length); } }); @@ -122,11 +136,12 @@ describe('[#6520] $icontains — the accept table, on every face', () => { * "fixed" the fold by making `$contains` insensitive too. When it was written * the matcher was the ONLY face answering `$contains` case-exactly, the query * path still folding Unicode (#6682); since #7723 all three agree, so the - * choice of face here is no longer load-bearing. + * choice of face here is no longer load-bearing — and with the matcher + * retired (#5930 step 4) it is asserted on the query path. */ - it('leaves $contains case-SENSITIVE on the reference matcher', () => { - expect(matcherIds({ name: { $contains: 'acme' } })).toEqual(['2']); - expect(matcherIds({ name: { $contains: 'ACME' } })).toEqual(['1']); + it('leaves $contains case-SENSITIVE on the query path', async () => { + expect(await queryIds(driver, { name: { $contains: 'acme' } })).toEqual(['2']); + expect(await queryIds(driver, { name: { $contains: 'ACME' } })).toEqual(['1']); }); }); @@ -161,8 +176,8 @@ describe('[#6520] $icontains comparand refusals, in the ADR-0112 envelope', () = expect(err.message).toContain('$icontains'); }); - it('refuses on the REFERENCE MATCHER too — one gate, three faces', () => { - const err = (() => { try { match(ROWS[0], { name: { $icontains: '' } }); return null; } catch (e) { return e as any; } })(); + it('refuses on the shared shape gate too — the retired matcher\'s door, one gate for every face', () => { + const err = (() => { try { assertFilterConditionShape({ name: { $icontains: '' } }, 'filter'); return null; } catch (e) { return e as any; } })(); expect(err).toBeInstanceOf(Error); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); diff --git a/packages/drivers/driver-memory/src/memory-like-pattern.test.ts b/packages/drivers/driver-memory/src/memory-like-pattern.test.ts index acf9b2a95da..c58959d3e04 100644 --- a/packages/drivers/driver-memory/src/memory-like-pattern.test.ts +++ b/packages/drivers/driver-memory/src/memory-like-pattern.test.ts @@ -38,11 +38,17 @@ * These cases go red if that widening is ever removed without removing the * arms, and `memory-filter-ast-vocabulary.test.ts` goes red if the arms are * removed without the widening. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is retired. For `$like` / `$ilike` it evaluated the spec's + * `matchesLikePattern` and nothing of its own, so the "both faces agree" half + * of each case is held on that shared predicate directly — the one `formula` + * evaluates too. The `$contains` control is asserted on the query path alone. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { matchesLikePattern } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; const TABLE = 'like_probe'; @@ -77,9 +83,19 @@ describe('[#7536] driver-memory — $like / $ilike on both faces', () => { .map((r: Record) => String(r.id)) .sort((a, b) => a.localeCompare(b)); - /** Ids the REFERENCE MATCHER returns, ascending. */ - const matcherIds = (where: unknown): string[] => - ROWS.filter((r) => match(r, where)).map((r) => r.id).sort((a, b) => a.localeCompare(b)); + /** + * Ids the spec's shared `matchesLikePattern` answers for a single `$like` / + * `$ilike` case, ascending — what the retired reference matcher evaluated. + * `null` for a case on any other operator. + */ + const sharedPatternIds = (where: Record): string[] | null => { + const spec = where.name as Record; + const op = Object.keys(spec)[0]; + if (op !== '$like' && op !== '$ilike') return null; + return ROWS.filter((r) => matchesLikePattern(r.name, String(spec[op]), op === '$ilike')) + .map((r) => r.id) + .sort((a, b) => a.localeCompare(b)); + }; const refusalOf = async (where: unknown): Promise => { const err = await driver @@ -94,9 +110,9 @@ describe('[#7536] driver-memory — $like / $ilike on both faces', () => { }); /** - * Every case is asserted on BOTH faces, and the faces are compared to each - * other FIRST — a drift between them reads as a drift rather than as two - * unrelated wrong answers. + * Every case is asserted on the query path and — for the pattern operators — + * on the shared predicate, compared to each other FIRST, so a drift between + * them reads as a drift rather than as two unrelated wrong answers. */ const CASES: Array<[string, Record, string[]]> = [ // The card's own repro table. @@ -130,8 +146,10 @@ describe('[#7536] driver-memory — $like / $ilike on both faces', () => { for (const [name, where, expected] of CASES) { it(name, async () => { const fromQuery = await queryIds(where); - const fromMatcher = matcherIds(where); - expect(fromMatcher, 'the reference matcher disagrees with the query path').toEqual(fromQuery); + const fromShared = sharedPatternIds(where); + if (fromShared !== null) { + expect(fromShared, 'the shared pattern predicate disagrees with the query path').toEqual(fromQuery); + } expect(fromQuery).toEqual(expected); }); } diff --git a/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts b/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts index 5e6a8b35a94..25f55f5674d 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-array-and-date-comparand.test.ts @@ -46,121 +46,150 @@ * — this refusal reaching the value side by accident — is still live, and the * assertion that the two sides stay distinct is the same assertion whichever * answer the value side gives. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED; this file keeps its name and holds the LIVE query path now + * (`InMemoryDriver.find`), cell for cell. Every expectation below is the one the + * matcher was asserted to give, and the live path gives each of them — measured + * before the move. A `Date` comparand meets a row through a declared `datetime` + * column, the case the equality cells were written for: that column holds + * canonical ISO text (ADR-0053 D-B1, `memory-temporal.ts`), and so does the + * comparand once it is put into the column's storage form (#4047). The ARRAY + * refusal is the shared shape gate's, which the live path runs. */ import { describe, it, expect } from 'vitest'; -import { match } from './memory-matcher.js'; +import { InMemoryDriver } from './memory-driver.js'; const INSTANT = '2026-01-01T00:00:00.000Z'; -describe('[#16810] Date comparands are compared by time value', () => { - it('a distinct Date object of the same instant matches', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: new Date(INSTANT) })).toBe(true); - }); - - it('a Date of a DIFFERENT instant does not match — the control for the case above', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: new Date('2026-06-01T00:00:00.000Z') })) +const FIELDS = { + created_at: { type: 'datetime' }, + tags: { type: 'text' }, + qty: { type: 'number' }, + name: { type: 'text' }, +}; + +/** A driver holding ONE row of the declared object `t`. */ +async function oneRow(stored: Record): Promise { + const driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + await driver.syncSchema('t', { fields: FIELDS } as never); + await driver.create('t', { id: 'r', ...stored }); + return driver; +} + +/** Does the live query path select that one row? — what the matcher's `match(row, where)` answered, asked of `find()`. */ +async function liveSelects(stored: Record, where: unknown): Promise { + const rows = await (await oneRow(stored)).find('t', { where } as never); + return rows.length === 1; +} + +/** The live query path's refusal, or a sentinel when it answered. */ +async function refusal(stored: Record, where: unknown): Promise { + return (await oneRow(stored)).find('t', { where } as never).then( + () => new Error('the live query path answered instead of refusing'), + (err: unknown) => err, + ); +} + +describe('[#16810] Date comparands are compared by time value (the live path, a declared datetime)', () => { + it('a distinct Date object of the same instant matches', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: new Date(INSTANT) })).toBe(true); + }); + + it('a Date of a DIFFERENT instant does not match — the control for the case above', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: new Date('2026-06-01T00:00:00.000Z') })) .toBe(false); }); - it('a Date comparand matches a stored ISO STRING of the same instant', () => { + it('a Date comparand matches a stored ISO STRING of the same instant', async () => { // The case that actually reaches a stored row: a declared `datetime` is // canonicalised to ISO text on write (ADR-0053 D-B1, `memory-temporal.ts`), // so a `Date` comparand meets a string. `==` stringified the Date to // "Wed Jan 01 2026 …", which no ISO value equals. - expect(match({ created_at: INSTANT }, { created_at: new Date(INSTANT) })).toBe(true); + expect(await liveSelects({ created_at: INSTANT }, { created_at: new Date(INSTANT) })).toBe(true); }); - it('a stored Date matches an ISO STRING comparand of the same instant — the same rule, mirrored', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: INSTANT })).toBe(true); + it('a stored Date matches an ISO STRING comparand of the same instant — the same rule, mirrored', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: INSTANT })).toBe(true); }); - it('an ISO string of a different instant does not match', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: '2026-06-01T00:00:00.000Z' })).toBe(false); + it('an ISO string of a different instant does not match', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: '2026-06-01T00:00:00.000Z' })).toBe(false); }); - it('a non-temporal string does not become a match by way of Date parsing', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: 'active' })).toBe(false); + it('a non-temporal string does not become a match by way of Date parsing', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: 'active' })).toBe(false); }); - it('an Invalid Date equals nothing, itself included', () => { + it('an Invalid Date equals nothing, itself included', async () => { // JS `Date` convention (NaN time value), `formula`'s `looseEq` answer, and // ADR-0053 D-F1's reading that an Invalid Date has no canonical text. - expect(match({ created_at: new Date('nope') }, { created_at: new Date('nope') })).toBe(false); + expect(await liveSelects({ created_at: new Date('nope') }, { created_at: new Date('nope') })).toBe(false); }); - it('$eq and $ne take the SAME equality as the implicit spelling', () => { + it('$eq and $ne take the SAME equality as the implicit spelling', async () => { // One predicate must not answer two ways depending on which spelling the // author used. - expect(match({ created_at: new Date(INSTANT) }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); - expect(match({ created_at: new Date(INSTANT) }, { created_at: { $ne: new Date(INSTANT) } })).toBe(false); - expect(match({ created_at: INSTANT }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: { $ne: new Date(INSTANT) } })).toBe(false); + expect(await liveSelects({ created_at: INSTANT }, { created_at: { $eq: new Date(INSTANT) } })).toBe(true); }); - it('the ordering operators keep answering a Date comparand', () => { - expect(match({ created_at: new Date(INSTANT) }, { created_at: { $gte: new Date(INSTANT) } })).toBe(true); - expect(match({ created_at: new Date(INSTANT) }, { created_at: { $gt: new Date(INSTANT) } })).toBe(false); + it('the ordering operators keep answering a Date comparand', async () => { + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: { $gte: new Date(INSTANT) } })).toBe(true); + expect(await liveSelects({ created_at: new Date(INSTANT) }, { created_at: { $gt: new Date(INSTANT) } })).toBe(false); }); }); describe('[#16810] an ARRAY comparand is refused, in the ADR-0112 envelope', () => { const envelope = { code: 'INVALID_FILTER', status: 400 }; - it('refuses the implicit-equality position', () => { - expect(() => match({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toThrow( - expect.objectContaining(envelope), - ); + it('refuses the implicit-equality position', async () => { + expect(await refusal({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toMatchObject(envelope); }); - it('refuses an EMPTY array too — the member of the cell whose two silent answers coincided', () => { - expect(() => match({ tags: [] }, { tags: [] })).toThrow(expect.objectContaining(envelope)); + it('refuses an EMPTY array too — the member of the cell whose two silent answers coincided', async () => { + expect(await refusal({ tags: [] }, { tags: [] })).toMatchObject(envelope); }); - it('refuses the $eq / $ne spelling of the same position', () => { - expect(() => match({ tags: ['a', 'b'] }, { tags: { $eq: ['a', 'b'] } })).toThrow( - expect.objectContaining(envelope), - ); - expect(() => match({ tags: ['a', 'b'] }, { tags: { $ne: ['a', 'b'] } })).toThrow( - expect.objectContaining(envelope), - ); + it('refuses the $eq / $ne spelling of the same position', async () => { + expect(await refusal({ tags: ['a', 'b'] }, { tags: { $eq: ['a', 'b'] } })).toMatchObject(envelope); + expect(await refusal({ tags: ['a', 'b'] }, { tags: { $ne: ['a', 'b'] } })).toMatchObject(envelope); }); - it('refuses an array on the ordering operators', () => { - expect(() => match({ qty: 5 }, { qty: { $gt: [1, 2] } })).toThrow(expect.objectContaining(envelope)); + it('refuses an array on the ordering operators', async () => { + expect(await refusal({ qty: 5 }, { qty: { $gt: [1, 2] } })).toMatchObject(envelope); }); - it('names the position, the received shape and the accepted set', () => { + it('names the position, the received shape and the accepted set', async () => { // The message is specific rather than generic: an author who wrote // `{ tags: ['a','b'] }` must be told which field, what arrived, what is // accepted, and which operators DO take a list. - let message = ''; - try { - match({ tags: ['a', 'b'] }, { tags: ['a', 'b'] }); - } catch (err) { - message = String((err as Error).message); - } + const message = String(((await refusal({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })) as Error).message); expect(message).toContain('tags'); expect(message).toContain('a string, number, bigint, boolean, null or Date'); expect(message).toContain('$in/$nin'); expect(message).toContain('$between'); }); - it('leaves the LIST operators alone — an array is their declared comparand', () => { - expect(match({ tags: 'a' }, { tags: { $in: ['a', 'z'] } })).toBe(true); - expect(match({ tags: 'a' }, { tags: { $nin: ['a', 'z'] } })).toBe(false); - expect(match({ qty: 5 }, { qty: { $between: [1, 10] } })).toBe(true); + it('leaves the LIST operators alone — an array is their declared comparand', async () => { + expect(await liveSelects({ tags: 'a' }, { tags: { $in: ['a', 'z'] } })).toBe(true); + expect(await liveSelects({ tags: 'a' }, { tags: { $nin: ['a', 'z'] } })).toBe(false); + expect(await liveSelects({ qty: 5 }, { qty: { $between: [1, 10] } })).toBe(true); }); - it('leaves the TEXT family alone — its comparand disposition is recorded elsewhere', () => { + it('leaves the TEXT family alone — its comparand disposition is recorded elsewhere', async () => { // `filter-refusal.ts` lists "a stringified comparand for the LIKE family" // among the shapes it deliberately does not refuse, fail-closed. This // refusal covers the ruled cell and does not widen past it. - expect(() => match({ name: 'alpha' }, { name: { $contains: ['a'] } })).not.toThrow(); + await expect(liveSelects({ name: 'alpha' }, { name: { $contains: ['a'] } })).resolves.toBeTypeOf('boolean'); }); }); describe('[#16810/#16838] the value side is NOT the comparand side — still two cells, both now answered', () => { - it('a scalar comparand against a stored array is MEMBERSHIP, and is not refused', () => { + it('a scalar comparand against a stored array is MEMBERSHIP, and is not refused', async () => { // [#16838] The three lines this block pinned as UNCHANGED under #16810, // with the two that #16838 moved and the one it did not: // @@ -174,23 +203,23 @@ describe('[#16810/#16838] the value side is NOT the comparand side — still two // side would turn the first two lines into a throw. Their VALUES track the // value side's own ruling; the shape of the assertion — an answer, not an // exception — is what #16810 pinned and it is unchanged. - expect(match({ tags: ['a', 'b'] }, { tags: 'a' })).toBe(true); - expect(match({ tags: ['a', 'b'] }, { tags: 'a,b' })).toBe(false); - expect(match({ tags: ['a'] }, { tags: 'a' })).toBe(true); + expect(await liveSelects({ tags: ['a', 'b'] }, { tags: 'a' })).toBe(true); + expect(await liveSelects({ tags: ['a', 'b'] }, { tags: 'a,b' })).toBe(false); + expect(await liveSelects({ tags: ['a'] }, { tags: 'a' })).toBe(true); }); - it('the ARRAY-comparand refusal did not follow the value side — a stored array is still evaluated', () => { + it('the ARRAY-comparand refusal did not follow the value side — a stored array is still evaluated', async () => { // The invariant this block was created to hold, stated directly rather than // left to be inferred from the three answers above: the door refuses an // array in the COMPARAND position and says nothing about a stored one, so a // scalar comparand against any stored array must ANSWER. for (const stored of [['a', 'b'], ['a'], [] as unknown[], [null, 'b'], [['a']]]) { - expect(() => match({ tags: stored }, { tags: 'a' }), `stored ${JSON.stringify(stored)} was refused`) - .not.toThrow(); + await expect(liveSelects({ tags: stored }, { tags: 'a' }), `stored ${JSON.stringify(stored)} was refused`) + .resolves.toBeTypeOf('boolean'); } // …while the comparand position still refuses, on the same row. - expect(() => match({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toThrow( - /requires a single comparable value/, - ); + expect(await refusal({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toMatchObject({ + message: expect.stringMatching(/requires a single comparable value/), + }); }); }); diff --git a/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts b/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts index 424cdebf719..077810e48a5 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts @@ -37,11 +37,19 @@ * a matcher that started answering "every row" to everything cannot pass this * file: the ruling is that a no-value row joins the negative answer, not that * predicates stop discriminating. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED. This file keeps its name — `@objectstack/spec`'s + * `filter-logic-conformance.ts` points here for the key-ABSENT reading of these + * cells — and holds the LIVE query path now (`InMemoryDriver.find`, through + * mingo), every cell and every expectation unchanged: the live path gave each + * of them already, measured before the move. (The `$exists` caution above is + * history: #13195 converged the live path on has-value.) */ import { describe, it, expect } from 'vitest'; -import { match } from './memory-matcher.js'; +import { InMemoryDriver } from './memory-driver.js'; /** `name` present but null — how a SQL NULL round-trips into a record. */ const NULLED: Array> = [ @@ -57,8 +65,16 @@ const MISSING: Array> = [ { id: '3' }, ]; -const ids = (rows: Array>, filter: unknown): string[] => - rows.filter((r) => match(r, filter)).map((r) => String(r.id)); +/** The ids the LIVE query path returns for `rows`, in fixture order. */ +async function ids(rows: Array>, filter: unknown): Promise { + const driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + for (const row of rows) await driver.create('t', { ...row }); + const found = new Set( + ((await driver.find('t', { where: filter } as never)) as Array>).map((r) => String(r.id)), + ); + return rows.map((r) => String(r.id)).filter((id) => found.has(id)); +} /** The ruling's answer: row 2 has a different value, row 3 has none. */ const NO_VALUE_INCLUDED = ['2', '3']; @@ -72,37 +88,37 @@ describe('[#13166] no-value rows and the negation-carrying operators', () => { ]; for (const [op, filter] of OPERATORS) { - it(`${op}: a null value satisfies it`, () => { - expect(ids(NULLED, filter)).toEqual(NO_VALUE_INCLUDED); + it(`${op}: a null value satisfies it`, async () => { + expect(await ids(NULLED, filter)).toEqual(NO_VALUE_INCLUDED); }); - it(`${op}: an absent key satisfies it`, () => { - expect(ids(MISSING, filter)).toEqual(NO_VALUE_INCLUDED); + it(`${op}: an absent key satisfies it`, async () => { + expect(await ids(MISSING, filter)).toEqual(NO_VALUE_INCLUDED); }); - it(`${op}: both readings of "no value" answer alike`, () => { - expect(ids(MISSING, filter)).toEqual(ids(NULLED, filter)); + it(`${op}: both readings of "no value" answer alike`, async () => { + expect(await ids(MISSING, filter)).toEqual(await ids(NULLED, filter)); }); } }); describe('the operators still discriminate — this is not "match everything"', () => { - it('each negation excludes the row that DOES carry the comparand', () => { - expect(ids(NULLED, { name: { $ne: 'alpha-one' } })).not.toContain('1'); - expect(ids(NULLED, { name: { $nin: ['alpha-one'] } })).not.toContain('1'); - expect(ids(NULLED, { name: { $notContains: 'one' } })).not.toContain('1'); + it('each negation excludes the row that DOES carry the comparand', async () => { + expect(await ids(NULLED, { name: { $ne: 'alpha-one' } })).not.toContain('1'); + expect(await ids(NULLED, { name: { $nin: ['alpha-one'] } })).not.toContain('1'); + expect(await ids(NULLED, { name: { $notContains: 'one' } })).not.toContain('1'); }); - it('the positive twins keep answering the complement over the VALUED rows', () => { + it('the positive twins keep answering the complement over the VALUED rows', async () => { // A no-value row is in NEITHER answer for the positive operators: the // ruling moved the negative cells only. - expect(ids(NULLED, { name: { $eq: 'alpha-one' } })).toEqual(['1']); - expect(ids(MISSING, { name: { $in: ['alpha-one'] } })).toEqual(['1']); - expect(ids(NULLED, { name: { $contains: 'one' } })).toEqual(['1']); - expect(ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']); + expect(await ids(NULLED, { name: { $eq: 'alpha-one' } })).toEqual(['1']); + expect(await ids(MISSING, { name: { $in: ['alpha-one'] } })).toEqual(['1']); + expect(await ids(NULLED, { name: { $contains: 'one' } })).toEqual(['1']); + expect(await ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']); }); - it('[#14079] a present non-string value SATISFIES $notContains — the predicate, not the type test', () => { + it('[#14079] a present non-string value SATISFIES $notContains — the predicate, not the type test', async () => { // This pin used to assert the opposite (`toEqual([])`), stated as out of // #13166's scope: "a value that is there and is not a string keeps the // answer it had". That answer was NO to `$notContains` AND to @@ -114,27 +130,27 @@ describe('[#13166] no-value rows and the negation-carrying operators', () => { // direction, and asserted with substance: the row IS in the negation, // is NOT in the positive twin, and the two still partition the rows. const rows = [{ id: '1', name: 'alpha-one' }, { id: '9', name: 42 }]; - expect(ids(rows, { name: { $notContains: 'one' } })).toEqual(['9']); - expect(ids(rows, { name: { $contains: 'one' } })).toEqual(['1']); - expect(ids([{ id: '0', name: 0 }, { id: 'f', name: false }], { name: { $notContains: 'one' } })).toEqual(['0', 'f']); + expect(await ids(rows, { name: { $notContains: 'one' } })).toEqual(['9']); + expect(await ids(rows, { name: { $contains: 'one' } })).toEqual(['1']); + expect(await ids([{ id: '0', name: 0 }, { id: 'f', name: false }], { name: { $notContains: 'one' } })).toEqual(['0', 'f']); }); }); describe('cross-operator agreement, the invariant that outlives the fixture', () => { - it('$nin answers exactly what $ne answers — it is the list form of it', () => { + it('$nin answers exactly what $ne answers — it is the list form of it', async () => { // `formula`'s suite states the same identity over its own fixture. `$ne` // is the operator ENROLLED in `FILTER_LOGIC_CASES`, so this is the link // between the enrolled cell and the two that are not enrolled yet. for (const rows of [NULLED, MISSING]) { - expect(ids(rows, { name: { $nin: ['alpha-one'] } })) - .toEqual(ids(rows, { name: { $ne: 'alpha-one' } })); + expect(await ids(rows, { name: { $nin: ['alpha-one'] } })) + .toEqual(await ids(rows, { name: { $ne: 'alpha-one' } })); } }); - it('$contains and $notContains partition the VALUED rows and both keep the no-value row out of the positive side', () => { + it('$contains and $notContains partition the VALUED rows and both keep the no-value row out of the positive side', async () => { for (const rows of [NULLED, MISSING]) { - const inside = ids(rows, { name: { $contains: 'one' } }); - const outside = ids(rows, { name: { $notContains: 'one' } }); + const inside = await ids(rows, { name: { $contains: 'one' } }); + const outside = await ids(rows, { name: { $notContains: 'one' } }); expect(inside).toEqual(['1']); expect(outside).toEqual(NO_VALUE_INCLUDED); expect(inside.filter((id) => outside.includes(id))).toEqual([]); diff --git a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts deleted file mode 100644 index c1dbfe79c1e..00000000000 --- a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#5146] `$not` and absent values — the answers this matcher gives, pinned. - * - * This backend needed no change for #5146: it already evaluates a negation in - * ordinary two-valued JS, so `{ $not: { stage: 'won' } }` matches a record whose - * `stage` is null or missing (`undefined !== 'won'`). `driver-sql` used to - * DISAGREE — SQL's `NOT (stage = 'won')` is UNKNOWN for a NULL column and a - * `WHERE` drops it — which meant one CEL `!expr` permission rule admitted a - * different set of rows depending on which driver ran it. #5146 ruled this - * backend's answer canonical (it is the 2:1 majority with `formula`) and - * `driver-sql` was rewritten to match. - * - * So these cases are a PIN, not a change: they are the reference the SQL - * compiler was aligned to, and `sql-driver-not-null-safe.test.ts` asserts the - * same ids over the same fixture. Changing an expectation here silently - * re-opens the divergence — the point of writing them down is that the next - * edit has to move both files, deliberately. - * - * Where this matcher and `formula`'s `matchesFilterCondition` disagree, the case - * says so and pins what each actually answers rather than pretending to a - * consensus; those disagreements are filed separately and are NOT what #5146 - * ruled on. - * - * Home for these eventually: `FILTER_LOGIC_CASES` in `@objectstack/spec/data`, - * so all five backends are held to one table (spec lane, with #5239). - */ - -import { describe, it, expect } from 'vitest'; -import { match } from './memory-matcher.js'; - -/** Fields present but null — how a SQL NULL round-trips into a record. */ -const NULLED: Array> = [ - { id: '1', stage: 'won', owner: 'u1', amount: 10 }, - { id: '2', stage: 'lost', owner: 'u2', amount: 20 }, - { id: '3', stage: null, owner: 'u1', amount: null }, - { id: '4', stage: null, owner: null, amount: 40 }, -]; - -/** The same rows with the null fields ABSENT — the shape a partial write leaves. */ -const MISSING: Array> = [ - { id: '1', stage: 'won', owner: 'u1', amount: 10 }, - { id: '2', stage: 'lost', owner: 'u2', amount: 20 }, - { id: '3', owner: 'u1' }, - { id: '4', amount: 40 }, -]; - -const ALL = ['1', '2', '3', '4']; - -const ids = (rows: Array>, filter: unknown): string[] => - rows.filter((r) => match(r, filter)).map((r) => String(r.id)); - -/** Both readings of "no value" must give the same answer unless noted. */ -const matched = (filter: unknown): string[] => { - const nulled = ids(NULLED, filter); - expect(ids(MISSING, filter), 'a null field and an absent field must match alike').toEqual(nulled); - return nulled; -}; - -describe('[#5146] memory-matcher — $not over records with no value', () => { - describe('a record with no value does not satisfy the negated condition', () => { - it('$not on an implicit equality matches the value-less records', () => { - expect(matched({ $not: { stage: 'won' } })).toEqual(['2', '3', '4']); - }); - - it('$not over multiple keys matches a record missing EITHER', () => { - expect(matched({ $not: { stage: 'won', owner: 'u1' } })).toEqual(['2', '3', '4']); - }); - - it('the RLS shape: a CEL `!(stage == "won")` scope keeps stage-less records', () => { - expect(matched({ $not: { stage: 'won' } })).toHaveLength(3); - }); - }); - - describe('nesting', () => { - it('$not of a $or rejects a value-less record whose OTHER branch matches', () => { - // Record 3 has no stage but owner = 'u1', so the $or holds and the - // negation must reject it. This is the case that forced `driver-sql` to - // compile its NULL guard onto each leaf instead of beside the `NOT`. - expect(matched({ $not: { $or: [{ stage: 'won' }, { owner: 'u1' }] } })).toEqual(['2', '4']); - }); - - it('$not of a $and matches every record failing either conjunct', () => { - expect(matched({ $not: { $and: [{ stage: 'won' }, { owner: 'u1' }] } })).toEqual(['2', '3', '4']); - }); - - it('a double negation is the positive filter again', () => { - expect(matched({ $not: { $not: { stage: 'won' } } })).toEqual(['1']); - expect(matched({ $not: { $not: { stage: 'won' } } })).toEqual(matched({ stage: 'won' })); - }); - - it('$not ANDs with its sibling keys', () => { - expect(matched({ $not: { stage: 'won' }, owner: 'u1' })).toEqual(['3']); - }); - }); - - describe('operator polarity — a negation is not a blanket "and also the empty ones"', () => { - it('$not of $ne still means "the field IS that value"', () => { - expect(matched({ $not: { stage: { $ne: 'won' } } })).toEqual(['1']); - }); - - it('$not of $in matches the value-less records', () => { - expect(matched({ $not: { stage: { $in: ['won'] } } })).toEqual(['2', '3', '4']); - }); - - it('$not of an ordering comparison matches the value-less records', () => { - expect(matched({ $not: { amount: { $gt: 15 } } })).toEqual(['1', '3']); - }); - - it('$not of $contains matches the value-less records', () => { - expect(matched({ $not: { stage: { $contains: 'w' } } })).toEqual(['2', '3', '4']); - }); - - it('$not of a null predicate', () => { - expect(matched({ $not: { stage: { $null: true } } })).toEqual(['1', '2']); - expect(matched({ $not: { stage: { $null: false } } })).toEqual(['3', '4']); - }); - }); - - describe('the boolean identities still hold here too (#5134)', () => { - it('$not: {} matches nothing — NOT TRUE ≡ FALSE', () => { - expect(matched({ $not: {} })).toEqual([]); - }); - - it('$not of an empty $or matches everything', () => { - expect(matched({ $not: { $or: [] } })).toEqual(ALL); - }); - }); - - // ── The three #5299 cells — two CONVERGED (#13166), one still open ───────── - - /** - * [#5299, settled 2026-08-10; the `$nin` / `$notContains` cells closed by - * #13166] These three cells were filed as "known disagreements with - * `formula`, not ruled on by #5146". They are settled, and the settled - * direction is INCLUDE: `$ne` / `$nin` / `$notContains` MATCH a no-value row - * (#5146, extended by #5298, shipped across eleven surfaces), and `$exists` - * means "has a value" (#5298 ③ / #5369, PR #5962). - * - * A ruling on 2026-08-10 07:33Z briefly went the other way — SQL's native - * three-valued logic as the common denominator, negative operators never - * matching no-value rows — which would have made this matcher's FORMER - * answers the canonical ones. Cells 1 and 3 of it were WITHDRAWN the same - * day, once the reversal's cross-backend cost had been measured, and include - * was re-affirmed. `$exists` = has-value is the leg that stands. - * - * ⚠️ The `$nin` and `$notContains` assertions below are INVERTED as of - * #13166, and what stood here before was the stated reason they had not been: - * "⛔ Nothing below is flipped, and the reason is not inertia: this package is - * inside the #5499 investment freeze, and both rulings leave - * `checkCondition`'s early-exit guard exactly as it is." That reason expired. - * The freeze dissolved on 2026-08-11 (head note of `@objectstack/spec`'s - * `aggregation-conformance.ts`), which left the divergence unexcused AND - * untracked — the DEBT ledger in `scripts/check-driver-conformance.mjs` never - * carried it, its granularity being per (driver × case-set). That is what - * #13166 was raised on, and it moved the guard rather than the ruling. - * - * ⛔ These lines were NOT re-baselined to whatever the matcher started - * printing. They are inverted deliberately, ONTO the answer `formula` and all - * four SQL compilers already gave — which is this file's whole point: it is - * the reference `driver-sql` was aligned to, so an edit here has to be a - * decision. `sql-driver-not-null-safe.test.ts` is unchanged by #13166 and - * still asserts `['1']` for both filters below; the two files agree again - * because this one moved TO the SQL family, not the family to it. - * - * Where each cell stands against the affirmed include direction: - * - * `$exists` CONVERGED, and this matcher was already right. Both - * evaluators read "has a value"; the formula-side assertion - * lives in `matches-filter-not-null-safe.test.ts`. Kept here - * because the two OTHER faces of this package — the live - * mingo query path and the analytics face — still read - * key-presence, so the package disagrees with itself and this - * test is the face that is correct. ⛔ That remaining gap is - * the neighbouring cell (#13195), not this one: it has a - * different backend list and was deliberately left alone. - * `$notContains` CONVERGED by #13166. The arm's `typeof value !== 'string'` - * test rejected a `null` on its TYPE rather than on the - * predicate, so a value-less field failed a negation it - * should satisfy. The arm now answers the no-value readings - * from `noValueSatisfiesNegation` first. - * `$nin` CONVERGED by #13166. A present-but-null value already - * matched; a MISSING key did not, because the early-exit - * guard's allowlist named `$ne` and not `$nin`. A SECOND and - * independent cause from the one above, reachable only from - * the other reading of "no value" — which is why the fixture - * carries both columns and why one cause could be fixed - * while the other stood. - */ - describe('[#5299] the settled no-value cells — $nin / $notContains converged (#13166)', () => { - it('$nin: an ABSENT field and a null one are now treated ALIKE', () => { - // Was: NULLED answered `['1']` and MISSING answered `['1', '3', '4']`. - // The divergence was never in the null column — it was the guard turning - // an absent key into "no match" before the `$nin` arm ran. Both columns - // are asserted separately rather than only through `matched()`, because - // the whole content of this cell is that the two readings agree. - expect(ids(NULLED, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); - expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); - expect(matched({ $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); - }); - - it('$notContains: a value-less field DOES satisfy it — converged', () => { - // Was `['1', '3', '4']`: `typeof null !== 'string'` answered false, so the - // negation readmitted the value-less rows. `formula` and all four SQL - // compilers answer `['1']` — this matcher was the odd one out among the - // eleven surfaces, and is not any more. - expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']); - }); - - it('$exists: a present-but-null field counts as NOT existing here — CONVERGED', () => { - // Both readings of "no value" answer alike, and `formula` now agrees: - // "has a value", the strict mirror of `$null` (#5298 ③ / #5369, PR #5962). - // No longer a disagreement — the assertion is kept because this package's - // other two filter faces still read key-presence. - expect(ids(NULLED, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); - expect(ids(MISSING, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); - expect(ids(NULLED, { stage: { $exists: true } })).toEqual(['1', '2']); - expect(ids(NULLED, { stage: { $exists: true } })).toEqual(ids(NULLED, { stage: { $null: false } })); - }); - }); -}); diff --git a/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts b/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts index dfb86f45173..61bc7480798 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts @@ -72,12 +72,17 @@ * Its cells stay unasserted here, now for the ruling's own reason — ⛔「不单独修 * matcher(死代码)」— and no ordering-vs-null semantics is defined anywhere, * so nothing here says what either face would have answered. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED. Every cell above had converged — both faces gave the literal row + * set each case states — so the file keeps its name and its expectations and + * holds the LIVE path alone; the matcher-only comparisons (a null value against + * an absent key, `$between` against its own two bounds) are asked of `find()`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; const sorted = (ids: string[]): string[] => [...ids].sort(); @@ -148,24 +153,27 @@ async function liveIds(driver: InMemoryDriver, where: unknown): Promise>).map((r) => String(r.id))); } -/** The REFERENCE face: the record-at-a-time matcher. */ -const matcherIds = (rows: Array>, where: unknown): string[] => - sorted(rows.filter((row) => match(row, where)).map((row) => String(row.id))); +/** + * The live path over a fresh table holding `rows` — for the cells that ask one + * row, or both readings of "no value", a question the fixtures above answer + * only together. What the retired matcher's record-at-a-time `matcherIds` was + * asked, now asked of `find()`. + */ +async function freshIds(rows: Array>, where: unknown): Promise { + const driver = await driverFor(rows); + try { + return await liveIds(driver, where); + } finally { + await driver.disconnect(); + } +} /** - * Every cell asserts the row set on BOTH faces, in one call, so a repair that - * moves one of them cannot pass. The expected set is written out literally — - * comparing the two faces to each other alone would be satisfied by both being - * wrong together. + * Every cell asserts the literal row set on the live path. (It asserted the same + * set on the retired reference matcher too, until #5930 step 4.) */ -async function bothFaces( - driver: InMemoryDriver, - rows: Array>, - where: unknown, - expected: string[], -): Promise { +async function livePath(driver: InMemoryDriver, where: unknown, expected: string[]): Promise { expect({ face: 'live', ids: await liveIds(driver, where) }).toEqual({ face: 'live', ids: expected }); - expect({ face: 'matcher', ids: matcherIds(rows, where) }).toEqual({ face: 'matcher', ids: expected }); } describe('[#13494] `$eq: null` is the null predicate on BOTH readings of "no value"', () => { @@ -173,36 +181,36 @@ describe('[#13494] `$eq: null` is the null predicate on BOTH readings of "no val // Was `[]` on the matcher against `['3']` live: `$eq` was not on the // pre-switch guard's allowlist, so an absent key short-circuited to "no // match" before the arm ran. #5332 ruled `$eq: null` IS the null predicate. - await bothFaces(missing, MISSING_ROWS, { name: { $eq: null } }, ['3']); + await livePath(missing, { name: { $eq: null } }, ['3']); }); it('a stored null matches it too — the reading that always worked', async () => { - await bothFaces(nulled, NULLED_ROWS, { name: { $eq: null } }, ['3']); + await livePath(nulled, { name: { $eq: null } }, ['3']); }); it('`$eq: null` now answers exactly what `$null: true` answers, on both readings', async () => { // The anchor #5332 aligned every other surface to. Before the repair these // two spellings of one predicate differed on the MISSING reading alone. - for (const [driver, rows] of [[missing, MISSING_ROWS], [nulled, NULLED_ROWS]] as const) { - const viaNull = matcherIds(rows, { name: { $null: true } }); - const viaEq = matcherIds(rows, { name: { $eq: null } }); + for (const driver of [missing, nulled]) { + const viaNull = await liveIds(driver, { name: { $null: true } }); + const viaEq = await liveIds(driver, { name: { $eq: null } }); expect(viaEq).toEqual(viaNull); expect(viaEq).toEqual(['3']); } }); it('`$ne: null` is unmoved — it was already on the allowlist, and was already right', async () => { - await bothFaces(missing, MISSING_ROWS, { name: { $ne: null } }, ['1']); - await bothFaces(nulled, NULLED_ROWS, { name: { $ne: null } }, ['1']); + await livePath(missing, { name: { $ne: null } }, ['1']); + await livePath(nulled, { name: { $ne: null } }, ['1']); }); it('a REAL comparand keeps the answer it had on a missing key', async () => { // The guard exemption moved the no-value cells and only those: the arm // reaches the same verdict the guard did (`undefined != 'a'` is true). - await bothFaces(missing, MISSING_ROWS, { name: { $eq: 'a' } }, ['1']); - await bothFaces(missing, MISSING_ROWS, { name: { $eq: '' } }, []); - await bothFaces(missing, MISSING_ROWS, { name: { $eq: false } }, []); - await bothFaces(nulled, NULLED_ROWS, { name: { $eq: 'a' } }, ['1']); + await livePath(missing, { name: { $eq: 'a' } }, ['1']); + await livePath(missing, { name: { $eq: '' } }, []); + await livePath(missing, { name: { $eq: false } }, []); + await livePath(nulled, { name: { $eq: 'a' } }, ['1']); }); }); @@ -210,34 +218,34 @@ describe('[#13495] a `$between` bound that is null no longer stops bounding', () it('`[null, null]` does not match the VALUED row', async () => { // Was `['1','3']` on the matcher against `['3']` live: `'a' < null` and // `'a' > null` are BOTH false, so the exclusion test excluded nothing. - await bothFaces(nulled, NULLED_ROWS, { name: { $between: [null, null] } }, ['3']); + await livePath(nulled, { name: { $between: [null, null] } }, ['3']); }); it('`[null, null]` on the MISSING reading selects nothing, on both faces', async () => { - await bothFaces(missing, MISSING_ROWS, { name: { $between: [null, null] } }, []); + await livePath(missing, { name: { $between: [null, null] } }, []); }); it('a HALF-null bound is the same defect and the same repair', async () => { // Neither card named these: #13495 measured `[null, null]` only. A range // with one real end and one absent end is not a meaningful range, and both // faces now select nothing rather than everything. - await bothFaces(nulled, NULLED_ROWS, { name: { $between: [null, 'z'] } }, []); - await bothFaces(nulled, NULLED_ROWS, { name: { $between: ['a', null] } }, []); - await bothFaces(missing, MISSING_ROWS, { name: { $between: [null, 'z'] } }, []); - await bothFaces(missing, MISSING_ROWS, { name: { $between: ['a', null] } }, []); + await livePath(nulled, { name: { $between: [null, 'z'] } }, []); + await livePath(nulled, { name: { $between: ['a', null] } }, []); + await livePath(missing, { name: { $between: [null, 'z'] } }, []); + await livePath(missing, { name: { $between: ['a', null] } }, []); }); it('a null bound over a NUMERIC column does not match the zero row', async () => { // `0 >= null` is `true` — null coerces to 0 — so the numeric column is // where a comparison-only repair silently keeps the defect. - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [null, null] } }, ['3']); + await livePath(numeric, { n: { $between: [null, null] } }, ['3']); }); }); describe('[#13549] a null VALUE is not inside a well-formed bounded range', () => { it("the card's cell: a bounded `$between` excludes the null-valued row", async () => { // Was `['1','2','4']` on the matcher against `['1','2']` live. - await bothFaces(sweep, SWEEP_ROWS, { v: { $between: ['2026-07-01', '2026-07-15'] } }, ['1', '2']); + await livePath(sweep, { v: { $between: ['2026-07-01', '2026-07-15'] } }, ['1', '2']); }); it('the two readings of "no value" now agree with EACH OTHER', async () => { @@ -245,8 +253,8 @@ describe('[#13549] a null VALUE is not inside a well-formed bounded range', () = // matched the range while the same absence spelled as a missing key did // not, because only the second met the pre-switch guard. const bounded = { v: { $between: ['2026-07-01', '2026-07-28'] } }; - const withNullValue = matcherIds([{ id: 'x', v: null }], bounded); - const withMissingKey = matcherIds([{ id: 'x' }], bounded); + const withNullValue = await freshIds([{ id: 'x', v: null }], bounded); + const withMissingKey = await freshIds([{ id: 'x' }], bounded); expect(withNullValue).toEqual(withMissingKey); expect(withNullValue).toEqual([]); }); @@ -257,34 +265,34 @@ describe('[#13549] a null VALUE is not inside a well-formed bounded range', () = // so `null >= -1 && null <= 1` is true and the null-valued row stays // inside the range. Comparability is decided before the comparison, and // this cell is what holds that to the code. - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [-1, 1] } }, ['2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [0, 10] } }, ['1', '2']); + await livePath(numeric, { n: { $between: [-1, 1] } }, ['2']); + await livePath(numeric, { n: { $between: [0, 10] } }, ['1', '2']); }); }); describe('[#13494/#13495/#13549] the ordinary vocabulary is untouched', () => { it('a well-formed range over valued rows still selects the range', async () => { - await bothFaces(sweep, SWEEP_ROWS, { v: { $between: ['2026-07-01', '2026-07-28'] } }, ['1', '2', '3']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [1, 9] } }, ['1']); + await livePath(sweep, { v: { $between: ['2026-07-01', '2026-07-28'] } }, ['1', '2', '3']); + await livePath(numeric, { n: { $between: [1, 9] } }, ['1']); }); it('a range that excludes every valued row still selects nothing', async () => { - await bothFaces(sweep, SWEEP_ROWS, { v: { $between: ['2026-09-01', '2026-09-30'] } }, []); + await livePath(sweep, { v: { $between: ['2026-09-01', '2026-09-30'] } }, []); }); it('the range boundaries stay CLOSED on both ends', async () => { // `$between` is `$gte min` AND `$lte max` — what the live path compiles it // to. An off-by-one in the repair would show up here first. - await bothFaces(sweep, SWEEP_ROWS, { v: { $between: ['2026-07-01', '2026-07-01'] } }, ['1']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [5, 5] } }, ['1']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [0, 0] } }, ['2']); + await livePath(sweep, { v: { $between: ['2026-07-01', '2026-07-01'] } }, ['1']); + await livePath(numeric, { n: { $between: [5, 5] } }, ['1']); + await livePath(numeric, { n: { $between: [0, 0] } }, ['2']); }); it('`$null` and `$exists` are unmoved on both readings', async () => { - await bothFaces(missing, MISSING_ROWS, { name: { $null: true } }, ['3']); - await bothFaces(nulled, NULLED_ROWS, { name: { $null: true } }, ['3']); - await bothFaces(missing, MISSING_ROWS, { name: { $exists: true } }, ['1']); - await bothFaces(nulled, NULLED_ROWS, { name: { $exists: true } }, ['1']); + await livePath(missing, { name: { $null: true } }, ['3']); + await livePath(nulled, { name: { $null: true } }, ['3']); + await livePath(missing, { name: { $exists: true } }, ['1']); + await livePath(nulled, { name: { $exists: true } }, ['1']); }); }); @@ -293,10 +301,10 @@ describe('[#13553] a no-value row is not inside `$gt` / `$gte` / `$lt` / `$lte`' // Was `['1','2','3']` / `['2','3']` on the matcher against `['1','2']` / // `['2']` live. Row 3 (`n: null`) was answered greater than -1 AND less // than 1 at the same time, because `null` coerces to `0`. - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gte: -1 } }, ['1', '2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gt: -1 } }, ['1', '2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $lte: 1 } }, ['2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $lt: 1 } }, ['2']); + await livePath(numeric, { n: { $gte: -1 } }, ['1', '2']); + await livePath(numeric, { n: { $gt: -1 } }, ['1', '2']); + await livePath(numeric, { n: { $lte: 1 } }, ['2']); + await livePath(numeric, { n: { $lt: 1 } }, ['2']); }); it('THE DISCRIMINATOR — the row storing `0` stays IN, on every arm', async () => { @@ -305,10 +313,10 @@ describe('[#13553] a no-value row is not inside `$gt` / `$gte` / `$lt` / `$lte`' // that `null` coerces to `0` — would drop this row too and still turn the // four cells above green, because they happen not to distinguish them. // Here they do: the no-value row leaves and the zero row stays. - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gte: 0 } }, ['1', '2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $lte: 0 } }, ['2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gte: -0.5 } }, ['1', '2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $lt: 0.5 } }, ['2']); + await livePath(numeric, { n: { $gte: 0 } }, ['1', '2']); + await livePath(numeric, { n: { $lte: 0 } }, ['2']); + await livePath(numeric, { n: { $gte: -0.5 } }, ['1', '2']); + await livePath(numeric, { n: { $lt: 0.5 } }, ['2']); }); it('the two readings of "no value" agree with EACH OTHER on all four arms', async () => { @@ -318,8 +326,8 @@ describe('[#13553] a no-value row is not inside `$gt` / `$gte` / `$lt` / `$lte`' // one answer the ruling gives — EXCLUDE. for (const op of ['$gt', '$gte', '$lt', '$lte'] as const) { const bounded = { n: { [op]: 0 } }; - const withNullValue = matcherIds([{ id: 'x', n: null }], bounded); - const withMissingKey = matcherIds([{ id: 'x' }], bounded); + const withNullValue = await freshIds([{ id: 'x', n: null }], bounded); + const withMissingKey = await freshIds([{ id: 'x' }], bounded); expect({ op, ids: withNullValue }).toEqual({ op, ids: withMissingKey }); expect({ op, ids: withNullValue }).toEqual({ op, ids: [] }); } @@ -332,29 +340,29 @@ describe('[#13553] a no-value row is not inside `$gt` / `$gte` / `$lt` / `$lte`' // row 3 — `$between` excluded the null-valued row while `$gte`/`$lte` // admitted it. One face, two answers, one query. for (const [min, max] of [[-1, 1], [0, 10], [-5, 5]] as const) { - const viaBetween = matcherIds(NUMERIC_ROWS, { n: { $between: [min, max] } }); - const viaBounds = matcherIds(NUMERIC_ROWS, { n: { $gte: min, $lte: max } }); + const viaBetween = await liveIds(numeric, { n: { $between: [min, max] } }); + const viaBounds = await liveIds(numeric, { n: { $gte: min, $lte: max } }); expect({ min, max, ids: viaBetween }).toEqual({ min, max, ids: viaBounds }); } - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gte: -1, $lte: 1 } }, ['2']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [-1, 1] } }, ['2']); + await livePath(numeric, { n: { $gte: -1, $lte: 1 } }, ['2']); + await livePath(numeric, { n: { $between: [-1, 1] } }, ['2']); }); it('the VALUED rows keep every answer they had — the repair moved no-value cells only', async () => { - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gt: 0 } }, ['1']); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gt: 5 } }, []); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $lt: 0 } }, []); - await bothFaces(numeric, NUMERIC_ROWS, { n: { $gte: 5 } }, ['1']); + await livePath(numeric, { n: { $gt: 0 } }, ['1']); + await livePath(numeric, { n: { $gt: 5 } }, []); + await livePath(numeric, { n: { $lt: 0 } }, []); + await livePath(numeric, { n: { $gte: 5 } }, ['1']); }); it('the STRING fixture is unmoved — it agreed before, and still does', async () => { // These four cells are why three cards passed over the defect: on strings // the comparison against a null is false in both directions already. They // are asserted so the repair is measured NOT to have moved them. - await bothFaces(sweep, SWEEP_ROWS, { v: { $gte: '2026-07-01' } }, ['1', '2', '3']); - await bothFaces(sweep, SWEEP_ROWS, { v: { $gt: '2026-07-01' } }, ['2', '3']); - await bothFaces(sweep, SWEEP_ROWS, { v: { $lte: '2026-07-15' } }, ['1', '2']); - await bothFaces(sweep, SWEEP_ROWS, { v: { $lt: '2026-07-15' } }, ['1']); + await livePath(sweep, { v: { $gte: '2026-07-01' } }, ['1', '2', '3']); + await livePath(sweep, { v: { $gt: '2026-07-01' } }, ['2', '3']); + await livePath(sweep, { v: { $lte: '2026-07-15' } }, ['1', '2']); + await livePath(sweep, { v: { $lt: '2026-07-15' } }, ['1']); }); it('⛔ `$between` is NOT in the ordering set — its degenerate cell is unmoved', async () => { @@ -362,7 +370,7 @@ describe('[#13553] a no-value row is not inside `$gt` / `$gte` / `$lt` / `$lte`' // OPPOSITE one: the range whose both ends are no value selects the // no-value rows (#13495). Adding `$between` to the guarded set would // return false before `valueWithinRange` ran and silently move this cell. - await bothFaces(numeric, NUMERIC_ROWS, { n: { $between: [null, null] } }, ['3']); - await bothFaces(nulled, NULLED_ROWS, { name: { $between: [null, null] } }, ['3']); + await livePath(numeric, { n: { $between: [null, null] } }, ['3']); + await livePath(nulled, { name: { $between: [null, null] } }, ['3']); }); }); diff --git a/packages/drivers/driver-memory/src/memory-matcher-or-semantics.test.ts b/packages/drivers/driver-memory/src/memory-matcher-or-semantics.test.ts deleted file mode 100644 index 29be0e348b3..00000000000 --- a/packages/drivers/driver-memory/src/memory-matcher-or-semantics.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Filter logical-combinator conformance for the in-memory matcher. - * - * The cases come from `@objectstack/spec/data` so this backend, `driver-sql`, - * `formula`'s `matchesFilterCondition` and `read-scope-sql` are all held to one - * standard — see `filter-logic-conformance.ts` for why that standard exists - * (#3774). Adding a case there adds it to all four at once; that is the point. - */ - -import { describe, it, expect } from 'vitest'; -import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data'; - -import { match } from './memory-matcher.js'; - -describe('memory-matcher — filter logic conformance', () => { - for (const c of FILTER_LOGIC_CASES) { - it(c.name, () => { - const got = FILTER_LOGIC_ROWS.filter((r) => match(r, c.filter)).map((r) => r.id); - expect(got, c.note).toEqual(c.expected); - }); - } -}); diff --git a/packages/drivers/driver-memory/src/memory-matcher-scalar-comparand-array-value.test.ts b/packages/drivers/driver-memory/src/memory-matcher-scalar-comparand-array-value.test.ts index 033f0796067..5a25d97c490 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-scalar-comparand-array-value.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-scalar-comparand-array-value.test.ts @@ -52,12 +52,18 @@ * ⚠️ One level only, measured rather than reasoned: mingo does not descend into * a NESTED array, so neither does this face — `[['a']]` does not match `'a'` on * either face, and that row is in the fixture to hold it. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED; this file keeps its name and holds the LIVE path alone. The + * matrix's cases were already asserted on the live path, case by case; the + * matcher's own assertions — the card's three rows, the OR-over-elements + * property and `$ne`'s per-row complement — are asked of `find()` now, a row at + * a time, because they are statements about the one arm both faces shared. */ import { describe, it, expect, beforeAll } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; const TABLE = 'array_value_equality'; @@ -141,11 +147,20 @@ const CASES: ReadonlyArray<{ const sorted = (ids: readonly string[]): string[] => [...ids].sort((x, y) => x.localeCompare(y)); -/** The reference face: `memory-matcher.ts`, one record at a time. */ -const referenceIds = (where: Record): string[] => - sorted(ROWS.filter((r) => match(r, where)).map((r) => String(r.id))); +/** + * Does the LIVE path select one row storing `tags`? — the matcher's + * `match({ tags }, where)`, asked of `find()` over a table holding that row + * alone. + */ +async function liveRowMatches(tags: unknown, where: Record): Promise { + const one = new InMemoryDriver({ persistence: false }); + await one.connect(); + await one.syncSchema(TABLE, { fields: { id: { type: 'text', name: 'id' }, tags: { type: 'text', name: 'tags' } } } as never); + await one.create(TABLE, { id: 'row', tags }); + return ((await one.find(TABLE, { where } as never)) as unknown[]).length === 1; +} -describe('[#16838] a scalar comparand against a stored array — both faces, one process', () => { +describe('[#16838] a scalar comparand against a stored array — the live path, one process', () => { let driver: InMemoryDriver; /** The live face: `InMemoryDriver.find`, through `normalizeFilterCondition` and mingo. */ let liveIds: (where: Record) => Promise; @@ -179,28 +194,30 @@ describe('[#16838] a scalar comparand against a stored array — both faces, one it(`${c.name} — the LIVE query path`, async () => { expect(await liveIds(c.where)).toEqual(sorted(c.expected)); }); - - it(`${c.name} — the REFERENCE matcher`, () => { - expect(referenceIds(c.where)).toEqual(sorted(c.expected)); - }); } - it('both faces answer the whole matrix identically', async () => { - for (const c of CASES) { - expect(await liveIds(c.where), `${c.name}: the live query path and the reference matcher disagree`) - .toEqual(referenceIds(c.where)); - } + /** + * This was "both faces answer the whole matrix identically" — the live path + * against the reference matcher. With the matcher retired (#5930 step 4) the + * whole matrix is held, in one assertion, to the expectations both faces were + * written against, so it survives the per-case loop above being edited. + */ + it('the live path answers the whole matrix as written', async () => { + const got: Record = {}; + for (const c of CASES) got[c.name] = await liveIds(c.where); + expect(got).toEqual(Object.fromEntries(CASES.map((c) => [c.name, sorted(c.expected)]))); }); /** - * The card's three rows, spelled exactly as it measured them — `match()` - * directly, one row, one filter — so the numbers in the card and the numbers - * here can be compared without reading the fixture above. + * The card's three rows, spelled as it measured them — one row, one filter — + * so the numbers in the card and the numbers here can be compared without + * reading the fixture above. The card measured them on the reference matcher + * (`match()`, retired); they are asked of the live path here. */ - it("the card's own three rows, on the reference matcher", () => { - expect(match({ tags: ['a', 'b'] }, { tags: 'a' })).toBe(true); // was false — the missing membership - expect(match({ tags: ['a', 'b'] }, { tags: 'a,b' })).toBe(false); // was true — the false positive - expect(match({ tags: ['a'] }, { tags: 'a' })).toBe(true); // the firing control, unmoved + it("the card's own three rows, one row at a time", async () => { + expect(await liveRowMatches(['a', 'b'], { tags: 'a' })).toBe(true); // was false on the matcher — the missing membership + expect(await liveRowMatches(['a', 'b'], { tags: 'a,b' })).toBe(false); // was true on the matcher — the false positive + expect(await liveRowMatches(['a'], { tags: 'a' })).toBe(true); // the firing control, unmoved }); /** @@ -209,7 +226,7 @@ describe('[#16838] a scalar comparand against a stored array — both faces, one * reintroduces any whole-array conversion breaks this for every case at once, * not only for the two the card happened to measure. */ - it('a stored array answers the OR of the answers its ELEMENTS would give', () => { + it('a stored array answers the OR of the answers its ELEMENTS would give', async () => { for (const c of CASES) { if (c.polarity !== 'equality') continue; for (const row of ROWS) { @@ -217,8 +234,11 @@ describe('[#16838] a scalar comparand against a stored array — both faces, one if (!Array.isArray(stored)) continue; // One level only: an element that is itself an array is not descended // into, on either face. - const elementwise = stored.some((element) => !Array.isArray(element) && match({ tags: element }, c.where)); - expect(match(row, c.where), `${c.name} / ${String(row.id)}: not the OR over its elements`) + let elementwise = false; + for (const element of stored) { + if (!Array.isArray(element) && (await liveRowMatches(element, c.where))) elementwise = true; + } + expect(await liveRowMatches(stored, c.where), `${c.name} / ${String(row.id)}: not the OR over its elements`) .toBe(elementwise); } } @@ -231,19 +251,18 @@ describe('[#16838] a scalar comparand against a stored array — both faces, one * fixed one direction only would leave a stored array both matching and not * matching the same comparand. */ - it('$ne is the per-row complement of $eq, arrays included', () => { + it('$ne is the per-row complement of $eq, arrays included', async () => { + const all = sorted(ROWS.map((r) => String(r.id))); for (const comparand of ['a', 'b', 'z', 'a,b', null]) { - for (const row of ROWS) { - expect( - match(row, { tags: { $ne: comparand } }), - `${String(row.id)} / ${JSON.stringify(comparand)}: $ne is not the complement of $eq`, - ).toBe(!match(row, { tags: { $eq: comparand } })); - } + const eq = await liveIds({ tags: { $eq: comparand } }); + const ne = await liveIds({ tags: { $ne: comparand } }); + expect(ne, `${JSON.stringify(comparand)}: $ne is not the per-row complement of $eq`) + .toEqual(all.filter((id) => !eq.includes(id))); } }); - it('a NESTED array is not descended into — one level, on both faces', async () => { - expect(match({ tags: [['a']] }, { tags: 'a' })).toBe(false); + it('a NESTED array is not descended into — one level', async () => { + expect(await liveRowMatches([['a']], { tags: 'a' })).toBe(false); expect(await liveIds({ tags: 'a' })).not.toContain('array-nested'); }); }); diff --git a/packages/drivers/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts deleted file mode 100644 index ffd206ffdfb..00000000000 --- a/packages/drivers/driver-memory/src/memory-matcher.ts +++ /dev/null @@ -1,704 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - - -/** - * Simple In-Memory Query Matcher - * - * Implements a subset of the ObjectStack Filter Protocol (MongoDB-compatible) - * for evaluating conditions against in-memory JavaScript objects. - * - * It answers a boolean for every filter it accepts. What it REFUSES — throwing - * `INVALID_FILTER` / 400 instead of answering — is decided by - * `assertFilterConditionShape` in `filter-refusal.ts`, the same gate - * `InMemoryDriver.find` runs, so this face and the live query path cannot - * disagree about which filters are evaluable: `{ field: {} }` (#5240), an - * operator outside the declared vocabulary (#5324), and a `$between` whose - * comparand is not `[min, max]` (#5328). - */ - -// [#5659] The Filter Protocol's boolean identity reduction, shared with -// driver-sql, driver-mongodb and the flow linter. See `evaluate` for why a -// record-at-a-time matcher consults a record-INDEPENDENT verdict first. -// [#6520] `$icontains`' ASCII-only fold, defined once in the spec and shared by -// every JS evaluation face — see `foldAsciiCase`'s docblock for why it is not -// re-implemented per package. -// [#7536] `$like`/`$ilike`'s pattern language, likewise the spec's one -// definition — shared with this package's query path, `formula`, and the SQL -// family's emitters. -import { reduceFilterVerdict, asciiCaseInsensitiveContains, matchesLikePattern } from '@objectstack/spec/data'; -// [#20444] `$empty`'s value-level half, the spec's one definition. This matcher -// holds no field declarations, so it takes the reading the spec gives such a -// face: null, `undefined`, `''` and `[]` are empty. -import { isEmptyFilterValue } from '@objectstack/spec/data'; - -import { assertFilterConditionShape } from './filter-refusal.js'; - -type RecordType = Record; - -/** - * matches - Check if a record matches a filter criteria - * @param record The data record to check - * @param filter The filter condition (where clause) - */ -export function match(record: RecordType, filter: any): boolean { - // [#5240] Shape first, then evaluate. The refusal is raised by a walk of the - // WHOLE tree rather than from inside the field loop below, because that loop - // short-circuits (`every`/`some`, and the loop returns on its first failing - // key) — a refusal raised mid-evaluation would fire or not fire depending on - // the RECORD being tested. Evaluation below is untouched. - // - // [#5324/#5328] The walk itself now lives in `filter-refusal.ts` and is the - // SAME function `InMemoryDriver.find` runs before handing a filter to mingo. - // It used to be a copy that refused one shape; a copy is how this face and - // the live one came to answer a malformed `$between` with EVERY row and NO - // row respectively. - assertFilterConditionShape(filter, 'filter'); - return evaluate(record, filter); -} - -/** - * [#5659] The boolean identities come from the SHARED reduction; everything - * record-dependent is still decided here. - * - * This matcher used to answer `{ $and: [] }`, `{ $or: [] }`, `{}` and - * `{ $not: {} }` as a by-product of the JS it happens to be written in — - * `every` over an empty array is `true`, `some` is `false`, an empty node falls - * through to the trailing `return true`. Those four answers are not an accident - * of `Array.prototype`, they are a ruling (#5322/#5134) that `driver-sql` and - * `driver-mongodb` each spell out in a `reduceFilterNode` of their own, and - * "emergent from the evaluator" is not a place a ruling can be read from or - * held to. Asking the shared predicate makes this backend's identity answers - * the same OBJECT as theirs rather than a third agreeing coincidence. - * - * It is a pre-pass and not a rewrite of the loops below, because the verdict is - * record-independent by construction and the evaluation is not: `'clause'` — - * the answer for every filter that carries a real predicate — falls straight - * through to the untouched code. And a `'true'` verdict can only be reached by - * a filter whose keys are ALL combinators that resolved (a field key always - * contributes `'clause'`), so returning early on it skips no field constraint. - */ -function evaluate(record: RecordType, filter: any): boolean { - if (!filter || Object.keys(filter).length === 0) return true; - - const verdict = reduceFilterVerdict(filter); - if (verdict === 'true') return true; - if (verdict === 'false') return false; - - // 1. Handle Top-Level Logical Operators ($and, $or, $not) - // These usually appear at the root or nested. - - // $and: [ { ... }, { ... } ] - if (Array.isArray(filter.$and)) { - if (!filter.$and.every((f: any) => evaluate(record, f))) { - return false; - } - } - - // $or: [ { ... }, { ... } ] - if (Array.isArray(filter.$or)) { - if (!filter.$or.some((f: any) => evaluate(record, f))) { - return false; - } - } - - // $not: { ... } - if (filter.$not) { - if (evaluate(record, filter.$not)) { - return false; - } - } - - // 2. Iterate over field constraints - for (const key of Object.keys(filter)) { - // Skip logical operators we already handled (or future ones) - if (key.startsWith('$')) continue; - - const condition = filter[key]; - const value = getValueByPath(record, key); - - if (!checkCondition(value, condition)) { - return false; - } - } - - return true; -} - -/** - * Access nested properties via dot-notation (e.g. "user.name") - */ -export function getValueByPath(obj: any, path: string): any { - if (!path.includes('.')) return obj[path]; - return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj); -} - -/** - * [#13166] Does a row whose field has NO VALUE satisfy this operator? - * - * The platform's settled answer for the negation-carrying operators is YES — - * the INCLUDE direction. #5146 made `$not` NULL-safe and ruled THIS matcher's - * answer canonical (`driver-sql` was rewritten to match it, not the other way - * round); #5298 option A extended the same answer to the non-negated `$ne` / - * `$nin` / `$notContains`. A ruling on 2026-08-10 briefly reversed it in favour - * of SQL's native three-valued logic and was WITHDRAWN the same day, once the - * cross-backend cost had been priced; include was re-affirmed. - * - * Ten other surfaces already answer this way, which is why aligning this one - * moves nothing on the SQL side: the four SQL compilers reach it through - * `nullSafeNegative` and their four copies of `nullValueSatisfiesOperator` - * (`$nin` → true, `$notContains` → true), `formula`'s `matchesFilterCondition` - * answers it directly, and `driver-mongodb` passes `$nin` through and compiles - * `$notContains` to `{ $not: { $regex } }` — both of which match a missing or - * null field in MongoDB. - * - * ⚠️ `$exists` is deliberately NOT here. It is not tolerant of an absent value, - * it is a question ABOUT one, and its own cell is a different, still-open - * divergence (#13195): this package's live mingo path and `driver-mongodb` read - * it as key-presence where this matcher and `formula` read it as has-value. - * `$null` is excluded for the same reason. - * - * Named rather than inlined because BOTH causes of the #13166 divergence have - * to answer the same question, and they sit in different places — a - * pre-switch guard that only sees `undefined`, and the `$notContains` arm that - * only sees `null`. Two spellings of one ruling is how they came apart. - */ -function noValueSatisfiesNegation(op: string): boolean { - return op === '$ne' || op === '$nin' || op === '$notContains'; -} - -/** - * [#13553] The four ORDERING operators — the ones whose arm answers a - * RELATIONAL comparison between the stored value and a comparand. - * - * A set, and consulted from the pre-switch guard, because what it selects is a - * RULING and not an implementation detail: a row whose field has NO VALUE is - * not inside `$gt` / `$gte` / `$lt` / `$lte`. Two independent authorities - * decide that and agree — this package's live mingo path excludes such a row - * (runtime truth, measured), and the platform's settled reading admits a - * no-value row only for the operators that carry a negation (`$ne` / `$nin` / - * `$notContains`, #5298 option A, re-affirmed 2026-08-10; see - * {@link noValueSatisfiesNegation}, whose membership is this set's exact - * complement on this question). - * - * ⚠️ `$between` is deliberately NOT a member, though it is a relational - * comparison too. It decides the no-value case itself, in - * {@link valueWithinRange}, and its answer is not this one: the degenerate - * range whose BOTH ends are no value SELECTS the no-value rows. Adding - * `$between` here would return `false` before that function ran and silently - * move a cell #13495 ruled. - */ -const ORDERING_OPERATORS: ReadonlySet = new Set(['$gt', '$gte', '$lt', '$lte']); - -/** - * [#13495/#13549] Is `value` inside the closed range `[min, max]`? - * - * `$between` is the conjunction of `$gte min` and `$lte max`. That is not an - * interpretation of the operator, it is what this package's LIVE path compiles - * it to — `memory-driver.ts`'s `$between` arm writes `$gte`/`$lte` and hands - * them to mingo — so this face has to compute the same predicate. - * - * The arm used to be written the other way round, as an EXCLUSION test: - * `if (value < min || value > max) return false`. The two are equivalent only - * while both comparisons are MEANINGFUL, and against a null they are not: JS - * answers a relational comparison between a null and a string `false` in both - * directions (`null` coerces to `0`, the string to `NaN`). Neither disjunct - * fired, nothing returned false, and a bounded range silently stopped bounding - * — the WIDENING direction, which on an RLS read scope is a permission bypass - * rather than a degraded filter (#3948, and the identical notes this file - * already carries for the malformed `$between` shape and for `$null`). Two - * cards measured the two ways in: - * - * - #13495, the COMPARAND axis: `{$between: [null, null]}` matched every - * VALUED row, because `'a' < null` and `'a' > null` are both false. - * - #13549, the VALUE axis: a null-valued row sat inside a well-formed bounded - * range, because `null < '2026-07-01'` and `null > '2026-07-15'` are both - * false. - * - * ⚠️ Flipping the test to `!(value >= min && value <= max)` repairs the - * string cells and NOT the numeric ones — measured, not reasoned: `null` - * coerces to `0`, so `null >= -1 && null <= 1` is `true` and a null-valued row - * stays inside a numeric range while looking repaired on every string fixture - * the cards used. Comparability has to be decided BEFORE the comparison, which - * is what this function does: - * - * - a no-value row is not inside a range with a real bound, and a valued row is - * not inside a range whose bound is no value — that comparison is not - * meaningful, and JS answers it anyway; - * - the degenerate range whose BOTH ends are no value selects the no-value - * rows, and only those. - * - * Every one of those answers is the one this package's live mingo path already - * gives, cell for cell — the tie-break this file has used since #5240, #5324, - * #5328 and #5374, each of which closed a "this face and the live one answer - * one filter two ways" divergence. No new reading of "no value" is asserted - * here: what a stored null MEANS is #13357's question and it is the - * maintainer's. - */ -function valueWithinRange(value: any, min: any, max: any): boolean { - const valueIsNoValue = value === null || value === undefined; - const minIsNoValue = min === null || min === undefined; - const maxIsNoValue = max === null || max === undefined; - - // Mixed: one side is a value and the other is an absence. Not comparable. - if (valueIsNoValue !== minIsNoValue || valueIsNoValue !== maxIsNoValue) return false; - - // Both ends and the value are absences: the degenerate null-to-null range. - if (valueIsNoValue) return true; - - return value >= min && value <= max; -} - -/** - * [#16810] Equality for a comparand this face compares by VALUE. - * - * `==` is kept for everything it was chosen for — the reasons the arm below - * records, "undefined/null mismatch" and "string/number coercion", both of - * which are about PRIMITIVES and both of which still hold. What `==` cannot do - * is the one type the contract's accepted comparand set names that is not a - * primitive: between two objects `==` compares REFERENCES, so a `Date` - * comparand matched only the very object it was handed and never an equal - * instant. - * - * The rule is `@objectstack/formula`'s `looseEq`, arm for arm — the sibling - * record-at-a-time matcher this face's conformance suites are held against, so - * the two agree by construction rather than by coincidence: - * - * - Date vs Date — equal when their time values are equal; - * - Date vs string / number — the counterpart is read as an instant first, - * which is the case that actually reaches a stored row here: this driver - * canonicalises a declared `datetime` to ISO TEXT on write - * (`memory-temporal.ts`, ADR-0053 D-B1), so a `Date` comparand meets a - * STRING in storage and `==` stringified the Date to `"Wed Jan 01 2026 …"`, - * which no ISO value equals; - * - everything else — the unchanged `==`. - * - * An Invalid Date has no time value (`NaN`), so it equals nothing, itself - * included — JS `Date` convention, `formula`'s answer, and ADR-0053 D-F1's - * reading that an Invalid Date has no canonical text. - * - * ⚠️ This function decides ONE stored value against one comparand. A stored - * ARRAY is not one value, and {@link comparandEquals} — the entry every arm - * calls — decides that case before reaching here. ⛔ Do not call this directly - * from an arm: `==` against an array is exactly the conversion #16838 removed. - */ -function singleValueEquals(value: any, condition: any): boolean { - if (value instanceof Date && condition instanceof Date) { - return value.getTime() === condition.getTime(); - } - if (value instanceof Date && (typeof condition === 'string' || typeof condition === 'number')) { - return value.getTime() === new Date(condition).getTime(); - } - if (condition instanceof Date && (typeof value === 'string' || typeof value === 'number')) { - return new Date(value).getTime() === condition.getTime(); - } - // Loose equality to handle undefined/null mismatch or string/number coercion if desired. - // But stick to == for JS loose equality which is often convenient in weakly typed queries. - return value == condition; -} - -/** - * [#16838] Equality as the arms ask it: one comparand against a stored value - * that may be an ARRAY. - * - * ## What `==` did to a stored array, and in which direction - * - * The arm used to end in `value == condition` for every stored value. `==` - * against an array converts it to a PRIMITIVE — `['a','b']` becomes the string - * `"a,b"` — and that one conversion made this face disagree with the live query - * path in BOTH directions on the same row: - * - * | filter | stored | this face, BEFORE | the live path (`InMemoryDriver.find` → mingo) | - * |---|---|---|---| - * | `{ tags: 'a' }` | `['a','b']` | no row | the row | - * | `{ tags: 'a,b' }` | `['a','b']` | the row | no row | - * - * The second is the sharper one — a FALSE POSITIVE, a filter written to narrow - * returning a row it should not, which on an RLS read scope is a permission - * concern rather than a degraded filter (#3948, and the identical notes this - * file carries for `$null`, for the malformed `$between` shape and for an - * unknown operator). The first is fail-open in the other direction and just as - * silent: `if (!rows.length)` cannot tell "genuinely none" from "the predicate - * asked the wrong question". - * - * ## The rule, and why it is not a free choice - * - * A stored array is read as its ELEMENTS, and each of them is asked the - * question {@link singleValueEquals} asks of a scalar. So the answer for a row - * storing an array is the OR of the answers for the rows storing its elements — - * a property `memory-matcher-scalar-comparand-array-value.test.ts` asserts over - * its whole matrix rather than case by case. - * - * That is MongoDB's array semantics and therefore mingo's, which is this file's - * standing tie-break (#5240, #5324, #5328, #5374): the live path is what users - * of this package actually run, so the reference face converges on it cell for - * cell instead of inventing a third reading. The string-join reading was never - * a reading — no author writes `"a,b"` meaning `['a','b']`. - * - * ⛔ REFUSING the shape, the way #16810 refused an array COMPARAND, is not - * available here and the difference is structural, not a preference: a refusal - * is raised from the FILTER by `assertFilterConditionShape`, once, before any - * row is seen. This cell is a property of the stored ROW, so refusing it would - * fire or not fire depending on the data — the exact record-dependence #5240 - * moved the shape walk out of the field loop to avoid. - * - * ⚠️ ONE level, measured and not reasoned: mingo does not descend into a nested - * array, so an element that is itself an array matches no scalar comparand - * here either (`[['a']]` against `'a'` is no row on both faces — it used to be - * a match on this one, by the same join). - * - * ⚠️ An array COMPARAND does not reach this composition. It is refused at the - * shape gate (#16810) and floored again at the top of {@link checkCondition}; - * if a direct caller gets one here anyway it keeps the answer it had, so this - * change cannot be read as this package growing array-equality semantics on the - * comparand side — the cell #16810 declined to invent. - */ -function comparandEquals(value: any, condition: any): boolean { - if (Array.isArray(value) && !Array.isArray(condition)) { - return value.some((element) => !Array.isArray(element) && singleValueEquals(element, condition)); - } - return singleValueEquals(value, condition); -} - -/** - * Evaluate a specific condition against a value - */ -function checkCondition(value: any, condition: any): boolean { - // [#16810] An ARRAY comparand is REFUSED, by `assertFilterConditionShape` in - // `filter-refusal.ts` — the one gate every face of this package runs before - // it evaluates anything, so the live query path and this matcher cannot - // answer it differently. It used to fall into the equality arm below and be - // compared by REFERENCE, which no deep-equal array satisfies; mingo, one - // file away, deep-equalled the same filter and returned the row. - // - // This arm is the totality floor for a DIRECT call that skips the gate, the - // same role `$between`'s surviving `Array.isArray` guard plays below and the - // same answer `@objectstack/formula`'s matcher gives ("a bare array value is - // not a valid field spec"). ⛔ It must not be deleted in favour of falling - // through: the operator-object arm further down would then read `['a','b']` - // as a key bag and JSON-compare it, inventing the array-equality semantics - // this cell was ruled AGAINST. - if (Array.isArray(condition)) return false; - - // Case A: Implicit Equality (e.g. status: 'active') - // If condition is a primitive or Date (exact match), treat as equality. - // - // [#16810] `Date` stays in this arm — the contract's accepted comparand set - // (`ACCEPTED_FILTER_COMPARAND_TYPES`) names it, and the conformance table - // requires that "a Date comparand must pass the door and execute - // everywhere" — but the comparison is {@link comparandEquals}, which - // compares an instant rather than an object identity. - if ( - typeof condition !== 'object' || - condition === null || - condition instanceof Date - ) { - return comparandEquals(value, condition); - } - - // Case B: Operator Object (e.g. { $gt: 10, $lt: 20 }) - const keys = Object.keys(condition); - const isOperatorObject = keys.some(k => k.startsWith('$')); - - if (!isOperatorObject) { - // It's just a nested object comparison or implicit equality against an object - // Simplistic check: - // [#5240] `condition` is never `{}` here — the caller refuses the - // zero-operator constraint before this point. That matters, because - // this arm answering `false` for `{}` was the whole of this backend's - // "FALSE" verdict on the shape: an accident of structural equality, not - // a semantic ruling anyone had made. - return JSON.stringify(value) === JSON.stringify(condition); - } - - // Iterate operators - for (const op of keys) { - const target = condition[op]; - - // Handle undefined values - // - // [#13166] The allowlist is {@link noValueSatisfiesNegation} plus the - // two operators that are ABOUT the absence rather than tolerant of it - // (`$exists` / `$null`), and it used to name `$ne` alone out of the - // three negative ones. That is why a MISSING key short-circuited to "no - // match" for `$nin` and `$notContains` before their arms ever ran — one - // of the two independent causes of the divergence #13166 measured, and - // the only one this guard can reach. - // - // [#13494] `$eq` joins them, and for the SAME reason its complement - // `$ne` was already here: its arm decides the no-value case itself, - // in one place, with the loose `!=` that reads `undefined` and `null` - // as one absence. This guard was deciding it FIRST and differently, so - // one operator answered the two readings of "no value" two ways — a - // NULLED row reached the arm and matched `$eq: null`, a MISSING row - // short-circuited to "no match" before the arm ever ran. #5332 ruled - // that `$eq: null` IS the null predicate, and every other surface that - // can express the MISSING reading already answers it so — including - // this package's own live mingo path, measured: `['3']` where this - // face said `[]`. - // - // A non-null comparand keeps the answer it had, because the arm - // reaches the same verdict the guard did: `undefined != 'a'` is true, - // so the row is excluded one line further down. - // - // ⚠️ `$eq` ONLY, deliberately. The exemption is written over the - // OPERATOR and not over "the comparand is null", because the latter - // spelling would have moved `$in: [null]` / `$nin: [null]` with it — - // and those were #13357's cells, `needs-user-decision`, held for the - // maintainer when this landed. They are measured byte-identical - // across this change. - // - // ⛔ They are NOT held any more, and a reader arriving here must not - // escalate them a second time: the maintainer ruled them on 2026-08-31 - // (option C, #13357) and the shapes are REFUSED at the contract's - // validation entrance (`assertListComparandShapes`) — the same door the - // `$gte: null` note below records for the ordering position — with the - // negative pin `memory-null-list-member-unreachable.test.ts`, and the - // sibling header in `memory-matcher-null-value-and-comparand.test.ts` - // already says so in the past tense. - // - // ⛔ That ruling is NOT a reason to re-spell this exemption over "the - // comparand is null". The reason above is unchanged: a rule written - // over the VALUE would still reach arms whose no-value answer is ruled - // elsewhere. Only the cells' STATE moved — refused at the door, rather - // than held for a ruling. - // [#20444] `$empty` is about the absence too, so its arm answers the - // MISSING reading itself — `undefined` is empty, like `null`. - if (value === undefined && op !== '$exists' && op !== '$null' && op !== '$eq' - && op !== '$empty' && !noValueSatisfiesNegation(op)) { - return false; - } - - // [#13553] The OTHER reading of "no value" — the key is present and - // holds `null` — on the four {@link ORDERING_OPERATORS}. It is decided - // here, beside the reading above, because the two readings have to land - // on ONE answer and this file's recurring defect is that they do not: - // the guard above sees only `undefined`, so a stored `null` used to - // reach the arm and be COMPARED. - // - // ⚠️ Being compared is the whole defect, and it is measured rather than - // reasoned: JS coerces `null` to `0` under a relational operator, so - // `null >= -1` is a true comparison between two NUMBERS and the - // no-value row lands inside the bound. On a STRING comparand the same - // line looks correct — `null >= '2026-07-01'` compares `0` against - // `NaN` and is false — which is why every fixture in #13494, #13495 and - // #13549 showed these four arms healthy. The repair is the one - // {@link valueWithinRange} landed for `$between`: comparability is - // decided BEFORE the comparison, never by it. - // - // Written over the OPERATOR, like the `$eq` exemption above and for the - // same reason — a rule spelled over "the value is null" alone would - // reach arms whose no-value answer is ruled elsewhere. - // - // ⛔ A no-value COMPARAND is excluded from this guard, deliberately. - // When #13553 landed, `$gt: null` was the one null-comparand position - // the contract still ACCEPTED (measured at `parseFilterAST`): the - // 2026-08-31 ruling had refused the three siblings — `$in` / `$nin` - // null members and `$between`'s null endpoints (#13357) — and #5332's - // landing had recorded this one in writing as "no ruling covers", so - // deciding it here would have picked a camp the platform declined to - // pick. That reason is gone: ruled 2026-09-01 (option A, #14080), the - // shape is REFUSED at the contract's validation entrance - // (`assertListComparandShapes`, inside `parseFilterAST` and at the - // engine seam), the same door and envelope as its siblings. These - // cells are now constructively unreachable through the compile face, - // and the exclusion stays for the ruling's own reason — ⛔「不单独修 - // matcher(死代码)」— with no ordering-vs-null semantics defined - // anywhere. Negative pin: - // `memory-null-ordering-comparand-unreachable.test.ts`. A direct - // caller that skips the compile face meets only this package's own - // `assertFilterConditionShape`, which deliberately does not carry the - // rule (⛔ 不做跨后端对齐工程) — the honest boundary, not a cell for an - // arm to decide. - if (value === null && ORDERING_OPERATORS.has(op) - && target !== null && target !== undefined) { - return false; - } - - switch (op) { - // [#16810] `$eq` / `$ne` are the operator spelling of the implicit - // comparand above, so they take the same equality — otherwise one - // predicate would answer two ways depending on which spelling the - // author used, on the very type (`Date`) the accepted comparand set - // names. An ARRAY target never reaches either arm: the shape gate - // refuses it for both spellings. - case '$eq': - if (!comparandEquals(value, target)) return false; - break; - case '$ne': - if (comparandEquals(value, target)) return false; - break; - - // Numeric / Date - case '$gt': - if (!(value > target)) return false; - break; - case '$gte': - if (!(value >= target)) return false; - break; - case '$lt': - if (!(value < target)) return false; - break; - case '$lte': - if (!(value <= target)) return false; - break; - case '$between': - // [#5328] `target` is a two-element array — the shape gate refused - // anything else before evaluation started. The `Array.isArray` - // guard stays as the totality floor for a direct call, but it is - // no longer this face's ANSWER to a malformed range: it used to - // skip the comparison entirely, which meant "matches EVERY row" - // — the opposite of what the live query path silently answered. - // - // [#13495/#13549] The comparison is {@link valueWithinRange} - // now, and no longer an EXCLUSION test spelled with `<` and - // `>`. Against a null comparand or a null value that test was - // false in BOTH directions, so nothing returned false and the - // range stopped bounding — the same widening this arm's note - // above records for the malformed SHAPE. - if (Array.isArray(target) && !valueWithinRange(value, target[0], target[1])) return false; - break; - - // Sets - case '$in': - if (!Array.isArray(target) || !target.includes(value)) return false; - break; - case '$nin': - if (Array.isArray(target) && target.includes(value)) return false; - break; - - // Existence - case '$exists': - const exists = value !== undefined && value !== null; - if (exists !== !!target) return false; - break; - - // Strings - case '$contains': - if (typeof value !== 'string' || !value.includes(target)) return false; - break; - case '$notContains': - // [#13166] A row with NO VALUE satisfies this — the second and - // INDEPENDENT cause of the same divergence. The guard above - // cannot reach this one: it only fires for `undefined`, so a - // field that is present and `null` arrived here and failed on - // `typeof value !== 'string'` — the TYPE test, not the - // predicate. Answering it from {@link noValueSatisfiesNegation} - // states which question is being answered. - if (value == null) { - if (noValueSatisfiesNegation(op)) break; - return false; - } - // [#14079] A present value that is NOT a string satisfies this - // too. This line used to read `typeof value !== 'string' || - // value.includes(target)` — a TYPE test standing in for the - // predicate, so `{ n: 5 }` failed `$contains: '5'` (correct: - // a number cannot contain a substring) AND `$notContains: - // '5'` (wrong: for the very same reason it cannot, it does not). - // Measured: this face answered `['3','4']` where this - // package's own live mingo path, `formula`, `having`, - // `driver-mongodb` and the analytics face answered all four - // rows. #13166's note that a non-string value "keeps the - // answer it had" recorded what that ruling chose to leave - // alone; the maintainer ruled the cell itself on 2026-09-05 - // (option A, type-gate): a stored value that is not a string - // never satisfies a positive text operator and always - // satisfies `$notContains`. `FILTER_TEXT_CASES`' `score` rows - // are the pin, on every face. Now the arm answers the - // PREDICATE — "contains" is false of a non-string, so "does - // not contain" is true — which is `formula`'s shape - // (`!(typeof actual === 'string' && actual.includes(v))`). - if (typeof value === 'string' && value.includes(target)) return false; - break; - case '$startsWith': - if (typeof value !== 'string' || !value.startsWith(target)) return false; - break; - case '$endsWith': - if (typeof value !== 'string' || !value.endsWith(target)) return false; - break; - // [#6520] The case-INSENSITIVE twin of `$contains`. ASCII case only, - // and the fold runs on BOTH sides — `asciiCaseInsensitiveContains` - // is the spec's own function, shared with the five other JS faces so - // the fold cannot drift per package. - // - // NOT `toLowerCase()`: that folds the whole Unicode range, so `CAFÉ` - // would match `café` on this face and not on the SQL family, which is - // the divergence #6520 closed rather than a nicety (#4706 Q1 = A). - case '$icontains': - if (typeof value !== 'string' || typeof target !== 'string' - || !asciiCaseInsensitiveContains(value, target)) return false; - break; - // [#7536] `$like` / `$ilike` — the caller's own pattern, anchored to - // the WHOLE value. `matchesLikePattern` is the spec's shared - // translation: the same one this driver's live query path binds as a - // regex, and the same pattern `driver-sql` hands to LIKE/GLOB — so - // the two faces of this package cannot answer one pattern two ways - // (the divergence #5374 fixed for `$contains` in this same file). - // - // A malformed pattern cannot reach here — `filter-refusal.ts` - // refuses a dangling trailing escape on the shape walk, before - // evaluation starts. The guard stays because `match()` is also - // called directly by driver doubles, and a total function must stay - // total. - case '$like': - case '$ilike': - if (typeof value !== 'string' || typeof target !== 'string') return false; - try { - if (!matchesLikePattern(value, target, op === '$ilike')) return false; - } catch { - return false; - } - break; - case '$null': - // $null: true → value must be null/undefined; $null: false → value must not be null/undefined - // - // [#5347] These two conditionals are EXHAUSTIVE now: the shape - // gate refuses a non-boolean `target` before evaluation starts. - // They were not, and that is what the issue's fixture could not - // see. A third value satisfied neither test, so the operator - // matched EVERY row — while the live query path compiled the - // same filter to IS NOT NULL and driver-sql to IS NULL. This - // face's answer was the widening one, which on an RLS read scope - // is a permission bypass, not a degraded filter (#3948, and the - // identical `$between` note above). - if (target === true && value != null) return false; - if (target === false && value == null) return false; - break; - // [#20444] The staged emptiness flag — ruling A on #20399 (record - // 5865693155) gives a face holding NO field declarations the - // by-value reading, and this matcher holds none: null, `undefined`, - // `''` and `[]` are empty, through the spec's `isEmptyFilterValue` - // rather than a copy of it. `false` is the exact complement. The - // shape gate refused a non-boolean `target` before evaluation - // started, so the comparison is exhaustive. - // - // It differs from the live query path's DECLARED row only on a - // stored state the declaration does not predict — `''` in a - // non-text column (a write-door defect), `[]` in a scalar one — - // never on a value the field's own type can hold. - case '$empty': - if (isEmptyFilterValue(value) !== (target === true)) return false; - break; - // [#5702] The `$regex` arm that stood here is GONE. It was the only - // real regex evaluator in the repo, and the reason #4706 retired the - // operator rather than standardising it: `new RegExp(target)` read - // `a.b` as a pattern (so it also matched `axb`) where every SQL - // backend read it as a literal, and its `catch { return false }` - // answered an ILLEGAL pattern with "no rows" — a silent wrong - // answer, not an error. Both spellings are refused by - // `filter-refusal.ts`'s vocabulary gate before this evaluator runs; - // `$icontains` is the replacement the refusal prescribes. - default: - // [#5324] Unreachable through `match`: the shape gate refuses an - // operator this driver does not evaluate, with the same - // `INVALID_FILTER` / 400 the live query path raises. This arm - // read "Unknown operator, ignore or fail. Ignoring safe for - // optional features." — and ignoring a constraint WIDENS the - // result set, which on a read scope is a permission bypass, not - // a degraded optional feature (#3948, and objectql's `having` - // says the same thing over its own vocabulary). - break; - } - } - - return true; -} diff --git a/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts b/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts index c71c9809a05..5dbade8a16a 100644 --- a/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts +++ b/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts @@ -28,16 +28,23 @@ * # Why these tests assert through BOTH faces * * The rule lives in exactly one function (`assertFilterConditionShape`) and both - * faces call it. A regression that re-forks them fails HERE rather than being + * faces called it. A regression that re-forks them fails HERE rather than being * discovered by a conformance table that only exercises one — the same reason * `memory-filter-vocabulary-refusal.test.ts` doubles every case. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED. Its half of each refusal was that one function's, so the second + * face asserted here is the gate itself, called directly — the live path and + * the gate must refuse identically. Its row answers were the live path's too, + * with ONE exception, pinned at the foot of this file: `$exists` over a + * non-boolean flag, where the matcher and the live path disagreed. */ import { describe, it, expect, beforeEach } from 'vitest'; import type { FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; +import { assertFilterConditionShape } from './filter-refusal.js'; interface WireBearingError extends Error { code?: string; @@ -59,7 +66,7 @@ const ROWS = [ const DRIVER_SQL_LEADING_SENTENCE = (field: string) => `Operator "$null" on field "${field}" requires a boolean comparand (true or false).`; -describe('[#5347] $null requires a boolean comparand, on both filter faces', () => { +describe('[#5347] $null requires a boolean comparand, on the live path and its shape gate', () => { let driver: InMemoryDriver; beforeEach(async () => { @@ -82,9 +89,6 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () return (rows as any[]).map((r) => String(r.id)).sort(); }; - const matchIds = (where: unknown): string[] => - ROWS.filter((row) => match(row, where as any)).map((r) => r.id).sort(); - const refusalOfFind = async (where: unknown): Promise => { try { await findIds(where); @@ -94,13 +98,14 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () throw new Error('expected the live query path to refuse this filter, but it resolved'); }; - const refusalOfMatch = (where: unknown): WireBearingError => { + /** The shared shape gate, called directly — the refusal the retired reference matcher raised. */ + const refusalOfGate = (where: unknown): WireBearingError => { try { - matchIds(where); + assertFilterConditionShape(where, 'filter'); } catch (e) { return e as WireBearingError; } - throw new Error('expected the reference matcher to refuse this filter, but it answered'); + throw new Error('expected the shape gate to refuse this filter, but it passed'); }; const NON_BOOLEAN: Array<[label: string, value: unknown]> = [ @@ -124,8 +129,8 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () expect(err.message).toContain('filter.stage.$null'); }); - it(`the reference matcher refuses ${label}, identically`, () => { - const err = refusalOfMatch({ stage: { $null: value } }); + it(`the shape gate refuses ${label}, identically`, () => { + const err = refusalOfGate({ stage: { $null: value } }); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); @@ -133,7 +138,7 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () }); } - it('both faces refuse it inside a combinator, at the position that names it', async () => { + it('the live path and the gate refuse it inside a combinator, at the position that names it', async () => { for (const [where, path] of [ [{ $and: [{ stage: { $null: 'yes' } }] }, 'filter.$and[0].stage.$null'], [{ $or: [{ stage: 'won' }, { stage: { $null: 1 } }] }, 'filter.$or[1].stage.$null'], @@ -142,8 +147,8 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () const findErr = await refusalOfFind(where); expect(findErr.code).toBe('INVALID_FILTER'); expect(findErr.message).toContain(path); - const matchErr = refusalOfMatch(where); - expect(matchErr.message).toBe(findErr.message); + const gateErr = refusalOfGate(where); + expect(gateErr.message).toBe(findErr.message); } }); @@ -155,31 +160,38 @@ describe('[#5347] $null requires a boolean comparand, on both filter faces', () { $or: [{}, { stage: { $null: 'x' } }] }, ]) { expect((await refusalOfFind(where)).code).toBe('INVALID_FILTER'); - expect(refusalOfMatch(where).code).toBe('INVALID_FILTER'); + expect(refusalOfGate(where).code).toBe('INVALID_FILTER'); } }); - it('true and false are unchanged on both faces, line by line', async () => { + it('true and false are unchanged, line by line', async () => { + // The retired reference matcher answered each line identically. expect(await findIds({ stage: { $null: true } })).toEqual(['2']); - expect(matchIds({ stage: { $null: true } })).toEqual(['2']); expect(await findIds({ stage: { $null: false } })).toEqual(['1']); - expect(matchIds({ stage: { $null: false } })).toEqual(['1']); }); - it('the ordinary vocabulary is untouched on both faces', async () => { + it('the ordinary vocabulary is untouched', async () => { + // The retired reference matcher answered each line identically. expect(await findIds({ stage: 'won' })).toEqual(['1']); - expect(matchIds({ stage: 'won' })).toEqual(['1']); expect(await findIds({ score: { $between: [5, 15] } })).toEqual(['1']); - expect(matchIds({ score: { $between: [5, 15] } })).toEqual(['1']); expect(await findIds({ $or: [{ stage: 'won' }, { score: 20 }] })).toEqual(['1', '2']); - expect(matchIds({ $or: [{ stage: 'won' }, { score: 20 }] })).toEqual(['1', '2']); expect(await findIds({})).toEqual(['1', '2']); }); - it('$exists is deliberately NOT tightened here', () => { + it('$exists is deliberately NOT tightened here — and the live path reads a non-boolean flag as FALSE', async () => { // #5347 ruled on `$null` alone. `$exists` diverges on its own axis (#5299 // holds the open question of what "exists" means for a null-valued key), so // it keeps today's answers rather than being settled as a rider. - expect(matchIds({ stage: { $exists: 'yes' } })).toEqual(['1']); + // + // [#5930 step 4] This line used to pin the REFERENCE MATCHER's answer, + // `['1']`: it read the flag by truthiness (`!!'yes'`), so `'yes'` meant + // "has a value". The live path never agreed — it lowers `val === true` to + // `$ne: null` and anything else to `$eq: null`, so `'yes'` asks for the + // rows with NO value. That was the matcher's own divergence from the path + // users run; with the matcher retired, the live path's answer is pinned, + // measured, and the refusal this cell lacks is reported rather than + // decided here. + expect(await findIds({ stage: { $exists: 'yes' } })).toEqual(['2']); + expect(await findIds({ stage: { $exists: true } })).toEqual(['1']); }); }); diff --git a/packages/drivers/driver-memory/src/memory-null-list-member-unreachable.test.ts b/packages/drivers/driver-memory/src/memory-null-list-member-unreachable.test.ts index bf02306aa48..af509c78534 100644 --- a/packages/drivers/driver-memory/src/memory-null-list-member-unreachable.test.ts +++ b/packages/drivers/driver-memory/src/memory-null-list-member-unreachable.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#13357] Ruling point 3's NEGATIVE pin, matcher side: a refused null list - * member cannot reach this package's reference matcher. + * [#13357] Ruling point 3's NEGATIVE pin, evaluator side: a refused null list + * member cannot reach this package's evaluator (the reference matcher when this + * was written, `InMemoryDriver.find` since that matcher's retirement). * * # What was ruled (2026-08-31, option C) * @@ -31,8 +32,8 @@ * `@objectstack/objectql`'s `engine-filter-array-lowering.test.ts`; the * wire/protocol face runs the same `parseFilterAST`. * - * The boundary, stated rather than hidden: `match()` and - * `InMemoryDriver.find()` remain plain library functions — a caller that + * The boundary, stated rather than hidden: `InMemoryDriver.find()` remains a + * plain library function (as `match()` was, until it was retired) — a caller that * skips the compile face meets only this package's own * `assertFilterConditionShape`, which is deliberately NOT extended to the * null-member rule (⛔ 不做跨后端对齐工程). That boundary is the same one @@ -42,7 +43,7 @@ import { describe, it, expect } from 'vitest'; import { parseFilterAST } from '@objectstack/spec/data'; -import { match } from './memory-matcher.js'; +import { InMemoryDriver } from './memory-driver.js'; type Refusal = Error & { code?: string; status?: number }; @@ -59,54 +60,64 @@ const MISSING_ROWS: Array> = [ /** * The direct-caller pipeline, exactly as the module note describes it: compile * first, evaluate second. The refusal has to land in step one — if compile - * returns, the matcher HAS been reached and the pin below fails on the - * sentinel rather than on a missing throw. + * returns, the driver HAS been reached and the pin below fails on the sentinel + * rather than on a missing throw. + * + * [#5930 step 4, ruling D6] Step two was the reference matcher's `match()`, + * which had no production caller and is retired; it is `InMemoryDriver.find` + * now — the evaluator a direct caller of this package actually reaches. */ -function compileThenMatch(rows: Array>, where: unknown): string[] { +async function compileThenFind(rows: Array>, where: unknown): Promise { const condition = parseFilterAST(where); - return rows.filter((row) => match(row, condition)).map((row) => String(row.id)); + const driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + for (const row of rows) await driver.create('t', { ...row }); + const found = new Set( + ((await driver.find('t', { where: condition } as never)) as Array>).map((r) => String(r.id)), + ); + return rows.map((row) => String(row.id)).filter((id) => found.has(id)); } -const refusalOf = (run: () => unknown): Refusal => { +const refusalOf = async (run: () => Promise): Promise => { try { - run(); + await run(); } catch (e) { return e as Refusal; } throw new Error('expected the compile face to refuse this filter, but it returned'); }; -describe('[#13357] a refused null list member cannot reach the matcher (ruled 2026-08-31)', () => { +describe('[#13357] a refused null list member cannot reach the driver (ruled 2026-08-31)', () => { it.each([ ['$in: [null]', { name: { $in: [null] } }], ['$nin: [null]', { name: { $nin: [null] } }], ['$between: [null, null]', { name: { $between: [null, null] } }], ['$between: [null, max]', { name: { $between: [null, 'z'] } }], ['$between: [min, null]', { name: { $between: ['a', null] } }], - ])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => { + ])('%s aborts at the compile face on BOTH readings of "no value"', async (_label, where) => { // Record-independent by construction — the compile face never sees a row — // so the two readings that split the matcher (#13357's table) cannot even // be posed. Driving both anyway is the point of the pin: neither fixture // gets an answer, so there is no divergence left to observe. for (const rows of [NULLED_ROWS, MISSING_ROWS]) { - const err = refusalOf(() => compileThenMatch(rows, where)); + const err = await refusalOf(() => compileThenFind(rows, where)); expect(err.code, _label).toBe('INVALID_FILTER'); expect(err.status, _label).toBe(400); } }); - it('the pipeline itself is real — a legal list compiles and the matcher answers', () => { + it('the pipeline itself is real — a legal list compiles and the driver answers', async () => { // Positive control: without it, the refusals above would also "pass" if - // compileThenMatch were broken outright. - expect(compileThenMatch(NULLED_ROWS, { name: { $in: ['a'] } })).toEqual(['1']); - expect(compileThenMatch(MISSING_ROWS, { name: { $nin: ['a'] } })).toEqual(['3']); + // compileThenFind were broken outright. + expect(await compileThenFind(NULLED_ROWS, { name: { $in: ['a'] } })).toEqual(['1']); + expect(await compileThenFind(MISSING_ROWS, { name: { $nin: ['a'] } })).toEqual(['3']); }); - it('an EMPTY list still passes the same face — the refusal is null-shaped, not list-shaped', () => { + it('an EMPTY list still passes the same face — the refusal is null-shaped, not list-shaped', async () => { // `$in: []` / `$nin: []` are declared predicates ("matches nothing" / // "matches everything") and PR #13630 pins them downstream; the carve-out // must not catch them. - expect(compileThenMatch(NULLED_ROWS, { name: { $in: [] } })).toEqual([]); - expect(compileThenMatch(NULLED_ROWS, { name: { $nin: [] } })).toEqual(['1', '3']); + expect(await compileThenFind(NULLED_ROWS, { name: { $in: [] } })).toEqual([]); + expect(await compileThenFind(NULLED_ROWS, { name: { $nin: [] } })).toEqual(['1', '3']); }); }); diff --git a/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts b/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts index 1711104ea86..17fc99488e4 100644 --- a/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts +++ b/packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#14080] Ruling point 4's NEGATIVE pin, matcher side: a refused null - * ORDERING comparand cannot reach this package's reference matcher. + * [#14080] Ruling point 4's NEGATIVE pin, evaluator side: a refused null + * ORDERING comparand cannot reach this package's evaluator (the reference + * matcher when this was written, `InMemoryDriver.find` since its retirement). * * # What was ruled (2026-09-01, option A) * @@ -33,7 +34,7 @@ * before any row is consulted. The engine half (every verb, driver-call * witness) is pinned in `@objectstack/objectql`'s * `engine-filter-array-lowering.test.ts`; the wire/protocol face runs the same - * `parseFilterAST`. `match()` and `InMemoryDriver.find()` remain plain library + * `parseFilterAST`. `InMemoryDriver.find()` (and `match()`, until retired) remain plain library * functions — a caller that skips the compile face meets only this package's * own `assertFilterConditionShape`, which is deliberately NOT extended to the * null-ordering rule (⛔ 不做跨后端对齐工程). Same boundary as every #5869 @@ -43,7 +44,7 @@ import { describe, it, expect } from 'vitest'; import { parseFilterAST } from '@objectstack/spec/data'; -import { match } from './memory-matcher.js'; +import { InMemoryDriver } from './memory-driver.js'; type Refusal = Error & { code?: string; status?: number }; @@ -64,25 +65,36 @@ const MISSING_ROWS: Array> = [ ]; /** - * The direct-caller pipeline: compile first, evaluate second. The refusal has - * to land in step one — if compile returns, the matcher HAS been reached and - * the pin below fails on the sentinel rather than on a missing throw. + * The direct-caller pipeline, exactly as the module note describes it: compile + * first, evaluate second. The refusal has to land in step one — if compile + * returns, the driver HAS been reached and the pin below fails on the sentinel + * rather than on a missing throw. + * + * [#5930 step 4, ruling D6] Step two was the reference matcher's `match()`, + * which had no production caller and is retired; it is `InMemoryDriver.find` + * now — the evaluator a direct caller of this package actually reaches. */ -function compileThenMatch(rows: Array>, where: unknown): string[] { +async function compileThenFind(rows: Array>, where: unknown): Promise { const condition = parseFilterAST(where); - return rows.filter((row) => match(row, condition)).map((row) => String(row.id)); + const driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + for (const row of rows) await driver.create('t', { ...row }); + const found = new Set( + ((await driver.find('t', { where: condition } as never)) as Array>).map((r) => String(r.id)), + ); + return rows.map((row) => String(row.id)).filter((id) => found.has(id)); } -const refusalOf = (run: () => unknown): Refusal => { +const refusalOf = async (run: () => Promise): Promise => { try { - run(); + await run(); } catch (e) { return e as Refusal; } throw new Error('expected the compile face to refuse this filter, but it returned'); }; -describe('[#14080] a refused null ordering comparand cannot reach the matcher (ruled 2026-09-01)', () => { +describe('[#14080] a refused null ordering comparand cannot reach the driver (ruled 2026-09-01)', () => { it.each([ ['$gt: null', { n: { $gt: null } }], ['$gte: null', { n: { $gte: null } }], @@ -90,33 +102,33 @@ describe('[#14080] a refused null ordering comparand cannot reach the matcher (r ['$lte: null', { n: { $lte: null } }], ['lowered array form, ">="', [['n', '>=', null]]], ['lowered array form, "before"', [['n', 'before', null]]], - ])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => { + ])('%s aborts at the compile face on BOTH readings of "no value"', async (_label, where) => { // Record-independent by construction — the compile face never sees a row — // so the two readings that split the faces (the card's table) cannot even // be posed. Driving both anyway is the point of the pin: neither fixture // gets an answer, so there is no divergence left to observe. for (const rows of [NULLED_ROWS, MISSING_ROWS]) { - const err = refusalOf(() => compileThenMatch(rows, where)); + const err = await refusalOf(() => compileThenFind(rows, where)); expect(err.code, _label).toBe('INVALID_FILTER'); expect(err.status, _label).toBe(400); } }); - it('the pipeline itself is real — a legal ordering comparand compiles and the matcher answers', () => { + it('the pipeline itself is real — a legal ordering comparand compiles and the driver answers', async () => { // Positive control: without it, the refusals above would also "pass" if - // compileThenMatch were broken outright. `0` is the discriminator the + // compileThenFind were broken outright. `0` is the discriminator the // numeric fixture exists for — a VALUE, kept in, on every arm. - expect(compileThenMatch(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']); - expect(compileThenMatch(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']); - expect(compileThenMatch(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']); - expect(compileThenMatch(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']); + expect(await compileThenFind(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']); + expect(await compileThenFind(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']); + expect(await compileThenFind(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']); + expect(await compileThenFind(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']); }); - it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', () => { + it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', async () => { // `$eq: null` IS the null predicate on both readings (#13494) and is the // spelling the refusal prescribes; the carve-out must not catch it. - expect(compileThenMatch(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']); - expect(compileThenMatch(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']); - expect(compileThenMatch(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']); + expect(await compileThenFind(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']); + expect(await compileThenFind(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']); + expect(await compileThenFind(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']); }); }); diff --git a/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts b/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts index 80adefd4324..099d3107552 100644 --- a/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts +++ b/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts @@ -28,9 +28,9 @@ * * ## The oracle * - * `memory-matcher.ts`'s `match()` loops the operators and therefore CANNOT - * express this defect, and it is the face #5962 aligned. Every cell below is - * scored against it. + * `memory-matcher.ts`'s `match()` looped the operators and therefore COULD NOT + * express this defect, and it was the face #5962 aligned. Every cell below was + * scored against it, and still is — against the literal answer it gave. * * ⚠️ That exception is now CLOSED, and this note records it rather than * repeating it. `$between` ALONE used to disagree with the reference matcher on @@ -45,8 +45,13 @@ * statement about what a CLOBBER test measures — two constraints on one field * select exactly the rows both select alone — and it keeps this file's verdict * independent of the matcher's own cells. It was never a workaround for the - * divergence, so closing the divergence does not change it. The matcher remains - * the oracle for the named cells, where the two agree operator by operator. + * divergence, so closing the divergence does not change it. The matcher remained + * the oracle for the named cells, where the two agreed operator by operator. + * + * [#5930 step 4, ruling D6] The reference matcher had no production caller and + * is RETIRED. Every named cell below asserted the SAME literal row set on the + * live path and on the matcher, so the matcher's half of each cell is kept as + * that literal — the answer the oracle gave — and the live path is held to it. * * ## Why the sweep ranges over the vocabulary and not over three operators * @@ -75,7 +80,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; -import { match } from './memory-matcher.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; import { SUPPORTED_FIELD_OPERATORS } from './filter-refusal.js'; @@ -134,10 +138,6 @@ async function sweepIds(where: unknown): Promise { return sorted((out as Array>).map((r) => String(r.id))); } -/** The ORACLE: the reference matcher, which loops the operators. */ -const matcherIds = (where: unknown): string[] => - sorted(ROWS.filter((r) => match(r, where)).map((r) => String(r.id))); - /** Both key orders of one two-operator field constraint. */ function bothOrders( field: string, @@ -158,8 +158,6 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // '2' is the row `$ne: 'b'` excludes (that constraint was dropped instead). expect(await liveIds(ab)).toEqual(['1']); expect(await liveIds(ba)).toEqual(['1']); - expect(matcherIds(ab)).toEqual(['1']); - expect(matcherIds(ba)).toEqual(['1']); }); it('`$null` + `$eq` — the other half of the same contested key pair', async () => { @@ -167,8 +165,6 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // Was ['1'] / ['3']. "is null AND equals 'a'" is a contradiction: no row. expect(await liveIds(ab)).toEqual([]); expect(await liveIds(ba)).toEqual([]); - expect(matcherIds(ab)).toEqual([]); - expect(matcherIds(ba)).toEqual([]); }); it('`$between` + `$gte` — the range`s lower bound, contested', async () => { @@ -184,8 +180,6 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // file is asserted twice. expect(await liveIds(ab)).toEqual([]); expect(await liveIds(ba)).toEqual([]); - expect(matcherIds(ab)).toEqual([]); - expect(matcherIds(ba)).toEqual([]); }); it('`$between` + `$lte` — the range`s upper bound, contested', async () => { @@ -195,8 +189,6 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // ⭐ note for why one direction proves nothing. expect(await liveIds(ab)).toEqual(['1', '2']); expect(await liveIds(ba)).toEqual(['1', '2']); - expect(matcherIds(ab)).toEqual(['1', '2']); - expect(matcherIds(ba)).toEqual(['1', '2']); }); it('THE FOURTH MEMBER — `$lte` on a bare calendar day lowers onto `$lt`', async () => { @@ -206,8 +198,6 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // had named this cell; the vocabulary sweep below is what found it. expect(await liveIds(ab)).toEqual(['1']); expect(await liveIds(ba)).toEqual(['1']); - expect(matcherIds(ab)).toEqual(['1']); - expect(matcherIds(ba)).toEqual(['1']); }); it('`$between` with a bare-day max contests `$lt` for the same reason', async () => { @@ -215,16 +205,12 @@ describe('[#13524] the card`s measured table, reproduced and repaired', () => { // Was ['1'] / ['1','2']. expect(await liveIds(ab)).toEqual(['1']); expect(await liveIds(ba)).toEqual(['1']); - expect(matcherIds(ab)).toEqual(['1']); - expect(matcherIds(ba)).toEqual(['1']); }); it('`$exists` + `$ne` — #13195`s cell, unmoved by the generalisation', async () => { const [ab, ba] = bothOrders('name', ['$exists', true], ['$ne', 'b']); expect(await liveIds(ab)).toEqual(['1']); expect(await liveIds(ba)).toEqual(['1']); - expect(matcherIds(ab)).toEqual(['1']); - expect(matcherIds(ba)).toEqual(['1']); }); it('`$notContains` is covered by construction — nothing else writes `$not`', () => { @@ -339,8 +325,6 @@ describe('[#13524] the analytics face — a WHOLESALE clobber, one level up', () // was ['1','3'] / ['1']. expect((await analytics(ab)).executed).toEqual(['1']); expect((await analytics(ba)).executed).toEqual(['1']); - expect(matcherIds(ab)).toEqual(['1']); - expect(matcherIds(ba)).toEqual(['1']); }); it('`$and`-folded nodes on one member clobbered too — that is the common shape', async () => {