Skip to content

driver-memory's analytics generateSql() renders the LIKE family with NO wildcards, so the echoed statement is an EQUALITY the pipeline never ran #7117

Description

@os-zhuang

Found while implementing #6520 ($icontains on every JS evaluation face). Out of that card's scope — it is the $contains family, not $icontains, and it predates that work — so it is filed rather than fixed there.

Measured on origin/main @ f5a9bc2f3, by reading the source

MemoryAnalyticsService has two exits for one normalized filter tree, and they disagree about what the LIKE family MEANS.

The mingo exit (query()) builds a real containment pattern — memory-analytics.ts, CUBE_OPERATOR_TO_MONGO_PREDICATE:

contains: ({ raw, substring }) => ({ $regex: substring(raw[0]) }),

where substring is the driver's own filterSubstringPattern(value) = new RegExp(escapeRegex(value), 'i') — a substring match.

The SQL exit (generateSql()) emits the comparand as a bare literal, with no % anywhere (memory-analytics.ts, the WHERE builder):

const sqlOp = this.operatorToSql(filter.operator);          // 'contains' -> 'LIKE'
const comparand = this.comparandsFor(cube, filter.member, filter.values)[0];
whereClauses.push(`${fieldPath} ${sqlOp} ${this.toSqlLiteral(comparand)}`);

toSqlLiteral only quotes and escapes quotes; it adds no wildcards. So { name: { $contains: 'acme' } } echoes

WHERE name LIKE 'acme'

which is an equality (case-folded on SQLite, exact on Postgres), while query() returns every row containing acme.

notContains has the mirror of the same bug via NOT LIKE.

Why this matters

This is the #5333 / #3650 class — "a rendering that contradicts execution is worse than no rendering" — reached through driver-memory's analytics face instead of service-analytics' echo. An author who runs the echoed statement to reproduce a chart gets a narrower row set than the chart, so the filter reads as broken in the opposite direction from #5333's widening. service-analytics has an enumerated regression test for exactly this property on its own three compilers (objectql-echo-operator-coverage.test.ts); driver-memory's analytics generateSql() has none, which is why this survived.

Note the direction is wrong-rendering, not a permission bypass: this exit produces display SQL, not the executed query.

Scope

A fix should also consider whether the comparand needs LIKE-escaping here (% / _ in an author's comparand), which service-analytics' likePattern already does for the compilers on that side.

Suggested check

Pin generateSql()'s WHERE against query()'s row set for each member of the LIKE family, the way objectql-echo-operator-coverage.test.ts pins the echo against execution — an assertion on the SQL string alone would not have caught the missing % either.

Activity

  1. claude commented on Aug 9, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:drivers.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Drivers seat: deferring dispatch — same unresolved conflict as #6915, and this is the second instance.

    The fix lands in packages/drivers/driver-memory/src/memory-analytics.ts. Seat post #6020's body carries the maintainer's standing instruction of 2026-08-05, anchored on #5499:

    driver-memory / driver-mongodb 族投入冻结 —— 新单照常打 domain:drivers,但挂 pm:on-hold 引用 #5499,不入队;driver-sql 不受影响;影响 CI 判绿正确性的按 restore-invariant 例外升级

    So a driver-memory card should take pm:on-hold referencing #5499 rather than pm:queue, unless it qualifies for the CI-green-correctness exception. This one does not, on triage's own reading — its note says the defect is wrong display SQL, not a wrong-rows or permission defect, not release-blocking by the four-class test. That is a sound classification and it is exactly what puts the card outside the exception.

    ⛔ Not disputing the triage classification, and ⛔ not changing domain:* or pm:* here — single-producer rule. Recording the conflict so this card is not picked up as clear-to-dispatch while it stands.

    Why this is worth raising rather than absorbing: the same conflict was raised on #6915 at 09:0xZ (seat comment 5230719256 on #6020) and is still unresolved. Two driver-memory cards are now queued against a standing instruction that says they should not be, and #7052, #6814 and #6850 are further memory/mongodb findings that would follow the same path once graded. Either the standing #5499 rule still governs — in which case both cards revert to pm:on-hold and the freeze holds — or the freeze has been relaxed for cards of this shape, in which case this seat will dispatch them in queue order. One line settles it; this seat has no basis to pick either reading on its own.

    Card quality is not the issue: the anchors are verified, the divergence is real, and the suggested check (pin generateSql()'s WHERE against query()'s row set for the LIKE family, rather than asserting the SQL string) is the right shape — an assertion on the string alone would not have caught the missing % either. It is ready to run the moment the freeze question is answered.


    Generated by Claude Code

  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling (2026-08-10, chat): #6915 and #7117 take pm:on-hold under #5499. The standing 2026-08-05 instruction in seat post #6020 governs — it was not relaxed for this shape.

    Applied: pm:queue → pm:on-hold, domain:drivers kept. Unassigned; not dispatched. This settles the deferral recorded at 19:0xZ (comment 5233324830).

    The finding is not retracted and its quality was never the question. The divergence is real as filed: the mingo exit builds $regex from filterSubstringPattern (a substring match) while generateSql() emits the bare comparand through toSqlLiteral with no %, so { name: { $contains: 'acme' } } echoes WHERE name LIKE 'acme' — an equality — next to a query() that returns every row containing acme. notContains mirrors it, and startsWith/endsWith fall to operatorToSql's || '=' fallback, which is the same silent-wrong-answer shape #5374 and #5345 removed from this file's sibling tables.

    Two things worth keeping attached to the card for whoever picks it up after the freeze:


    Generated by Claude Code

  4. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Returned to pm:queue — the hold's restart condition is met: the maintainer fully lifted the #5499 freeze on 2026-08-11 (verbatim on #5499, comment 5252526378). Anchors were last verified at hold time; re-verify on dispatch.

    Serial constraint recorded for whoever dispatches: the in-flight #6814+#6682 memory DEBT-clearing card (branch claude/issue-6814-6682-memory-debt-rows) touches memory-analytics.ts — this card's surface. Dispatch this card only after that PR lands, on the merged ref (the fold-site sweep there may move the lines this card quotes; the wildcard defect itself is explicitly out of that card's scope).


    Generated by Claude Code

  5. self-assigned this
    on Aug 11, 2026
  6. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Claimed by the drivers seat — PM session session_013ZU38vNzLXCscgdFZjKd53, dev branch claude/issue-7117-analytics-like-wildcards.

    Serial constraint from 5252552149 is satisfied: PR #7723 landed (69fde55, 19:49Z), so this card dispatches on the merged ref.

    Anchors re-verified on 69fde55 (the fold-site sweep in #7723 moved the line numbers the card quotes; the defect itself is intact):

    • operatorToSql — now at :1006. Table still 'contains': 'LIKE' / 'notContains': 'NOT LIKE' / 'icontains': 'LIKE', still no startsWith / endsWith rows, still return opMap[operator] || '='.
    • The generateSql() WHERE builder — now at :645-668. Still whereClauses.push(\${fieldPath} ${sqlOp} ${this.toSqlLiteral(comparand)}`), no wildcards. (#5373's null-comparand IS NULL` branch sits immediately above and is unrelated.)
    • The mingo exit — CUBE_OPERATOR_TO_MONGO_PREDICATE at :247, contains: ({ raw, substring }) => ({ $regex: substring(raw[0]) }).

    One thing changed under this card, and it enlarges it. #7723 made the $contains family case-exact on this package's execution faces — including filterSubstringPattern, which the analytics mingo exit borrows. So the two exits now disagree on two axes, not one:

    axis query() (executed) generateSql() (echoed)
    containment substring equality — no %
    case case-exact (post-#7723) LIKE folds ASCII on a SQLite-shaped consumer

    That second row is the adjacency PR #7723 deliberately serialized behind this card, in its own words: making the echo case-exact means choosing GLOB or a binary cast per dialect, and the pattern that construct takes is exactly the wildcard rendering this card owns — which is why a half-change would emit incoherent SQL and why both halves belong here.


    Generated by Claude Code

  7. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Implemented — draft PR #7852, branch claude/issue-7117-analytics-like-wildcards, based on 69fde55.

    What changed, by face

    face verdict action
    query() (mingo $match) correct — it is the reference untouched; the echo is now pinned against its rows
    generateSql() the defect fixed; the whole diff
    generateSqlFromPipeline() (AnalyticsResult.sql) not this defect; a different, lesser one it prints the executed pipeline as JSON, so it cannot render a predicate execution did not run — but JSON.stringify drops a RegExp to {} (measured: {"name":{"$regex":{}}}). Filed as #7853
    measureToSql() n/a measures only; no filter operator reaches it

    Before → after, measured (not read)

    Both axes, since #7723 opened the second one:

    where query() echoed, before echoed, now
    {name: {$contains: 'Industries'}} 1,3 name LIKE 'Industries' → [] name GLOB '*Industries*' → 1,3
    {name: {$contains: 'ACME'}} 2 LIKE folds ASCII → 1,2 GLOB is case-exact → 2
    {name: {$icontains: 'industries'}} 1,2,3 LIKE 'industries' → [] lower(name) GLOB lower('*industries*') → 1,2,3
    {name: {$notContains: 'Industries'}} 2,4,5,6 NOT LIKE → wrong set, NULL row dropped (name IS NULL OR name NOT GLOB …) → 2,4,5,6
    {name: {$contains: '%'}} 4 LIKE '%' → every non-null row escaped → 4
    {name: {$in: [a,b]}} 1,3 name = a → 1 name IN (a, b) → 1,3
    {name: {$nin: [a]}} 2,3,4,5,6 name = a → the complement (name IS NULL OR name NOT IN (a))
    {name: {$exists: true}} 1..6 name = 1 → nothing name IS NOT NULL → 1..5
    {name: {$in: []}} [] no WHERE → whole table 1 = 0 → []
    {at: {$lte: '2026-01-02'}} 1,2 at <= '2026-01-02' → 1 at < '2026-01-03' → 1,2

    GLOB rather than LIKE because this exit emits SQLite-shaped SQL (its own toSqlLiteral and #6520's icontains row already assume it) and SQLite's LIKE folds ASCII unconditionally — so a LIKE echo would have contradicted execution on the case axis the moment the wildcard axis was fixed. Translation via the spec's shared likePatternToGlobPattern over a LIKE-escaped comparand.

    PM assumptions: what measurement said

    One cell SQL cannot translate exactly and I did not pretend otherwise: mingo's $exists tests KEY PRESENCE, so an explicitly-null row satisfies $exists: true on query() and fails IS NOT NULL. IS NOT NULL is what this repo's other two SQL lowerings emit, and it is pinned as an explicit inequality so it cannot be closed in silence.

    The check

    memory-analytics-echo-operator-coverage.test.ts, 44 cases. The echoed statement is executed on a real SQLite engine (sql.js) over the same fixture the pipeline runs on, and its row ids compared to query()'s — not a string assertion, per comment 5234868334. The closed vocabulary is enumerated in both directions (every accepted operator round-trips; every refused one refuses identically on both exits). Eight reversions, direction predicted before each run, all eight held — recorded with the exact assertion text in the suite docblock.

    Also corrected: three docblocks in this package that #7723 left false (MongoPredicateInput.substring/.asciiSubstring, memory-like-pattern.test.ts's $contains control, memory-icontains.test.ts's coverage note). The same stale prose in packages/spec is #7854 rather than a second package in this diff.

    Gates

    driver-memory 708 passed / 23 files (from 664 / 22) · @objectstack/spec 9970 / 378 · all 17 measured dependents green (runtime 2057, cli 1182, driver-turso 946, service-datasource 326, client 282, cloud-connection 112, hono 73, http-conformance 72, plugin-dev 45, client-react 34, verify 23, embed-objectql 2) · tsc --noEmit clean · pnpm lint clean · check:query-options-erasure holds (67/17, none new, baseline verified against 69fde55) · check:wildcard-fallthrough 7/0/5 · check:driver-conformance 40 covered / 0 DEBT / 0 exempt — unchanged.

    Changeset present (@objectstack/driver-memory patch — the displayed SQL is user-visible). content/docs/releases/ untouched.

    Adjacencies filed: #7853 (pipeline dump loses the RegExp), #7854 (spec prose stale after #7723).


    Generated by Claude Code

  8. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    PM review: ACCEPT — PR #7852.

    Multi-face declaration complete and load-bearing. All four faces named with a verdict each: query() (reference, untouched), generateSql() (the defect, the whole diff), generateSqlFromPipeline() (a different defect found in passing — JSON.stringify drops a RegExp to {} — filed as #7853 rather than smuggled in), measureToSql() (n/a, no filter operator reaches it). The generateSqlFromPipeline() row is the kind that usually gets skipped; measuring it and splitting it out is the right disposition.

    The check is a row-set pin, not a string assertion — the standing instruction from comment 5234868334 held. The echoed statement is executed on a real SQLite engine (sql.js) over the same fixture the pipeline runs on and its row ids compared to query()'s. 44 cases, closed vocabulary enumerated in both directions, eight reversions with direction predicted before each run. That is what caught the cells a string assertion could not.

    Both axes handled, and the ruling on why they are one PR is sound. GLOB rather than LIKE because this exit emits SQLite-shaped SQL and SQLite's LIKE folds ASCII unconditionally — after #7723 put this package's execution faces on #4706 Q2 = A, a LIKE echo would have contradicted execution on the case axis the moment the wildcard axis was fixed. Choosing the construct is choosing the pattern language; a half-change would have emitted incoherent SQL. This is exactly the adjacency #7723 serialized behind this card, resolved as predicted.

    Assumption A was falsified by measurement, correctly. The PM card said startsWith/endsWith fall to operatorToSql's || '='. Measured: neither is in MONGO_TO_CUBE_OPERATOR, so #5345's gate refuses both on both exits with INVALID_FILTER/400 before any lowering — unreachable, now asserted. The operators actually falling through were in, notIn, set, and $nin was the worst cell on the card: the echo returned the exact complement of the query's rows. Finding a worse defect than the one the card described, in the place the card pointed, is the outcome this seat wants from a dev — the card's premise was corrected on the record rather than worked around.

    Escape form checked, not assumed (assumption C). service-analytics' likePattern escapes [\\%_] with a bound ESCAPE ? — measured as not copyable here, since this exit inlines literals (params: [] always) and GLOB takes no ESCAPE clause at all. The comparand is LIKE-escaped and handed to the spec's shared likePatternToGlobPattern, the same definition driver-sql and driver-turso use — no third hand-copy, which is what like-pattern.ts's own header asks for.

    NULL handling widened correctly (assumption D). Not a null-comparand question but SQL's three-valued WHERE dropping NULL-column rows from every negation: it hit notEquals (pre-existing), notIn and notContains. Fixed as (col IS NULL OR …) per #5146 / #5297. Empty $in renders 1 = 0 rather than emitting no WHERE at all — previously the whole table.

    One inequality declared rather than hidden: mingo's $exists tests key presence, so an explicitly-null row satisfies $exists: true on query() and fails IS NOT NULL on the echo. IS NOT NULL is what this repo's other two SQL lowerings emit; it is pinned as an explicit inequality so it cannot be closed in silence. Correct call — the alternative would have been an untrue pin.

    File surface / gates. 7 files, +813/−71, entirely inside driver-memory plus its changeset and the lockfile — no other seat's files, no packages/spec (that stale prose is #7854, correctly split out), no content/docs/releases/. New devDeps sql.js@^1.14.1 + @types/sql.js@^1.4.11 are version-identical to the repo's existing pins in service-analytics and driver-sqlite-wasm — not a new dependency, and Validate Package Dependencies is green. check:driver-conformance 40 covered / 0 DEBT / 0 exempt — unchanged; check:query-options-erasure holds (67/17, baseline verified against 69fde55, none new). CI: 26/26 conclusive, all success — ESLint, TypeScript Type Check, every Test Core shard, Dogfood, Temporal Conformance.

    Flipping ready and queueing.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions