Skip to content

fix: support COUNT(DISTINCT col1, col2, ...) multi-column distinct#25325

Merged
mergify[bot] merged 12 commits into
matrixorigin:mainfrom
ck89119:issue-25284-main
Jul 6, 2026
Merged

fix: support COUNT(DISTINCT col1, col2, ...) multi-column distinct#25325
mergify[bot] merged 12 commits into
matrixorigin:mainfrom
ck89119:issue-25284-main

Conversation

@ck89119

@ck89119 ck89119 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #25284

What this PR does / why we need it:

Support COUNT(DISTINCT col1, col2, ...) multi-column distinct count, matching MySQL semantics:

  • COUNT(DISTINCT col1, col2) — counts distinct non-NULL combinations
  • COUNT(DISTINCT (col1, col2)) — tuple syntax, same semantics
  • COUNT(col1, col2) without DISTINCT — properly rejected with syntax error

Changes

Layer File Change
Plan/Function list_agg.go Allow COUNT to accept multiple arguments
Plan/Binder having_binder.go Reject multi-arg COUNT without DISTINCT
Plan/Optimizer distinct_agg.go Skip GROUP BY optimization for multi-arg/tuple; expand tuple args
Plan utils.go Fix increaseRefCnt missing *plan.Expr_List handling
Executor count2.go, types.go Support multiple arg types
Executor aggState.go Multi-vector encoding with length-prefix; boundary check fix
Tests count2_test.go 4 new unit tests (distinct/NULL/multi-group/merge)
BVT func_aggr_count.test/result New test cases, updated expected results

🤖 Generated with Claude Code

- Allow COUNT to accept multiple arguments (len(inputs) >= 1)
- Reject COUNT(col1, col2) without DISTINCT with syntax error
- Skip GROUP BY optimization for multi-arg and tuple COUNT DISTINCT
- Expand tuple args in distinct_agg to reuse multi-vector encoding path
- Fix increaseRefCnt missing *plan.Expr_List handling (scan output columns)
- Implement multi-vector encoding in batchFillArgs with length-prefix format
- Update readStateArg to guard argTypes[0] access with usesOpaqueArgEncoding
- Change makeCount/newCountColumnExec signatures to accept []types.Type
- Add unit tests for multi-column distinct (distinct/NULL/multi-group/merge)
- Update BVT test cases and expected results

Co-Authored-By: Claude <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for one correctness blocker.

COUNT(DISTINCT (a, b)) is only normalized inside optimizeDistinctAgg, and only after the rule has already accepted the narrow single-aggregate shape. That leaves valid tuple syntax unexpanded when another aggregate is present in the same AGG node.

Example planner reproduction on this PR:

select count(distinct (n_nationkey, n_regionkey)), count(*) from nation;

The resulting AGG node still contains count with args=1 and T_tuple for the first aggregate, while starcount is the second aggregate. This happens because distinct_agg.go returns before the tuple-expansion block when len(node.AggList) != 1.

That shape is not executable as a row-wise multi-column distinct key. The group operator evaluates aggregate args through MakeEvalVector, and Expr_List is handled by ListExpressionExecutor, which builds a vector from the list elements themselves (UnionOne(vec, 0) for each child) rather than producing one tuple value per input row. So an unexpanded tuple aggregate input is semantically wrong and can miscount or fail once BatchFill indexes it using input-row offsets.

Suggested fix:

  • Do semantic normalization outside this optimization rule. Prefer flattening COUNT(DISTINCT (a, b)) into the same arg list as COUNT(DISTINCT a, b) during binding or in a general aggregate-normalization pass that runs for every aggregate expression, independent of len(node.AggList) and independent of whether the distinct-agg optimization fires.
  • Keep optimizeDistinctAgg only as an optimization; it should not be required for correctness.
  • Add regression coverage for tuple syntax with more than one aggregate, for example:
    • select count(distinct (g, v)), count(*) from t_count_distinct_multi;
    • select txt, count(distinct (g, v)), count(*) from t_count_distinct_multi group by txt;
    • compare count(distinct (g, v)) with count(distinct g, v) in the same query.

The direct multi-arg executor path and local merge tests look directionally correct, but this tuple path still violates the PR's stated "tuple syntax, same semantics" contract.

ck89119 and others added 2 commits July 2, 2026 00:30
Previously, COUNT(DISTINCT (a, b)) tuple args were only expanded inside
optimizeDistinctAgg, which runs only when len(AggList)==1 and the
single-aggregate shape is accepted. When another aggregate was present
(e.g. count(distinct (a,b)), count(*)), the tuple was never expanded,
causing incorrect results.

