Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/20822-driver-memory-face-copies.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 26 additions & 24 deletions packages/drivers/driver-memory/src/filter-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string> = new Set<string>([
...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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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}`);
});
}

Expand All @@ -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);
Expand All @@ -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, unknown>) => 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]);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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] });
});
}
});
Loading
Loading