Skip to content

fix(explore): omit constant Custom SQL dimensions from GROUP BY - #45271

Merged
sadpandajoe merged 4 commits into
masterfrom
fix-literal-adhoc-column-group-by
Oct 22, 2026
Merged

sadpandajoe merged 4 commits into
masterfrom
fix-literal-adhoc-column-group-by

Conversation

@sadpandajoe

Copy link
Copy Markdown
Member

SUMMARY

A chart dimension or X-axis defined as a Custom SQL constant (for example the string literal 'Other') is emitted as a GROUP BY key as well as a SELECT column. PostgreSQL and related engines reject a bare non-integer constant there, so the chart fails with non-integer constant in GROUP BY; an integer constant is silently read as a select-list ordinal instead. The same literal works in the SELECT list, and an expression such as CASE WHEN 1=1 THEN 'x' END happens to work because it is not a bare constant.

For example, a Bar Chart with X-axis Custom SQL 'Other', metric SUM(amount) and dimension region produced:

SELECT 'Other' AS bucket, region, SUM(amount) AS total
FROM orders
GROUP BY 'Other', region

Root Cause

ExploreMixin.get_sqla_query (superset/models/helpers.py) turns every selected groupby expression into a GROUP BY key, unconditionally. The Label wrapper added for the SELECT column does not change how the key is rendered in GROUP BY, so a bare constant is emitted verbatim. Grouping by a constant never changes the groups, so omitting it is result-preserving whenever another key remains. The pattern is the same for Custom SQL, calculated columns whose expression is a literal, raw-string groupby entries, and the series-limit inner subquery; there is no other GROUP BY emitter for chart queries.

Reproduced on PostgreSQL 16.2: 'Other', ('Other'), 1.5 and NULL as a GROUP BY key all fail, while CASE …, CAST('Other' AS VARCHAR) and aliases succeed.

The fix

  • superset/sql/parse.py: new is_constant_expression() next to has_aggregate(). It is True only for a single bare string/numeric literal, NULL, or boolean literal (parentheses stripped); anything that merely contains a constant (CASE, CAST, function calls, 'a' || col) is not constant, and a parse error fails closed. TRUE/FALSE are not treated as constants on SQLite, where they resolve to a same-named column.
  • superset/models/helpers.py: one helper, ExploreMixin._groupby_clause_exprs, filters provably-constant keys out of the GROUP BY at the four emission sites (main query, series-limit inner subquery, and the two "Others" rebuilds). The constant stays in the SELECT list under its label. Queries without a constant key are unchanged.
  • Alternatives considered: positional or alias GROUP BY (Oracle forbids ordinals, and PostgreSQL matches metric expressions structurally), wrapping the literal in CAST (needs a per-dialect type and MSSQL still rejects it), and an engine-spec flag (a per-dialect list that cannot be verified).
  • Known limitation: constants are only omitted while at least one other key remains. If every key is constant, the keys are left unchanged (existing behavior), because dropping GROUP BY there is only correct when the query aggregates. The GROUPING SETS branch is untouched.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Not applicable: backend SQL generation change with no frontend diff. The regression tests below show the generated SQL before and after.

TESTING INSTRUCTIONS

pytest tests/unit_tests/models/groupby_literal_column_test.py tests/unit_tests/sql/parse_tests.py -k "constant or literal or group_by or is_constant"

The new tests compile the query for the PostgreSQL dialect and assert no bare constant remains in GROUP BY for Custom SQL, calculated-column, raw-string and series-limit shapes; assert CASE/CAST/function expressions stay as keys; and run the generated SQL on in-memory SQLite to check that a TRUE column still groups correctly. Before the change the primary test fails with bare constant in GROUP BY: ["'Other'"].

Manual check on PostgreSQL: create a Bar Chart with X-axis > Custom SQL 'Other', a metric, and a dimension, then run it. It previously failed with non-integer constant in GROUP BY.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.29630% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 84.13%. Comparing base (98fac91) to head (0e34933).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
superset/models/helpers.py 93.33% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #45271   +/-   ##
=======================================
  Coverage   84.12%   84.13%           
=======================================
  Files        3056     3056           
  Lines      201418   201438   +20     
  Branches    47095    47098    +3     
