Skip to content

fix(analytics)!: a measure emits what it declares, instead of COUNT(*) (#4157) - #4184

Merged
os-zhuang merged 1 commit into
mainfrom
claude/metric-expression-4157
Jul 30, 2026
Merged

fix(analytics)!: a measure emits what it declares, instead of COUNT(*) (#4157)#4184
os-zhuang merged 1 commit into
mainfrom
claude/metric-expression-4157

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4157.

COUNT(*) was the answer to three different questions

NativeSQLStrategy.resolveMeasureSql could not answer three inputs, and returned COUNT(*) to each — aliased under the name the caller asked for, so the result looked like an answer rather than a failure:

Input What came back
a measure the cube does not declare COUNT(*) AS "revenue" — a row count presented as revenue
a number/string/boolean metric the author's SQL expression discarded, replaced by a row count
an unrecognised type same silent substitution

The first is the one most likely to bite: lookupMember's synthetic relation fallback is dimension-only (if (kind === 'dimension')), so any undeclared or mistyped measure name reached it.

The second is what the spec explicitly invites — AggregationMetricType documents those three as "Custom SQL expression returning a number / string / boolean". The measure's sql is the computation: a ratio, a CASE, a window function. There is nothing to wrap; the only correct emission is the expression.

Now

  • undeclared measure → throws, naming the measures the cube does declare, so a typo is self-correcting;
  • custom-expression type → emits the expression unwrapped;
  • unrecognised type → throws, naming both accepted vocabularies;
  • the six aggregates are byte-for-byte unchanged.

A dot no longer implies a relationship hop

qualifyAndRegisterJoin split any dotted string into a join chain. So the expression SUM(account.amount) became:

"SUM(account"."amount)"          -- the "column"
LEFT JOIN "SUM(account" ON-- a join to a table that does not exist

Invalid SQL plus a phantom join. That was harmless only while the result was being thrown away for COUNT(*) — emitting the expression makes it matter, so the fix is a precondition, not a bonus.

A dotted string is now a path only when every segment is a bare identifier. account.amount still lowers to "account"."amount" with its LEFT JOIN; an expression is emitted as written. This also fixes the same mangling for an aggregate measure whose sql is an expression — type: 'sum' with sql: 'SUM(account.amount)' was producing the same garbage, independently of #4157.

Breaking, narrowly — and deliberately

Two inputs that used to produce SQL now raise: a query naming an undeclared measure, and a cube measure whose type is outside AggregationMetricType.

Both were returning a wrong number rather than data, so nothing correct can depend on them. A caller who was silently getting row counts will now see an error — which is the point, and the same trade #3948 settled for the drivers.

Datasets are unaffected. aggregateToMetricType only ever emits an AggregationFunction member and throws for a measure with no aggregate, so a compiled dataset never had a custom-expression measure or an unknown type. The reachable path is a hand-authored Cube.

The guard

metric-type-coverage.test.ts asserts the aggregate and expression sets partition AggregationMetricType, so a tenth metric type fails a test rather than reaching the throw.

Both sets are named, not derived as each other's complement. Deriving would have classified a new aggregate the spec grows (median, …) as an expression and emitted a bare column — a different silent wrong answer. Same reasoning as #4153, which named the aggregate set for the same reason.

Verification

  • 460 tests across 35 files green — including the four existing suites that assert COUNT(*). All four use a declared type: 'count' metric ({ type: 'count', sql: '*' }), so none of them relied on a fallback; that is what makes this safe.
  • 14 new tests: the three expression types emitted verbatim, grouped and ungrouped; the expression-with-a-dot left intact with no phantom join; a real relationship path still qualified and joined; and each of the three throws, including that the error text names the declared measures and both vocabularies.
  • Confirmed the tests have teeth: reverting all three fixes fails 6 of 10 in the behaviour suite, naming each regression.
  • tsc --noEmit: 0 errors in the three touched files, with workspace deps built with types. eslint --no-inline-config: clean.

One judgement left in place on purpose: in a grouped query a custom expression must itself be aggregate-shaped, because measures never join GROUP BY (only dimensions do). That is the author's contract to keep — silently substituting COUNT(*) was not keeping it for them, and the code comment now says so.

Refs #4153, #3948, objectstack-ai/objectui#2945

🤖 Generated with Claude Code

#4157)

resolveMeasureSql answered `COUNT(*)` to three questions it could not
otherwise answer, each time aliased under the name the caller asked for so
the result looked like an answer:

1. A measure the cube does not declare. lookupMember's synthetic relation
   fallback is dimension-only, so any undeclared or mistyped measure landed
   here — `measures: ['revenue']` returned `COUNT(*) AS "revenue"`.
2. A number/string/boolean metric. AggregationMetricType documents these as
   "Custom SQL expression returning a …": the measure's `sql` IS the
   computation. It was discarded and replaced by a row count.
3. An unrecognised `type`. Same silent substitution.

Now the first and third throw — naming the declared measures, and both
accepted vocabularies — and a custom-expression type emits its expression
unwrapped. The six aggregates are unchanged.

Also: a dot no longer implies a relationship hop. qualifyAndRegisterJoin
split any dotted string into a join chain, so `SUM(account.amount)` became
`"SUM(account"."amount)"` plus a `LEFT JOIN "SUM(account"` — invalid SQL
naming a table that does not exist. Harmless only while the result was
discarded for COUNT(*). A dotted string is a path only when every segment is
a bare identifier, so `account.amount` still lowers to a qualified column
and a join. That also fixes the same mangling for an AGGREGATE measure whose
sql is an expression.

Breaking, narrowly: two inputs that used to produce SQL now raise. Both were
returning a wrong number rather than data, so nothing correct can depend on
them — the trade #3948 settled for the drivers. Datasets are unaffected;
aggregateToMetricType only ever emits an AggregationFunction member, so the
reachable path is a hand-authored Cube.

metric-type-coverage.test.ts asserts the aggregate and expression sets
PARTITION AggregationMetricType, so a tenth type fails a test rather than
reaching the throw. Both sets are named rather than derived as each other's
complement — deriving would classify a new aggregate as an expression and
emit a bare column.

460 tests / 35 files green, including the four suites asserting COUNT(*) (all
use a declared `type: 'count'` metric). The 14 new tests were confirmed
failing against the old behaviour before the fix.

Closes #4157. Refs #4153, #3948, objectstack-ai/objectui#2945

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 2:23pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): packages/services.

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

  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/plugins/packages.mdx (via packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)

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
os-zhuang merged commit 99ffc04 into main Jul 30, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/metric-expression-4157 branch July 30, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A number/string/boolean metric's SQL expression is replaced by COUNT(*)

1 participant