From 4f405230ecd9393ad8f1a6c4d2f280dafba2edb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:15:38 +0000 Subject: [PATCH 01/11] Fix UNION mode grouping in EXPLAIN AST output Group UNION DISTINCT selects when followed by UNION ALL to match ClickHouse's EXPLAIN AST output format. Only applies when DISTINCT transitions to ALL, not the reverse. Fixes stmt9 and stmt15 in 02008_test_union_distinct_in_subquery. --- internal/explain/select.go | 65 ++++++++++++++++++- .../metadata.json | 7 +- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/internal/explain/select.go b/internal/explain/select.go index 4adc1023a4..543a6e855f 100644 --- a/internal/explain/select.go +++ b/internal/explain/select.go @@ -298,8 +298,13 @@ func explainSelectWithUnionQuery(sb *strings.Builder, n *ast.SelectWithUnionQuer // ClickHouse optimizes UNION ALL when selects have identical expressions but different aliases. // In that case, only the first SELECT is shown since column names come from the first SELECT anyway. selects := simplifyUnionSelects(n.Selects) + + // Check if we need to group selects due to mode changes + // e.g., A UNION DISTINCT B UNION ALL C -> (A UNION DISTINCT B) UNION ALL C + groupedSelects := groupSelectsByUnionMode(selects, n.UnionModes) + // Wrap selects in ExpressionList - fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(selects)) + fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(groupedSelects)) // Check if first operand has a WITH clause to be inherited by subsequent operands var inheritedWith []ast.Expression @@ -307,7 +312,7 @@ func explainSelectWithUnionQuery(sb *strings.Builder, n *ast.SelectWithUnionQuer inheritedWith = extractWithClause(selects[0]) } - for i, sel := range selects { + for i, sel := range groupedSelects { if i > 0 && len(inheritedWith) > 0 { // Subsequent operands inherit the WITH clause from the first operand explainSelectQueryWithInheritedWith(sb, sel, inheritedWith, depth+2) @@ -620,6 +625,62 @@ func simplifyUnionSelects(selects []ast.Statement) []ast.Statement { return selects } +// groupSelectsByUnionMode groups selects when union modes change from DISTINCT to ALL. +// For example, A UNION DISTINCT B UNION ALL C becomes (A UNION DISTINCT B) UNION ALL C. +// This matches ClickHouse's EXPLAIN AST output which nests DISTINCT groups before ALL. +// Note: The reverse (ALL followed by DISTINCT) does NOT trigger nesting. +func groupSelectsByUnionMode(selects []ast.Statement, unionModes []string) []ast.Statement { + if len(selects) < 3 || len(unionModes) < 2 { + return selects + } + + // Normalize union modes (strip "UNION " prefix if present) + normalizeMode := func(mode string) string { + if len(mode) > 6 && mode[:6] == "UNION " { + return mode[6:] + } + return mode + } + + // Only group when DISTINCT transitions to ALL + // Find first DISTINCT mode, then check if it's followed by ALL + firstMode := normalizeMode(unionModes[0]) + if firstMode != "DISTINCT" { + return selects + } + + // Find where DISTINCT ends and ALL begins + modeChangeIdx := -1 + for i := 1; i < len(unionModes); i++ { + if normalizeMode(unionModes[i]) == "ALL" { + modeChangeIdx = i + break + } + } + + // If no DISTINCT->ALL transition found, return as-is + if modeChangeIdx == -1 { + return selects + } + + // Create a nested SelectWithUnionQuery for selects 0..modeChangeIdx (inclusive) + // modeChangeIdx is the index of the union operator, so we include selects[0] through selects[modeChangeIdx] + nestedSelects := selects[:modeChangeIdx+1] + nestedModes := unionModes[:modeChangeIdx] + + nested := &ast.SelectWithUnionQuery{ + Selects: nestedSelects, + UnionModes: nestedModes, + } + + // Result is [nested, selects[modeChangeIdx+1], ...] + result := make([]ast.Statement, 0, len(selects)-modeChangeIdx) + result = append(result, nested) + result = append(result, selects[modeChangeIdx+1:]...) + + return result +} + func countSelectQueryChildren(n *ast.SelectQuery) int { count := 1 // columns ExpressionList // WITH clause diff --git a/parser/testdata/02008_test_union_distinct_in_subquery/metadata.json b/parser/testdata/02008_test_union_distinct_in_subquery/metadata.json index ccc75f74d5..0967ef424b 100644 --- a/parser/testdata/02008_test_union_distinct_in_subquery/metadata.json +++ b/parser/testdata/02008_test_union_distinct_in_subquery/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt15": true, - "stmt9": true - } -} +{} From f3ddb568c649cbadf00ba6ca54ff37b9954b0ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:21:04 +0000 Subject: [PATCH 02/11] Handle SYSTEM RESTORE REPLICA command parsing Add RESTORE to system command keywords so it's not parsed as a table name. Also add RESTORE REPLICA to the list of commands that output the table name twice in EXPLAIN AST. Fixes stmt11 and stmt12 in 03168_attach_as_replicated_materialized_view. --- parser/parser.go | 3 ++- .../metadata.json | 7 +------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/parser/parser.go b/parser/parser.go index a4affacc8e..488b71f77f 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -6771,6 +6771,7 @@ func (p *Parser) parseSystem() *ast.SystemQuery { upperCmd := strings.ToUpper(sys.Command) if strings.Contains(upperCmd, "RELOAD DICTIONARY") || strings.Contains(upperCmd, "DROP REPLICA") || + strings.Contains(upperCmd, "RESTORE REPLICA") || strings.Contains(upperCmd, "STOP DISTRIBUTED SENDS") || strings.Contains(upperCmd, "START DISTRIBUTED SENDS") || strings.Contains(upperCmd, "FLUSH DISTRIBUTED") { @@ -6803,7 +6804,7 @@ func (p *Parser) parseSystem() *ast.SystemQuery { func (p *Parser) isSystemCommandKeyword() bool { switch p.current.Token { case token.TTL, token.SYNC, token.DROP, token.FORMAT, token.FOR, token.INDEX, token.INSERT, - token.PRIMARY, token.KEY, token.DISTRIBUTED: + token.PRIMARY, token.KEY, token.DISTRIBUTED, token.RESTORE: return true } // Handle identifiers that are part of SYSTEM commands (not table names) diff --git a/parser/testdata/03168_attach_as_replicated_materialized_view/metadata.json b/parser/testdata/03168_attach_as_replicated_materialized_view/metadata.json index ec09c7e10e..0967ef424b 100644 --- a/parser/testdata/03168_attach_as_replicated_materialized_view/metadata.json +++ b/parser/testdata/03168_attach_as_replicated_materialized_view/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt11": true, - "stmt12": true - } -} +{} From 26617b88419f6b73a2db32dd8541b09cd49d725b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:28:21 +0000 Subject: [PATCH 03/11] Add APPLY DELETED MASK alter command support Parse and explain ALTER TABLE ... APPLY DELETED MASK command, including optional IN PARTITION clause. Fixes stmt13 and stmt21 in 02932_apply_deleted_mask and additional tests in 02932_lwd_and_mutations and 03404_json_tables. --- ast/ast.go | 1 + internal/explain/statements.go | 4 ++-- parser/parser.go | 14 ++++++++++++++ .../02932_apply_deleted_mask/metadata.json | 7 +------ .../testdata/02932_lwd_and_mutations/metadata.json | 6 +----- parser/testdata/03404_json_tables/metadata.json | 6 +----- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/ast/ast.go b/ast/ast.go index c724583cf4..4fe3407653 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -700,6 +700,7 @@ const ( AlterModifyOrderBy AlterCommandType = "MODIFY_ORDER_BY" AlterModifySampleBy AlterCommandType = "MODIFY_SAMPLE_BY" AlterRemoveSampleBy AlterCommandType = "REMOVE_SAMPLE_BY" + AlterApplyDeletedMask AlterCommandType = "APPLY_DELETED_MASK" ) // TruncateQuery represents a TRUNCATE statement. diff --git a/internal/explain/statements.go b/internal/explain/statements.go index 93c582e590..c45ecdc0cf 100644 --- a/internal/explain/statements.go +++ b/internal/explain/statements.go @@ -1802,7 +1802,7 @@ func explainAlterCommand(sb *strings.Builder, cmd *ast.AlterCommand, indent stri case ast.AlterModifySetting: fmt.Fprintf(sb, "%s Set\n", indent) case ast.AlterDropPartition, ast.AlterDetachPartition, ast.AlterAttachPartition, - ast.AlterReplacePartition, ast.AlterFetchPartition, ast.AlterMovePartition, ast.AlterFreezePartition, ast.AlterApplyPatches: + ast.AlterReplacePartition, ast.AlterFetchPartition, ast.AlterMovePartition, ast.AlterFreezePartition, ast.AlterApplyPatches, ast.AlterApplyDeletedMask: if cmd.Partition != nil { // PARTITION ALL is shown as Partition_ID (empty) in EXPLAIN AST if ident, ok := cmd.Partition.(*ast.Identifier); ok && strings.ToUpper(ident.Name()) == "ALL" { @@ -2085,7 +2085,7 @@ func countAlterCommandChildren(cmd *ast.AlterCommand) int { case ast.AlterModifySetting: children = 1 case ast.AlterDropPartition, ast.AlterDetachPartition, ast.AlterAttachPartition, - ast.AlterReplacePartition, ast.AlterFetchPartition, ast.AlterMovePartition, ast.AlterFreezePartition, ast.AlterApplyPatches: + ast.AlterReplacePartition, ast.AlterFetchPartition, ast.AlterMovePartition, ast.AlterFreezePartition, ast.AlterApplyPatches, ast.AlterApplyDeletedMask: if cmd.Partition != nil { children++ } diff --git a/parser/parser.go b/parser/parser.go index 488b71f77f..a335d643c7 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -5879,6 +5879,7 @@ func (p *Parser) parseAlterCommand() *ast.AlterCommand { } case token.APPLY: // APPLY PATCHES IN PARTITION expr + // APPLY DELETED MASK [IN PARTITION expr] p.nextToken() // skip APPLY if p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "PATCHES" { p.nextToken() // skip PATCHES @@ -5890,6 +5891,19 @@ func (p *Parser) parseAlterCommand() *ast.AlterCommand { cmd.Partition = p.parseExpression(LOWEST) } } + } else if p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "DELETED" { + p.nextToken() // skip DELETED + if p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "MASK" { + p.nextToken() // skip MASK + cmd.Type = ast.AlterApplyDeletedMask + if p.currentIs(token.IN) { + p.nextToken() // skip IN + if p.currentIs(token.PARTITION) { + p.nextToken() // skip PARTITION + cmd.Partition = p.parseExpression(LOWEST) + } + } + } } case token.DELETE: // DELETE WHERE condition - mutation to delete rows diff --git a/parser/testdata/02932_apply_deleted_mask/metadata.json b/parser/testdata/02932_apply_deleted_mask/metadata.json index 49a71d5123..0967ef424b 100644 --- a/parser/testdata/02932_apply_deleted_mask/metadata.json +++ b/parser/testdata/02932_apply_deleted_mask/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt13": true, - "stmt21": true - } -} +{} diff --git a/parser/testdata/02932_lwd_and_mutations/metadata.json b/parser/testdata/02932_lwd_and_mutations/metadata.json index 983800a6c0..0967ef424b 100644 --- a/parser/testdata/02932_lwd_and_mutations/metadata.json +++ b/parser/testdata/02932_lwd_and_mutations/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt23": true - } -} +{} diff --git a/parser/testdata/03404_json_tables/metadata.json b/parser/testdata/03404_json_tables/metadata.json index ab9202e88e..0967ef424b 100644 --- a/parser/testdata/03404_json_tables/metadata.json +++ b/parser/testdata/03404_json_tables/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt11": true - } -} +{} From 1b7808a370c8b3477bd3aed6ff424a065bcaa626 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:34:40 +0000 Subject: [PATCH 04/11] Handle ATTACH PARTITION ... FROM as REPLACE_PARTITION in EXPLAIN Parse the FROM clause in ALTER TABLE ATTACH PARTITION statements and show it as REPLACE_PARTITION in EXPLAIN AST output to match ClickHouse's behavior. Fixes multiple tests across several test files. --- internal/explain/statements.go | 4 ++++ parser/parser.go | 8 ++++++++ .../00626_replace_partition_from_table/metadata.json | 7 +------ parser/testdata/01055_compact_parts_1/metadata.json | 6 +----- .../testdata/01710_minmax_count_projection/metadata.json | 1 - .../01901_test_attach_partition_from/metadata.json | 6 +----- .../metadata.json | 7 +------ .../02998_projection_after_attach_partition/metadata.json | 6 +----- 8 files changed, 17 insertions(+), 28 deletions(-) diff --git a/internal/explain/statements.go b/internal/explain/statements.go index c45ecdc0cf..d8acc46baa 100644 --- a/internal/explain/statements.go +++ b/internal/explain/statements.go @@ -1620,6 +1620,10 @@ func explainAlterCommand(sb *strings.Builder, cmd *ast.AlterCommand, indent stri if cmdType == ast.AlterClearStatistics { cmdType = ast.AlterDropStatistics } + // ATTACH PARTITION ... FROM table is shown as REPLACE_PARTITION in EXPLAIN AST + if cmdType == ast.AlterAttachPartition && cmd.FromTable != "" { + cmdType = ast.AlterReplacePartition + } // DETACH_PARTITION is shown as DROP_PARTITION in EXPLAIN AST if cmdType == ast.AlterDetachPartition { cmdType = ast.AlterDropPartition diff --git a/parser/parser.go b/parser/parser.go index a335d643c7..fa7382566f 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -5817,6 +5817,14 @@ func (p *Parser) parseAlterCommand() *ast.AlterCommand { cmd.PartitionIsID = true } cmd.Partition = p.parseExpression(LOWEST) + // Handle FROM table (ATTACH PARTITION ... FROM table) + if p.currentIs(token.FROM) { + p.nextToken() + if p.currentIs(token.IDENT) || p.current.Token.IsKeyword() { + cmd.FromTable = p.current.Value + p.nextToken() + } + } } else if p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "PART" { // ATTACH PART uses ATTACH_PARTITION type in ClickHouse EXPLAIN cmd.Type = ast.AlterAttachPartition diff --git a/parser/testdata/00626_replace_partition_from_table/metadata.json b/parser/testdata/00626_replace_partition_from_table/metadata.json index cc4446e67a..0967ef424b 100644 --- a/parser/testdata/00626_replace_partition_from_table/metadata.json +++ b/parser/testdata/00626_replace_partition_from_table/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt44": true, - "stmt46": true - } -} +{} diff --git a/parser/testdata/01055_compact_parts_1/metadata.json b/parser/testdata/01055_compact_parts_1/metadata.json index dbdbb76d4f..0967ef424b 100644 --- a/parser/testdata/01055_compact_parts_1/metadata.json +++ b/parser/testdata/01055_compact_parts_1/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt6": true - } -} +{} diff --git a/parser/testdata/01710_minmax_count_projection/metadata.json b/parser/testdata/01710_minmax_count_projection/metadata.json index af0aabc403..7bf4b04abe 100644 --- a/parser/testdata/01710_minmax_count_projection/metadata.json +++ b/parser/testdata/01710_minmax_count_projection/metadata.json @@ -1,6 +1,5 @@ { "explain_todo": { - "stmt20": true, "stmt33": true } } diff --git a/parser/testdata/01901_test_attach_partition_from/metadata.json b/parser/testdata/01901_test_attach_partition_from/metadata.json index dbdbb76d4f..0967ef424b 100644 --- a/parser/testdata/01901_test_attach_partition_from/metadata.json +++ b/parser/testdata/01901_test_attach_partition_from/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt6": true - } -} +{} diff --git a/parser/testdata/02731_replace_partition_from_temporary_table/metadata.json b/parser/testdata/02731_replace_partition_from_temporary_table/metadata.json index c52ae2d780..0967ef424b 100644 --- a/parser/testdata/02731_replace_partition_from_temporary_table/metadata.json +++ b/parser/testdata/02731_replace_partition_from_temporary_table/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt31": true, - "stmt34": true - } -} +{} diff --git a/parser/testdata/02998_projection_after_attach_partition/metadata.json b/parser/testdata/02998_projection_after_attach_partition/metadata.json index ab9202e88e..0967ef424b 100644 --- a/parser/testdata/02998_projection_after_attach_partition/metadata.json +++ b/parser/testdata/02998_projection_after_attach_partition/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt11": true - } -} +{} From 1ad9fac40e0dce36f496a17de9421ade9b854422 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:42:03 +0000 Subject: [PATCH 05/11] Handle tuples with subqueries in WITH clause EXPLAIN output Add isSimpleLiteralOrNestedLiteral helper to properly detect when a tuple in a WITH clause contains complex expressions (like subqueries) vs just nested literals. Tuples with subqueries render as Function tuple, while tuples of nested literals stay as Literal Tuple. Fixes tests in 01461_query_start_time_microseconds and 01651_bugs_from_15889. --- internal/explain/expressions.go | 62 ++++++++++++++++--- .../metadata.json | 7 +-- .../01651_bugs_from_15889/metadata.json | 7 +-- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/internal/explain/expressions.go b/internal/explain/expressions.go index 43a87b1765..98e2d95415 100644 --- a/internal/explain/expressions.go +++ b/internal/explain/expressions.go @@ -211,6 +211,31 @@ func isSimpleLiteralOrNegation(e ast.Expression) bool { return false } +// isSimpleLiteralOrNestedLiteral checks if an expression is a literal (including nested tuples/arrays of literals) +// Returns false for complex expressions like subqueries, function calls, identifiers, etc. +func isSimpleLiteralOrNestedLiteral(e ast.Expression) bool { + if lit, ok := e.(*ast.Literal); ok { + // For nested arrays/tuples, recursively check if all elements are also literals + if lit.Type == ast.LiteralArray || lit.Type == ast.LiteralTuple { + if exprs, ok := lit.Value.([]ast.Expression); ok { + for _, elem := range exprs { + if !isSimpleLiteralOrNestedLiteral(elem) { + return false + } + } + } + } + return true + } + // Unary minus of a literal integer/float is also simple (negative number) + if unary, ok := e.(*ast.UnaryExpr); ok && unary.Op == "-" { + if lit, ok := unary.Operand.(*ast.Literal); ok { + return lit.Type == ast.LiteralInteger || lit.Type == ast.LiteralFloat + } + } + return false +} + // containsOnlyArraysOrTuples checks if a slice of expressions contains // only array or tuple literals (including empty arrays). // Returns true if the slice is empty or contains only arrays/tuples. @@ -952,16 +977,39 @@ func explainWithElement(sb *strings.Builder, n *ast.WithElement, indent string, // When name is empty, don't show the alias part switch e := n.Query.(type) { case *ast.Literal: - // Empty tuples should be rendered as Function tuple, not Literal + // Tuples containing complex expressions (subqueries, function calls, etc) should be rendered as Function tuple + // But tuples of simple literals (including nested tuples of literals) stay as Literal if e.Type == ast.LiteralTuple { - if exprs, ok := e.Value.([]ast.Expression); ok && len(exprs) == 0 { - if n.Name != "" { - fmt.Fprintf(sb, "%sFunction tuple (alias %s) (children %d)\n", indent, n.Name, 1) + if exprs, ok := e.Value.([]ast.Expression); ok { + needsFunctionFormat := false + // Empty tuples always use Function tuple format + if len(exprs) == 0 { + needsFunctionFormat = true } else { - fmt.Fprintf(sb, "%sFunction tuple (children %d)\n", indent, 1) + for _, expr := range exprs { + // Check if any element is a truly complex expression (not just a literal) + if !isSimpleLiteralOrNestedLiteral(expr) { + needsFunctionFormat = true + break + } + } + } + if needsFunctionFormat { + if n.Name != "" { + fmt.Fprintf(sb, "%sFunction tuple (alias %s) (children %d)\n", indent, n.Name, 1) + } else { + fmt.Fprintf(sb, "%sFunction tuple (children %d)\n", indent, 1) + } + if len(exprs) > 0 { + fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(exprs)) + } else { + fmt.Fprintf(sb, "%s ExpressionList\n", indent) + } + for _, expr := range exprs { + Node(sb, expr, depth+2) + } + return } - fmt.Fprintf(sb, "%s ExpressionList\n", indent) - return } } // Arrays containing non-literal expressions should be rendered as Function array diff --git a/parser/testdata/01461_query_start_time_microseconds/metadata.json b/parser/testdata/01461_query_start_time_microseconds/metadata.json index 05aa6dfc72..0967ef424b 100644 --- a/parser/testdata/01461_query_start_time_microseconds/metadata.json +++ b/parser/testdata/01461_query_start_time_microseconds/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt4": true, - "stmt8": true - } -} +{} diff --git a/parser/testdata/01651_bugs_from_15889/metadata.json b/parser/testdata/01651_bugs_from_15889/metadata.json index bfbd10f0e3..0967ef424b 100644 --- a/parser/testdata/01651_bugs_from_15889/metadata.json +++ b/parser/testdata/01651_bugs_from_15889/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt21": true, - "stmt22": true - } -} +{} From 180460d6217f7016ba78a035d1e8784d6a04ced4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:51:08 +0000 Subject: [PATCH 06/11] Add WITH clause support in projection definitions (#116) Parse and explain WITH clause in ProjectionSelectQuery for CREATE TABLE statements with projections. --- ast/ast.go | 1 + internal/explain/statements.go | 10 ++++++++++ parser/parser.go | 6 ++++++ .../03340_projections_formatting/metadata.json | 7 +------ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ast/ast.go b/ast/ast.go index 4fe3407653..985f721ac4 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -637,6 +637,7 @@ func (p *Projection) End() token.Position { return p.Position } // ProjectionSelectQuery represents the SELECT part of a projection. type ProjectionSelectQuery struct { Position token.Position `json:"-"` + With []Expression `json:"with,omitempty"` // WITH clause expressions Columns []Expression `json:"columns"` GroupBy []Expression `json:"group_by,omitempty"` OrderBy []Expression `json:"order_by,omitempty"` // ORDER BY columns diff --git a/internal/explain/statements.go b/internal/explain/statements.go index d8acc46baa..19a03226c5 100644 --- a/internal/explain/statements.go +++ b/internal/explain/statements.go @@ -1914,6 +1914,9 @@ func explainProjection(sb *strings.Builder, p *ast.Projection, indent string, de func explainProjectionSelectQuery(sb *strings.Builder, q *ast.ProjectionSelectQuery, indent string, depth int) { children := 0 + if len(q.With) > 0 { + children++ + } if len(q.Columns) > 0 { children++ } @@ -1924,6 +1927,13 @@ func explainProjectionSelectQuery(sb *strings.Builder, q *ast.ProjectionSelectQu children++ } fmt.Fprintf(sb, "%sProjectionSelectQuery (children %d)\n", indent, children) + // Output WITH clause first + if len(q.With) > 0 { + fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(q.With)) + for _, w := range q.With { + Node(sb, w, depth+2) + } + } if len(q.Columns) > 0 { fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(q.Columns)) for _, col := range q.Columns { diff --git a/parser/parser.go b/parser/parser.go index fa7382566f..62f47b00d6 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -7785,6 +7785,12 @@ func (p *Parser) parseProjection() *ast.Projection { Position: p.current.Pos, } + // Parse WITH clause if present + if p.currentIs(token.WITH) { + p.nextToken() // skip WITH + proj.Select.With = p.parseWithClause() + } + // Parse SELECT keyword (optional in projection) if p.currentIs(token.SELECT) { p.nextToken() diff --git a/parser/testdata/03340_projections_formatting/metadata.json b/parser/testdata/03340_projections_formatting/metadata.json index aeb01f1428..0967ef424b 100644 --- a/parser/testdata/03340_projections_formatting/metadata.json +++ b/parser/testdata/03340_projections_formatting/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt1": true, - "stmt4": true - } -} +{} From de977f98039a92b3038bed0a2849cd7b6b0e3e8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:02:58 +0000 Subject: [PATCH 07/11] Fix empty tuple ExpressionList formatting in IN expressions (#117) Don't include "(children 0)" suffix for empty ExpressionList in tuple literals within IN expressions. ClickHouse outputs just "ExpressionList" without children count for empty tuples. --- internal/explain/functions.go | 7 ++++++- .../03710_empty_tuple_lhs_in_function/metadata.json | 7 +------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/explain/functions.go b/internal/explain/functions.go index 53870ed619..c0cd00fcde 100644 --- a/internal/explain/functions.go +++ b/internal/explain/functions.go @@ -1133,7 +1133,12 @@ func explainInExpr(sb *strings.Builder, n *ast.InExpr, indent string, depth int) fmt.Fprintf(sb, "%s Function tuple (children %d)\n", indent, 1) if allParenthesizedPrimitives { // Expand the elements - fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(elems)) + // For empty tuples, don't include children count + if len(elems) == 0 { + fmt.Fprintf(sb, "%s ExpressionList\n", indent) + } else { + fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(elems)) + } for _, elem := range elems { Node(sb, elem, depth+4) } diff --git a/parser/testdata/03710_empty_tuple_lhs_in_function/metadata.json b/parser/testdata/03710_empty_tuple_lhs_in_function/metadata.json index 85329699d6..9e26dfeeb6 100644 --- a/parser/testdata/03710_empty_tuple_lhs_in_function/metadata.json +++ b/parser/testdata/03710_empty_tuple_lhs_in_function/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt31": true, - "stmt7": true - } -} +{} \ No newline at end of file From 9f51bfecc1953cfd18003a1e05e558f5ff868bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:14:51 +0000 Subject: [PATCH 08/11] Add SHOW FIELDS and SHOW FULL COLUMNS parsing (#118) Handle FIELDS as an alias for COLUMNS in SHOW statements, and support FULL modifier before COLUMNS/FIELDS. Both parse to ShowColumns type. --- parser/parser.go | 20 +++++++++++++++++-- .../testdata/02706_show_columns/metadata.json | 7 +------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/parser/parser.go b/parser/parser.go index 62f47b00d6..7314e75806 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -6389,6 +6389,13 @@ func (p *Parser) parseShow() ast.Statement { case token.SETTINGS: show.ShowType = ast.ShowSettings p.nextToken() + case token.FULL: + // SHOW FULL COLUMNS/FIELDS FROM table - treat as ShowColumns + p.nextToken() + if p.currentIs(token.COLUMNS) || (p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "FIELDS") { + p.nextToken() + } + show.ShowType = ast.ShowColumns default: // Handle SHOW PROCESSLIST, SHOW DICTIONARIES, SHOW FUNCTIONS, etc. if p.currentIs(token.IDENT) { @@ -6402,9 +6409,18 @@ func (p *Parser) parseShow() ast.Statement { show.ShowType = ast.ShowFunctions case "SETTING": show.ShowType = ast.ShowSetting - case "INDEXES", "INDICES", "KEYS": - // SHOW INDEXES/INDICES/KEYS FROM table - treat as ShowColumns + case "INDEXES", "INDICES", "KEYS", "FIELDS": + // SHOW INDEXES/INDICES/KEYS/FIELDS FROM table - treat as ShowColumns show.ShowType = ast.ShowColumns + case "FULL": + // SHOW FULL COLUMNS/FIELDS FROM table - treat as ShowColumns + p.nextToken() + if p.currentIs(token.COLUMNS) || (p.currentIs(token.IDENT) && strings.ToUpper(p.current.Value) == "FIELDS") { + p.nextToken() + } + show.ShowType = ast.ShowColumns + // Don't consume another token, fall through to FROM parsing + goto parseFrom case "EXTENDED": // SHOW EXTENDED INDEX FROM table - treat as ShowColumns p.nextToken() diff --git a/parser/testdata/02706_show_columns/metadata.json b/parser/testdata/02706_show_columns/metadata.json index 25122ac4f4..9e26dfeeb6 100644 --- a/parser/testdata/02706_show_columns/metadata.json +++ b/parser/testdata/02706_show_columns/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt5": true, - "stmt9": true - } -} +{} \ No newline at end of file From e477f7df0175476f1de5a90717bd6a77b57ce5c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:25:51 +0000 Subject: [PATCH 09/11] Add IF EXISTS support for RENAME TABLE statement (#119) Parse the IF EXISTS modifier in RENAME TABLE statements. Added IfExists field to RenameQuery struct and parsing logic. --- ast/ast.go | 1 + parser/parser.go | 7 +++++++ parser/testdata/01109_exchange_tables/metadata.json | 7 +------ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/ast/ast.go b/ast/ast.go index 985f721ac4..f1f9f1a0a8 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -985,6 +985,7 @@ type RenameQuery struct { To string `json:"to,omitempty"` // Deprecated: for backward compat OnCluster string `json:"on_cluster,omitempty"` Settings []*SettingExpr `json:"settings,omitempty"` + IfExists bool `json:"if_exists,omitempty"` // IF EXISTS modifier } func (r *RenameQuery) Pos() token.Position { return r.Position } diff --git a/parser/parser.go b/parser/parser.go index 7314e75806..4929931b16 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -6887,6 +6887,13 @@ func (p *Parser) parseRename() *ast.RenameQuery { return nil } + // Handle IF EXISTS after TABLE/DICTIONARY + if p.currentIs(token.IF) && p.peekIs(token.EXISTS) { + p.nextToken() // skip IF + p.nextToken() // skip EXISTS + rename.IfExists = true + } + // Parse rename pairs (can have multiple: t1 TO t2, t3 TO t4, ...) for { pair := &ast.RenamePair{} diff --git a/parser/testdata/01109_exchange_tables/metadata.json b/parser/testdata/01109_exchange_tables/metadata.json index 6599b4f20b..9e26dfeeb6 100644 --- a/parser/testdata/01109_exchange_tables/metadata.json +++ b/parser/testdata/01109_exchange_tables/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt43": true, - "stmt44": true - } -} +{} \ No newline at end of file From 7e2615598bb2e2ad2ba29e4e76b7d5aef9c2fba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:40:36 +0000 Subject: [PATCH 10/11] Handle ORDER BY with ASC/DESC modifiers in CREATE TABLE (#120) Parse ASC/DESC modifiers after non-parenthesized ORDER BY expressions in CREATE TABLE statements. When modifiers are present, output the column wrapped in StorageOrderByElement in EXPLAIN AST output. --- internal/explain/statements.go | 8 +++++++- parser/parser.go | 8 +++++++- parser/testdata/03257_reverse_sorting_key/metadata.json | 6 +----- .../03257_reverse_sorting_key_simple/metadata.json | 6 +----- .../03257_reverse_sorting_key_zookeeper/metadata.json | 6 +----- .../metadata.json | 6 +----- .../03459-reverse-sorting-key-stable-result/metadata.json | 6 +----- .../testdata/03513_read_in_order_nullable/metadata.json | 7 +------ 8 files changed, 20 insertions(+), 33 deletions(-) diff --git a/internal/explain/statements.go b/internal/explain/statements.go index 19a03226c5..ed66b7c4cc 100644 --- a/internal/explain/statements.go +++ b/internal/explain/statements.go @@ -446,7 +446,13 @@ func explainCreateQuery(sb *strings.Builder, n *ast.CreateQuery, indent string, if len(n.OrderBy) > 0 { if len(n.OrderBy) == 1 { if ident, ok := n.OrderBy[0].(*ast.Identifier); ok { - fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, ident.Name()) + // When ORDER BY has modifiers (ASC/DESC), wrap in StorageOrderByElement + if n.OrderByHasModifiers { + fmt.Fprintf(sb, "%s StorageOrderByElement (children %d)\n", storageIndent, 1) + fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, ident.Name()) + } else { + fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, ident.Name()) + } } else if lit, ok := n.OrderBy[0].(*ast.Literal); ok && lit.Type == ast.LiteralTuple { // Handle tuple literal - for ORDER BY with modifiers (DESC/ASC), // ClickHouse outputs just "Function tuple" without children diff --git a/parser/parser.go b/parser/parser.go index 4929931b16..fd6636e927 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -2626,7 +2626,13 @@ func (p *Parser) parseTableOptions(create *ast.CreateQuery) { } } else { // Use ALIAS_PREC to avoid consuming AS keyword (for AS SELECT) - create.OrderBy = []ast.Expression{p.parseExpression(ALIAS_PREC)} + expr := p.parseExpression(ALIAS_PREC) + create.OrderBy = []ast.Expression{expr} + // Handle ASC/DESC modifier after single non-parenthesized ORDER BY expression + if p.currentIs(token.ASC) || p.currentIs(token.DESC) { + create.OrderByHasModifiers = true + p.nextToken() + } } } case p.currentIs(token.PRIMARY): diff --git a/parser/testdata/03257_reverse_sorting_key/metadata.json b/parser/testdata/03257_reverse_sorting_key/metadata.json index 3a06a4a1ac..0967ef424b 100644 --- a/parser/testdata/03257_reverse_sorting_key/metadata.json +++ b/parser/testdata/03257_reverse_sorting_key/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt5": true - } -} +{} diff --git a/parser/testdata/03257_reverse_sorting_key_simple/metadata.json b/parser/testdata/03257_reverse_sorting_key_simple/metadata.json index 3a06a4a1ac..0967ef424b 100644 --- a/parser/testdata/03257_reverse_sorting_key_simple/metadata.json +++ b/parser/testdata/03257_reverse_sorting_key_simple/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt5": true - } -} +{} diff --git a/parser/testdata/03257_reverse_sorting_key_zookeeper/metadata.json b/parser/testdata/03257_reverse_sorting_key_zookeeper/metadata.json index 1295a45747..0967ef424b 100644 --- a/parser/testdata/03257_reverse_sorting_key_zookeeper/metadata.json +++ b/parser/testdata/03257_reverse_sorting_key_zookeeper/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt3": true - } -} +{} diff --git a/parser/testdata/03362_reverse_sorting_key_explicit_primary_key/metadata.json b/parser/testdata/03362_reverse_sorting_key_explicit_primary_key/metadata.json index ef58f80315..0967ef424b 100644 --- a/parser/testdata/03362_reverse_sorting_key_explicit_primary_key/metadata.json +++ b/parser/testdata/03362_reverse_sorting_key_explicit_primary_key/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt2": true - } -} +{} diff --git a/parser/testdata/03459-reverse-sorting-key-stable-result/metadata.json b/parser/testdata/03459-reverse-sorting-key-stable-result/metadata.json index ef58f80315..0967ef424b 100644 --- a/parser/testdata/03459-reverse-sorting-key-stable-result/metadata.json +++ b/parser/testdata/03459-reverse-sorting-key-stable-result/metadata.json @@ -1,5 +1 @@ -{ - "explain_todo": { - "stmt2": true - } -} +{} diff --git a/parser/testdata/03513_read_in_order_nullable/metadata.json b/parser/testdata/03513_read_in_order_nullable/metadata.json index cdfe9ecd52..9e26dfeeb6 100644 --- a/parser/testdata/03513_read_in_order_nullable/metadata.json +++ b/parser/testdata/03513_read_in_order_nullable/metadata.json @@ -1,6 +1 @@ -{ - "explain_todo": { - "stmt14": true, - "stmt36": true - } -} +{} \ No newline at end of file From 7540ee321d25bdb8a232ba44a0977f68078d655d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:56:33 +0000 Subject: [PATCH 11/11] Handle UnaryExpr in WITH clause element explaining (#121) Add handling for UnaryExpr (unary negation) in explainWithElement to properly output negative numeric literals with their aliases. Adds formatNegativeLiteral helper function to format negative numbers in the standard Int64_-N format. --- internal/explain/expressions.go | 30 +++++++++++++++++++ internal/explain/format.go | 20 +++++++++++++ .../01277_fromUnixTimestamp64/metadata.json | 2 +- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/explain/expressions.go b/internal/explain/expressions.go index 98e2d95415..67e9bd7f26 100644 --- a/internal/explain/expressions.go +++ b/internal/explain/expressions.go @@ -1112,6 +1112,36 @@ func explainWithElement(sb *strings.Builder, n *ast.WithElement, indent string, explainArrayAccessWithAlias(sb, e, n.Name, indent, depth) case *ast.BetweenExpr: explainBetweenExprWithAlias(sb, e, n.Name, indent, depth) + case *ast.UnaryExpr: + // For unary minus with numeric literal, output as negative literal with alias + if e.Op == "-" { + if lit, ok := e.Operand.(*ast.Literal); ok && (lit.Type == ast.LiteralInteger || lit.Type == ast.LiteralFloat) { + // Format as negative literal + negLit := &ast.Literal{ + Position: lit.Position, + Type: lit.Type, + Value: lit.Value, + } + if n.Name != "" { + fmt.Fprintf(sb, "%sLiteral %s (alias %s)\n", indent, formatNegativeLiteral(negLit), n.Name) + } else { + fmt.Fprintf(sb, "%sLiteral %s\n", indent, formatNegativeLiteral(negLit)) + } + return + } + } + // For other unary expressions, output as function + fnName := "negate" + if e.Op == "NOT" { + fnName = "not" + } + if n.Name != "" { + fmt.Fprintf(sb, "%sFunction %s (alias %s) (children %d)\n", indent, fnName, n.Name, 1) + } else { + fmt.Fprintf(sb, "%sFunction %s (children %d)\n", indent, fnName, 1) + } + fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, 1) + Node(sb, e.Operand, depth+2) default: // For other types, just output the expression (alias may be lost) Node(sb, n.Query, depth) diff --git a/internal/explain/format.go b/internal/explain/format.go index e9e5711611..1cb9efa886 100644 --- a/internal/explain/format.go +++ b/internal/explain/format.go @@ -144,6 +144,26 @@ func FormatLiteral(lit *ast.Literal) string { } } +// formatNegativeLiteral formats a numeric literal with a negative sign prepended +func formatNegativeLiteral(lit *ast.Literal) string { + switch lit.Type { + case ast.LiteralInteger: + switch val := lit.Value.(type) { + case int64: + return fmt.Sprintf("Int64_-%d", val) + case uint64: + return fmt.Sprintf("Int64_-%d", val) + default: + return fmt.Sprintf("Int64_-%v", lit.Value) + } + case ast.LiteralFloat: + val := lit.Value.(float64) + return fmt.Sprintf("Float64_-%s", FormatFloat(val)) + default: + return fmt.Sprintf("-%v", lit.Value) + } +} + // formatArrayLiteral formats an array literal for EXPLAIN AST output func formatArrayLiteral(val interface{}) string { exprs, ok := val.([]ast.Expression) diff --git a/parser/testdata/01277_fromUnixTimestamp64/metadata.json b/parser/testdata/01277_fromUnixTimestamp64/metadata.json index 02d3e5a7c6..9e26dfeeb6 100644 --- a/parser/testdata/01277_fromUnixTimestamp64/metadata.json +++ b/parser/testdata/01277_fromUnixTimestamp64/metadata.json @@ -1 +1 @@ -{"explain_todo":{"stmt21":true}} +{} \ No newline at end of file