Move the normalization to HavingBinder.BindAggFunc so that every
aggregate expression with a T_tuple arg gets expanded to multi-arg
form, independent of the optimization rule.

Add regression tests:
- tuple syntax with multiple aggregates
- tuple syntax with GROUP BY
- tuple vs multi-arg comparison in same query

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

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on current head 7580283.

The previous tuple-with-peer-aggregate blocker looks fixed, but the current implementation opens a broader COUNT argument-validation hole.

pkg/sql/plan/function/list_agg.go:33 now lets count bind with len(inputs) >= 1, while the non-DISTINCT multi-arg guard only exists in HavingBinder.BindAggFunc (pkg/sql/plan/having_binder.go:149). That does not cover tuple-as-one-arg and does not cover the window binder path. I verified these with a temporary planner test: each query below expected a bind error, but BuildPlan returned nil error on this PR:

select count((n_nationkey, n_regionkey)) from nation;
select count(n_nationkey, n_regionkey) over() from nation;
select count((n_nationkey, n_regionkey)) over() from nation;

This is not only a parse/compat issue. If the non-DISTINCT aggregate tuple path reaches execution after HavingBinder expands the tuple, countColumnExec.BatchFill takes the non-DISTINCT branch and only reads vectors[0] (pkg/sql/colexec/aggexec/count2.go:169-175), so it can silently behave like COUNT(first_arg) instead of rejecting an invalid COUNT form.

Suggested fix direction:

  • Put COUNT argument validation in one shared binder helper and call it from both aggregate and window binding paths before bindFuncExprImplByAstExpr.
  • Only allow multiple COUNT arguments for aggregate COUNT(DISTINCT expr, expr, ...).
  • Only expand COUNT(DISTINCT (a, b)) when funcName == "count" && astExpr.Type == tree.FUNC_TYPE_DISTINCT; do not expand tuple args for non-DISTINCT COUNT.
  • Window COUNT has DISTINCT disabled here already, so reject both COUNT(a,b) OVER() and COUNT((a,b)) OVER().
  • Add regression coverage for the three negative queries above, alongside the existing positive tuple/peer-aggregate cases.

Also please add an executor round-trip test for multi-column COUNT(DISTINCT ...) through SaveIntermediateResult/UnmarshalFromReader. I locally checked that such a round trip currently works, but this PR changed writeStateArg/readStateArg opaque key encoding, so that path should be explicitly covered.

Introduce validateCountArgs() as a single validation helper, called from
both HavingBinder.BindAggFunc and bindWindowFuncExpr before the
bindFuncExprImplByAstExpr call. This closes two holes in the previous
COUNT argument validation:

1. COUNT((a,b)) (tuple) — len(astExpr.Exprs)==1 so the old multi-arg
   guard missed it; the tuple was then expanded unconditionally.
2. Window COUNT(a,b) OVER() and COUNT((a,b)) OVER() — the window binder
   had no COUNT argument validation at all.

The tuple-expansion block in BindAggFunc now additionally checks
funcName=="count" && astExpr.Type==FUNC_TYPE_DISTINCT, so it never
expands a non-DISTINCT tuple into multi-arg form.

Fixes issues raised by XuPeng-SH's review on PR matrixorigin#25325.

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

@aunjgr aunjgr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tuple expansion only fires in the optimization rule — count((a,b)) without DISTINCT still leaks through. Validation is scattered across HavingBinder, window binder, and the optimizer. Should have a single binding-time gate that rejects multi-arg / tuple non-DISTINCT COUNT uniformly.

…round-trip

- BVT: count((g,v)), count(g,v) over(), count((g,v)) over() must fail
  with 'Incorrect arguments to COUNT'
- UT: multi-column COUNT(DISTINCT) state survives
  SaveIntermediateResultOfChunk/UnmarshalFromReader round trip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ck89119 and others added 3 commits July 4, 2026 01:41
- having_binder: only expand a genuine Expr_List tuple, not a row subquery
  Expr_Sub that also carries Typ.Id == T_tuple, which nil-derefed GetList().
- list_agg: build a cast list matching arg count so COUNT(DISTINCT NULL, x)
  returns 0 instead of failing with 'cast types length not match args length'.
- aggState: grow the distinct-key arena by enough to fit an oversized key
  (arenaskl.MaxNodeSize) instead of a fixed 512 KiB step, which could still
  ErrArenaFull for large multi-column keys.