=======================================
+ Hits       169441   169471   +30     
+ Misses      28729    28718   -11     
- Partials     3248     3249    +1     
Flag Coverage Δ
hive 34.88% <22.22%> (-0.01%) ⬇️
mysql 52.83% <70.37%> (+<0.01%) ⬆️
postgres 52.83% <70.37%> (+<0.01%) ⬆️
presto 36.61% <48.14%> (+<0.01%) ⬆️
python 87.36% <96.29%> (+0.01%) ⬆️
sqlite 52.59% <70.37%> (+<0.01%) ⬆️
unit 81.74% <96.29%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sadpandajoe
sadpandajoe marked this pull request as ready for review October 16, 2026 23:04

def test_all_constant_keys_with_a_metric_keep_group_by(table: SqlaTable) -> None:
select, sql = _compile(table, columns=[_adhoc("'Other'")])
assert _group_keys(select) == ["'Other'"], sql

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.

Suggestion: _compile only checks generated SQL, so this assertion passes with GROUP BY 'Other', which PostgreSQL rejects; charts with only a constant dimension still fail.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Incomplete implementation

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/models/groupby_literal_column_test.py
**Line:** 186:186
**Comment:**
	*Incomplete Implementation: `_compile` only checks generated SQL, so this assertion passes with `GROUP BY 'Other'`, which PostgreSQL rejects; charts with only a constant dimension still fail.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the current approach: when every GROUP BY key is constant the keys are left unchanged on purpose, because omitting GROUP BY is only correct when the query aggregates, which is not known at that point (disclosed in the PR description). The tests compile the generated SQL for the PostgreSQL dialect like the neighbouring unit tests; a live PostgreSQL run is outside the unit-test layer.

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.

✅ Customized review instruction saved!

Instruction:

Do not require omitting constant GROUP BY keys when aggregate context is unavailable; omitting GROUP BY is only correct when the query is known to aggregate.

Applied to:

  • tests/unit_tests/models/groupby_literal_column_test.py

💡 To manage or update this instruction, visit: CodeAnt AI Settings

@codeant-ai-for-open-source

codeant-ai-for-open-source Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

CodeAnt PR Risk: Medium Risk

  • The PR needs attention before merging.
  • When every dimension is constant, the GROUP BY retains the literal; PostgreSQL rejects this, so charts with only a constant dimension can still fail.

Assessed commit: 0e34933ce5d8

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Careful fix, the fails-closed is_constant_expression (including the SQLite TRUE/FALSE-as-identifier edge case) and restricting detection to raw literal_column/ColumnClause constructs rather than compiling arbitrary expressions are the right calls. Good test coverage compiling real SQL through sqlglot rather than just checking the Python-side expression tree.

CodeAnt's "every key is constant" point is the same limitation already disclosed in the PR body (GROUP BY is left alone there since dropping it depends on whether the query aggregates, which isn't known at that point) — not a new gap. LGTM!

@EnxDev EnxDev 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.

Read through this one and the approach holds up. Filtering at the four GROUP BY sites through one helper, and only looking at raw literal_column text, keeps the blast radius small.

Left two notes inline: a series-limit case where the chart still fails even though the main query has a real key, and negative numeric literals slipping past is_constant_expression. I didn't repeat the all-constant-keys limitation since it's disclosed and already discussed above. The red check-openapi-spec-drift check looks unrelated, nothing here touches the API.

inner_time_filter = [_inner_filter]
subq = subq.where(and_(*(where_clause_and + inner_time_filter)))
subq = subq.group_by(*inner_groupby_exprs)
subq = subq.group_by(*self._groupby_clause_exprs(inner_groupby_exprs))

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.

The all-constant fallback bites here in a case that isn't the disclosed one. A line chart with a time x-axis, one dimension 'Other' and a series limit gets a fixed outer query (GROUP BY ds), but the inner subquery only groups by series columns, so it's back to GROUP BY 'Other' and Postgres still rejects the chart.

When every series column is constant there's exactly one series, so the limit can't cut anything. Could we skip the series-limit subquery (and the Others rebuild) in that case? A test with a non-constant x-axis plus a single constant series column would pin it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, this is a real gap and I looked at it closely. I prototyped skipping the series-limit pass when every series column is constant. It works for the plain case, but review kept finding queries where the result differs from today's behaviour: a separate selection window (including timezone-aware bounds that compare equal as instants but render to different SQL), a NULL series (NULL = NULL never joins), a time filter consumed by a dataset template, non-deterministic filters evaluated separately in the outer query and the selection, a templated constant that renders differently in the no-join prequery path, and an empty-string series on Oracle. Closing those needs more than a local condition, so I've left the series-limit path unchanged in this PR; the constants-only limitation stays documented in the description. Happy to take it on in a follow-up PR if you think it is worth pursuing.

Comment thread superset/sql/parse.py Outdated
node = parsed.expressions[0].unnest()
if isinstance(node, exp.Boolean):
return dialect != Dialects.SQLITE
return isinstance(node, (exp.Literal, exp.Null))

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.

sqlglot parses -1 and -1.5 as Neg(Literal), so they come back non-constant and stay in GROUP BY. Postgres folds the minus into the constant, so a -1 sentinel dimension still fails (GROUP BY position -1 is not in select list), and -1.5 hits the same non-integer constant error.

is_number already treats a negated numeric literal as a number:

Suggested change
return isinstance(node, (exp.Literal, exp.Null))
return node.is_number or isinstance(node, (exp.Literal, exp.Null))

Worth adding -1 and (-1.5) to the parametrized cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in ef2a16b. is_constant_expression now returns node.is_number or isinstance(node, (exp.Literal, exp.Null)), so negated numeric literals such as -1 and (-1.5) are treated as constants and omitted from GROUP BY. Added -1, (-1.5) and SQLite -1 as constant cases and -region, -(1 + 1), - 'a' and -TRUE as non-constant cases to test_is_constant_expression, plus -1/(-1.5) to the model-level constant-dimension test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Update: I rebased the branch onto current master (it was behind, which broke an unrelated required e2e step). The patch is unchanged; this fix is now 0e34933 (was ef2a16b).

@netlify

netlify Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 0e34933
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ada7eb54ad223a2efbd4efb
😎 Deploy Preview https://deploy-preview-45271--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

A Custom SQL dimension holding a bare string literal is emitted verbatim as
a GROUP BY key, which PostgreSQL rejects with "non-integer constant in
GROUP BY". Pin the compiled PostgreSQL GROUP BY so the constant is not a
group key while the labelled column stays in SELECT.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Custom SQL dimension that is a bare constant (e.g. 'Other') was emitted
verbatim as a GROUP BY key. PostgreSQL rejects a non-integer constant there
("non-integer constant in GROUP BY"), and an integer constant is read as a
select-list ordinal.

Omit provably-constant keys (bare string/numeric/boolean literal or NULL,
parentheses stripped) from the GROUP BY of the main query, the series-limit
inner subquery and the "Others" rebuilds; they stay in SELECT under their
label. If every key is constant and the query aggregates, GROUP BY is
omitted; without an aggregate the keys are left unchanged. Expressions that
merely contain a constant (CASE, CAST, concatenation) remain keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… as identifiers on SQLite

Only omit constant GROUP BY keys while a non-constant key remains. When
every key is constant the keys are left unchanged, since whether GROUP BY
can be dropped depends on the query aggregating, which is not known there.

On SQLite TRUE and FALSE are identifiers that resolve to a same-named
column, so they are not bare constants: dropping (TRUE) as a key would
merge groups. Boolean literals stay constants on other engines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
sqlglot parses a negated number such as `-1` as Neg(Literal), so it was not
recognized as a bare constant and stayed a GROUP BY key. PostgreSQL rejects
`GROUP BY -1` and `GROUP BY -1.5`, so treat a negated number as a constant.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sadpandajoe
sadpandajoe force-pushed the fix-literal-adhoc-column-group-by branch from ef2a16b to 0e34933 Compare October 22, 2026 21:22
@sadpandajoe
sadpandajoe merged commit 43006c1 into master Oct 22, 2026
73 checks passed
@sadpandajoe
sadpandajoe deleted the fix-literal-adhoc-column-group-by branch October 22, 2026 22:26
hughhhh added a commit that referenced this pull request Oct 23, 2026
Two conflicts, both additive:
- superset/datasets/schemas.py: keep FilterOperator (partition mapping
  preview) and master's is_valid_d3_format import (#45633).
- superset/models/helpers.py: the series-limit subquery keeps the branch's
  inner-window partition mirrors in its WHERE and takes master's
  _groupby_clause_exprs for its GROUP BY (#45271).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants