Uh oh!
There was an error while loading. Please reload this page.
fix(driver-mongodb): take a structured GroupByNode, and answer count/count_distinct like every other backend - #7550
Conversation
…count_distinct like every other backend (#6850, #6814) `driver-mongodb` is now enrolled in the shared `AGGREGATION_CASES` standard. Clearing that cell fixed three divergences, all of the kind that ANSWER rather than fail — which is why none of them ever surfaced as an error: 1. #6850 — a structured `GroupByNode` had no lowering at all. `groupBy` was annotated `string[]` and `groupId[field] = '$' + field` stringified the object, so the `$group._id` key became the literal `"[object Object]"` and its value a field path matching nothing: rows grouped by a nonexistent path, under a column of that name. `MongoDBDriver.aggregate` passed the value through an `any` cast, which is why the declared union never met that annotation at `tsc`. Both sides now spell `GroupByNode[]`, and the `_id` keys on `alias ?? field` with the FIELD as its value — the #6401 rule. 2. #6814 — `count_distinct` sized a `$addToSet` that keeps explicit nulls, so a nullable column answered 3 where the standard says 2. The sizing now excludes null, matching `COUNT(DISTINCT col)` and the in-memory fallback. 3. Measured here, named by neither card — `count(col)` ignored `field` and emitted `{ $sum: 1 }`, so `count(stage)` came back 6 where the standard says 4. It now counts non-null values, a missing field reading as null. A `dateGranularity` node is refused with NOT_IMPLEMENTED/501 in the ADR-0112 envelope rather than silently ignored — the `driver-sql` / `driver-turso` refusal, first sentence for first sentence (#6212). A native `$dateTrunc` lowering needs the engine's bucket labels, a published capability record and `date-bucket-parity.test.ts`, so it is its own change. A `groupBy` entry that is neither half of the union is refused with INVALID_QUERY/400. The suite is server-free (the real-mongod halves are opt-in since #5517): it drives the EMITTED pipeline through a strict in-process evaluator that refuses every shape it does not model, and each pre-fix lowering is replayed through it and must fail the case it broke. It holds the LOWERING to the table and does not answer "does MongoDB agree?" — recorded as open rather than implied. The `driver-mongodb` x `AGGREGATION_CASES` DEBT row goes with it, in this PR, per the gate's rule. driver-memory is untouched: it stays under the #5499 freeze and keeps its row, so #6814 stays open for its half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PeD85qA1JNLXodmYdnu4Zm
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#6850
Part of #6814 — the driver-mongodb half only.
driver-memorystays under the #5499 freeze and keeps itsAGGREGATION_CASESDEBT row, so #6814 must stay OPEN after this lands.Authorized by the maintainer's 2026-08-11 partial unfreeze on #5499 (comment
5249019855), which is the restart condition both findings-round gradings named.What was wrong — three divergences, all of which ANSWER
driver-mongodbis now enrolled in the sharedAGGREGATION_CASESstandard (@objectstack/spec/data). Clearing the cell fixed three things. None of them ever surfaced as an error, which is the point: each returned plausible rows.1. A structured
GroupByNodehad no lowering at all (#6850).GroupByNodeSchemadeclares a union — a bare field name, or{ field, dateGranularity?, alias? }.buildAggregationPipelineannotatedgroupByasstring[]and didgroupId[field] = '$' + field. A structured node is an object in that loop, so the$group._idkey became the literal"[object Object]"and its value the field path"$[object Object]", which matches nothing.MongoDBDriver.aggregatepassed the value through(query as any).groupBy, which is why the declared union never met that annotation attsc.Both sides now spell
GroupByNode[]. The_idkeys onalias ?? fieldwith the field as its value, so the projected column is renamed and the grouping does not move — the rule #6401 converged the three SQL faces onto, and the onein-memory-aggregation.tshas always applied. The bare-string spelling emits exactly what it emitted before (pinned).Verified the type guard is now real rather than nominal: a probe assigning
DriverQuery['groupBy']tostring[]failstscwith the full union, so the next drift between the two is a compile error.2.
count_distinctcounted NULL as a distinct value (#6814).$addToSetkeeps an explicitnull, so a nullable column sized one higher thanCOUNT(DISTINCT col)— 3 where the standard says 2.3.
count(col)counted ROWS, not values — named by neither card. Measured while writing the suite: thecountarm ignoredfieldand emitted{ $sum: 1 }for both spellings, socount(stage)answered 6 — the numbercount(*)already has — where the standard says 4. This is the case-set doing exactly what its own note says it is for ("three different numbers over one column"). It now counts non-null values, with a missing field reading as null (the SQL reading: an absent value is NULL, there is no third state).Decisions worth reviewing
dateGranularityis REFUSED, not implemented and not ignored.NOT_IMPLEMENTED/ 501 in the ADR-0112 envelope —driver-sql's anddriver-turso's refusal, first sentence for first sentence (#6212). The reasoning is measured, not assumed:MongoDBDriver.supportspublishes noqueryDateGranularity, so the engine buckets every granularity in memory and never pushes a bucketed node down here; the refusal fires only for a caller that reached the builder directly — who previously got the"[object Object]"grouping. A native$dateTrunclowering is buildable and this is not a verdict that it cannot be: it has to emit the engine's bucket labels ('2026-01','2026-Q1', ISO'2026-W03'), publish the capability record, and be held todate-bucket-parity.test.ts— a card of its own. Silently ignoring a declared key was the one option not available.The
count_distinctnull exclusion is inpostProcessAggregation, not in the pipeline. Both server-side spellings the finding sketched were measured against that and not taken:$ne: nullbefore the$addToSet— as a$matchit drops the row from the whole pipeline, so acount(*)orsum()sharing it would silently lose the null rows too. It is correct only for a pipeline carrying nothing else, which this builder cannot assume.$sizeof a$setDifferenceagainst[null]— sound, and it would size server-side instead of shipping the array. It needs a$projectstage the builder does not emit when there is nogroupBy, i.e. a pipeline shape change no suite here can execute. Worth doing when this cell gains a live half; the existingArray.isArrayguard would let it fall through harmlessly.Excluding it in post-processing is exact, needs no unobserved server semantics to be true, and is pinned directly.
The suite, and what it does not claim
mongodb-aggregation-translation.test.ts— the server-free half #5517 requires, mirroringmongodb-filter-logic-translation.test.ts. It drives the emitted pipeline through a strict in-process evaluator of the stages the builder emits; every stage, accumulator and expression it does not model is a thrown error, never a tolerated no-op. Its discrimination is proved rather than asserted: each of the three pre-fix lowerings is replayed through it and must FAIL the case it broke, including the"[object Object]"$group._id— executed, it collapses all six rows into one group under a column literally named[object Object], holding null.It also runs every grouped case a second time through the plain structured spelling
{ field: 'region' }— the half of the union that no capability bit guards and that the engine pushes down to every driver.What it deliberately does not answer: whether a real mongod agrees.
$cond/$ifNull/$addToSetare modelled from the documentation, not observed — this environment cannot fetch a mongod binary (proxy 403), and a suite nobody has executed is a claim, not a check. That bound is written into the suite header, into theAGGREGATION_CASESenrolment list, and into the ledger note, because a green cell reads as more than it is.Ledger and table
scripts/check-driver-conformance.mjs: thedriver-mongodb×AGGREGATION_CASESDEBT row is deleted in this PR, with the suite that replaces it — the gate fails either half alone.driver-memory's row is untouched. The section note records that the deleted row was right about what it measured and under-counted what it had not (defect 3), which is the argument for the suite rather than against the ledger.packages/spec/src/data/aggregation-conformance.ts: comment-only —driver-mongodbmoves from the DEBT table to the enrolled list, with the bound above stated.Gates run locally
pnpm --filter @objectstack/driver-mongodb testpnpm --filter @objectstack/driver-mongodb typecheckpnpm --filter @objectstack/spec typecheckpnpm check:driver-conformance(+--self-test)eslint --no-inline-configon every changed file--filter @objectstack/driver-mongodb...)The rest of the lint farm is left to CI.
Deliberately untouched
packages/drivers/driver-memory— frozen under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499; the 2026-08-11 ruling lifted the freeze fordriver-mongodbalone.string_aggarm — retired fromAggregationFunctionat [spec] AggregationFunction 声明 8 个,SQL 族只实现 5 个 —— count_distinct / array_agg / string_agg 按 ADR-0049 enforce-or-remove 定去留 #6188 and unreachable through a spec-valid request. [drivers] driver-memory / driver-mongodb disagree with the SQL family oncount_distinct— the two AGGREGATION_CASES cells #6409 left open #6814 names it as not-a-defect; cleaning it is not this card.(query as any).aggregations || (query as any).aggregateread — that cast also carries the undeclaredquery.aggregatelimb [finding]query.aggregate与agg.func是 spec 从未声明的宽容别名,唯一书写者是驱动自己的 fixture(#4984 家族) #6321 deleted from the SQL faces. A separate convergence, noted in place.🤖 Generated with Claude Code
https://claude.ai/code/session_01PeD85qA1JNLXodmYdnu4Zm
Generated by Claude Code