Tests:
- plan: TestCountDistinctRowSubqueryNoPanic (BuildPlan must not panic).
- aggexec: multi-large-key-arena-grow (two 700 KiB text cols).
- bvt: COUNT(DISTINCT NULL, ...) positive cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A multi-column row subquery — COUNT(DISTINCT (SELECT a, b ...)) — binds to an
Expr_Sub whose Typ.Id is T_tuple but is not an Expr_List. Previously the guard
merely skipped expansion to avoid a nil-deref, leaving a single T_tuple arg that
downstream handled as an opaque NYI error and, in flatten paths, could collapse
to the subquery's first column. Reject it explicitly during binding instead.

Strengthen the planner test to assert the bind-time error (and nil plan) rather
than only NotPanics, and add a BVT negative case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…main

# Conflicts:
#	pkg/sql/plan/build_test.go

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 05e8dd1. The previous blockers are fixed in the right place:

  • COUNT arg validation is now a shared binding-time gate for aggregate and window paths, so non-DISTINCT multi-arg/tuple COUNT is rejected before execution.
  • COUNT(DISTINCT (a, b)) is normalized during aggregate binding instead of depending on the distinct-agg optimization rule, so tuple syntax works with peer aggregates and grouped aggregates.
  • Multi-column row subquery input is rejected explicitly instead of leaking an Expr_Sub/T_tuple into execution.
  • Executor state now treats multi-arg distinct keys as opaque encoded keys, with merge/marshal and large-key arena growth covered.

Local verification:

  • go test ./pkg/sql/colexec/aggexec -run 'TestCount|TestPR25325Manual'
  • go test ./pkg/sql/plan -run 'TestCountDistinctRowSubqueryRejected|TestPR25325ManualCountArgBindingClosure|TestRewriteCountNotNullColToStarcount'

I also added temporary local-only scratch cases for varchar boundary-collision keys like ('a','bc') vs ('ab','c') and for valid/invalid planner closure around aggregate/window COUNT. They passed and were removed before review submission.

CI is green on the current head. Non-blocking note: the PR is still BEHIND main, so it may need an update branch before merge depending on queue policy.

@mergify

mergify Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-07-06 06:40 UTC · Rule: main · triggered by rule Automatic queue on approval for main
  • Checks failed · in-place
  • 🚫 Left the queue2026-07-06 08:17 UTC · at 682c7d91a6874184b8392cbc5309164490f80854

This pull request spent 1 hour 37 minutes 2 seconds in the queue, with no time running CI.

Waiting for
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-skipped = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-success = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-skipped = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-success = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
    • check-success = Matrixone Utils CI / Coverage
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64
    • check-success = Matrixone CI / SCA Test on Linux/arm64
All conditions
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-skipped = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-success = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-skipped = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-success = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
    • check-success = Matrixone Utils CI / Coverage
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64
    • check-success = Matrixone CI / SCA Test on Linux/arm64
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-approved [🛡 GitHub branch protection]
  • github-review-decision = APPROVED [🛡 GitHub branch protection]

Reason

The merge conditions cannot be satisfied due to failing checks

Failing checks:

Hint

You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a @mergifyio queue comment.

Requeued — the merge queue status continues in this comment ↓.

@mergify

mergify Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-07-06 10:21 UTC · Rule: main · triggered by rule Automatic queue on approval for main
  • Checks skipped · PR is already up-to-date
  • Merged2026-07-06 10:21 UTC · at 682c7d91a6874184b8392cbc5309164490f80854 · squash

This pull request spent 23 seconds in the queue, including 1 second running CI.

Required conditions to merge
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-approved [🛡 GitHub branch protection]
  • github-review-decision = APPROVED [🛡 GitHub branch protection]
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-neutral = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
    • check-skipped = Matrixone Standlone CI / Multi-CN e2e BVT Test on Linux/x64(LAUNCH, PROXY)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH, PESSIMISTIC)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(Optimistic/PUSH)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-neutral = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
    • check-skipped = Matrixone Standlone CI / e2e BVT Test on Linux/x64(LAUNCH,Optimistic)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-neutral = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
    • check-skipped = Matrixone Upgrade CI / Compatibility Test With Target on Linux/x64(LAUNCH)
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone Utils CI / Coverage
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
  • any of [🛡 GitHub branch protection]:
    • check-success = Matrixone CI / SCA Test on Linux/arm64
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working size/L Denotes a PR that changes [500,999] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants