Skip to content

Fix accumulate / merge - #2494

Draft
shangyian wants to merge 4 commits into
DataJunction:mainfrom
shangyian:fix/single-level-accumulate
Draft

shangyian wants to merge 4 commits into
DataJunction:mainfrom
shangyian:fix/single-level-accumulate

Conversation

@shangyian

Copy link
Copy Markdown
Collaborator

Summary

Test Plan

  • PR has an associated issue: #
  • make check passes
  • make test shows 100% unit test coverage

Deployment Plan

@netlify

netlify Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 canceled.

Name Link
🔨 Latest commit 3b294fb
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6a99fa0ad56b00000817c4ee

Fixes DataJunction#2489.

A non-decomposable metric (a percentile, MAX_BY, ...) drags every metric
sharing its parent down to a raw-grain grain group, so the CTE projects
raw columns instead of applying each component's accumulate function.
The final SELECT still applied the components' merge functions, which is
only correct where accumulate and merge coincide:

  AVG(x)                    -> SUM(x) / SUM(x), silently 1.0
  COUNT(x)                  -> SUM(x), adds up the values
  COUNT(*)                  -> SUM(alias.*), invalid SQL
  APPROX_COUNT_DISTINCT(x)  -> sketch union over a raw column
  SUM/MIN/MAX               -> unaffected

Route those metrics down the branch that already serves genuinely
non-decomposable metrics: emit the metric's own expression over the raw
columns. The invariant is now carried explicitly as
GrainGroupSQL.components_accumulated instead of being inferred from a
component_aliases miss, which used to fall back to a raw column name and
produce wrong SQL rather than failing.

Also project each raw column once. Two components can read the same
column (AVG needs SUM and COUNT), and a component can read a column the
grain group already projects, both of which made references to it
ambiguous. COUNT(*)'s "*" component is skipped entirely -- it reads no
column, and projecting it re-projected every column of the parent.

Adds a regression test per accumulate != merge shape, each paired with a
control asserting the pre-aggregated form when the metric is requested
alone, plus SUM alongside a percentile to pin the shape that was always
correct.
approx_percentile(col, percentage) dispatched to the right infer_type
overload and then died calling it: the accuracy parameter was annotated
`ct.NumberType | None` but had no default, so a two-argument call raised
`TypeError: infer_type() missing 1 required positional argument`. Creating
a metric like `SELECT APPROX_PERCENTILE(price, 0.9) FROM ...` surfaced
that as "Unknown TypeError on column ...". Give accuracy the `= None`
default the other optional parameters in this module use.

Also widen the scalar overload's percentage from FloatType to NumberType,
matching PERCENTILE, so a DOUBLE percentage is accepted wherever a FLOAT
one is.
Derived metrics are built by inlining their base metrics' expressions
(build_intermediate_metric_expr), so the fix for DataJunction#2489 already carries
them: v3.revenue_per_customer beside a percentile now divides by
APPROX_COUNT_DISTINCT(customer_id) instead of unioning HLL sketches that
were never built. Add the tests that pin this, and say so where the
inlining happens -- it is no longer only about COUNT DISTINCT from _agg
CTEs, it is what keeps the merge functions out of a raw-grain group.

Tests cover a derived metric whose components are self-merging
(v3.avg_order_value: SUM over COUNT DISTINCT, correct before and after --
its expression is byte-identical, only the grain group CTE lost the
duplicate order_id projection) and one whose components are not
(v3.revenue_per_customer), each alone and beside a percentile.

The percentile fixture is now APPROX_PERCENTILE, the function that
motivated the issue, and a PERCENTILE metric stays as a second
non-decomposable aggregation so the behavior is visibly not tied to one
function. Also assert an APPROX_PERCENTILE metric with an explicit
accuracy argument can be created and builds SQL.
find_upstream_node_names ends its recursive CTE with SELECT DISTINCT and
no ORDER BY, so parent_map's per-child list order is whatever the plan
produced. For a derived metric that order decides which base metric
resolves first, and with it the order their components are projected in
the grain group CTE -- so the same request produced different SQL in CI
than locally. Reversing every parent list reproduces CI's output exactly;
with this change the SQL is byte-identical either way.

Order the CTE, and sort the base metrics by name in
get_base_metrics_for_derived so component order is a property of the
metrics rather than of the graph walk that reached them. Sorting
grain_group.components by name would also work, but it reorders thirteen
existing expectations and replaces requested-metric order with
alphabetical-by-hash in the measures response; this way nothing else
moves.

find_join_paths_batch had the same exposure with more at stake: row order
set the frontier order and broke ties for a (source, dimension, role)
key, where the first path found is the one kept. Two same-depth routes to
one dimension sharing a role path would therefore join along whichever
route the query happened to return first. Order those queries too, so the
lowest dimension link id wins consistently.

The new tests perturb parent_map rather than pinning a string: they build
the SQL twice, once with every parent list reversed, and assert the two
are identical.
@shangyian
shangyian force-pushed the fix/single-level-accumulate branch from 068631b to 3b294fb Compare September 3, 2026 22:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant