fix: classify source-free aggregate calls correctly (COUNT(*)) - #25
Merged
Merged
Conversation
determine_edge_kind correctly identifies any aggregate function call as EdgeKind::ViaAggregation regardless of its arguments, but that kind was only ever attached to the graph as an edge to an ancestor column. COUNT(*) has no column ancestor (`*` is a FunctionArgExpr::Wildcard, not an Expr, so collect_ancestors never visits it), so the correctly computed kind was silently discarded and the output's transform classification fell back to Direct — indistinguishable from a literal constant. Store the defining expression's intrinsic edge kind on the Output node itself, and use it as a fallback in derive_transform whenever no ancestor edge exists to classify from. This generalizes to any zero-ancestor aggregate/conditional/expression, not just COUNT(*).
funcpp
force-pushed
the
fix/source-free-aggregates
branch
from
September 22, 2026 01:31
7036ebf to
35018c8
Compare
The kind a projection computes is stamped in two places — on the output
column and on every edge it draws to an ancestor — so the two normally
agree, and the previous rule read whichever was non-empty. They do not
always agree. A set operation redirects the other branch's edges onto this
column, carrying that branch's kind, and a branch that reaches no source
leaves no edge at all. Preferring the edges silently drops the column's own
kind in exactly the case where it is the only record of a branch:
SELECT COUNT(*) AS c FROM t UNION ALL SELECT a FROM u
`COUNT(*)` touches no column, so the left branch contributes no edge; only
the right branch's `Direct` edge survives, and the column read as `Direct`.
It is now `Aggregation`. Both records count, so the rule is a union.
That also settles the naming. Nothing here is intrinsic or a fallback — it
is the column's own kind, so `intrinsic_kind` becomes `kind`. And
`determine_edge_kind` never was about edges: six of its seven callers stamp
the result on a node, and the seventh is the set-operation redirect. It
classifies an expression, so it is `classify_expr`.
`derive_transform` no longer builds a slice to iterate; it probes the two
records in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three tests over the new rule: - which source-free projections move off `Direct` and which stay there, so the literal/function line is a decision and not an accident; - that a set operation counts every branch, including one that reached no source; - that classification still survives only the last hop, with the star form disagreeing with the named form on the same derived table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
funcpp
force-pushed
the
fix/source-free-aggregates
branch
from
September 22, 2026 01:56
35018c8 to
ebd86e8
Compare
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cherry-picked from #13 (
38149bf), one of the contributions in #15. Authorship preserved; the only conflict was the MERGE block restructured in #22.The defect
A projection's kind is computed once and stamped in one place — on the edges it draws to its ancestors:
An expression that reads no column draws no edge, so its kind had nowhere to go and
derive_transformfell through toDirect— indistinguishable fromSELECT 1 AS c.COUNT(*)is this case:*is aFunctionArgExpr::Wildcard, not anExpr, socollect_ancestorsnever visits it.classify_exprhad already called the expression an aggregate; there was just nowhere to put the answer.The kind is now stamped on the output column as well, and a column is classified from its own kind together with the kinds of its edges.
Why a union and not a fallback
The two records normally agree, so it is tempting to read whichever is present. They do not always agree. A set operation redirects the other branch's edges onto this column, carrying that branch's kind, and a branch that reaches no source leaves no edge at all. Preferring the edges drops the column's own kind in exactly the case where it is the only record of a branch:
The left branch touches no column and so contributes no edge; only the right branch's
Directedge survives. Under a fallback rulecreads asDirect. It isAggregation.Measured the other way too: classifying from the column's own kind alone passes the whole suite and changes exactly one of 42 shapes, because six of the seven edge-creation sites stamp the column's own kind onto its edges and nothing else reads
RawEdge.kind. The seventh — the set-operation redirect — is the one place the edges carry information of their own, and it is the reason both records have to count.Scope is wider than the title
This reaches every projection that resolves to no source, on every path that builds one. Measured against
927025dacross 42 query shapes — 18 changed, every one of themDirect→ something more specific, none the other way:SELECT COUNT(*),COUNT(1)DirectAggregationSELECT 1 + 2,-1,NOW()DirectExpressionSELECT CASE WHEN 1 = 1 THEN 2 ELSE 3 ENDDirectConditionalSELECT COUNT(*) OVER ()DirectExpressionSELECT (SELECT COUNT(*) FROM u),EXISTS (...)DirectExpressionUPDATE t SET a = NOW()DirectExpressionMERGE ... WHEN MATCHED THEN UPDATE SET a = NOW()DirectExpressionINSERT INTO o SELECT COUNT(*) FROM tDirectAggregationSELECT * FROM (SELECT COUNT(*) AS c FROM t) dDirectAggregationSELECT COUNT(*) FROM t UNION ALL SELECT a FROM uDirectAggregationWITH x AS (SELECT COUNT(*) AS n FROM t) SELECT SUM(n) FROM xDirectAggregationSELECT 1,'lit',NULL,CAST(1 AS INT)DirectDirectSELECT MAX(a),SELECT aTwo judgement calls worth naming:
Direct.classify_exprhas always mappedExpr::ValuetoDirect, but a literal has no ancestors, so that branch never produced an edge and the answer was unobservable. It is load-bearing now.Directwith an emptysourcesis still distinguishable from a real pass-through, which has one.COUNT(*) OVER ()lands onExpression, matching the documented meaning ofTransformKind::Expression("Expression or window function") and whatSUM(x) OVER (...)already reported.TransformKind::Windowis marked reserved and is produced nowhere.Worth calling out for consumers: filtering on
transform == Directto find pass-through columns will now correctly stop matchingNOW()and1 + 2.Known limitation, pinned by a test
Only the last hop classifies a column.
collect_output_sourcesrecords the kind of the immediate incoming edge and drops every kind met deeper in the walk, so an aggregate below a CTE or a derived table still reads asDirect:The third line shows this is not a question of missing sources. The column's own kind does not reach it either — that kind describes the outer projection, which really is a plain reference. Fixing it means propagating kinds through the transitive walk, which is deeper than this change and in the area #14 reworks. Unchanged from
main, so not a regression.One consequence is new here: expanding a star takes no second hop, so
SELECT * FROM (SELECT COUNT(*) AS c FROM t) dreportsAggregationwhileSELECT c FROM (...) dreportsDirect. Both wereDirectbefore. The test pins all four shapes.Also left for later:
RawEdge.kindis redundant everywhere except the set-operation redirect, and removing it would let a source-free branch contribute its kind through the node instead of through an edge it cannot draw — which is whatSELECT a FROM t UNION ALL SELECT COUNT(*) FROM ustill needs. Filed separately.Commits
776e9af— the fix, as contributed.57d2c70— classify from both records rather than preferring one, with the naming that follows:intrinsic_kind→kind,determine_edge_kind→classify_expr.ebd86e8— tests: the source-free boundary, the set-operation union, the last-hop gap and the star/named disagreement.Compatibility
No public type or signature change.
transformvalues change for the projections listed above — a 0.3.0 item alongside #18 and #23.🤖 Generated with Claude Code