Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4dd7573
Add kql() function transformation to view() in EXPLAIN AST output
claude Jan 3, 2026
2e961f1
Support dotted identifiers in ALTER TABLE RENAME COLUMN
claude Jan 3, 2026
5b10dde
Add support for multi-word data types in parser
claude Jan 3, 2026
350e9c7
Escape single quotes in alias names in EXPLAIN output
claude Jan 3, 2026
cceec3b
Fix handling of INT64_MIN and very large negative integers in CAST
claude Jan 3, 2026
367bb0d
Add tuple expansion and regex pattern EXCEPT support
claude Jan 3, 2026
51ba8db
Add INDEX and SETTINGS support for ATTACH TABLE statements
claude Jan 3, 2026
744ac7b
Handle COMMENT in MODIFY COLUMN without data type
claude Jan 3, 2026
98d87e5
Fix ANY/ALL keyword conflict with any()/all() function calls in expre…
claude Jan 3, 2026
2704378
Remove incorrect ln->log function name normalization
claude Jan 3, 2026
202b372
Handle boolean literals correctly in CAST expressions
claude Jan 3, 2026
aa3f58e
Parse column definitions after TO target in MATERIALIZED VIEW
claude Jan 3, 2026
3e66201
Fix IN expression to include :: cast on right side without parentheses
claude Jan 3, 2026
28417cb
Add IF NOT EXISTS support for ATTACH TABLE statement
claude Jan 3, 2026
3b54762
Add implicit NULL for caseWithExpression without ELSE clause
claude Jan 3, 2026
a7ea9a0
Add BACKUP and RESTORE statement support
claude Jan 3, 2026
f5ddaca
Distinguish EXCEPT set operation from column exclusion
claude Jan 3, 2026
db52b3a
Include function arguments in BACKUP/RESTORE explain output
claude Jan 3, 2026
e2dfe9c
Allow keywords as CTE names in WITH clause
claude Jan 3, 2026
244b71f
Support WITH TIES modifier after TOP clause
claude Jan 3, 2026
6417e6e
Support SHOW TABLE and SHOW DATABASE as aliases
claude Jan 3, 2026
c322581
Strip session/global prefix from MySQL system variables
claude Jan 3, 2026
8219118
Add \e escape sequence support for PHP/MySQL style strings
claude Jan 3, 2026
ebb3072
Fix OFFSET ROW parsing to accept both singular and plural forms
claude Jan 3, 2026
a1cb2fd
Support empty USING () clause in JOINs
claude Jan 3, 2026
b482d3d
Fix SYSTEM command parsing for TTL MERGES table names
claude Jan 3, 2026
6a78945
Allow EXISTS keyword as column identifier when not followed by (
claude Jan 3, 2026
0c3ede9
Fix table alias parsing order - alias before FINAL
claude Jan 3, 2026
705a905
Fix ADD CONSTRAINT explain output to show expression
claude Jan 3, 2026
f1f302b
Fix tuple literal expansion in IN expressions and explain output
claude Jan 3, 2026
e61ed87
Propagate WITH clause to subsequent SELECTs in UNION queries
claude Jan 3, 2026
f0aac10
Fix database-qualified dictionary names in DETACH/ATTACH statements
claude Jan 3, 2026
c104ce8
Accept keywords as index type names in ALTER ADD INDEX
claude Jan 3, 2026
c7b4c6e
Render arrays with parenthesized elements as Function array
claude Jan 3, 2026
1204a30
Handle LIMIT offset, count syntax after LIMIT BY clause
claude Jan 3, 2026
dc5e91b
Support IN PARTITION clause in DELETE statements
claude Jan 3, 2026
76f3e3b
Support qualified identifiers starting with keywords
claude Jan 3, 2026
d50de22
Support TTL elements with WHERE conditions
claude Jan 3, 2026
e059896
Support PARTITION ID syntax in OPTIMIZE TABLE statements
claude Jan 3, 2026
8470f41
Fix ALTER ADD INDEX tuple expression parsing
claude Jan 3, 2026
a59b23f
Add support for KILL QUERY/MUTATION statements
claude Jan 3, 2026
3783bb2
Enable duplicate output for RELOAD DICTIONARY in SYSTEM queries
claude Jan 3, 2026
20a7593
Handle large number overflow and preserve original source text
claude Jan 3, 2026
0ccea21
Add alias support for ArrayAccess and BetweenExpr in WITH clauses
claude Jan 3, 2026
700f2d9
Handle empty PRIMARY KEY () in CREATE TABLE explain output
claude Jan 3, 2026
8bb4771
Support TTL DELETE WHERE clause in ALTER TABLE MODIFY TTL
claude Jan 3, 2026
8c9aa17
Trim whitespace in query parameter name and type parsing
claude Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 83 additions & 16 deletions ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ type CreateQuery struct {
Indexes []*IndexDefinition `json:"indexes,omitempty"`
Projections []*Projection `json:"projections,omitempty"`
Constraints []*Constraint `json:"constraints,omitempty"`
ColumnsPrimaryKey []Expression `json:"columns_primary_key,omitempty"` // PRIMARY KEY in column list
ColumnsPrimaryKey []Expression `json:"columns_primary_key,omitempty"` // PRIMARY KEY in column list
HasEmptyColumnsPrimaryKey bool `json:"has_empty_columns_primary_key,omitempty"` // TRUE if PRIMARY KEY () was seen with empty parens
Engine *EngineClause `json:"engine,omitempty"`
OrderBy []Expression `json:"order_by,omitempty"`
OrderByHasModifiers bool `json:"order_by_has_modifiers,omitempty"` // True if ORDER BY has ASC/DESC modifiers
Expand Down Expand Up @@ -496,11 +497,22 @@ type TTLClause struct {
Position token.Position `json:"-"`
Expression Expression `json:"expression"`
Expressions []Expression `json:"expressions,omitempty"` // Additional TTL expressions (for multiple TTL elements)
Elements []*TTLElement `json:"elements,omitempty"` // TTL elements with WHERE conditions
}

func (t *TTLClause) Pos() token.Position { return t.Position }
func (t *TTLClause) End() token.Position { return t.Position }

// TTLElement represents a single TTL element with optional WHERE condition.
type TTLElement struct {
Position token.Position `json:"-"`
Expr Expression `json:"expr"`
Where Expression `json:"where,omitempty"` // WHERE condition for DELETE
}

func (t *TTLElement) Pos() token.Position { return t.Position }
func (t *TTLElement) End() token.Position { return t.Position }

// DropQuery represents a DROP statement.
type DropQuery struct {
Position token.Position `json:"-"`
Expand Down Expand Up @@ -707,11 +719,12 @@ func (t *TruncateQuery) statementNode() {}

// DeleteQuery represents a lightweight DELETE statement.
type DeleteQuery struct {
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table"`
Where Expression `json:"where,omitempty"`
Settings []*SettingExpr `json:"settings,omitempty"`
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table"`
Partition Expression `json:"partition,omitempty"` // IN PARTITION clause
Where Expression `json:"where,omitempty"`
Settings []*SettingExpr `json:"settings,omitempty"`
}

func (d *DeleteQuery) Pos() token.Position { return d.Position }
Expand Down Expand Up @@ -743,11 +756,14 @@ func (d *DetachQuery) statementNode() {}
// AttachQuery represents an ATTACH statement.
type AttachQuery struct {
Position token.Position `json:"-"`
IfNotExists bool `json:"if_not_exists,omitempty"`
Database string `json:"database,omitempty"`
Table string `json:"table,omitempty"`
Dictionary string `json:"dictionary,omitempty"`
Columns []*ColumnDeclaration `json:"columns,omitempty"`
ColumnsPrimaryKey []Expression `json:"columns_primary_key,omitempty"` // PRIMARY KEY in column list
ColumnsPrimaryKey []Expression `json:"columns_primary_key,omitempty"` // PRIMARY KEY in column list
HasEmptyColumnsPrimaryKey bool `json:"has_empty_columns_primary_key,omitempty"` // TRUE if PRIMARY KEY () was seen with empty parens
Indexes []*IndexDefinition `json:"indexes,omitempty"` // INDEX definitions in column list
Engine *EngineClause `json:"engine,omitempty"`
OrderBy []Expression `json:"order_by,omitempty"`
PrimaryKey []Expression `json:"primary_key,omitempty"`
Expand All @@ -756,12 +772,47 @@ type AttachQuery struct {
InnerUUID string `json:"inner_uuid,omitempty"` // TO INNER UUID clause
PartitionBy Expression `json:"partition_by,omitempty"`
SelectQuery Statement `json:"select_query,omitempty"` // AS SELECT clause
Settings []*SettingExpr `json:"settings,omitempty"` // SETTINGS clause
}

func (a *AttachQuery) Pos() token.Position { return a.Position }
func (a *AttachQuery) End() token.Position { return a.Position }
func (a *AttachQuery) statementNode() {}

// BackupQuery represents a BACKUP statement.
type BackupQuery struct {
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table,omitempty"`
Dictionary string `json:"dictionary,omitempty"`
All bool `json:"all,omitempty"` // BACKUP ALL
Temporary bool `json:"temporary,omitempty"`
Target *FunctionCall `json:"target,omitempty"` // Disk('path') or Null
Settings []*SettingExpr `json:"settings,omitempty"`
Format string `json:"format,omitempty"`
}

func (b *BackupQuery) Pos() token.Position { return b.Position }
func (b *BackupQuery) End() token.Position { return b.Position }
func (b *BackupQuery) statementNode() {}

// RestoreQuery represents a RESTORE statement.
type RestoreQuery struct {
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table,omitempty"`
Dictionary string `json:"dictionary,omitempty"`
All bool `json:"all,omitempty"` // RESTORE ALL
Temporary bool `json:"temporary,omitempty"`
Source *FunctionCall `json:"source,omitempty"` // Disk('path') or Null
Settings []*SettingExpr `json:"settings,omitempty"`
Format string `json:"format,omitempty"`
}

func (r *RestoreQuery) Pos() token.Position { return r.Position }
func (r *RestoreQuery) End() token.Position { return r.Position }
func (r *RestoreQuery) statementNode() {}

// DescribeQuery represents a DESCRIBE statement.
type DescribeQuery struct {
Position token.Position `json:"-"`
Expand Down Expand Up @@ -860,15 +911,16 @@ func (s *SetQuery) statementNode() {}

// OptimizeQuery represents an OPTIMIZE statement.
type OptimizeQuery struct {
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table"`
Partition Expression `json:"partition,omitempty"`
Final bool `json:"final,omitempty"`
Cleanup bool `json:"cleanup,omitempty"`
Dedupe bool `json:"dedupe,omitempty"`
OnCluster string `json:"on_cluster,omitempty"`
Settings []*SettingExpr `json:"settings,omitempty"`
Position token.Position `json:"-"`
Database string `json:"database,omitempty"`
Table string `json:"table"`
Partition Expression `json:"partition,omitempty"`
PartitionByID bool `json:"partition_by_id,omitempty"` // PARTITION ID vs PARTITION expr
Final bool `json:"final,omitempty"`
Cleanup bool `json:"cleanup,omitempty"`
Dedupe bool `json:"dedupe,omitempty"`
OnCluster string `json:"on_cluster,omitempty"`
Settings []*SettingExpr `json:"settings,omitempty"`
}

func (o *OptimizeQuery) Pos() token.Position { return o.Position }
Expand Down Expand Up @@ -995,6 +1047,20 @@ func (s *ShowGrantsQuery) Pos() token.Position { return s.Position }
func (s *ShowGrantsQuery) End() token.Position { return s.Position }
func (s *ShowGrantsQuery) statementNode() {}

// KillQuery represents a KILL QUERY/MUTATION statement.
type KillQuery struct {
Position token.Position `json:"-"`
Type string `json:"type"` // "QUERY" or "MUTATION"
Where Expression `json:"where,omitempty"` // WHERE condition
Sync bool `json:"sync,omitempty"` // SYNC mode (default false = ASYNC)
Test bool `json:"test,omitempty"` // TEST mode
Format string `json:"format,omitempty"` // FORMAT clause
}

func (k *KillQuery) Pos() token.Position { return k.Position }
func (k *KillQuery) End() token.Position { return k.Position }
func (k *KillQuery) statementNode() {}

// ShowPrivilegesQuery represents a SHOW PRIVILEGES statement.
type ShowPrivilegesQuery struct {
Position token.Position `json:"-"`
Expand Down Expand Up @@ -1360,6 +1426,7 @@ type ColumnTransformer struct {
Apply string `json:"apply,omitempty"` // function name for APPLY
ApplyLambda Expression `json:"apply_lambda,omitempty"` // lambda expression for APPLY x -> expr
Except []string `json:"except,omitempty"` // column names for EXCEPT
Pattern string `json:"pattern,omitempty"` // regex pattern for EXCEPT('pattern')
Replaces []*ReplaceExpr `json:"replaces,omitempty"` // replacement expressions for REPLACE
}

Expand Down
6 changes: 6 additions & 0 deletions internal/explain/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ func Node(sb *strings.Builder, node interface{}, depth int) {
explainDetachQuery(sb, n, indent)
case *ast.AttachQuery:
explainAttachQuery(sb, n, indent, depth)
case *ast.BackupQuery:
explainBackupQuery(sb, n, indent)
case *ast.RestoreQuery:
explainRestoreQuery(sb, n, indent)
case *ast.AlterQuery:
explainAlterQuery(sb, n, indent, depth)
case *ast.OptimizeQuery:
Expand All @@ -254,6 +258,8 @@ func Node(sb *strings.Builder, node interface{}, depth int) {
explainUpdateQuery(sb, n, indent, depth)
case *ast.ParallelWithQuery:
explainParallelWithQuery(sb, n, indent, depth)
case *ast.KillQuery:
explainKillQuery(sb, n, indent, depth)

// Types
case *ast.DataType:
Expand Down
79 changes: 57 additions & 22 deletions internal/explain/expressions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"github.com/sqlc-dev/doubleclick/ast"
)

// escapeAlias escapes backslashes in alias names for EXPLAIN output
// escapeAlias escapes backslashes and single quotes in alias names for EXPLAIN output
func escapeAlias(alias string) string {
return strings.ReplaceAll(alias, "\\", "\\\\")
// Escape backslashes first, then single quotes
result := strings.ReplaceAll(alias, "\\", "\\\\")
result = strings.ReplaceAll(result, "'", "\\'")
return result
}

func explainIdentifier(sb *strings.Builder, n *ast.Identifier, indent string) {
Expand Down Expand Up @@ -53,19 +56,17 @@ func explainLiteral(sb *strings.Builder, n *ast.Literal, indent string, depth in
fmt.Fprintf(sb, "%s ExpressionList\n", indent)
return
}
// Single-element tuples (from trailing comma syntax like (1,)) always render as Function tuple
if len(exprs) == 1 {
fmt.Fprintf(sb, "%sFunction tuple (children %d)\n", indent, 1)
fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(exprs))
for _, e := range exprs {
Node(sb, e, depth+2)
}
return
}
// Check if any element is parenthesized (e.g., ((1), (2)) vs (1, 2))
// Parenthesized elements mean the tuple should render as Function tuple
hasParenthesizedElement := false
hasComplexExpr := false
for _, e := range exprs {
// Simple literals (numbers, strings, etc.) are OK
// Check for parenthesized literals
if lit, isLit := e.(*ast.Literal); isLit {
if lit.Parenthesized {
hasParenthesizedElement = true
break
}
// Nested tuples that contain only primitive literals are OK
if lit.Type == ast.LiteralTuple {
if !containsOnlyPrimitiveLiteralsWithUnary(lit) {
Expand All @@ -79,7 +80,6 @@ func explainLiteral(sb *strings.Builder, n *ast.Literal, indent string, depth in
hasComplexExpr = true
break
}
// Other literals are simple
continue
}
// Unary negation of numeric literals is also simple
Expand All @@ -94,8 +94,9 @@ func explainLiteral(sb *strings.Builder, n *ast.Literal, indent string, depth in
hasComplexExpr = true
break
}
if hasComplexExpr {
// Render as Function tuple instead of Literal
// Single-element tuples (from trailing comma syntax like (1,)) always render as Function tuple
// Tuples with complex expressions or parenthesized elements also render as Function tuple
if len(exprs) == 1 || hasComplexExpr || hasParenthesizedElement {
fmt.Fprintf(sb, "%sFunction tuple (children %d)\n", indent, 1)
fmt.Fprintf(sb, "%s ExpressionList (children %d)\n", indent, len(exprs))
for _, e := range exprs {
Expand Down Expand Up @@ -131,6 +132,10 @@ func explainLiteral(sb *strings.Builder, n *ast.Literal, indent string, depth in

for _, e := range exprs {
if lit, ok := e.(*ast.Literal); ok {
// Parenthesized elements require Function array format
if lit.Parenthesized {
shouldUseFunctionArray = true
}
if lit.Type == ast.LiteralArray {
hasNestedArrays = true
// Check if inner array needs Function array format:
Expand Down Expand Up @@ -395,8 +400,13 @@ func collectLogicalOperands(n *ast.BinaryExpr) []ast.Expression {
operands = append(operands, n.Left)
}

// Don't flatten right side - explicit parentheses would be on the left in left-associative parsing
operands = append(operands, n.Right)
// Also flatten right side if it's the same operator and not parenthesized
// This handles both left-associative and right-associative parsing
if right, ok := n.Right.(*ast.BinaryExpr); ok && right.Op == n.Op && !right.Parenthesized {
operands = append(operands, collectLogicalOperands(right)...)
} else {
operands = append(operands, n.Right)
}

return operands
}
Expand Down Expand Up @@ -425,8 +435,15 @@ func explainUnaryExpr(sb *strings.Builder, n *ast.UnaryExpr, indent string, dept
// ClickHouse normalizes -0 to UInt64_0
if val == 0 {
fmt.Fprintf(sb, "%sLiteral UInt64_0\n", indent)
} else {
} else if val <= 9223372036854775808 {
// Value fits in int64 when negated
// Note: -9223372036854775808 is int64 min, so 9223372036854775808 is included
fmt.Fprintf(sb, "%sLiteral Int64_-%d\n", indent, val)
} else {
// Value too large for int64 - output as Float64
f := -float64(val)
s := FormatFloat(f)
fmt.Fprintf(sb, "%sLiteral Float64_%s\n", indent, s)
}
return
}
Expand Down Expand Up @@ -647,7 +664,16 @@ func explainAliasedExpr(sb *strings.Builder, n *ast.AliasedExpr, depth int) {
fmt.Fprintf(sb, "%sLiteral Int64_%d (alias %s)\n", indent, -val, escapeAlias(n.Alias))
return
case uint64:
fmt.Fprintf(sb, "%sLiteral Int64_-%d (alias %s)\n", indent, val, escapeAlias(n.Alias))
if val <= 9223372036854775808 {
// Value fits in int64 when negated
// Note: -9223372036854775808 is int64 min, so 9223372036854775808 is included
fmt.Fprintf(sb, "%sLiteral Int64_-%d (alias %s)\n", indent, val, escapeAlias(n.Alias))
} else {
// Value too large for int64 - output as Float64
f := -float64(val)
s := FormatFloat(f)
fmt.Fprintf(sb, "%sLiteral Float64_%s (alias %s)\n", indent, s, escapeAlias(n.Alias))
}
return
}
case ast.LiteralFloat:
Expand Down Expand Up @@ -789,9 +815,14 @@ func explainSingleTransformer(sb *strings.Builder, t *ast.ColumnTransformer, ind
case "apply":
fmt.Fprintf(sb, "%s ColumnsApplyTransformer\n", indent)
case "except":
fmt.Fprintf(sb, "%s ColumnsExceptTransformer (children %d)\n", indent, len(t.Except))
for _, col := range t.Except {
fmt.Fprintf(sb, "%s Identifier %s\n", indent, col)
// If it's a regex pattern, output without children
if t.Pattern != "" {
fmt.Fprintf(sb, "%s ColumnsExceptTransformer\n", indent)
} else {
fmt.Fprintf(sb, "%s ColumnsExceptTransformer (children %d)\n", indent, len(t.Except))
for _, col := range t.Except {
fmt.Fprintf(sb, "%s Identifier %s\n", indent, col)
}
}
case "replace":
fmt.Fprintf(sb, "%s ColumnsReplaceTransformer (children %d)\n", indent, len(t.Replaces))
Expand Down Expand Up @@ -1029,6 +1060,10 @@ func explainWithElement(sb *strings.Builder, n *ast.WithElement, indent string,
}
case *ast.CastExpr:
explainCastExprWithAlias(sb, e, n.Name, indent, depth)
case *ast.ArrayAccess:
explainArrayAccessWithAlias(sb, e, n.Name, indent, depth)
case *ast.BetweenExpr:
explainBetweenExprWithAlias(sb, e, n.Name, indent, depth)
default:
// For other types, just output the expression (alias may be lost)
Node(sb, n.Query, depth)
Expand Down
Loading