Skip to content

fix(objectql)!: one key for the empty group bucket — real null, on both aggregation paths (#3839) - #3848

Merged
os-zhuang merged 1 commit into
mainfrom
fix/3839-null-bucket-key
Jul 28, 2026
Merged

fix(objectql)!: one key for the empty group bucket — real null, on both aggregation paths (#3839)#3848
os-zhuang merged 1 commit into
mainfrom
fix/3839-null-bucket-key

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3839.

Outcome

A grouped row whose dimension value is empty now carries real null for that dimension, whichever way the aggregate ran. Downstream code can test the empty bucket with a plain value == null again: charts render their own empty label, a drill on that bucket builds field = null and returns the rows it should, and a dashboard no longer changes shape when the driver, the granularity or the reference timezone changes.

The reproduction from the issue, re-run against this branch:

--- 日期分桶 groupBy (dateGranularity: 'month') ---
SQL : [{"key":"2026-01","type":"string","total":1},{"key":null,"type":"null","total":2}]
in-memory: [{"key":"2026-01","type":"string","total":1},{"key":null,"type":"null","total":2}]
MATCH : ✅ same key + type
--- 普通 groupBy (['stage']) ---
SQL : [{"key":null,"type":"null","total":2},{"key":"won","type":"string","total":1}]
in-memory: [{"key":null,"type":"null","total":2},{"key":"won","type":"string","total":1}]
MATCH : ✅ same key + type

Which way, and why that direction

The issue asked for the investigation before the change. Enumerating every consumer of '(null)' and of empty bucket keys across framework + objectui + cloud, the evidence is one-sided:

  • Nothing in production depends on the sentinel. The only hard dependencies in all three repos are four assertions in two objectql test files. Zero production code, in any repo, compares against '(null)'.
  • The stated justification is dead.in-memory-aggregation.ts said the string existed "to remain consistent with the client useReportData hook". That hook was removed with ADR-0021 — objectui packages/plugin-report/src/index.tsx is now just its epitaph — and the literal never appeared in it even when it existed.
  • Every consumer is already written against null, and the sentinel actively breaks them. objectui's empty labels are all keyed off == null / ??formatDimensionValue, useGroupedData / PivotTable / ReportViewer(empty), kanban → localized "Uncategorized", chart-series drops the null series. The sentinel bypassed all of them and rendered a raw English debug string. Worse, buildDatasetDrillFilter and ReportView build the drill as field = '(null)', so clicking the empty bucket returned zero rows instead of the empty-valued ones.
  • Everything else already emits real null: the pushed-down SQL path, cloud's Turso remote transport, and framework's own newer semantic-layer evaluator (preview-evaluator.ts).
  • No type or schema blocks it in either direction — the result row shape is untyped, and objectui types the group key as Record<string, unknown>.

Converging to null is therefore a bug fix, not just a cleanup.

Changes

  • applyInMemoryAggregation / bucketDateValue (objectql) key the empty bucket as null; bucketDateValue now returns string | null. A null instant and an unparseable one still share one bucket, because SQL cannot tell them apart either (strftime('%Y-%m', 'not-a-date') is NULL).
  • The internal composite bucket id is JSON-encoded so the empty bucket stays distinct from a row whose value is the literal string "null"${null} is 'null', so plain interpolation would have merged two real groups. Same in service-analytics' cross-object rebucketing.
  • bucketKeyToCalendarRange (core) accepts string | null. Behavior unchanged — the empty bucket has no calendar span, so a drill on it opens the unscoped superset — but the call site no longer casts a lie.
  • The driver output contract in spec now states the rule: a row with no value keys as null, never a sentinel. Propagating NULL through the bucket expression is the whole of it; a driver only breaks it by adding a COALESCE.

Gates

As the issue requested, checkDateBucketParity's deliberate exclusion is removed and its FIXTURE now carries a null instant.

Two latent bugs in that check had to be fixed first, or the new fixture would have been meaningless — and one of them would have made it silently bless the divergence:

  1. It folded bucket labels through String(value), which turns SQL NULL into 'null' — a label a TEXT column can genuinely hold. A side spelling "empty" as a string could compare equal to one returning real NULL. The empty bucket is now keyed out of band.
  2. It compared label sets with JSON.stringify, which is sensitive to key insertion order. Row order is not part of this contract and the two paths legitimately differ (SQL sorts its groups; the in-memory path emits first-seen order), so a driver with entirely correct buckets was reported as disagreeing — with an empty diff message, since describeDiff is keyed and could not name one. This actually fired on both real drivers once the null row was added. The comparison is now order-insensitive.

A new dogfood check, empty-group-bucket-parity.test.ts, covers the non-date half against real drivers — the issue's point that this was never date-specific — for both driver-sql and driver-sqlite-wasm, both groupBy shapes.

Verification

  • Both gates proven able to fail. With the sentinel temporarily restored and objectql rebuilt, all four new/extended real-driver cases go red with exactly the 空分组桶的键两条路不一致:下推 SQL 给 null,内存兜底给 "(null)"(不限日期分桶) #3839 signature: (null): sql=— in-memory=1, ‹empty bucket›: sql=1 in-memory=—. Restored and green again.
  • Two new negative controls pin the sentinel detection itself (a driver spelling empty as '(null)', and as 'null').
  • Suites green: objectql 1138, driver-sql 401, driver-sqlite-wasm 126, core 414, service-analytics 275, verify 7, dogfood 387.
  • pnpm build clean with no generated-artifact drift; pnpm lint clean; check:nul-bytes / check:doc-authoring / check:role-word / check:release-notes all OK.

Note for reviewers

The in-memory path also String()-coerces every non-null group value, so a numeric column's bucket keys come back as strings there and as numbers from SQL. That is a second, separate divergence in the same function with a much wider blast radius, and it is not touched here. Worth its own issue if you agree.

🤖 Generated with Claude Code

…both aggregation paths (#3839)
`engine.aggregate` has two implementations of one feature and picks between
them per query: it pushes the aggregate down as SQL when the driver advertises
every requested granularity and the reference timezone is UTC, otherwise it
fetches rows and buckets them in JS. The two disagreed about how to spell
"empty" — SQL NULL against the in-memory literal `'(null)'` — so the same
dataset produced a different bucket key type on SQLite+UTC+`month` than on
`week`, a non-UTC timezone, or driver-rest / driver-memory / a remote Turso.
The measures were always right; only the key's type and literal differed, which
is why it went unnoticed. It was never date-specific either — a plain
`groupBy: ['stage']` over a NULL column diverged the same way.
Consumers are written against `null`: they check `== null` and supply their own
empty label. The sentinel defeated every one of them, leaking a raw English
debug string into the UI and compiling a drill on the empty bucket to
`field = '(null)'`, which matches nothing. The comment justifying the string
cited the client `useReportData` hook, removed with ADR-0021 — and the literal
never appeared in it.
- `applyInMemoryAggregation` / `bucketDateValue` key the empty bucket as `null`;
`bucketDateValue` returns `string | null`. Null and unparseable instants still
share one bucket, because SQL cannot tell them apart either.
- The internal composite bucket id is JSON-encoded, so the empty bucket stays
distinct from a row whose value is the literal string `"null"`. Same in
service-analytics' cross-object rebucketing.
- `bucketKeyToCalendarRange` accepts `string | null` — the empty bucket has no
calendar span, so a drill on it opens the unscoped superset as before.
- The driver output contract in spec now states the rule.
Gates: `checkDateBucketParity`'s fixture deliberately had no null instant
because the divergence would have failed it for a reason it was not about; it
has one now. Two fixes made that meaningful — the check folded labels through
`String(value)`, which turns SQL NULL into `'null'` and could compare equal to a
sentinel string, and it compared label sets with `JSON.stringify`, which is
sensitive to key insertion order that the two paths legitimately differ on (a
correct driver could be flagged, with an empty diff message). A new dogfood
check covers the non-date half against real drivers.
Both gates were confirmed to fail with the sentinel restored.
Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 28, 2026 9:42am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 8 package(s): @objectstack/core, @objectstack/objectql, @objectstack/driver-sql, @objectstack/driver-sqlite-wasm, packages/qa, packages/services, @objectstack/spec, @objectstack/verify.

118 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core)
  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/core, packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/core, packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-sql, @objectstack/driver-sqlite-wasm, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql, @objectstack/driver-sqlite-wasm)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/core, packages/qa, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx(via packages/qa)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core, @objectstack/driver-sql)
  • content/docs/plugins/development.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/driver-sql, @objectstack/driver-sqlite-wasm, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql, @objectstack/driver-sqlite-wasm, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/driver-sql, @objectstack/driver-sqlite-wasm, @objectstack/spec, @objectstack/verify)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v15.mdx(via @objectstack/core, @objectstack/verify)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang

Copy link
Copy Markdown
ContributorAuthor

Follow-up filed as #3849 — the String() coercion divergence flagged in the "Note for reviewers" section, now with evidence. It is worse than described: on a boolean column the two paths produce 0/1 (SQL) vs "false"/"true" (in-memory) — no overlap at all, so that is a semantic divergence, not just a type one. Deliberately out of scope here.

@os-zhuang
os-zhuang merged commit a227ed7 into mainJul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the fix/3839-null-bucket-key branch July 28, 2026 11:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

空分组桶的键两条路不一致:下推 SQL 给 null,内存兜底给 "(null)"(不限日期分桶)

1 participant

@os-zhuang