fix(driver-memory): render the analytics echo as the query it describes (#7117) - #7852
Conversation
…es (#7117) `MemoryAnalyticsService` has two exits for one normalized filter tree and they disagreed about what the LIKE family MEANS. `query()` builds a real containment pattern; `generateSql()` emitted the comparand as a bare literal with no wildcard anywhere, so `{name: {$contains: 'acme'}}` echoed `WHERE name LIKE 'acme'` — an EQUALITY — beside a chart drawn from every row CONTAINING `acme`. `notContains` mirrored it through `NOT LIKE`. The `$contains` family now renders `GLOB '*v*'` / `NOT GLOB`, and `$icontains` `lower(col) GLOB lower('*v*')`. GLOB rather than LIKE because this exit emits SQLite-shaped SQL and SQLite's LIKE folds ASCII unconditionally, while #4706 Q2 = A rules the family case-SENSITIVE and #7723 put this package's execution faces on that answer — a LIKE echo would have contradicted execution on a second axis the moment the wildcards were added. The translation is the spec's shared `likePatternToGlobPattern`, over a LIKE-escaped comparand, so an author's own `%` / `_` / `*` / `?` / `[` stay literal instead of becoming the match-every-row bypass (#5567). `operatorToSql`'s `|| '='` fallback is gone with it: a name→name map cannot hold a wildcard, a list, or a null-safe negation, so it is now a builder table keyed by `CubeOperator` — the shape #5374 gave the mingo exit — and a widened vocabulary fails to compile until its SQL spelling exists. Three operators were reaching that fallback and are fixed with it: `{$in: [a,b]}` echoed `= a`, `{$nin: [a]}` echoed the exact COMPLEMENT of the query's rows, and `{$exists: true}` echoed `name = 1`, which selects nothing. Three smaller divergences on the same builder went too — negations are null-safe (#5146 / #5297), an empty `$in`/`$nin` renders a predicate instead of no WHERE, and a bare-day `$lte` renders half-open as the pipeline has read it since #4042. `startsWith` / `endsWith` never reached the fallback and are unchanged: this face does not lower them, so both exits refuse them with `INVALID_FILTER` / 400. `memory-analytics-echo-operator-coverage.test.ts` pins the WHERE against `query()`'s ROW SET by executing the echoed statement on a real SQLite engine (`sql.js`), enumerates the closed vocabulary on both exits, and records the eight-way reverse verification. Only the DISPLAYED SQL changes; `query()`'s rows are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPYmYr8mjAsbZ6RqwEk4TT
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7117
MemoryAnalyticsServicehas two exits for one normalized filter tree, and they disagreed about what the LIKE family MEANS.query()builds a real containment pattern;generateSql()emitted the comparand as a bare literal with no wildcard anywhere, so{name: {$contains: 'acme'}}echoed— an equality — beside a chart drawn from every row containing
acme.$notContainsmirrored it throughNOT LIKE. The echo's only job is reproducing execution, so an author who ran it to debug the chart got a narrower row set and read the filter as broken: the #5333 / #3650 class, reached through driver-memory's analytics face.Both axes, because #7723 opened the second one
#7723 made the
$containsfamily case-exact on this package's execution faces (#4706 Q2 = A) — includingfilterSubstringPattern, which this face borrows. Measured on69fde55before this PR:query()$contains)LIKEfolds ASCII on a SQLite consumerThe halves are one fix, exactly as #7723's body predicted: the case-exact construct on SQLite is
GLOB,GLOBspeaks a different pattern language fromLIKE, so choosing the construct and rendering the wildcards are the same decision — half of it emits incoherent SQL.What the echo emits now.
$contains→col GLOB '*v*',$notContains→(col IS NULL OR col NOT GLOB '*v*'),$icontains→lower(col) GLOB lower('*v*'). SQLite because that is the dialect this exit has always emitted (toSqlLiteralspells booleans1/0; #6520'sicontainsrow reasoned from SQLite's ASCII-onlyLIKE) — stated as an executed premise in the new suite so it goes red rather than stale. The translation is the spec's sharedlikePatternToGlobPatternover a LIKE-escaped comparand, so an author's own%/_/*/?/[stay literal instead of becoming the match-every-row bypass (#5567); a third hand-copy of that escape is whatservice-analytics'like-pattern.tsheader says to refuse.Every face of this package's analytics surface, declared
query()(mingo$match)generateSql()generateSqlFromPipeline()(AnalyticsResult.sql)/* Stage n: $match */ <JSON>, so it cannot render a predicate execution did not run. It does lose a RegExp operand to{}inJSON.stringify(measured:{"name":{"$regex":{}}}). Visibly degenerate rather than plausibly wrong, and a separate surface — filed, not folded inmeasureToSql()COUNT/SUM/…); no filter operator reaches itThe
|| '='fallback, and what measurement said about itThe card expected
startsWith/endsWithto fall tooperatorToSql's|| '='. They cannot — they are not inMONGO_TO_CUBE_OPERATOR, so #5345's gate refuses them on both exits withINVALID_FILTER/ 400 before any lowering. That premise is falsified, and the new suite asserts the refusal so it stays that way.Three other operators were reaching it. A name→name map cannot hold a wildcard, a list, or a null-safe negation, so
operatorToSqlis now a builder table keyed byCubeOperator— the shape #5374 gave the mingo exit — and widening the vocabulary fails to compile until the SQL spelling exists. Measured againstquery():wherequery(){name: {$in: [a, b]}}name = a— one rowname IN (a, b){name: {$nin: [a]}}name = a— the complement(name IS NULL OR name NOT IN (a)){name: {$exists: true}}name = 1— no rowsname IS NOT NULLThree smaller divergences on the same builder went with them, each measured before it was changed:
WHEREkeeps only TRUE, so a barecol != v/col NOT GLOB vdropped every NULL-column row the pipeline returns.$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 ruled that closed repo-wide;read-scope-sql.ts'snullSafeNegativeis the spelling copied here.$in/$ninis a predicate. Thevalues.length > 0guard emitted no clause at all, so{$in: []}echoed the whole table whilequery()returns nothing. The mingo exit retired the identical guard in driver-memory analytics 面的$notContains编译成裸 mingo{$not: 'x'},该谓词不约束任何行 —— 结果被放大到全表 #5374.$lteis half-open, as the pipeline has read it since driver-memory / driver-mongodb:裸日期$lte上界在 datetime 值上同样丢当天数据(#3777 的非 SQL 驱动对齐) #4042 (SQL twin dashboard 的日期区间上界打在datetime列上丢失当天数据 —— 默认配置即命中 #3777):at <= '2026-01-02'dropped that day's timestamped rows, so the echo was one row narrower than its chart.The one cell SQL cannot translate exactly, stated rather than hidden: mingo's
$existstests KEY PRESENCE, which a relational column always has, so a row storing an explicitnullsatisfies$exists: trueonquery()and failsIS NOT NULLin the echo.IS NOT NULLis nonetheless the spelling both of this repo's other SQL lowerings use (read-scope-sql.ts's$existsarm;driver-sql's "a present field is a non-null column in SQL"), and it is a far smaller gap than thename = 1it replaces, which matched nothing. It is pinned as an explicit inequality in the new suite so it cannot be closed in silence.The check is the one the maintainer required
memory-analytics-echo-operator-coverage.test.ts(44 cases) pinsgenerateSql()'s WHERE againstquery()'s row set, not against a string — comment5234868334, and the shapeobjectql-echo-operator-coverage.test.tsuses. The echoed statement is executed on a real SQLite engine (sql.js, pure WASM, for the ABI reasonnative-sql-filter-logic-conformance.test.tsgives) over the same six-row fixture the pipeline runs on. A string assertion would not have caught the missing%either:WHERE name LIKE 'acme'reads as a working predicate. The closed vocabulary is enumerated in both directions — every accepted operator round-trips, every refused one refuses identically on both exits — so the two tables cannot drift apart unnoticed.Reverse verification, predicted first then measured — all eight held, recorded in the suite's docblock. Examples: reverting
GLOBtoLIKE '%v%'keeps containment and breaks CASE (expected ['1','2'] to deeply equal ['2']); dropping the comparand's LIKE-escape widens{$contains: '%'}from one row to every non-null row; restoring thevalues.length > 0guard makes an empty$inecho the whole table.Prose corrected
Docblocks in this package that #7723 made false and this PR reasons directly against:
MongoPredicateInput.substring/.asciiSubstring(called the family Unicode-folding and cited #6682 as open),memory-like-pattern.test.ts's$containscontrol (said the two faces disagree — measured, they now both answer['1','2','3']), andmemory-icontains.test.ts's coverage note (said the DEBT row stays until #6682 closes). Equivalent stale#6682prose inpackages/specis filed separately rather than dragged into adriver-memorypatch.Gates
@objectstack/driver-memorytests@objectstack/spectestspnpm --filter '...@objectstack/driver-memory', measured — 17 packages)tsc --noEmitpnpm lintcheck:query-options-erasure69fde55check:wildcard-fallthroughcheck:driver-conformanceChangeset:
@objectstack/driver-memorypatch — the displayed SQL is user-visible.content/docs/releases/untouched.Generated by Claude Code