Skip to content

Fix expression String methods - #3803

Open
zachmu wants to merge 85 commits into
mainfrom
zachmu/audit-expression-string
Open

Fix expression String methods#3803
zachmu wants to merge 85 commits into
mainfrom
zachmu/audit-expression-string

Conversation

@zachmu

@zachmu zachmu commented Sep 4, 2026

Copy link
Copy Markdown
Member

expr.String() is load-bearing in several contexts, and was implemented incorrectly for many expressions. This PR audits all expression types and fixes those problems.

This PR also implements Describe() for many types that were missing it, which is responsible for the changes in query plan test expectations.

Fixes SQL generation for expression String methods with companion plan updates in dolthub/doltgresql#3277.

@zachmu

zachmu commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Companion DoltgreSQL PR: dolthub/doltgresql#3277

@itoqa

itoqa Bot commented Sep 4, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: 7b35bdd: 19 test cases ran, 1 failed ❌, 17 passed ✅, 1 additional finding ⚠️.

Summary

Coverage spans SQL expression generation and execution, prepared-value binding, aggregates and window calculations, subqueries, JSON and spatial functions, typed literals, and safe handling of adversarial values. The run exercises both normal workflows and edge cases, with most behaviors working but a valid grouped window calculation failing during execution.

Merge with caution — the PR has a medium-severity, attributable execution failure affecting valid grouped queries that combine aggregation with window calculations, creating a correctness and availability risk for those users. A separate medium-severity binding concern is not attributable to this PR and is a flag for later rather than a merge driver.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity General The grouped query SELECT grp, ord, SUM(SUM(val)) OVER (PARTITION BY grp ORDER BY ord ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_sum FROM bf_order GROUP BY grp, ord ORDER BY grp, ord returned ERROR 1105: unable to find field with index 5 in row of 3 columns. A control query using the same partition and ordering rules without the grouped composition returned the expected tie-sensitive running totals, so the failure is specific to combining grouping with the window aggregate.
Aggregate Aggregate queries with arguments, partitions, ordering, and explicit frames ran successfully and returned the expected running totals and counts.
General JSON search worked with no path, one path, and two paths. A path containing a comma stayed together, so the search returned the expected paths.
General The geometry constructor accepted one, two, and three arguments and rejected zero and four with clear argument-count errors.
General Verified acceptable by independent adversarial review: the observed behavior is intended and documented in this codebase. Review notes: The finding invents a required binding error for an unset user variable, but both the session API tests and the prepared EXECUTE integration test affirmatively define an unset user variable as SQL NULL without error. Consequently, an empty result from a predicate involving that NULL is expected SQL behavior rather than a hidden binding failure. The PR also preserves the relevant lookup and error h…
General An implicit name for a scalar subquery was rejected, while named aliases worked through nested projections and returned 42.
Binding A prepared query read a value from the current session and used it in the right parameter position. With the session value set to 4, the query returned 5 as expected.
Binding A prepared query treated a session value set to NULL as SQL NULL, while a second value stayed 7. The query returned the expected result.
Json The JSON search expression keeps the escape value and optional paths in the supplied order. Native SQL checks returned the expected paths for multiple paths, escaped wildcard text, and no optional path.
Json JSON_VALUE omits the default return type and includes the requested non-default type. Both forms parsed and returned 42, and the focused serialization test passed.
Literal The date value stayed intact and the quoted column name was preserved in the generated SQL. The browser error came from connecting to a database port with an HTTP tool, not from the SQL expression behavior.
Literal A value containing a quote, semicolon, DROP TABLE text, and a comment marker stayed one value. The query returned the sentinel value and did not run an extra statement.
Rev The two-value angle calculation kept its operand order when converted to SQL. The direct query and the round-tripped query returned 0.4636476090008061, while reversing the values returned a different result as expected.
Rev Grouped queries returned the expected distinct values in order, with custom separators preserved. The code test also confirmed that escaped separators and window SQL are written correctly.
Rev The aggregate generator produced the same source as the checked-in file. The generator tests and aggregation package tests also passed, so aggregate SQL generation is working as expected.
Spatial The SQL function registry recognizes ST_EQUALS, builds the intended spatial expression, and produces SQL that the parser accepts.
Subquery Scalar subqueries ran successfully and returned the expected values. The query plan parsed, and an unnamed subquery expression could not be used as an outer column name.
Subquery Queries using IN with a subquery returned the right matches. A value in the subquery returned 1, a value outside it returned 0, and filtering the sample table returned Jane Doe.
⚠️ Medium severity General A prepared query using both session values and ordinary values could not complete the mixed-binding check, and the supported session-only control preserved its slots. Source inspection shows that the mixed path replaces the external binding set instead of merging it, so ordinary values can be lost.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 Mixed query values are lost
  • Severity: Medium Medium severity
  • Description: A prepared query using both session values and ordinary values could not complete the mixed-binding check, and the supported session-only control preserved its slots. Source inspection shows that the mixed path replaces the external binding set instead of merging it, so ordinary values can be lost.
  • Impact: Queries that mix session values with ordinary values may use the wrong values for some positions. This can return incorrect results for applications using this binding feature, but it does not by itself change stored data.
  • Steps to Reproduce:
    1. Prepare a statement with several positional parameters, such as SELECT ? AS p1, ? AS p2, ? AS p3, ? AS p4.
    2. Execute it with alternating session user variables and ordinary external values.
    3. Check that every supplied value appears in its original positional slot rather than being dropped or replaced.
  • Stub / mock content: The test used a local MySQL-compatible server and a temporary probe; no application mocks or route stubs were applied. The temporary test-only probe was removed after execution.
  • Code Analysis: In /tmp/output-agent-workspace/repo/engine.go, bindExecuteQueryNode builds tempBindings from eq.BindVars at lines 581-601. UserVar entries are converted and stored under v1, v2, and so on at line 597; ordinary entries are stored under v0, v1, and so on at line 599. The important defect is the branch at lines 603-607: when tempBindings is non-empty, the code calls binder.SetBindingsWithExpr(tempBindings) and does not merge the caller-provided bindings map. SetBindingsWithExpr in /tmp/output-agent-workspace/repo/sql/planbuilder/builder.go:155-160 assigns the supplied map as the complete BindvarContext. Therefore any ordinary positional bindings that exist only in the external bindings map are unavailable during the subsequent BindOnly call at engine.go:609-612. The PR diff changes the loop from string-prefix detection to *expression.UserVar detection and retains the existing SetBindingsWithExpr replacement; it does not add the missing merge. The smallest practical fix is to construct one binding map that starts with the external bindings and overlays the resolved user-variable entries using the correct positional keys, then pass that merged map to SetBindingsWithExpr.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

@zachmu

zachmu commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

@itoqa The attributable grouped-window failure is fixed in 457fd50 with an engine regression test. Please rerun against the current head.

@zachmu

zachmu commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

@itoqa The current head is 006422b; its only follow-up after the grouped-window fix scopes the MySQL-specific regression test to the MySQL dialect. CI is green. Please rerun against this head.

@itoqa

itoqa Bot commented Sep 4, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Report7b35bdd006422b: 12 test cases ran, 1 new failure ❌, 1 fixed ✅, 10 passing ✅.

Diff Summary

Coverage exercises grouped and ungrouped reporting calculations, running totals, ordering, filtering, null handling, repeated-query behavior, and result cleanup. It includes both normal report flows and edge-case or adversarial checks around query planning and resource lifecycle behavior.

Merge with caution — a PR-attributable medium-severity failure causes valid grouped reports that combine a running total with row numbering to return an error, despite other grouped-window behaviors passing. This is a functional limitation in the changed reporting path and is more than a minor caveat.

Tests run by Ito

View full run

Result State Severity Type Description
❌ New Failure Medium severity Rev The database returned an error for the query containing both the running total and row number. The running total query alone returned 15, 22, 3, and 7, and the row-number query alone returned 1 and 2 within each group, so the expected combined result should also have succeeded.
❌->✅ Fixed General The grouped query returned one row for each group and order pair, with running totals of 15, 22, 3, and 7. The earlier error came from the local SQL session setup, not from the query logic.
Passing General Each grouped row kept the correct group and order values, and the running totals stayed within the right group. The earlier error came from the local database session setup, not from the application query logic.
Passing General The grouped-window planner keeps the columns needed by HAVING and final ordering, so downstream checks use the correct grouped rows. The earlier error came from the local database session settings, not from the query behavior.
Passing General Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The cited code contradicts the finding's premise that closing a normally consumed result once can reach Dispose with a nil aggregation cache. That state requires a prior Close on the same populated grouped iterator; the earlier close in the reproduction is on a separately constructed iterator, and the reachable sort/window cleanup chain closes the grouped child only once. The PR does introduce a r…
Passing General The grouped window query completed successfully and returned the expected four rows. The earlier error came from the local SQL session setup, not from the query planner.
Passing General After a grouped query, the non-grouped query returned all five original rows with the expected running totals. A fresh session returned the same rows and values, so grouped query state did not change the later result.
Passing Grouping The grouped query returned one row for each group and order pair. Running totals were 15 and 22 for group 1, and 3 and 7 for group 2.
Passing Rev Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The repository does not support the finding's decisive causal explanation. A named ORDER BY resolves to the projection alias before planning, becomes a GetField-style reference by ID, and does not add an aggregation dependency; the new aggregation code explicitly filters the window alias until buildWindow computes and projects it. The cited ERROR 1140 validator examines Window projected expression…
Passing Rev The query returned both groups and kept the group with only NULL values. The running count was 0 for group 1 and 1 for group 2, as expected.
Passing Window The grouped query returned all four expected rows, and the result closed without an error.
Passing Window The query returned all five original rows and calculated the running totals 10, 15, 22, 3, and 7. Running it after a grouped query produced the expected results, so the planner did not collapse or leak grouped state into the non-grouped query.
⏸️ Skipped Aggregate Aggregate queries with arguments, partitions, ordering, and explicit frames ran successfully and returned the expected running totals and counts.
⏸️ Skipped General JSON search worked with no path, one path, and two paths. A path containing a comma stayed together, so the search returned the expected paths.
⏸️ Skipped General The geometry constructor accepted one, two, and three arguments and rejected zero and four with clear argument-count errors.
⏸️ Skipped General Verified acceptable by independent adversarial review: the observed behavior is intended and documented in this codebase. Review notes: The finding invents a required binding error for an unset user variable, but both the session API tests and the prepared EXECUTE integration test affirmatively define an unset user variable as SQL NULL without error. Consequently, an empty result from a predicate involving that NULL is expected SQL behavior rather than a hidden binding failure. The PR also preserves the relevant lookup and error h…
⏸️ Skipped General An implicit name for a scalar subquery was rejected, while named aliases worked through nested projections and returned 42.
⏸️ Skipped Binding A prepared query read a value from the current session and used it in the right parameter position. With the session value set to 4, the query returned 5 as expected.
⏸️ Skipped Binding A prepared query treated a session value set to NULL as SQL NULL, while a second value stayed 7. The query returned the expected result.
⏸️ Skipped Json The JSON search expression keeps the escape value and optional paths in the supplied order. Native SQL checks returned the expected paths for multiple paths, escaped wildcard text, and no optional path.
⏸️ Skipped Json JSON_VALUE omits the default return type and includes the requested non-default type. Both forms parsed and returned 42, and the focused serialization test passed.
⏸️ Skipped Literal The date value stayed intact and the quoted column name was preserved in the generated SQL. The browser error came from connecting to a database port with an HTTP tool, not from the SQL expression behavior.
⏸️ Skipped Literal A value containing a quote, semicolon, DROP TABLE text, and a comment marker stayed one value. The query returned the sentinel value and did not run an extra statement.
⏸️ Skipped Rev The two-value angle calculation kept its operand order when converted to SQL. The direct query and the round-tripped query returned 0.4636476090008061, while reversing the values returned a different result as expected.
⏸️ Skipped Rev Grouped queries returned the expected distinct values in order, with custom separators preserved. The code test also confirmed that escaped separators and window SQL are written correctly.
⏸️ Skipped Rev The aggregate generator produced the same source as the checked-in file. The generator tests and aggregation package tests also passed, so aggregate SQL generation is working as expected.
⏸️ Skipped Spatial The SQL function registry recognizes ST_EQUALS, builds the intended spatial expression, and produces SQL that the parser accepts.
⏸️ Skipped Subquery Scalar subqueries ran successfully and returned the expected values. The query plan parsed, and an unnamed subquery expression could not be used as an outer column name.
⏸️ Skipped Subquery Queries using IN with a subquery returned the right matches. A value in the subquery returned 1, a value outside it returned 0, and filtering the sample table returned Jane Doe.
Tests that are no longer relevant

Below are tests that previously ran and are no longer relevant:

Type Test Description
General Mixed query values are lost Dropped because The prior binding fallback test is outside this commit's changed query-planning and WindowIter cleanup surface.

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread sql/planbuilder/select.go Outdated
@@ -95,6 +95,10 @@ func (b *Builder) buildSelect(inScope *scope, s *ast.Select) (outScope *scope) {
if b.needsAggregation(fromScope, s) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

View All Evidence

🆕 New Failure: identified in this diff run

Medium severity Grouped queries reject multiple window expressions

What failed: The database returned an error for the query containing both the running total and row number. The running total query alone returned 15, 22, 3, and 7, and the row-number query alone returned 1 and 2 within each group, so the expected combined result should also have succeeded.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: Medium Medium severity
  • Impact: Applications that use grouped reports with both a running total and row numbers receive a database error instead of results. They must remove one calculation or split the query to complete the report.
  • Steps to Reproduce:
    1. Create a table with grp, ord, and val columns and insert rows that form two groups with two ordinals in each group.
    2. Clear the session SQL mode and run a grouped query with SUM(SUM(val)) OVER (...) and ROW_NUMBER() OVER (...) in the same SELECT list.
    3. Check the result. The combined query returns ERROR 1140 instead of one row for each grouped (grp, ord) pair.
    4. Run each window expression in a separate grouped query. Each separate query returns the expected four rows, isolating the failure to the combination.
  • Stub / mock content: The test used a local SQL fixture and cleared the session SQL mode as required for this engine regression. No mocks, route interception, or application bypasses were used.
  • Code Analysis: The PR adds a grouped-window branch in sql/planbuilder/select.go:95-101. After buildAggregation creates the grouped scope at line 97, lines 98-101 copy fromScope.windowFuncs to the output scope and call buildWindow, which is the direct production path required by REV-10. The PR also changes sql/planbuilder/aggregates.go:205-245 to track window-function column IDs while collecting aggregation dependencies, and sql/planbuilder/aggregates.go:612-685 builds one plan.Window node from every entry in fromScope.windowFuncs and then adds projection aliases. The independent SUM(SUM(val)) and ROW_NUMBER() queries prove that each expression is supported alone; the deterministic ERROR 1140 only when both are present points to incorrect dependency or scope handling while the changed aggregation and multi-window lists are combined. The smallest practical fix is to correct the changed dependency transfer/projection construction so every window expression remains a window dependency and all required grouped columns are available to the single post-aggregation Window node, then retain the regression query with both expressions.
  • Why this is likely a bug: This is a real engine failure, not a browser or setup failure. Port 3306 speaks the MySQL protocol, so the browser error was correctly bypassed with a local MySQL client; the client connected, created the fixture, and produced valid control results. Clearing SQL mode removed the initial only_full_group_by setup condition, but the combined query continued to return ERROR 1140 while each expression worked independently. The failing input exactly targets the PR's new aggregation-then-window path and its multiple-window collection. A normal grouped report should be able to add a ranking column without making an otherwise valid running aggregate fail, so the changed planner path needs a targeted correction rather than a test-only workaround.
Relevant code

sql/planbuilder/select.go:95-101

if b.needsAggregation(fromScope, s) {
	groupingCols := b.buildGroupingCols(fromScope, projScope, s.GroupBy, s.SelectExprs)
	outScope = b.buildAggregation(fromScope, projScope, groupingCols)
	if len(fromScope.windowFuncs) > 0 {
		outScope.windowFuncs = fromScope.windowFuncs
		outScope = b.buildWindow(outScope, projScope)
	}
}

sql/planbuilder/aggregates.go:205-245

windowIds := make(map[sql.ColumnId]struct{}, len(fromScope.windowFuncs))
for _, col := range fromScope.windowFuncs {
	windowIds[sql.ColumnId(col.id)] = struct{}{}
}
...
case *expression.GetField:
	if _, ok := windowIds[e.Id()]; ok {
		return false
	}

sql/planbuilder/aggregates.go:612-685

func (b *Builder) buildWindow(fromScope, projScope *scope) *scope {
	if len(fromScope.windowFuncs) == 0 {
		return fromScope
	}
	...
	for _, col := range fromScope.windowFuncs {
		...
		selectExprs = append(selectExprs, e)
	}
	window := plan.NewWindow(selectExprs, fromScope.node)
	fromScope.node = window
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**Medium severity — Grouped queries reject multiple window expressions**

**What failed:** The database returned an error for the query containing both the running total and row number. The running total query alone returned 15, 22, 3, and 7, and the row-number query alone returned 1 and 2 within each group, so the expected combined result should also have succeeded.

- **Impact:** Applications that use grouped reports with both a running total and row numbers receive a database error instead of results. They must remove one calculation or split the query to complete the report.
- **Steps to reproduce:**
  1. Create a table with grp, ord, and val columns and insert rows that form two groups with two ordinals in each group.
  2. Clear the session SQL mode and run a grouped query with SUM(SUM(val)) OVER (...) and ROW_NUMBER() OVER (...) in the same SELECT list.
  3. Check the result. The combined query returns ERROR 1140 instead of one row for each grouped (grp, ord) pair.
  4. Run each window expression in a separate grouped query. Each separate query returns the expected four rows, isolating the failure to the combination.
- **Stub / mock content:** The test used a local SQL fixture and cleared the session SQL mode as required for this engine regression. No mocks, route interception, or application bypasses were used.
- **Code analysis:** The PR adds a grouped-window branch in sql/planbuilder/select.go:95-101. After buildAggregation creates the grouped scope at line 97, lines 98-101 copy fromScope.windowFuncs to the output scope and call buildWindow, which is the direct production path required by REV-10. The PR also changes sql/planbuilder/aggregates.go:205-245 to track window-function column IDs while collecting aggregation dependencies, and sql/planbuilder/aggregates.go:612-685 builds one plan.Window node from every entry in fromScope.windowFuncs and then adds projection aliases. The independent SUM(SUM(val)) and ROW_NUMBER() queries prove that each expression is supported alone; the deterministic ERROR 1140 only when both are present points to incorrect dependency or scope handling while the changed aggregation and multi-window lists are combined. The smallest practical fix is to correct the changed dependency transfer/projection construction so every window expression remains a window dependency and all required grouped columns are available to the single post-aggregation Window node, then retain the regression query with both expressions.
- **Why this is likely a bug:** This is a real engine failure, not a browser or setup failure. Port 3306 speaks the MySQL protocol, so the browser error was correctly bypassed with a local MySQL client; the client connected, created the fixture, and produced valid control results. Clearing SQL mode removed the initial only_full_group_by setup condition, but the combined query continued to return ERROR 1140 while each expression worked independently. The failing input exactly targets the PR's new aggregation-then-window path and its multiple-window collection. A normal grouped report should be able to add a ranking column without making an otherwise valid running aggregate fail, so the changed planner path needs a targeted correction rather than a test-only workaround.

**Relevant code:**

`sql/planbuilder/select.go:95-101`

~~~go
if b.needsAggregation(fromScope, s) {
	groupingCols := b.buildGroupingCols(fromScope, projScope, s.GroupBy, s.SelectExprs)
	outScope = b.buildAggregation(fromScope, projScope, groupingCols)
	if len(fromScope.windowFuncs) > 0 {
		outScope.windowFuncs = fromScope.windowFuncs
		outScope = b.buildWindow(outScope, projScope)
	}
}
~~~

`sql/planbuilder/aggregates.go:205-245`

~~~go
windowIds := make(map[sql.ColumnId]struct{}, len(fromScope.windowFuncs))
for _, col := range fromScope.windowFuncs {
	windowIds[sql.ColumnId(col.id)] = struct{}{}
}
...
case *expression.GetField:
	if _, ok := windowIds[e.Id()]; ok {
		return false
	}
~~~

`sql/planbuilder/aggregates.go:612-685`

~~~go
func (b *Builder) buildWindow(fromScope, projScope *scope) *scope {
	if len(fromScope.windowFuncs) == 0 {
		return fromScope
	}
	...
	for _, col := range fromScope.windowFuncs {
		...
		selectExprs = append(selectExprs, e)
	}
	window := plan.NewWindow(selectExprs, fromScope.node)
	fromScope.node = window
}
~~~

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.

@itoqa Fixed in 110dc09. Explicit GROUP BY state is now preserved during projection analysis, allowing multiple grouped window expressions without relaxing the existing implicit-grouping validation. The engine regression covers the reported query and reversed SELECT-list order; go test ./... passes. Please rerun against the current head.

@itoqa

itoqa Bot commented Sep 4, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Report006422b110dc09: 8 test cases ran, 1 regression ❌, 1 fixed ✅, 6 passing ✅.

Diff Summary

Coverage spans normal grouped and ungrouped running calculations, ordering and projection variations, null and empty inputs, and rejection of invalid aggregate/window combinations. These tests exercise core query behavior along with edge cases and planner-boundary conditions, with most covered behavior remaining healthy.

Merge with caution — this PR introduces a medium-severity regression where filtered grouped results can produce incorrect running totals, causing users to see wrong query results. The issue is limited to queries combining grouped calculations, filtering, and running totals, but it is directly attributable to the change and remains a merge risk.

Tests run by Ito

View full run

Result State Severity Type Description
🆕 Regression Medium severity General The running total still includes a grouped row that HAVING removed. Group 2 is returned with 7 instead of the expected 4.
❌->✅ Fixed Rev Both query versions returned four grouped rows with the expected sums, running totals, and row numbers. Changing the order of the window expressions did not change the values assigned to each row.
Passing General The grouped query returned one row for each group and order pair. The running totals were 15, 22, 3, and 7, matching the expected results.
Passing Group The grouped query returned one row for each group and order pair, with running totals of 15, 22, 3, and 7 as expected.
Passing Group The SQL engine rejected a query that combines SUM with ROW_NUMBER without GROUP BY. It returned the expected non-aggregated-column error before producing any rows.
Passing Rev The grouped query succeeded and returned one row for each distinct group: (1, 1) and (2, 2). The duplicate source row in group 2 was correctly collapsed.
Passing Rev The grouped query kept a NULL total for the group containing only NULL values and returned 4 for the next group. After all rows were deleted, the same query completed successfully with no rows.
Passing Window The query returned all five input rows. Row numbers restarted for each group, and duplicate order values did not remove any rows.
⏸️ Skipped General Each grouped row kept the correct group and order values, and the running totals stayed within the right group. The earlier error came from the local database session setup, not from the application query logic.
⏸️ Skipped General Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The cited code contradicts the finding's premise that closing a normally consumed result once can reach Dispose with a nil aggregation cache. That state requires a prior Close on the same populated grouped iterator; the earlier close in the reproduction is on a separately constructed iterator, and the reachable sort/window cleanup chain closes the grouped child only once. The PR does introduce a r…
⏸️ Skipped General The grouped window query completed successfully and returned the expected four rows. The earlier error came from the local SQL session setup, not from the query planner.
⏸️ Skipped General After a grouped query, the non-grouped query returned all five original rows with the expected running totals. A fresh session returned the same rows and values, so grouped query state did not change the later result.
⏸️ Skipped Grouping The grouped query returned one row for each group and order pair. Running totals were 15 and 22 for group 1, and 3 and 7 for group 2.
⏸️ Skipped Rev Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The repository does not support the finding's decisive causal explanation. A named ORDER BY resolves to the projection alias before planning, becomes a GetField-style reference by ID, and does not add an aggregation dependency; the new aggregation code explicitly filters the window alias until buildWindow computes and projects it. The cited ERROR 1140 validator examines Window projected expression…
⏸️ Skipped Rev The query returned both groups and kept the group with only NULL values. The running count was 0 for group 1 and 1 for group 2, as expected.
⏸️ Skipped Window The grouped query returned all four expected rows, and the result closed without an error.
⏸️ Skipped Window The query returned all five original rows and calculated the running totals 10, 15, 22, 3, and 7. Running it after a grouped query produced the expected results, so the planner did not collapse or leak grouped state into the non-grouped query.

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread sql/planbuilder/select.go
// projections from (4).
// 6) Finish with final target projections.
fromScope := b.buildFrom(inScope, s.From)
fromScope.explicitGrouping = len(s.GroupBy) > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

View All Evidence

🔁 Regression: previously passing at 006422b

Medium severity Filtered groups change running totals incorrectly

What failed: The running total still includes a grouped row that HAVING removed. Group 2 is returned with 7 instead of the expected 4.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: Medium Medium severity
  • Impact: Queries that combine grouped results, HAVING, and a running total can show incorrect totals. Users can avoid the error by filtering the grouped results before calculating the running total.
  • Steps to Reproduce:
    1. Create grouped rows with two groups, and make the HAVING condition remove the first ordered row from group 2.
    2. Run a grouped query that calculates a running window total and applies HAVING to the grouped result.
    3. Compare it with a reference query that materializes the grouped rows and applies HAVING before the window calculation.
    4. Observe that the direct query reports group 2 running_total as 7, while the filtered reference reports 4.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: Builder.buildSelect in sql/planbuilder/select.go records the query's GROUP BY state at line 70, builds grouped aggregation at lines 96-98, and then constructs the Window node at lines 99-102. It does not build the Having node until line 114, after the window stage has already consumed the grouped rows. The test comparison confirms the consequence: the direct query returns group 2's running_total as 7, while the materialized reference, where the HAVING-filtered grouped rows are the window input, returns 4. The PR's sql/planbuilder/aggregates.go change at lines 303-306 relaxes the aggregate/window rejection for explicit GROUP BY queries, and select.go line 70 supplies that new state, making this previously rejected combination executable. The smallest practical fix is to place the HAVING filter on the grouped relation before constructing the window stage, while preserving the existing final projection and ORDER BY handling; add a regression assertion for a HAVING-filtered grouped window result.
  • Why this is likely a bug: This is a deterministic semantic mismatch, not a browser or setup failure: the same fixture produces 7 from the direct query and 4 from the HAVING-filtered materialized reference. SQL users expect a window over a grouped query to use the rows that remain after HAVING for this test's documented stage ordering. The planner source independently confirms that the Window node is built before the Having node, which explains the stale contribution and gives a focused fix location. The browser could not render localhost:3306 because it is a MySQL protocol endpoint, but the local SQL client comparison and source path establish the defect without relying on production access.
Relevant code

sql/planbuilder/select.go:70

fromScope.explicitGrouping = len(s.GroupBy) > 0

sql/planbuilder/select.go:96-116

outScope = b.buildAggregation(fromScope, projScope, groupingCols)
if len(fromScope.windowFuncs) > 0 {
    outScope.windowFuncs = fromScope.windowFuncs
    outScope = b.buildWindow(outScope, projScope)
}
...
b.buildHaving(fromScope, projScope, outScope, s.Having)
b.buildOrderBy(outScope, orderByScope)

sql/planbuilder/aggregates.go:303-306

if len(inScope.windowFuncs) > 0 && !inScope.explicitGrouping {
    err := sql.ErrNonAggregatedColumnWithoutGroupBy.New()
    b.handleErr(err)
}

sql/plan/having.go:32-34

func NewHaving(cond sql.Expression, child sql.Node) *Having {
    return &Having{UnaryNode{Child: child}, cond}
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**Medium severity — Filtered groups change running totals incorrectly**

**What failed:** The running total still includes a grouped row that HAVING removed. Group 2 is returned with 7 instead of the expected 4.

- **Impact:** Queries that combine grouped results, HAVING, and a running total can show incorrect totals. Users can avoid the error by filtering the grouped results before calculating the running total.
- **Steps to reproduce:**
  1. Create grouped rows with two groups, and make the HAVING condition remove the first ordered row from group 2.
  2. Run a grouped query that calculates a running window total and applies HAVING to the grouped result.
  3. Compare it with a reference query that materializes the grouped rows and applies HAVING before the window calculation.
  4. Observe that the direct query reports group 2 running_total as 7, while the filtered reference reports 4.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** Builder.buildSelect in sql/planbuilder/select.go records the query's GROUP BY state at line 70, builds grouped aggregation at lines 96-98, and then constructs the Window node at lines 99-102. It does not build the Having node until line 114, after the window stage has already consumed the grouped rows. The test comparison confirms the consequence: the direct query returns group 2's running_total as 7, while the materialized reference, where the HAVING-filtered grouped rows are the window input, returns 4. The PR's sql/planbuilder/aggregates.go change at lines 303-306 relaxes the aggregate/window rejection for explicit GROUP BY queries, and select.go line 70 supplies that new state, making this previously rejected combination executable. The smallest practical fix is to place the HAVING filter on the grouped relation before constructing the window stage, while preserving the existing final projection and ORDER BY handling; add a regression assertion for a HAVING-filtered grouped window result.
- **Why this is likely a bug:** This is a deterministic semantic mismatch, not a browser or setup failure: the same fixture produces 7 from the direct query and 4 from the HAVING-filtered materialized reference. SQL users expect a window over a grouped query to use the rows that remain after HAVING for this test's documented stage ordering. The planner source independently confirms that the Window node is built before the Having node, which explains the stale contribution and gives a focused fix location. The browser could not render localhost:3306 because it is a MySQL protocol endpoint, but the local SQL client comparison and source path establish the defect without relying on production access.

**Relevant code:**

`sql/planbuilder/select.go:70`

~~~go
fromScope.explicitGrouping = len(s.GroupBy) > 0
~~~

`sql/planbuilder/select.go:96-116`

~~~go
outScope = b.buildAggregation(fromScope, projScope, groupingCols)
if len(fromScope.windowFuncs) > 0 {
    outScope.windowFuncs = fromScope.windowFuncs
    outScope = b.buildWindow(outScope, projScope)
}
...
b.buildHaving(fromScope, projScope, outScope, s.Having)
b.buildOrderBy(outScope, orderByScope)
~~~

`sql/planbuilder/aggregates.go:303-306`

~~~go
if len(inScope.windowFuncs) > 0 && !inScope.explicitGrouping {
    err := sql.ErrNonAggregatedColumnWithoutGroupBy.New()
    b.handleErr(err)
}
~~~

`sql/plan/having.go:32-34`

~~~go
func NewHaving(cond sql.Expression, child sql.Node) *Having {
    return &Having{UnaryNode{Child: child}, cond}
}
~~~

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.

@itoqa Fixed in 6e61c15. Grouped-query HAVING now filters the aggregation result before the Window node is built, and the regression verifies that a removed group no longer contributes to the running total. The full GMS suite passes; please rerun against the current head.

@itoqa

itoqa Bot commented Sep 4, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Report110dc09b7d031c: 11 test cases ran, 1 fixed ✅, 10 passing ✅.

Diff Summary

Coverage exercised core SQL behavior for grouped and ungrouped running totals, filtering grouped results, partition boundaries, null and empty-result handling, and validation of invalid aggregate/window combinations. It also covered expression round-tripping and keeping readable query text separate from internal plan details, including nested expressions and edge-case predicate behavior.

Safe to merge — the exercised behavior passed without any PR-attributable regressions, new failures, or previously failing tests that remain unresolved. The untested cases were previously passing and are a coverage caveat rather than a merge blocker.

Tests run by Ito

View full run

Result State Severity Type Description
❌->✅ Fixed General The grouped query kept the expected rows after HAVING, and its running totals matched the materialized reference exactly: (1,1,15), (1,2,22), and (2,2,4).
Passing General Grouped rows kept their partition keys, order, and running totals. The direct query returned the same four rows as the materialized reference.
Passing General The grouped query returned the expected four running-sum rows. Removing GROUP BY was correctly rejected with the required validation error.
Passing Describe Nested SQL descriptions include the child details while keeping the parent expression readable.
Passing Error The SQL engine rejected a query that combined an aggregate with a window expression without GROUP BY, returning the expected only_full_group_by validation error.
Passing Grouping The query returned one row for each group and calculated the running total in the right order. The totals were 15, 22, 3, and 7 across the two groups.
Passing Hash The query keeps ordinary membership text that can be parsed as SQL, while plan output clearly marks the hash-based membership check. The direct local regression test passed for both the simple and nested cases.
Passing Rev Direct and nested IN predicates returned the same rows after being reconstructed. The focused expression tests also passed, so users can use these predicates without changing query results.
Passing Rev Debug output keeps the physical lookup details, while generated SQL stays valid and readable.
Passing Rev The grouped query kept only the group whose sum was greater than 10 and returned a total of 15. A threshold greater than 20 returned no rows, so filtered groups did not affect the result.
Passing Window The query returned all three rows and calculated the running totals correctly within each group.
⏸️ Skipped General The grouped query returned one row for each group and order pair. The running totals were 15, 22, 3, and 7, matching the expected results.
⏸️ Skipped Group The grouped query returned one row for each group and order pair, with running totals of 15, 22, 3, and 7 as expected.
⏸️ Skipped Group The SQL engine rejected a query that combines SUM with ROW_NUMBER without GROUP BY. It returned the expected non-aggregated-column error before producing any rows.
⏸️ Skipped Rev Both query versions returned four grouped rows with the expected sums, running totals, and row numbers. Changing the order of the window expressions did not change the values assigned to each row.
⏸️ Skipped Rev The grouped query succeeded and returned one row for each distinct group: (1, 1) and (2, 2). The duplicate source row in group 2 was correctly collapsed.
⏸️ Skipped Rev The grouped query kept a NULL total for the group containing only NULL values and returned 4 for the next group. After all rows were deleted, the same query completed successfully with no rows.
⏸️ Skipped Window The query returned all five input rows. Row numbers restarted for each group, and duplicate order values did not remove any rows.

Tip

Reply with @itoqa to send us feedback on this test run.

@zachmu

zachmu commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

@itoqa Unary aggregate Describe implementations, projected-field debug descriptions, and stronger String round-trip assertions are now in 3ff64aa. Please rerun against the current head.

@zachmu
zachmu requested a review from angelamayxie September 4, 2026 23:21
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