diff --git a/CHANGELOG.md b/CHANGELOG.md index 50360c8b..d22c8826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Parse and faithfully render Snowflake PIVOT defaults, ClickHouse FINAL/SAMPLE + (also on joined tables, `TableModifiers.Sample`), SQL Server INSERT TOP, MySQL / + MariaDB ALTER TABLE partition actions (ADD/DROP [IF [NOT] EXISTS], TRUNCATE, + REORGANIZE, COALESCE, EXCHANGE, ANALYZE/CHECK/OPTIMIZE/REBUILD/REPAIR, + DISCARD/IMPORT, REMOVE/UPGRADE PARTITIONING, ALL), PostgreSQL ROWS FROM and + array subscripts, and ROLLUP/CUBE/nested GROUPING SETS inside GROUPING SETS; + cover identity constraints. Partition actions outside the structured grammar + still parse as as-written `AlterTableOther` text. - `ast.PivotClause.AggregateAlias`, `ast.PivotClause.ExtraAggregates` (`[]ast.PivotAggregate`), `ast.UnpivotClause.NullHandling`, `ast.UnpivotClause.ValueColumns` and `ast.UnpivotNullHandling`; rendered by @@ -36,6 +44,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identifier delimiters through rendering. ### Changed +- Parsed ClickHouse SAMPLE sizes and offsets now use inspectable expression + fields, which take rendering precedence over the retained legacy string fields. +- ClickHouse SAMPLE accepts only decimal numbers (hex such as `0x10` is + rejected) and renders before ARRAY JOIN; a SAMPLE clause with no valid size is + omitted instead of rendered as a bare `SAMPLE`. +- A column with more than one of DEFAULT, an identity clause and a generation + expression is rejected; signed numbers in identity options render as `-1`. - **Dialect extension fields**: the new dialect-specific clauses live behind optional pointers so that common statements do not carry them: `SelectStatement.Extras` (`SelectExtras`: SELECT modifiers, INTO, LIMIT BY, diff --git a/pkg/formatter/dialect_constructs_roundtrip_test.go b/pkg/formatter/dialect_constructs_roundtrip_test.go new file mode 100644 index 00000000..2c496ccd --- /dev/null +++ b/pkg/formatter/dialect_constructs_roundtrip_test.go @@ -0,0 +1,149 @@ +package formatter_test + +import ( + "reflect" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestDialectConstructsRoundTripAndInspection(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectSnowflake, `SELECT * FROM t PIVOT (SUM(amt) FOR month IN (ANY ORDER BY month) DEFAULT ON NULL (0)) AS p`}, + {keywords.DialectSnowflake, `SELECT * FROM t PIVOT (SUM(amt) FOR month IN ('JAN', 'FEB') DEFAULT ON NULL (0)) AS p`}, + {keywords.DialectClickHouse, `SELECT * FROM hits FINAL SAMPLE 0.1 WHERE x = 1`}, + {keywords.DialectClickHouse, `SELECT * FROM hits FINAL SAMPLE 1/10 OFFSET 1/2 WHERE x = 1`}, + {keywords.DialectClickHouse, `SELECT * FROM hits FINAL`}, + {keywords.DialectClickHouse, `SELECT * FROM hits SAMPLE 0.1`}, + {keywords.DialectClickHouse, `SELECT * FROM hits AS h FINAL SAMPLE 0.1`}, + {keywords.DialectSQLServer, `INSERT TOP (10) INTO t (a) SELECT a FROM s`}, + {keywords.DialectSQLServer, `INSERT TOP (10) PERCENT INTO t (a) SELECT a FROM s`}, + {keywords.DialectSQLServer, `INSERT TOP (@n) t (a) SELECT a FROM s`}, + {keywords.DialectSQLServer, `INSERT "TOP" (a) SELECT a FROM s`}, + {keywords.DialectPostgreSQL, `CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY (START WITH 10 INCREMENT BY 5) PRIMARY KEY, b int GENERATED BY DEFAULT AS IDENTITY NOT NULL, c int NOT NULL GENERATED ALWAYS AS IDENTITY)`}, + {keywords.DialectMySQL, `ALTER TABLE t PARTITION BY RANGE (YEAR(d)) (PARTITION p0 VALUES LESS THAN (2020), PARTITION p1 VALUES LESS THAN MAXVALUE)`}, + {keywords.DialectMySQL, `ALTER TABLE t ADD PARTITION (PARTITION p2 VALUES LESS THAN (2030))`}, + {keywords.DialectMySQL, `ALTER TABLE t DROP PARTITION p0`}, + {keywords.DialectMySQL, `ALTER TABLE t TRUNCATE PARTITION p1`}, + {keywords.DialectMySQL, `ALTER TABLE t REORGANIZE PARTITION p1 INTO (PARTITION p2 VALUES LESS THAN (2030), PARTITION p3 VALUES LESS THAN MAXVALUE)`}, + {keywords.DialectMySQL, `ALTER TABLE t REMOVE PARTITIONING`}, + {keywords.DialectPostgreSQL, `SELECT * FROM ROWS FROM (generate_series(1,3), unnest(ARRAY['a','b'])) WITH ORDINALITY AS r(n, s, i)`}, + {keywords.DialectPostgreSQL, `SELECT * FROM ROWS FROM (f(x) AS (a int, b text))`}, + {keywords.DialectPostgreSQL, `SELECT (ARRAY[1,2,3])[2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[1,2,3][2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[[1,2],[3,4]][1][2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[1,2,3][1:2]`}, + } + for _, dialect := range []keywords.SQLDialect{keywords.DialectPostgreSQL, keywords.DialectSnowflake, keywords.DialectSQLServer, keywords.DialectOracle} { + cases = append(cases, struct { + dialect keywords.SQLDialect + sql string + }{dialect, `SELECT a, b, SUM(c) FROM t GROUP BY GROUPING SETS ((a), ROLLUP(b), CUBE(a, b), ())`}) + } + for _, tc := range cases { + t.Run(string(tc.dialect)+"/"+tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + checkDialectConstructInspection(t, tree) + formatted, err := gosqlx.FormatWithDialect(tc.sql, gosqlx.DefaultFormatOptions(), tc.dialect) + if err != nil { + t.Fatal(err) + } + outputs := []string{tree.SQL(), formatted, formatter.FormatAST(tree, ast.CompactStyle()), formatter.FormatAST(tree, ast.ReadableStyle())} + clearDialectConstructLocations(reflect.ValueOf(tree.Statements)) + for _, output := range outputs { + again, err := gosqlx.ParseWithDialect(output, tc.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", output, err) + } + defer ast.ReleaseAST(again) + clearDialectConstructLocations(reflect.ValueOf(again.Statements)) + if !reflect.DeepEqual(tree.Statements, again.Statements) { + t.Errorf("AST changed after rendering %q", output) + } + checkDialectConstructInspection(t, again) + } + }) + } +} + +// Source positions change with whitespace; every other field must be identical. +func clearDialectConstructLocations(v reflect.Value) { + if !v.IsValid() { + return + } + if v.Type() == reflect.TypeFor[models.Location]() && v.CanSet() { + v.SetZero() + return + } + switch v.Kind() { + case reflect.Interface, reflect.Pointer: + if !v.IsNil() { + clearDialectConstructLocations(v.Elem()) + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + clearDialectConstructLocations(v.Field(i)) + } + } + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + clearDialectConstructLocations(v.Index(i)) + } + } +} + +// Compare traversal against the fields themselves so a missing Children edge +// cannot hide an expression from the acceptance tests. +func checkDialectConstructInspection(t *testing.T, tree *ast.AST) { + t.Helper() + visited := map[ast.Expression]bool{} + ast.Inspect(tree, func(node ast.Node) bool { + if e, ok := node.(ast.Expression); ok { + visited[e] = true + } + return true + }) + var check func(reflect.Value) + check = func(v reflect.Value) { + if !v.IsValid() { + return + } + if v.Kind() == reflect.Pointer && v.IsNil() { + return + } + if v.CanInterface() { + if e, ok := v.Interface().(ast.Expression); ok && !visited[e] { + t.Errorf("expression %T (%s) is not reachable through Inspect", e, e.TokenLiteral()) + } + } + switch v.Kind() { + case reflect.Interface, reflect.Pointer: + if !v.IsNil() { + check(v.Elem()) + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + check(v.Field(i)) + } + } + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + check(v.Index(i)) + } + } + } + check(reflect.ValueOf(tree.Statements)) +} diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 366c0a86..f5fe38a8 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -301,14 +301,15 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(joinSQL(&j, f)) } - if s.ArrayJoin != nil { + // ClickHouse writes SAMPLE with the table expression, before ARRAY JOIN. + if sample := ast.SampleExpressionSQL(s.Sample, f.kw); sample != "" { sb.WriteString(f.clauseSep()) - sb.WriteString(arrayJoinSQL(s.ArrayJoin, f)) + sb.WriteString(sample) } - if s.Sample != nil { + if s.ArrayJoin != nil { sb.WriteString(f.clauseSep()) - sb.WriteString(sampleSQL(s.Sample, f)) + sb.WriteString(arrayJoinSQL(s.ArrayJoin, f)) } if s.PrewhereClause != nil { @@ -1179,6 +1180,8 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString("(") sb.WriteString(f.tableSubquerySQL(t.Subquery)) sb.WriteString(")") + } else if rows := ast.RowsFromSQL(t, f.kw); rows != "" { + sb.WriteString(rows) } else if t.TableFunc != nil { sb.WriteString(f.exprSQL(t.TableFunc)) if t.WithOrdinality { @@ -1241,33 +1244,6 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { return sb.String() } -// sampleSQL renders a ClickHouse SAMPLE clause. -func sampleSQL(s *ast.SampleClause, f *nodeFormatter) string { - var sb strings.Builder - sb.WriteString(f.kw("SAMPLE")) - sb.WriteString(" ") - // The ratio and offset parts are numeric literals; anything else can only - // come from a hand-built AST and is dropped rather than written raw. - if ast.IsPlainNumberText(s.Value) { - sb.WriteString(s.Value) - if ast.IsPlainNumberText(s.Denominator) { - sb.WriteString("/") - sb.WriteString(s.Denominator) - } - } - if ast.IsPlainNumberText(s.Offset) { - sb.WriteString(" ") - sb.WriteString(f.kw("OFFSET")) - sb.WriteString(" ") - sb.WriteString(s.Offset) - if ast.IsPlainNumberText(s.OffsetDenominator) { - sb.WriteString("/") - sb.WriteString(s.OffsetDenominator) - } - } - return sb.String() -} - // joinSQL renders a JOIN clause. func joinSQL(j *ast.JoinClause, f *nodeFormatter) string { var sb strings.Builder diff --git a/pkg/formatter/render_dml.go b/pkg/formatter/render_dml.go index 2cd89098..dcdf2da6 100644 --- a/pkg/formatter/render_dml.go +++ b/pkg/formatter/render_dml.go @@ -139,6 +139,7 @@ func writeInsertHead(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStateme sb.WriteString(f.kw("INSERT")) sb.WriteString(" ") writeDMLModifiers(sb, f, i.DialectExtras().Modifiers) + writeDMLTop(sb, f, i.DialectExtras().Top) sb.WriteString(f.kw("INTO")) sb.WriteString(" ") if i.DialectExtras().TableFunction != nil { diff --git a/pkg/formatter/render_expression_case.go b/pkg/formatter/render_expression_case.go index 53446cf7..4129ea2b 100644 --- a/pkg/formatter/render_expression_case.go +++ b/pkg/formatter/render_expression_case.go @@ -250,12 +250,33 @@ func (f *nodeFormatter) exprSQL(e ast.Expression) string { copy.Expressions = c.exprs(v.Expressions) return c.result(©) case *ast.GroupingSetsExpression: - copy := *v - copy.Sets = make([][]ast.Expression, len(v.Sets)) - for i := range v.Sets { - copy.Sets[i] = c.exprs(v.Sets[i]) + // Rendered here rather than through slots: SQL() leaves a set that is + // a lone ROLLUP, CUBE or GROUPING SETS unparenthesised, and a slot + // would hide that element's type from it. + var b strings.Builder + b.WriteString("grouping sets(") + for i, set := range v.Sets { + if i > 0 { + b.WriteString(", ") + } + if len(set) == 1 { + switch set[0].(type) { + case *ast.RollupExpression, *ast.CubeExpression, *ast.GroupingSetsExpression: + b.WriteString(f.exprSQL(set[0])) + continue + } + } + b.WriteByte('(') + for j, e := range set { + if j > 0 { + b.WriteString(", ") + } + b.WriteString(f.exprSQL(e)) + } + b.WriteByte(')') } - return c.result(©) + b.WriteByte(')') + return b.String() case *ast.OuterJoinMarker: copy := *v copy.Expr = c.expr(v.Expr) diff --git a/pkg/formatter/sample_expression_safety_test.go b/pkg/formatter/sample_expression_safety_test.go new file mode 100644 index 00000000..961fbb85 --- /dev/null +++ b/pkg/formatter/sample_expression_safety_test.go @@ -0,0 +1,64 @@ +package formatter_test + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestDialectConstructsSampleFormatterSafety(t *testing.T) { + evil := "1; DROP TABLE audit; --" + number := func(v string) ast.Expression { return &ast.LiteralValue{Value: v, Type: "int"} } + var nilRatio *ast.BinaryExpression + clauses := []*ast.SampleClause{ + {Size: number(evil)}, + {Size: number("1"), OffsetSize: number(evil)}, + {Size: &ast.BinaryExpression{Left: number(evil), Operator: "/", Right: number("10")}}, + {Size: &ast.BinaryExpression{Left: number("1"), Operator: "/", Right: number(evil)}}, + {Size: number("1"), OffsetSize: &ast.BinaryExpression{Left: number(evil), Operator: "/", Right: number("2")}}, + {Size: number("1"), OffsetSize: &ast.BinaryExpression{Left: number("1"), Operator: "/", Right: number(evil)}}, + {Size: nilRatio}, + {Size: number("1"), OffsetSize: nilRatio}, + } + for i, clause := range clauses { + tree := &ast.AST{Statements: []ast.Statement{&ast.SelectStatement{Columns: []ast.Expression{&ast.Identifier{Name: "x"}}, From: []ast.TableReference{{Name: "hits"}}, Sample: clause}}} + for _, output := range []string{tree.SQL(), formatter.FormatAST(tree, ast.CompactStyle())} { + if strings.Contains(output, "DROP TABLE") || strings.Contains(output, ";") || strings.Contains(output, "--") { + t.Errorf("case %d emitted unsafe text: %q", i, output) + } + } + } +} + +func TestDialectConstructsSampleCanonicalMutation(t *testing.T) { + tree, err := gosqlx.ParseWithDialect("SELECT * FROM hits SAMPLE 1/10 OFFSET 1/2", keywords.DialectClickHouse) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + for _, output := range []string{tree.SQL(), formatter.FormatAST(tree, ast.CompactStyle())} { + if !strings.Contains(output, "SAMPLE 1/10 OFFSET 1/2") { + t.Fatalf("normal ratio changed: %q", output) + } + } + stmt.Sample.Size = &ast.LiteralValue{Value: "0.25", Type: "float"} + stmt.Sample.OffsetSize = &ast.LiteralValue{Value: "0.5", Type: "FLOAT"} + for _, output := range []string{tree.SQL(), formatter.FormatAST(tree, ast.CompactStyle())} { + if !strings.Contains(output, "SAMPLE 0.25 OFFSET 0.5") { + t.Fatalf("canonical mutation ignored: %q", output) + } + again, err := gosqlx.ParseWithDialect(output, keywords.DialectClickHouse) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(again) + } +} diff --git a/pkg/formatter/table_factor_render_test.go b/pkg/formatter/table_factor_render_test.go index ea52c1dc..dceb676b 100644 --- a/pkg/formatter/table_factor_render_test.go +++ b/pkg/formatter/table_factor_render_test.go @@ -319,12 +319,18 @@ func TestRender_TableReferenceRendersEveryField(t *testing.T) { func TestRender_TableModifiersRendersEveryField(t *testing.T) { num := func(v string) *ast.LiteralValue { return &ast.LiteralValue{Value: v, Type: "int"} } samples := map[string]func(*ast.TableModifiers){ + "RowsFrom": func(m *ast.TableModifiers) { + m.RowsFrom = []ast.RowsFromItem{{Function: &ast.FunctionCall{Name: "generate_series", Arguments: []ast.Expression{num("1"), num("3")}}}} + }, "Values": func(m *ast.TableModifiers) { m.Values = &ast.Values{Rows: [][]ast.Expression{{num("1")}}} }, "TableSample": func(m *ast.TableModifiers) { m.TableSample = &ast.TableSampleClause{Keyword: "SAMPLE", Args: []ast.Expression{num("5")}} }, + "Sample": func(m *ast.TableModifiers) { + m.Sample = &ast.SampleClause{Size: &ast.LiteralValue{Value: "0.5", Type: "float"}} + }, "Flashback": func(m *ast.TableModifiers) { m.Flashback = &ast.FlashbackClause{Kind: "SCN", Point: num("9")} }, "DBLink": func(m *ast.TableModifiers) { m.DBLink = "lnk" }, "Partitions": func(m *ast.TableModifiers) { m.Partitions = []string{"p0"} }, diff --git a/pkg/sql/ast/alter.go b/pkg/sql/ast/alter.go index 70f5728c..9ea33b69 100644 --- a/pkg/sql/ast/alter.go +++ b/pkg/sql/ast/alter.go @@ -238,6 +238,20 @@ type AlterTableOperation struct { Assignments []UpdateExpression PartitionExpr Expression Where Expression + + // Structured MySQL partition actions; Keyword identifies the action. + Partitioning *PartitionBy + PartitionDefinitions []PartitionDefinition + PartitionNames []*Ident + // PartitionAll is true when the action names ALL partitions. + PartitionAll bool + // PartitionCount is n of COALESCE PARTITION n and ADD PARTITION + // PARTITIONS n. + PartitionCount Expression + // ExchangeTable and Validation describe EXCHANGE PARTITION p WITH TABLE + // t [WITH | WITHOUT VALIDATION]; Validation is "", "WITH" or "WITHOUT". + ExchangeTable ObjectName + Validation string } func (a *AlterTableOperation) alterOperationNode() {} @@ -305,6 +319,8 @@ const ( UpdateMutation // DeleteMutation is the ClickHouse DELETE [IN PARTITION p] WHERE cond. DeleteMutation + // AlterPartitioning is a structured MySQL partition action. + AlterPartitioning ) // RoleOption represents an option in ROLE statement diff --git a/pkg/sql/ast/alter_ext.go b/pkg/sql/ast/alter_ext.go index 0ea26326..ed6b3f65 100644 --- a/pkg/sql/ast/alter_ext.go +++ b/pkg/sql/ast/alter_ext.go @@ -45,5 +45,17 @@ func (a *AlterTableOperation) extChildren() []Node { if a.Where != nil { children = append(children, a.Where) } + if a.Partitioning != nil { + children = append(children, a.Partitioning) + } + for i := range a.PartitionDefinitions { + children = append(children, &a.PartitionDefinitions[i]) + } + for _, name := range a.PartitionNames { + children = append(children, name) + } + if a.PartitionCount != nil { + children = append(children, a.PartitionCount) + } return children } diff --git a/pkg/sql/ast/alter_partition.go b/pkg/sql/ast/alter_partition.go new file mode 100644 index 00000000..578433d7 --- /dev/null +++ b/pkg/sql/ast/alter_partition.go @@ -0,0 +1,117 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +// partitionActionKeywords are the AlterPartitioning keyword phrases the +// renderer writes. +var partitionActionKeywords = []string{ + "ADD PARTITION", "DROP PARTITION", "TRUNCATE PARTITION", "REORGANIZE PARTITION", + "COALESCE PARTITION", "EXCHANGE PARTITION", "ANALYZE PARTITION", "CHECK PARTITION", + "OPTIMIZE PARTITION", "REBUILD PARTITION", "REPAIR PARTITION", "DISCARD PARTITION", + "IMPORT PARTITION", "REMOVE PARTITIONING", "UPGRADE PARTITIONING", +} + +func (w alterWriter) writePartitionAction(op *AlterTableOperation) { + if op.Keyword == "PARTITION BY" { + if op.Partitioning != nil { + buf := getBuf() + defer putBuf(buf) + writePartitionBy(buf, op.Partitioning, w.st) + w.str(buf.String()[1:]) + } + writePartitionDefinitions(w.buf, op.PartitionDefinitions, w.st) + return + } + kw := keywordOneOf(op.Keyword, partitionActionKeywords) + if kw == "" { + return + } + w.kwStr(kw) + switch { + case op.IfNotExists: + w.str(" ") + w.kwStr("IF NOT EXISTS") + case op.IfExists: + w.str(" ") + w.kwStr("IF EXISTS") + } + if op.PartitionAll { + w.str(" ") + w.kwStr("ALL") + } + for i, name := range op.PartitionNames { + if i == 0 { + w.str(" ") + } else { + w.str(", ") + } + w.str(w.identName(name)) + } + if op.PartitionCount != nil { + w.str(" ") + if kw == "ADD PARTITION" { + w.kwStr("PARTITIONS") + w.str(" ") + } + w.expr(op.PartitionCount) + } + if kw == "REORGANIZE PARTITION" && len(op.PartitionDefinitions) > 0 { + w.str(" ") + w.kwStr("INTO") + } + writePartitionDefinitions(w.buf, op.PartitionDefinitions, w.st) + if kw == "EXCHANGE PARTITION" { + w.str(" ") + w.kwStr("WITH TABLE") + w.str(" ") + w.str(QuoteQualifiedNameWith(op.ExchangeTable.Name, w.st.nq(op.ExchangeTable.Quote))) + if v := keywordOneOf(op.Validation, []string{"WITH", "WITHOUT"}); v != "" { + w.str(" ") + w.kwStr(v + " VALIDATION") + } + } + if kw == "DISCARD PARTITION" || kw == "IMPORT PARTITION" { + w.str(" ") + w.kwStr("TABLESPACE") + } +} + +func releaseAlterPartitionFields(op *AlterTableOperation, mode releaseMode) { + if op.Partitioning != nil { + for _, expr := range op.Partitioning.Boundary { + releaseExpression(expr, mode) + } + releasePartitionByExtras(op.Partitioning, mode) + *op.Partitioning = PartitionBy{} + op.Partitioning = nil + } + for i := range op.PartitionDefinitions { + def := &op.PartitionDefinitions[i] + for _, expr := range def.Values { + releaseExpression(expr, mode) + } + for _, expr := range def.InValues { + releaseExpression(expr, mode) + } + releaseExpression(def.LessThan, mode) + releaseExpression(def.From, mode) + releaseExpression(def.To, mode) + *def = PartitionDefinition{} + } + op.PartitionDefinitions = nil + op.PartitionNames = nil + releaseExpression(op.PartitionCount, mode) + op.PartitionCount = nil +} diff --git a/pkg/sql/ast/alter_partition_pool_test.go b/pkg/sql/ast/alter_partition_pool_test.go new file mode 100644 index 00000000..bc2390aa --- /dev/null +++ b/pkg/sql/ast/alter_partition_pool_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "testing" + +func TestAlterPartitionReleaseModes(t *testing.T) { + for _, mode := range []releaseMode{releaseDrawn, releaseAll} { + for _, legacy := range []bool{false, true} { + leaf := &LiteralValue{Value: int64(2030)} + array := GetArrayConstructor() + array.Elements = []Expression{leaf} + key := &Identifier{Name: "year"} + part := &PartitionBy{Type: "RANGE", Expressions: []Expression{key}} + op := &AlterTableOperation{Type: AlterPartitioning, Partitioning: part, PartitionDefinitions: []PartitionDefinition{{Name: "p", LessThan: array}}, PartitionNames: []*Ident{{Name: "old"}}, PartitionCount: &LiteralValue{Value: "2", Type: "int"}} + stmt := GetAlterStatement() + stmt.Operation = op + if !legacy { + stmt.Operations = []AlterOperation{op} + } + releaseAlterStatement(stmt, mode) + if op.Partitioning != nil || op.PartitionDefinitions != nil || op.PartitionNames != nil || op.PartitionCount != nil { + t.Fatalf("mode %v legacy %v: partition fields not cleared", mode, legacy) + } + if len(array.Elements) != 0 { + t.Errorf("mode %v: pooled array not released", mode) + } + if mode == releaseAll { + if leaf.Value != nil || key.Name != "" { + t.Error("releaseAll did not clear leaves") + } + } else if leaf.Value != int64(2030) || key.Name != "year" { + t.Error("releaseDrawn reset undrawn leaves") + } + } + } +} diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 20a442f3..cf50106b 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -306,9 +306,17 @@ func (t TableReference) Children() []Node { nodes = append(nodes, t.ForSystemTime) } if m := t.Modifiers; m != nil { + for i := range m.RowsFrom { + if m.RowsFrom[i].Function != nil { + nodes = append(nodes, m.RowsFrom[i].Function) + } + } if m.TableSample != nil { nodes = append(nodes, m.TableSample) } + if m.Sample != nil { + nodes = append(nodes, m.Sample) + } if m.Flashback != nil { nodes = append(nodes, m.Flashback) } @@ -1174,6 +1182,10 @@ type ArrayJoinElement struct { } type SampleClause struct { + // Size and OffsetSize are the parsed numeric expressions. When non-nil, + // they take precedence over the legacy raw token fields below. + Size Expression + OffsetSize Expression // Value is the sampling size/ratio as a raw token string (e.g., "0.1", "1000", "1/10"). Value string // Denominator is set when the fraction form "N/D" is used (denominator part). @@ -1187,4 +1199,13 @@ type SampleClause struct { func (s *SampleClause) expressionNode() {} func (s SampleClause) TokenLiteral() string { return "SAMPLE" } -func (s SampleClause) Children() []Node { return nil } +func (s SampleClause) Children() []Node { + var nodes []Node + if s.Size != nil { + nodes = append(nodes, s.Size) + } + if s.OffsetSize != nil { + nodes = append(nodes, s.OffsetSize) + } + return nodes +} diff --git a/pkg/sql/ast/ast_dml.go b/pkg/sql/ast/ast_dml.go index 49dfe98b..9ea07b43 100644 --- a/pkg/sql/ast/ast_dml.go +++ b/pkg/sql/ast/ast_dml.go @@ -135,6 +135,8 @@ func (o OutputInto) Children() []Node { return nodifyExpressions(o.Columns) // InsertDialectFields holds the dialect-specific parts of INSERT. It is // held behind InsertStatement.Extras, allocated only for dialect clauses. type InsertDialectFields struct { + // Top is the SQL Server TOP (expression) [PERCENT] row limit. + Top *TopClause // Modifiers are the keywords between INSERT and INTO, upper-cased and in // source order: MySQL IGNORE / LOW_PRIORITY / HIGH_PRIORITY / DELAYED, // SQLite OR REPLACE / OR IGNORE / OR ABORT / OR FAIL / OR ROLLBACK, @@ -199,6 +201,9 @@ func (l LogErrorsClause) Children() []Node { // insertDialectChildren appends the nodes of d to children. func (d InsertDialectFields) insertDialectChildren(children []Node) []Node { + if d.Top != nil { + children = append(children, d.Top) + } if d.TableFunction != nil { children = append(children, d.TableFunction) } diff --git a/pkg/sql/ast/insert_top_release_test.go b/pkg/sql/ast/insert_top_release_test.go new file mode 100644 index 00000000..fcc2b877 --- /dev/null +++ b/pkg/sql/ast/insert_top_release_test.go @@ -0,0 +1,21 @@ +package ast + +import "testing" + +func TestDialectConstructsInsertTopRelease(t *testing.T) { + for _, mode := range []releaseMode{releaseAll, releaseDrawn} { + t.Run(string(rune('0'+mode)), func(t *testing.T) { + count := GetArrayConstructor() + count.Elements = append(count.Elements, &LiteralValue{Value: "10", Type: "number"}) + fields := InsertDialectFields{Top: &TopClause{Count: count, Parenthesized: true, IsPercent: true}} + defer releaseInsertDialectFields(&fields, mode) + releaseInsertDialectFields(&fields, mode) + if fields.Top != nil { + t.Fatal("released INSERT retains TOP") + } + if len(count.Elements) != 0 { + t.Fatal("TOP count was not released") + } + }) + } +} diff --git a/pkg/sql/ast/pivot_ext.go b/pkg/sql/ast/pivot_ext.go index 16f4151f..1c8a0075 100644 --- a/pkg/sql/ast/pivot_ext.go +++ b/pkg/sql/ast/pivot_ext.go @@ -21,6 +21,8 @@ import "strings" // Oracle: PIVOT XML (SUM(c) FOR a IN (ANY)) // Snowflake: PIVOT (AVG(a) FOR q IN (ANY ORDER BY q)), IN (SELECT ...) type PivotExtensions struct { + // DefaultOnNull replaces NULL pivot results in Snowflake. + DefaultOnNull Expression // XML is the Oracle PIVOT XML form. XML bool // InAny is IN (ANY [ORDER BY ...]) in place of a value list. @@ -39,6 +41,9 @@ type PivotExtensions struct { } func (e *PivotExtensions) pivotExtensionChildren(nodes []Node) []Node { + if e.DefaultOnNull != nil { + nodes = append(nodes, e.DefaultOnNull) + } for i := range e.InAnyOrderBy { nodes = append(nodes, &e.InAnyOrderBy[i]) } @@ -153,7 +158,15 @@ func writePivot(w textWriter, p *PivotClause, kw func(string) string) { default: writePivotItemList(w, p.InValues) } - w.WriteString("))") + w.WriteByte(')') + if p.DefaultOnNull != nil { + w.WriteByte(' ') + w.WriteString(kw("DEFAULT ON NULL")) + w.WriteString(" (") + w.WriteString(exprSQL(p.DefaultOnNull)) + w.WriteByte(')') + } + w.WriteByte(')') } func writePivotAggregateAlias(w textWriter, alias string, kw func(string) string) { @@ -233,6 +246,7 @@ func pivotTupleLen(s string) int { // releasePivotExtensions releases dynamic IN-list expressions. Aggregate // expressions are owned by AggregateFunction and ExtraAggregates. func releasePivotExtensions(p *PivotClause, mode releaseMode) { + releaseExpression(p.DefaultOnNull, mode) for i := range p.InAnyOrderBy { releaseExpression(p.InAnyOrderBy[i].Expression, mode) } diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index 878dddea..1de74dbd 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -642,7 +642,7 @@ func releaseStatement(stmt Statement, mode releaseMode) { case *ReplaceStatement: releaseReplaceStatement(s, mode) case *AlterStatement: - PutAlterStatement(s) + releaseAlterStatement(s, mode) case *SetOperation: // Not pooled itself; its operands are. Operand links are cleared, // so a second visit releases nothing. @@ -886,12 +886,24 @@ func GetAlterStatement() *AlterStatement { } // PutAlterStatement returns an AlterStatement to the pool. -// It zeroes all fields; the Operation interface value is cleared but -// its internal allocations are not recursively pooled (they use custom types). +// It zeroes all fields and releases structured partition expressions. func PutAlterStatement(stmt *AlterStatement) { + releaseAlterStatement(stmt, releaseAll) +} + +func releaseAlterStatement(stmt *AlterStatement, mode releaseMode) { if stmt == nil { return } + if len(stmt.Operations) > 0 { + for _, operation := range stmt.Operations { + if op, ok := operation.(*AlterTableOperation); ok && op != nil { + releaseAlterPartitionFields(op, mode) + } + } + } else if op, ok := stmt.Operation.(*AlterTableOperation); ok && op != nil { + releaseAlterPartitionFields(op, mode) + } stmt.Type = 0 stmt.Name = "" diff --git a/pkg/sql/ast/pool_dml_release.go b/pkg/sql/ast/pool_dml_release.go index 32bb9990..e0e4f0e4 100644 --- a/pkg/sql/ast/pool_dml_release.go +++ b/pkg/sql/ast/pool_dml_release.go @@ -53,6 +53,9 @@ func releaseDMLTargetFields(d *DMLTargetFields, mode releaseMode) { // releaseInsertDialectFields releases the expressions reachable from the // INSERT dialect fields and zeroes them. func releaseInsertDialectFields(d *InsertDialectFields, mode releaseMode) { + if d.Top != nil { + releaseExpression(d.Top.Count, mode) + } if d.OutputInto != nil { for _, e := range d.OutputInto.Columns { releaseExpression(e, mode) diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index 0a71d727..7bb3e63f 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -331,7 +331,7 @@ func GetSelectStatement() *SelectStatement { // - Joins (Right TableRef, Condition; Left is a non-owning copy, never released) // - ArrayJoin (element Exprs) // - PrewhereClause -// - Sample (no Expressions, but zeroed for hygiene) +// - Sample (size and offset expressions) // - Where // - GroupBy // - Having @@ -445,7 +445,8 @@ func releaseSelectStatement(stmt *SelectStatement, mode releaseMode) { stmt.ConnectBy = nil } - // ── SAMPLE (no expression children, just drop) ───────────────────── + // ── SAMPLE ──────────────────────────────────────────────────────── + releaseSampleClause(stmt.Sample, mode) stmt.Sample = nil // ── GROUP BY ─────────────────────────────────────────────────────── diff --git a/pkg/sql/ast/render_ddl_name_quoting_632_test.go b/pkg/sql/ast/render_ddl_name_quoting_632_test.go index 5f438b77..6b0c2ffb 100644 --- a/pkg/sql/ast/render_ddl_name_quoting_632_test.go +++ b/pkg/sql/ast/render_ddl_name_quoting_632_test.go @@ -353,7 +353,7 @@ func TestDDLNameRendering_Benign_PR632(t *testing.T) { {"", `CREATE TABLE "Mixed" ("Col" INT)`, `CREATE TABLE "Mixed" ("Col" INT)`}, {"", `CREATE TABLE t ("select" INT)`, `CREATE TABLE t ("select" INT)`}, {"", `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = -2), d DateTime64(3, 'UTC'))`, - `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = - 2), d DateTime64(3, 'UTC'))`}, + `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = -2), d DateTime64(3, 'UTC'))`}, {"", `CREATE TABLE t (a ENUM('x', 'y'))`, `CREATE TABLE t (a ENUM('x', 'y'))`}, {"", `CREATE TABLE t (a INT) ENGINE = InnoDB`, `CREATE TABLE t (a INT) ENGINE=InnoDB`}, {keywords.DialectClickHouse, `CREATE TABLE t (a INT) ENGINE = MergeTree()`, `CREATE TABLE t (a INT) ENGINE=MergeTree()`}, diff --git a/pkg/sql/ast/rows_from.go b/pkg/sql/ast/rows_from.go new file mode 100644 index 00000000..8823fa2c --- /dev/null +++ b/pkg/sql/ast/rows_from.go @@ -0,0 +1,55 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "strings" + +// RowsFromItem is a function and optional record column definitions in ROWS FROM. +type RowsFromItem struct { + Function *FunctionCall + Columns *ColumnAliasList +} + +// RowsFromSQL renders a PostgreSQL ROWS FROM table body and its ordinality. +func RowsFromSQL(t *TableReference, kw func(string) string) string { + if t == nil || t.Modifiers == nil || len(t.Modifiers.RowsFrom) == 0 { + return "" + } + if kw == nil { + kw = identityKeyword + } + var w strings.Builder + w.WriteString(kw("ROWS FROM")) + w.WriteString(" (") + for i, item := range t.Modifiers.RowsFrom { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(exprSQL(item.Function)) + if item.Columns != nil { + w.WriteByte(' ') + w.WriteString(kw("AS")) + w.WriteString(" (") + writeColumnAliasEntries(&w, item.Columns, nil) + w.WriteByte(')') + } + } + w.WriteByte(')') + if t.WithOrdinality { + w.WriteByte(' ') + w.WriteString(kw("WITH ORDINALITY")) + } + return w.String() +} diff --git a/pkg/sql/ast/sample_expression_safety_test.go b/pkg/sql/ast/sample_expression_safety_test.go new file mode 100644 index 00000000..051fe937 --- /dev/null +++ b/pkg/sql/ast/sample_expression_safety_test.go @@ -0,0 +1,46 @@ +package ast + +import ( + "strings" + "testing" +) + +func TestDialectConstructsSampleCanonicalSafety(t *testing.T) { + evil := "0.1; DROP TABLE audit; --" + number := func(value string) Expression { return &LiteralValue{Value: value, Type: "INTEGER"} } + var nilBinary *BinaryExpression + var nilLiteral *LiteralValue + cases := []struct { + name string + clause SampleClause + want string + }{ + {"size injection", SampleClause{Size: number(evil)}, ""}, + {"offset injection", SampleClause{Size: number("1"), OffsetSize: number(evil)}, "SAMPLE 1"}, + {"numerator injection", SampleClause{Size: &BinaryExpression{Left: number(evil), Operator: "/", Right: number("10")}}, ""}, + {"denominator injection", SampleClause{Size: &BinaryExpression{Left: number("1"), Operator: "/", Right: number(evil)}}, ""}, + {"offset ratio injection", SampleClause{Size: number("1"), OffsetSize: &BinaryExpression{Left: number("1"), Operator: "/", Right: number(evil)}}, "SAMPLE 1"}, + {"nil size binary", SampleClause{Size: nilBinary}, ""}, + {"nil offset binary", SampleClause{Size: number("1"), OffsetSize: nilBinary}, "SAMPLE 1"}, + {"nil literal", SampleClause{Size: nilLiteral}, ""}, + {"string type", SampleClause{Size: &LiteralValue{Value: "1", Type: "STRING"}}, ""}, + {"unknown type", SampleClause{Size: &LiteralValue{Value: "1", Type: "unknown"}}, ""}, + {"identifier", SampleClause{Size: &Identifier{Name: "ratio"}}, ""}, + {"nested operator", SampleClause{Size: &BinaryExpression{Left: &BinaryExpression{Left: number("1"), Operator: "+", Right: number("2")}, Operator: "/", Right: number("10")}}, ""}, + {"negated division", SampleClause{Size: &BinaryExpression{Left: number("1"), Operator: "/", Right: number("10"), Not: true}}, ""}, + {"normal int", SampleClause{Size: &LiteralValue{Value: 1, Type: "int"}}, "SAMPLE 1"}, + {"normal float", SampleClause{Size: &LiteralValue{Value: "0.1", Type: "FLOAT"}}, "SAMPLE 0.1"}, + {"normal ratio", SampleClause{Size: &BinaryExpression{Left: number("1"), Operator: "/", Right: number("10")}, OffsetSize: &BinaryExpression{Left: number("1"), Operator: "/", Right: number("2")}}, "SAMPLE 1/10 OFFSET 1/2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.clause.SQL() + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + if strings.Contains(got, "DROP TABLE") { + t.Fatal("unsafe sample text") + } + }) + } +} diff --git a/pkg/sql/ast/sample_ext.go b/pkg/sql/ast/sample_ext.go new file mode 100644 index 00000000..e968ce6b --- /dev/null +++ b/pkg/sql/ast/sample_ext.go @@ -0,0 +1,100 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "strings" + +// SampleExpressionSQL renders a ClickHouse SAMPLE clause from Size / +// OffsetSize, or from the legacy numeric string fields when Size is nil. It +// returns "" when the clause has no valid sampling size (only possible in a +// hand-built AST), so callers never write a bare SAMPLE keyword; an invalid +// offset is likewise omitted rather than written empty. +func SampleExpressionSQL(s *SampleClause, kw func(string) string) string { + if s == nil { + return "" + } + if kw == nil { + kw = identityKeyword + } + size := sampleLegacyRatio(s.Value, s.Denominator) + if s.Size != nil { + size = sampleExpressionRatio(s.Size) + } + if size == "" { + return "" + } + result := kw("SAMPLE") + " " + size + offset := sampleLegacyRatio(s.Offset, s.OffsetDenominator) + if s.OffsetSize != nil { + offset = sampleExpressionRatio(s.OffsetSize) + } + if offset != "" { + result += " " + kw("OFFSET") + " " + offset + } + return result +} + +func sampleLegacyRatio(n, d string) string { + if !IsPlainNumberText(n) { + return "" + } + if IsPlainNumberText(d) { + return n + "/" + d + } + return n +} + +// sampleExpressionRatio accepts only the numeric grammar supported by SAMPLE. +// General expression rendering would permit raw text in hand-built numeric +// LiteralValue nodes, bypassing the checks on the legacy string fields. +func sampleExpressionRatio(e Expression) string { + if b, ok := e.(*BinaryExpression); ok { + if b == nil || b.Operator != "/" || b.Not || b.CustomOp != nil || + b.PipesOr || b.BitwiseXor || (b.Extras != nil && b.Extras.Escape != nil) { + return "" + } + left, right := sampleNumericLiteral(b.Left), sampleNumericLiteral(b.Right) + if left == "" || right == "" { + return "" + } + return left + "/" + right + } + return sampleNumericLiteral(e) +} + +func sampleNumericLiteral(e Expression) string { + literal, ok := e.(*LiteralValue) + if !ok || literal == nil { + return "" + } + switch { + case strings.EqualFold(literal.Type, "int"), strings.EqualFold(literal.Type, "INTEGER"), + strings.EqualFold(literal.Type, "float"): + text := literalText(literal.Value) + if IsPlainNumberText(text) { + return text + } + } + return "" +} + +func releaseSampleClause(s *SampleClause, mode releaseMode) { + if s == nil { + return + } + releaseExpression(s.Size, mode) + releaseExpression(s.OffsetSize, mode) + *s = SampleClause{} +} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 955da45f..300be3fc 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1237,6 +1237,13 @@ func (g *GroupingSetsExpression) writeSQL(buf *bytes.Buffer) { if i > 0 { buf.WriteString(", ") } + if len(set) == 1 { + switch set[0].(type) { + case *RollupExpression, *CubeExpression, *GroupingSetsExpression: + writeExpr(buf, set[0]) + continue + } + } buf.WriteByte('(') writeExprList(buf, set) buf.WriteByte(')') @@ -1303,6 +1310,12 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { writeJoin(buf, &s.Joins[i]) } + // ClickHouse writes SAMPLE with the table expression, before ARRAY JOIN. + if sample := s.Sample.SQL(); sample != "" { + buf.WriteByte(' ') + buf.WriteString(sample) + } + if s.ArrayJoin != nil { if s.ArrayJoin.Left { buf.WriteString(" LEFT ARRAY JOIN ") @@ -1321,11 +1334,6 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { } } - if s.Sample != nil { - buf.WriteByte(' ') - buf.WriteString(s.Sample.SQL()) - } - if s.PrewhereClause != nil { buf.WriteString(" PREWHERE ") writeExpr(buf, s.PrewhereClause) @@ -2203,6 +2211,8 @@ func writeTableRef(buf *bytes.Buffer, t *TableReference) { buf.WriteByte('(') t.Subquery.writeSQL(buf) buf.WriteByte(')') + } else if rows := RowsFromSQL(t, nil); rows != "" { + buf.WriteString(rows) } else if t.TableFunc != nil { t.TableFunc.writeSQL(buf) if t.WithOrdinality { @@ -2822,34 +2832,8 @@ func (c *ConnectByClause) ToSQL() string { return b.String() } -// writeSampleRatio writes a SAMPLE ratio or offset, n or n/d, writing each -// part only when it is a plain number (IsPlainNumberText). -func writeSampleRatio(buf *bytes.Buffer, n, d string) { - if !IsPlainNumberText(n) { - return - } - buf.WriteString(n) - if IsPlainNumberText(d) { - buf.WriteByte('/') - buf.WriteString(d) - } -} - // SQL renders a ClickHouse SAMPLE clause, e.g. "SAMPLE 0.1" or // "SAMPLE 1/10 OFFSET 2/10". func (s *SampleClause) SQL() string { - if s == nil { - return "" - } - sb := getBuf() - defer putBuf(sb) - sb.WriteString("SAMPLE ") - // The ratio and offset are numeric literals; any other text can only come - // from a hand-built AST and is dropped rather than written raw. - writeSampleRatio(sb, s.Value, s.Denominator) - if IsPlainNumberText(s.Offset) { - sb.WriteString(" OFFSET ") - writeSampleRatio(sb, s.Offset, s.OffsetDenominator) - } - return sb.String() + return SampleExpressionSQL(s, nil) } diff --git a/pkg/sql/ast/sql_alter.go b/pkg/sql/ast/sql_alter.go index bf448c50..ba2a0b42 100644 --- a/pkg/sql/ast/sql_alter.go +++ b/pkg/sql/ast/sql_alter.go @@ -424,6 +424,8 @@ func (w alterWriter) writeOperation(op *AlterTableOperation) { w.str(columnDefSQL(op.ColumnDef)) } w.writeColumnPosition(op) + case AlterPartitioning: + w.writePartitionAction(op) case AlterTableOther: w.writeKeywordText(op.Keyword, op.Text) case UpdateMutation: diff --git a/pkg/sql/ast/sql_dml.go b/pkg/sql/ast/sql_dml.go index 0bbd9287..d9e4b010 100644 --- a/pkg/sql/ast/sql_dml.go +++ b/pkg/sql/ast/sql_dml.go @@ -141,6 +141,7 @@ func writeReturning(buf *bytes.Buffer, returning, into []Expression) { func (i *InsertStatement) writeInsertHead(buf *bytes.Buffer) { buf.WriteString("INSERT ") writeDMLModifiers(buf, i.DialectExtras().Modifiers) + writeDMLTop(buf, i.DialectExtras().Top) buf.WriteString("INTO ") if i.DialectExtras().TableFunction != nil { buf.WriteString("FUNCTION ") diff --git a/pkg/sql/ast/sql_golden_test.go b/pkg/sql/ast/sql_golden_test.go index 68f2410e..59ec34fc 100644 --- a/pkg/sql/ast/sql_golden_test.go +++ b/pkg/sql/ast/sql_golden_test.go @@ -458,7 +458,7 @@ var goldenNodeWant = map[string]string{ "rollup": "ROLLUP(a, b)", "cube": "CUBE(a)", "grouping sets": "GROUPING SETS((a, b), ())", - "select/everything": "WITH RECURSIVE c (x) AS NOT MATERIALIZED (SELECT 1 FROM t), 10 AS k SELECT DISTINCT ON (a) a, b AS bb FROM t x FINAL WITH (NOLOCK), LATERAL (SELECT z FROM t) s, h FOR SYSTEM_TIME ALL LEFT JOIN u y ON x.id = y.id CROSS JOIN v LEFT ARRAY JOIN arr AS e, arr2 SAMPLE 1/10 OFFSET 2/10 PREWHERE p = 1 WHERE w = 2 GROUP BY a, ROLLUP(b) HAVING h = 3 START WITH parent IS NULL CONNECT BY NOCYCLE PRIOR id = parent WINDOW w1 AS (PARTITION BY a RANGE UNBOUNDED PRECEDING), w2 AS (w1 ORDER BY b) ORDER BY a DESC NULLS LAST WITH FILL FROM 1 TO 10 STEP 2, b WITH FILL LIMIT 10 OFFSET -5 ROWS OFFSET 3 ROWS FETCH NEXT 7 PERCENT ROWS WITH TIES FOR SHARE OF t, u NOWAIT SKIP LOCKED", + "select/everything": "WITH RECURSIVE c (x) AS NOT MATERIALIZED (SELECT 1 FROM t), 10 AS k SELECT DISTINCT ON (a) a, b AS bb FROM t x FINAL WITH (NOLOCK), LATERAL (SELECT z FROM t) s, h FOR SYSTEM_TIME ALL LEFT JOIN u y ON x.id = y.id CROSS JOIN v SAMPLE 1/10 OFFSET 2/10 LEFT ARRAY JOIN arr AS e, arr2 PREWHERE p = 1 WHERE w = 2 GROUP BY a, ROLLUP(b) HAVING h = 3 START WITH parent IS NULL CONNECT BY NOCYCLE PRIOR id = parent WINDOW w1 AS (PARTITION BY a RANGE UNBOUNDED PRECEDING), w2 AS (w1 ORDER BY b) ORDER BY a DESC NULLS LAST WITH FILL FROM 1 TO 10 STEP 2, b WITH FILL LIMIT 10 OFFSET -5 ROWS OFFSET 3 ROWS FETCH NEXT 7 PERCENT ROWS WITH TIES FOR SHARE OF t, u NOWAIT SKIP LOCKED", "select/distinct no from": "SELECT DISTINCT 1", "select/fetch minimal": "SELECT a FETCH FIRST ROW ONLY", "select/empty": "SELECT ", diff --git a/pkg/sql/ast/table_factor.go b/pkg/sql/ast/table_factor.go index e32f75ee..29ff8d51 100644 --- a/pkg/sql/ast/table_factor.go +++ b/pkg/sql/ast/table_factor.go @@ -118,11 +118,17 @@ type IndexHint struct { // pointer on TableReference so the many references without any of them pay // one word. type TableModifiers struct { + // RowsFrom combines PostgreSQL set-returning functions in one table. + RowsFrom []RowsFromItem // Values is a derived table whose body is a VALUES table constructor: // FROM (VALUES (1, 'a'), (2, 'b')) AS v(id, name). Values *Values // TableSample is a row-sampling clause: TABLESAMPLE / SAMPLE. TableSample *TableSampleClause + // Sample is the ClickHouse SAMPLE k [OFFSET m] of a table expression in + // a FROM list with joins or several tables; a lone FROM table keeps its + // SAMPLE on SelectStatement.Sample. + Sample *SampleClause // Flashback is the Oracle AS OF / VERSIONS BETWEEN flashback clause. Flashback *FlashbackClause // DBLink is the Oracle database link of t@dblink. @@ -328,17 +334,21 @@ func writeTableRefPreAlias(w textWriter, t *TableReference, kw func(string) stri // TABLESAMPLE, ClickHouse FINAL, MySQL index hints and SQLite INDEXED BY. // Each is preceded by a space. func writeTableRefPostAlias(w textWriter, t *TableReference, kw func(string) string) { - if s := t.tableSample(); s != nil && s.AfterAlias { - writeTableSample(w, s, kw) - } if t.Final { w.WriteByte(' ') w.WriteString(kw("FINAL")) } + if s := t.tableSample(); s != nil && s.AfterAlias { + writeTableSample(w, s, kw) + } m := t.Modifiers if m == nil { return } + if s := SampleExpressionSQL(m.Sample, kw); s != "" { + w.WriteByte(' ') + w.WriteString(s) + } for _, h := range m.IndexHints { writeIndexHint(w, h, kw) } @@ -376,7 +386,7 @@ func TableRefPreAliasSQL(t *TableReference, kw func(string) string) string { // space; see TableRefPreAliasSQL. func TableRefPostAliasSQL(t *TableReference, kw func(string) string) string { if t == nil || (!t.Final && (t.tableSample() == nil || !t.tableSample().AfterAlias) && - (t.Modifiers == nil || (len(t.Modifiers.IndexHints) == 0 && t.Modifiers.IndexedBy == "" && !t.Modifiers.NotIndexed))) { + (t.Modifiers == nil || (t.Modifiers.Sample == nil && len(t.Modifiers.IndexHints) == 0 && t.Modifiers.IndexedBy == "" && !t.Modifiers.NotIndexed))) { return "" } if kw == nil { @@ -721,6 +731,15 @@ func writeMatchRecognize(w textWriter, mr *MatchRecognizeClause, kw func(string) // releaseTableFactorModifiers releases the expressions held by the // table-factor modifiers of tr and clears them. func releaseTableFactorModifiers(tr *TableReference, mode releaseMode) { + if m := tr.Modifiers; m != nil { + for i := range m.RowsFrom { + releaseExpression(m.RowsFrom[i].Function, mode) + m.RowsFrom[i] = RowsFromItem{} + } + m.RowsFrom = nil + releaseSampleClause(m.Sample, mode) + m.Sample = nil + } if s := tr.tableSample(); s != nil { for i := range s.Args { releaseExpression(s.Args[i], mode) diff --git a/pkg/sql/ast/table_review_gaps_release_test.go b/pkg/sql/ast/table_review_gaps_release_test.go new file mode 100644 index 00000000..c678729b --- /dev/null +++ b/pkg/sql/ast/table_review_gaps_release_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "testing" + +func TestDialectTableExtensionsRelease(t *testing.T) { + for _, mode := range []releaseMode{releaseDrawn, releaseAll} { + t.Run(map[releaseMode]string{releaseDrawn: "drawn", releaseAll: "all"}[mode], func(t *testing.T) { + sample := &SampleClause{Size: &LiteralValue{Value: "1", Type: "INTEGER"}, OffsetSize: &LiteralValue{Value: "2", Type: "INTEGER"}} + releaseSampleClause(sample, mode) + if sample.Size != nil || sample.OffsetSize != nil { + t.Fatal("SAMPLE expressions retained after release") + } + pivot := &PivotClause{PivotExtensions: PivotExtensions{DefaultOnNull: &LiteralValue{Value: "0", Type: "INTEGER"}}} + releasePivotExtensions(pivot, mode) + if pivot.DefaultOnNull != nil { + t.Fatal("DEFAULT ON NULL retained after release") + } + items := []RowsFromItem{{Function: &FunctionCall{Name: "f", Arguments: []Expression{&LiteralValue{Value: "1", Type: "INTEGER"}}}, Columns: &ColumnAliasList{Names: []string{"a"}, Types: []string{"int"}}}} + ref := &TableReference{Modifiers: &TableModifiers{RowsFrom: items}} + releaseTableFactorModifiers(ref, mode) + if ref.Modifiers != nil || items[0].Function != nil || items[0].Columns != nil { + t.Fatal("ROWS FROM retained after release") + } + joined := &TableReference{Modifiers: &TableModifiers{Sample: &SampleClause{Size: &LiteralValue{Value: "0.5", Type: "float"}}}} + sampleOf := joined.Modifiers.Sample + releaseTableFactorModifiers(joined, mode) + if joined.Modifiers != nil || sampleOf.Size != nil { + t.Fatal("joined-table SAMPLE retained after release") + } + }) + } +} diff --git a/pkg/sql/parser/alter_partition.go b/pkg/sql/parser/alter_partition.go new file mode 100644 index 00000000..ca60fe2a --- /dev/null +++ b/pkg/sql/parser/alter_partition.go @@ -0,0 +1,274 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// isMySQLPartitionAction reports whether the current token starts a MySQL / +// MariaDB partition action (partition_options of ALTER TABLE). +func (p *Parser) isMySQLPartitionAction() bool { + switch p.utilityWord(p.currentPos) { + case "PARTITION": + return p.utilityWord(p.currentPos+1) == "BY" + case "ADD", "DROP", "TRUNCATE", "REORGANIZE", "COALESCE", "EXCHANGE", "ANALYZE", + "CHECK", "OPTIMIZE", "REBUILD", "REPAIR", "DISCARD", "IMPORT": + return p.utilityWord(p.currentPos+1) == "PARTITION" + case "REMOVE", "UPGRADE": + return p.utilityWord(p.currentPos+1) == "PARTITIONING" + } + return false +} + +// parseMySQLPartitionActionOrGeneric parses a MySQL / MariaDB partition +// action structurally. Forms the structured grammar does not cover (partition +// options such as ENGINE or COMMENT, subpartitions, MariaDB CONVERT +// PARTITION, ...) fall back to the generic as-written action, as before. +func (p *Parser) parseMySQLPartitionActionOrGeneric(op *ast.AlterTableOperation) (*ast.AlterTableOperation, bool) { + start := p.currentPos + res, err := p.parseMySQLPartitionAction(op) + if err == nil && (p.isType(models.TokenTypeComma) || p.isType(models.TokenTypeSemicolon) || + p.isType(models.TokenTypeEOF) || p.isStatementStartingKeyword() || p.statementMayEndAt(p.currentPos)) { + return res, true + } + p.seek(start) + *op = ast.AlterTableOperation{} + return nil, false +} + +func (p *Parser) parseMySQLPartitionAction(op *ast.AlterTableOperation) (*ast.AlterTableOperation, error) { + verb := p.utilityWord(p.currentPos) + op.Type = ast.AlterPartitioning + op.Keyword = verb + " " + p.utilityWord(p.currentPos+1) + p.advance() + p.advance() + mariadb := p.dialect == string(keywords.DialectMariaDB) + switch verb { + case "PARTITION": + part, err := p.parsePartitionByClause() + if err != nil { + return nil, err + } + switch part.Type { + case "RANGE", "RANGE COLUMNS", "LIST", "LIST COLUMNS", "HASH", "KEY": + if part.Interval != nil { + return nil, p.expectedError("MySQL partition options") + } + case "SYSTEM_TIME": + if !mariadb { + return nil, p.expectedError("RANGE, LIST, HASH, or KEY") + } + default: + return nil, p.expectedError("RANGE, LIST, HASH, or KEY") + } + op.Partitioning = part + if p.isType(models.TokenTypeLParen) { + if err := p.parseAlterPartitionDefinitions(op); err != nil { + return nil, err + } + } + case "ADD": + // MariaDB: ADD PARTITION [IF NOT EXISTS] (...) + if mariadb && p.isType(models.TokenTypeIf) && p.utilityWord(p.currentPos+1) == "NOT" && + p.utilityWord(p.currentPos+2) == "EXISTS" { + p.advance() + p.advance() + p.advance() + op.IfNotExists = true + } + // HASH / KEY: ADD PARTITION PARTITIONS n + if !op.IfNotExists && p.utilityWord(p.currentPos) == "PARTITIONS" { + p.advance() + if err := p.parsePartitionCount(op); err != nil { + return nil, err + } + break + } + if err := p.parseAlterPartitionDefinitions(op); err != nil { + return nil, err + } + case "DROP": + // MariaDB: DROP PARTITION [IF EXISTS] names + if mariadb && p.isType(models.TokenTypeIf) && p.utilityWord(p.currentPos+1) == "EXISTS" { + p.advance() + p.advance() + op.IfExists = true + } + if err := p.parsePartitionNames(op, false); err != nil { + return nil, err + } + case "REORGANIZE": + if err := p.parsePartitionNames(op, false); err != nil { + return nil, err + } + if !p.matchType(models.TokenTypeInto) { + return nil, p.expectedError("INTO") + } + if err := p.parseAlterPartitionDefinitions(op); err != nil { + return nil, err + } + case "TRUNCATE", "ANALYZE", "CHECK", "OPTIMIZE", "REBUILD", "REPAIR": + if err := p.parsePartitionNames(op, true); err != nil { + return nil, err + } + case "DISCARD", "IMPORT": + if err := p.parsePartitionNames(op, true); err != nil { + return nil, err + } + if p.utilityWord(p.currentPos) != "TABLESPACE" { + return nil, p.expectedError("TABLESPACE") + } + p.advance() + case "COALESCE": + if err := p.parsePartitionCount(op); err != nil { + return nil, err + } + case "EXCHANGE": + name, err := p.parsePartitionName() + if err != nil { + return nil, err + } + op.PartitionNames = []*ast.Ident{name} + if p.utilityWord(p.currentPos) != "WITH" || p.utilityWord(p.currentPos+1) != "TABLE" { + return nil, p.expectedError("WITH TABLE") + } + p.advance() + p.advance() + tbl, quote, err := p.parseQualifiedNameQuoted() + if err != nil { + return nil, p.qualifiedNameError(err, "table name") + } + op.ExchangeTable = ast.ObjectName{Name: tbl, Quote: quote} + if w := p.utilityWord(p.currentPos); (w == "WITH" || w == "WITHOUT") && p.utilityWord(p.currentPos+1) == "VALIDATION" { + op.Validation = w + p.advance() + p.advance() + } + } + return op, nil +} + +// parsePartitionName parses one partition name. +func (p *Parser) parsePartitionName() (*ast.Ident, error) { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("partition name") + } + name := p.parseColumnName() + if name == nil { + return nil, p.expectedError("partition name") + } + return &ast.Ident{Name: name.Name, Quote: name.Quotes}, nil +} + +// parsePartitionNames parses a partition name list, or ALL when allowAll. +// The list stops before ", ALGORITHM = ..." / ", LOCK = ...": table options +// that follow a partition action are further ALTER TABLE actions. +func (p *Parser) parsePartitionNames(op *ast.AlterTableOperation, allowAll bool) error { + if allowAll && p.isType(models.TokenTypeAll) { + p.advance() + op.PartitionAll = true + return nil + } + for { + name, err := p.parsePartitionName() + if err != nil { + return err + } + op.PartitionNames = append(op.PartitionNames, name) + if !p.isType(models.TokenTypeComma) || p.tableOptionFollowsComma() { + return nil + } + p.advance() + } +} + +// tableOptionFollowsComma reports whether the current ',' is followed by a +// table option (name = value, or ALGORITHM / LOCK with "=" omitted). +func (p *Parser) tableOptionFollowsComma() bool { + w := p.utilityWord(p.currentPos + 1) + if w == "" { + return false + } + if p.tokenIsEq(p.currentPos + 2) { + return true + } + return (w == "ALGORITHM" || w == "LOCK") && p.utilityWord(p.currentPos+2) != "" +} + +// parsePartitionCount parses the partition count of COALESCE PARTITION n and +// ADD PARTITION PARTITIONS n. +func (p *Parser) parsePartitionCount(op *ast.AlterTableOperation) error { + if !p.isType(models.TokenTypeNumber) { + return p.expectedError("partition count") + } + e, err := p.parsePrimaryExpression() + if err != nil { + return err + } + op.PartitionCount = e + return nil +} + +func (p *Parser) parseAlterPartitionDefinitions(op *ast.AlterTableOperation) error { + if !p.matchType(models.TokenTypeLParen) { + return p.expectedError("( before partition definitions") + } + for { + def, err := p.parsePartitionDefinition() + if err != nil { + return err + } + want := "" + if op.Partitioning != nil { + switch op.Partitioning.Type { + case "RANGE", "RANGE COLUMNS": + want = "LESS THAN" + case "LIST", "LIST COLUMNS": + want = "IN" + case "SYSTEM_TIME": + if def.Type != "HISTORY" && def.Type != "CURRENT" { + return p.expectedError("HISTORY or CURRENT") + } + default: + return p.expectedError("partition count instead of definitions for HASH or KEY") + } + } + if want != "" && def.Type != want { + return p.expectedError("VALUES " + want) + } + if op.Partitioning == nil { + switch def.Type { + case "", "LESS THAN", "IN": + case "HISTORY", "CURRENT": + if p.dialect != string(keywords.DialectMariaDB) { + return p.expectedError("VALUES LESS THAN or VALUES IN") + } + default: + return p.expectedError("VALUES LESS THAN or VALUES IN") + } + } + op.PartitionDefinitions = append(op.PartitionDefinitions, *def) + if !p.matchType(models.TokenTypeComma) { + break + } + } + if !p.matchType(models.TokenTypeRParen) { + return p.expectedError(") after partition definitions") + } + return nil +} diff --git a/pkg/sql/parser/alter_partition_identity_roundtrip_test.go b/pkg/sql/parser/alter_partition_identity_roundtrip_test.go new file mode 100644 index 00000000..f0bacfa9 --- /dev/null +++ b/pkg/sql/parser/alter_partition_identity_roundtrip_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser_test + +import ( + "reflect" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestDialectConstructsDDLRoundTrip(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, "CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY (START WITH 10 INCREMENT BY 5) PRIMARY KEY, b int GENERATED BY DEFAULT AS IDENTITY NOT NULL, c int NOT NULL GENERATED ALWAYS AS IDENTITY)"}, + {keywords.DialectMySQL, "ALTER TABLE t PARTITION BY RANGE (YEAR(d)) (PARTITION p0 VALUES LESS THAN (2020), PARTITION p1 VALUES LESS THAN MAXVALUE)"}, + {keywords.DialectMySQL, "ALTER TABLE t ADD PARTITION (PARTITION p2 VALUES LESS THAN (2030))"}, + {keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION p0"}, + {keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION p1, p2"}, + {keywords.DialectMySQL, "ALTER TABLE t TRUNCATE PARTITION p1"}, + {keywords.DialectMySQL, "ALTER TABLE t REORGANIZE PARTITION p1 INTO (PARTITION p2 VALUES LESS THAN (2030))"}, + {keywords.DialectMySQL, "ALTER TABLE t REMOVE PARTITIONING"}, + {keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION `2020`"}, + {keywords.DialectMySQL, "ALTER TABLE t TRUNCATE PARTITION `p-0`"}, + {keywords.DialectMySQL, "ALTER TABLE t REORGANIZE PARTITION `p-0` INTO (PARTITION p2 VALUES LESS THAN (2030))"}, + {keywords.DialectMySQL, "ALTER TABLE t PARTITION BY LIST (d) (PARTITION p VALUES IN (1, 2))"}, + {keywords.DialectMySQL, "ALTER TABLE t PARTITION BY HASH (d) PARTITIONS 4"}, + {keywords.DialectMariaDB, "ALTER TABLE t PARTITION BY SYSTEM_TIME INTERVAL 1 MONTH (PARTITION p_old HISTORY, PARTITION p_cur CURRENT)"}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + if tc.dialect == keywords.DialectMySQL { + stmt, ok := tree.Statements[0].(*ast.AlterStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + op, ok := stmt.Operation.(*ast.AlterTableOperation) + if !ok { + t.Fatalf("got %T", stmt.Operation) + } + if op.Type != ast.AlterPartitioning { + t.Fatalf("partition action has type %v", op.Type) + } + visited := map[ast.Node]bool{} + ast.Inspect(stmt, func(node ast.Node) bool { + if node != nil { + visited[node] = true + } + return true + }) + for _, def := range op.PartitionDefinitions { + if def.LessThan != nil && !visited[def.LessThan] { + t.Error("partition boundary not visited") + } + } + if op.Partitioning != nil { + for _, expr := range op.Partitioning.Expressions { + if !visited[expr] { + t.Error("partition key not visited") + } + } + } + } + formatted, err := gosqlx.FormatWithDialect(tc.sql, gosqlx.DefaultFormatOptions(), tc.dialect) + if err != nil { + t.Fatal(err) + } + writer, ok := tree.Statements[0].(interface{ SQL() string }) + if !ok { + t.Fatalf("statement %T lacks SQL", tree.Statements[0]) + } + for _, rendered := range []string{writer.SQL(), formatted} { + other, err := gosqlx.ParseWithDialect(rendered, tc.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", rendered, err) + } + defer ast.ReleaseAST(other) + clearDDLSourcePositions(reflect.ValueOf(tree)) + clearDDLSourcePositions(reflect.ValueOf(other)) + if !reflect.DeepEqual(tree.Statements, other.Statements) { + t.Errorf("tree changed after rendering %q", rendered) + } + } + }) + } +} + +// Source locations are metadata and change when the formatter inserts line breaks. +func clearDDLSourcePositions(v reflect.Value) { + if !v.IsValid() { + return + } + if v.Type() == reflect.TypeOf(models.Location{}) && v.CanSet() { + v.Set(reflect.Zero(v.Type())) + return + } + switch v.Kind() { + case reflect.Pointer, reflect.Interface: + if !v.IsNil() { + clearDDLSourcePositions(v.Elem()) + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + clearDDLSourcePositions(v.Field(i)) + } + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + clearDDLSourcePositions(v.Index(i)) + } + } +} + +func TestDialectConstructsPartitionMalformed(t *testing.T) { + for _, sql := range []string{ + "ALTER TABLE t PARTITION BY SYSTEM_TIME INTERVAL 1 MONTH", + "ALTER TABLE t PARTITION BY RANGE (d) (PARTITION p VALUES IN (1))", + "ALTER TABLE t PARTITION BY RANGE COLUMNS (d) (PARTITION p VALUES IN (1))", + "ALTER TABLE t PARTITION BY LIST (d) (PARTITION p VALUES LESS THAN (1))", + "ALTER TABLE t PARTITION BY LIST COLUMNS (d) (PARTITION p VALUES LESS THAN (1))", + "ALTER TABLE t PARTITION BY HASH (d) (PARTITION p VALUES IN (1))", + "ALTER TABLE t PARTITION BY KEY (d) (PARTITION p VALUES LESS THAN (1))", + "ALTER TABLE t PARTITION BY RANGE (d) INTERVAL (1)", + "ALTER TABLE t PARTITION BY RANGE (YEAR(d)) (PARTITION p VALUES LESS THAN)", + } { + t.Run(sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMySQL) + if err == nil { + ast.ReleaseAST(tree) + t.Fatal("expected syntax error") + } + }) + } +} + +// TestDialectConstructsPartitionFallback covers partition actions outside the +// structured grammar that earlier releases accepted as as-written text: they +// still parse, as AlterTableOther, and round-trip. +func TestDialectConstructsPartitionFallback(t *testing.T) { + for _, sql := range []string{ + "ALTER TABLE t ADD PARTITION ()", + "ALTER TABLE t DROP PARTITION", + "ALTER TABLE t DROP PARTITION 123", + "ALTER TABLE t DROP PARTITION 'p0'", + "ALTER TABLE t TRUNCATE PARTITION 1.5", + "ALTER TABLE t TRUNCATE PARTITION 'p0'", + "ALTER TABLE t REORGANIZE PARTITION 123 INTO (PARTITION p2 VALUES LESS THAN (2030))", + "ALTER TABLE t REORGANIZE PARTITION 'p0' INTO (PARTITION p2 VALUES LESS THAN (2030))", + "ALTER TABLE t REORGANIZE PARTITION p INTO ()", + "ALTER TABLE t ADD PARTITION (PARTITION p0 VALUES LESS THAN (10) ENGINE = InnoDB)", + "ALTER TABLE t DROP PARTITION IF EXISTS p0", + "ALTER TABLE t CONVERT PARTITION p TO TABLE t2", + } { + t.Run(sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMySQL) + if err != nil { + t.Fatalf("parse: %v", err) + } + defer ast.ReleaseAST(tree) + stmt, ok := tree.Statements[0].(*ast.AlterStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + op, ok := stmt.Operation.(*ast.AlterTableOperation) + if !ok || op.Type != ast.AlterTableOther { + t.Fatalf("operation = %#v, want AlterTableOther", stmt.Operation) + } + out := tree.SQL() + again, err := gosqlx.ParseWithDialect(out, keywords.DialectMySQL) + if err != nil { + t.Fatalf("reparse %q: %v", out, err) + } + defer ast.ReleaseAST(again) + if got := again.SQL(); got != out { + t.Fatalf("round trip: %q != %q", got, out) + } + }) + } +} diff --git a/pkg/sql/parser/alter_table.go b/pkg/sql/parser/alter_table.go index 9af22e35..50673268 100644 --- a/pkg/sql/parser/alter_table.go +++ b/pkg/sql/parser/alter_table.go @@ -83,6 +83,11 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS func (p *Parser) parseAlterTableAction() (*ast.AlterTableOperation, error) { op := &ast.AlterTableOperation{} word := p.utilityWord(p.currentPos) + if p.alterDialectStrict(dialectMySQLFamily) && p.isMySQLPartitionAction() { + if res, ok := p.parseMySQLPartitionActionOrGeneric(op); ok { + return res, nil + } + } // SQL Server: WITH CHECK | WITH NOCHECK ADD CONSTRAINT ... if word == "WITH" && p.dialect == string(keywords.DialectSQLServer) { diff --git a/pkg/sql/parser/alter_table_test.go b/pkg/sql/parser/alter_table_test.go index c524ca37..b6f3dd24 100644 --- a/pkg/sql/parser/alter_table_test.go +++ b/pkg/sql/parser/alter_table_test.go @@ -539,7 +539,6 @@ func TestAlterTable_OtherActions(t *testing.T) { {"options are separate actions", keywords.DialectMySQL, "ALTER TABLE t ENGINE=InnoDB, ALGORITHM=INPLACE, LOCK=NONE", 3, "", "ENGINE=InnoDB"}, {"default charset", keywords.DialectMySQL, "ALTER TABLE t DEFAULT CHARSET=utf8mb4", 1, "DEFAULT", "CHARSET=utf8mb4"}, {"convert to character set", keywords.DialectMySQL, "ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4", 1, "CONVERT TO CHARACTER SET", "utf8mb4"}, - {"drop partition list", keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION p1, p2", 1, "DROP PARTITION", "p1, p2"}, {"order by", keywords.DialectMySQL, "ALTER TABLE t ORDER BY a, b DESC", 1, "ORDER BY", "a, b DESC"}, {"rename index", keywords.DialectMySQL, "ALTER TABLE t RENAME INDEX a TO b", 1, "RENAME INDEX", "a TO b"}, {"disable keys", keywords.DialectMySQL, "ALTER TABLE t DISABLE KEYS", 1, "DISABLE KEYS", ""}, diff --git a/pkg/sql/parser/ddl_columns.go b/pkg/sql/parser/ddl_columns.go index 462ca8c0..9a0622e2 100644 --- a/pkg/sql/parser/ddl_columns.go +++ b/pkg/sql/parser/ddl_columns.go @@ -101,7 +101,9 @@ func (p *Parser) parseColumnDef() (*ast.ColumnDef, error) { } // Parse column constraints + var generated, hasDefault bool for { + pos := p.currentLocation() constraint, ok, err := p.parseColumnConstraint() if err != nil { return nil, err @@ -109,12 +111,35 @@ func (p *Parser) parseColumnDef() (*ast.ColumnDef, error) { if !ok { break } + // A column has at most one value source: an identity, a generation + // expression or a DEFAULT (SQL standard; PostgreSQL rejects + // "both default and identity specified" and similar). + switch isGen, isDefault := isGeneratedColumnConstraint(constraint.Type), constraint.Type == "DEFAULT"; { + case isGen && generated: + return nil, goerrors.InvalidSyntaxError("multiple identity or generation clauses for column "+colDef.Name, pos, "") + case (isGen && hasDefault) || (isDefault && generated): + return nil, goerrors.InvalidSyntaxError("both DEFAULT and an identity or generation clause for column "+colDef.Name, pos, "") + default: + generated = generated || isGen + hasDefault = hasDefault || isDefault + } colDef.Constraints = append(colDef.Constraints, *constraint) } return colDef, nil } +// isGeneratedColumnConstraint reports whether a column constraint type is a +// GENERATED ... AS IDENTITY or GENERATED ALWAYS AS (expression) clause. +func isGeneratedColumnConstraint(typ string) bool { + switch typ { + case "GENERATED ALWAYS AS IDENTITY", "GENERATED BY DEFAULT AS IDENTITY", + "GENERATED BY DEFAULT ON NULL AS IDENTITY", "GENERATED ALWAYS AS": + return true + } + return false +} + // parseColumnConstraint parses a single column constraint, optionally named // with CONSTRAINT . Returns (constraint, found, error). func (p *Parser) parseColumnConstraint() (*ast.ColumnConstraint, bool, error) { @@ -419,6 +444,17 @@ func (p *Parser) parseTypeArgsString() (string, error) { prevWasIdent = false p.advance() continue + case models.TokenTypeMinus, models.TokenTypePlus: + // A sign before a number stays attached to it: INCREMENT BY -1. + if p.peekToken().Token.Type == models.TokenTypeNumber { + if prevWasIdent { + buf.WriteByte(' ') + } + buf.WriteString(tok.Value) + prevWasIdent = false + p.advance() + continue + } } if err := p.writeRawLeaf(&buf, tok, prevWasIdent); err != nil { diff --git a/pkg/sql/parser/dml_insert.go b/pkg/sql/parser/dml_insert.go index b375e92c..22520c28 100644 --- a/pkg/sql/parser/dml_insert.go +++ b/pkg/sql/parser/dml_insert.go @@ -43,6 +43,18 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { } } + // SQL Server INSERT permits TOP (expression) [PERCENT], without WITH TIES. + if p.IsSQLServer() && p.isUnquotedWord("TOP") { + top, err := p.parseTopClause() + if err != nil { + return nil, err + } + if !top.Parenthesized || top.WithTies { + return nil, p.expectedError("TOP (expression) [PERCENT] in INSERT") + } + stmt.EnsureExtras().Top = top + } + // Parse INTO. SQL Server, MySQL and MariaDB make it optional. if p.isType(models.TokenTypeInto) { p.advance() // Consume INTO diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 75cc9507..711e2d05 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -140,7 +140,14 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { if p.isType(models.TokenTypeArray) { // Handle ARRAY[...] or ARRAY(SELECT ...) constructor - return p.parseArrayConstructor() + array, err := p.parseArrayConstructor() + if err != nil { + return nil, err + } + if p.isType(models.TokenTypeLBracket) { + return p.parseArrayAccessExpression(array) + } + return array, nil } // ClickHouse / Snowflake array literal: [expr, expr, ...] without the diff --git a/pkg/sql/parser/grouping.go b/pkg/sql/parser/grouping.go index 3b0aa0ad..638a2538 100644 --- a/pkg/sql/parser/grouping.go +++ b/pkg/sql/parser/grouping.go @@ -18,6 +18,8 @@ package parser import ( + "strings" + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -98,6 +100,12 @@ func (p *Parser) parseCube() (*ast.CubeExpression, error) { // Allows explicit specification of grouping sets // Example: GROUPING SETS((a, b), (a), ()) generates exactly those three grouping sets func (p *Parser) parseGroupingSets() (*ast.GroupingSetsExpression, error) { + // GROUPING SETS nest (PostgreSQL); bound the recursion. + p.depth++ + defer func() { p.depth-- }() + if p.depth > MaxRecursionDepth { + return nil, goerrors.RecursionDepthLimitError(p.depth, MaxRecursionDepth, p.currentLocation(), "") + } // Handle both "GROUPING SETS" as compound keyword or separate tokens if p.currentToken.Token.Value == "GROUPING SETS" { p.advance() // Consume "GROUPING SETS" compound token @@ -147,6 +155,26 @@ func (p *Parser) parseGroupingSets() (*ast.GroupingSetsExpression, error) { } } p.advance() // Consume ) + } else if p.isType(models.TokenTypeRollup) || p.isType(models.TokenTypeCube) { + var expr ast.Expression + var err error + if p.isType(models.TokenTypeRollup) { + expr, err = p.parseRollup() + } else { + expr, err = p.parseCube() + } + if err != nil { + return nil, err + } + set = []ast.Expression{expr} + } else if p.isGroupingSetsStart() { + // Nested GROUPING SETS (...): its sets are concatenated into the + // enclosing list. + nested, err := p.parseGroupingSets() + if err != nil { + return nil, err + } + set = []ast.Expression{nested} } else { // Single column without parens expr, err := p.parseExpression() @@ -172,3 +200,10 @@ func (p *Parser) parseGroupingSets() (*ast.GroupingSetsExpression, error) { Sets: sets, }, nil } + +// isGroupingSetsStart reports whether the current token starts GROUPING SETS, +// written as one compound token or as GROUPING followed by SETS. +func (p *Parser) isGroupingSetsStart() bool { + return p.currentToken.Token.Value == "GROUPING SETS" || + (p.isType(models.TokenTypeGrouping) && strings.EqualFold(p.peekToken().Token.Value, "SETS")) +} diff --git a/pkg/sql/parser/insert_top_array_grouping_test.go b/pkg/sql/parser/insert_top_array_grouping_test.go new file mode 100644 index 00000000..f2df617f --- /dev/null +++ b/pkg/sql/parser/insert_top_array_grouping_test.go @@ -0,0 +1,177 @@ +package parser_test + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func dialectConstructTreeShape(t *testing.T, tree *ast.AST) any { + t.Helper() + data, err := json.Marshal(tree.Statements) + if err != nil { + t.Fatal(err) + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + var clearPositions func(any) + clearPositions = func(v any) { + switch n := v.(type) { + case map[string]any: + delete(n, "Pos") + // Unquoted function names are case-insensitive, and lower-case + // formatting writes them in lower case (SUM -> sum). + if name, ok := n["Name"].(string); ok && n["NameQuote"] == float64(0) { + if _, call := n["Arguments"]; call { + n["Name"] = strings.ToUpper(name) + } + } + for _, child := range n { + clearPositions(child) + } + case []any: + for _, child := range n { + clearPositions(child) + } + } + } + clearPositions(value) + return value +} + +func TestDialectConstructsInsertArrayGroupingRoundTrip(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectSQLServer, `INSERT TOP (10) INTO t (a) SELECT a FROM s`}, + {keywords.DialectSQLServer, `INSERT TOP (10) PERCENT INTO t (a) SELECT a FROM s`}, + {keywords.DialectSQLServer, `INSERT TOP (@n) t (a) SELECT a FROM s`}, + {keywords.DialectPostgreSQL, `SELECT (ARRAY[1,2,3])[2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[1,2,3][2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[[1,2],[3,4]][1][2]`}, + {keywords.DialectPostgreSQL, `SELECT ARRAY[1,2,3][1:2]`}, + } + for _, d := range []keywords.SQLDialect{keywords.DialectPostgreSQL, keywords.DialectSnowflake, keywords.DialectSQLServer, keywords.DialectOracle} { + cases = append(cases, struct { + dialect keywords.SQLDialect + sql string + }{d, `SELECT a, b, SUM(c) FROM t GROUP BY GROUPING SETS ((a), ROLLUP(b), CUBE(a, b), ())`}) + } + for _, tc := range cases { + t.Run(string(tc.dialect)+tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + want := dialectConstructTreeShape(t, tree) + formatted, err := gosqlx.FormatWithDialect(tc.sql, gosqlx.DefaultFormatOptions(), tc.dialect) + if err != nil { + t.Fatal(err) + } + outputs := append(formatterOutputs(tree), formatted) + for _, output := range outputs { + again, err := gosqlx.ParseWithDialect(output, tc.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", output, err) + } + defer ast.ReleaseAST(again) + if got := dialectConstructTreeShape(t, again); !reflect.DeepEqual(got, want) { + t.Fatalf("tree changed after rendering %q\nwant %#v\ngot %#v", output, want, got) + } + } + }) + } +} + +func TestDialectConstructsInsertTopInspection(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(`INSERT TOP (@n) PERCENT t (a) SELECT a FROM s`, keywords.DialectSQLServer) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt, ok := tree.Statements[0].(*ast.InsertStatement) + if !ok { + t.Fatalf("expected INSERT, got %T", tree.Statements[0]) + } + top := stmt.DialectExtras().Top + if top == nil || !top.IsPercent { + t.Fatal("missing TOP PERCENT") + } + seenTop, seenCount := false, false + ast.Inspect(stmt, func(n ast.Node) bool { + if n == top { + seenTop = true + } + if n == top.Count { + seenCount = true + } + return true + }) + if !seenTop || !seenCount { + t.Fatal("TOP and count must be inspectable") + } +} + +func TestDialectConstructsArrayGroupingInspection(t *testing.T) { + cases := []struct { + sql string + constructors, subscripts, slices, rollups, cubes, literals int + }{ + {`SELECT ARRAY[[1,2],[3,4]][1][2]`, 3, 2, 0, 0, 0, 6}, + {`SELECT (ARRAY[1,2,3])[1:2]`, 1, 0, 1, 0, 0, 5}, + {`SELECT a FROM t GROUP BY GROUPING SETS ((a), ROLLUP(b), CUBE(a,b), ())`, 0, 0, 0, 1, 1, 0}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + var constructors, subscripts, slices, rollups, cubes, literals int + ast.Inspect(tree.Statements[0], func(n ast.Node) bool { + switch n.(type) { + case *ast.ArrayConstructorExpression: + constructors++ + case *ast.ArraySubscriptExpression: + subscripts++ + case *ast.ArraySliceExpression: + slices++ + case *ast.RollupExpression: + rollups++ + case *ast.CubeExpression: + cubes++ + case *ast.LiteralValue: + literals++ + } + return true + }) + got := []int{constructors, subscripts, slices, rollups, cubes, literals} + want := []int{tc.constructors, tc.subscripts, tc.slices, tc.rollups, tc.cubes, tc.literals} + if !reflect.DeepEqual(got, want) { + t.Fatalf("inspection counts got %v, want %v", got, want) + } + }) + } +} + +func TestDialectConstructsInsertTopRejectsUnsupportedForms(t *testing.T) { + for _, sql := range []string{ + `INSERT TOP 10 INTO t VALUES (1)`, + `INSERT TOP (10) WITH TIES INTO t VALUES (1)`, + } { + if tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectSQLServer); err == nil { + defer ast.ReleaseAST(tree) + t.Errorf("accepted invalid INSERT syntax: %s", sql) + } + } +} diff --git a/pkg/sql/parser/pivot.go b/pkg/sql/parser/pivot.go index a4032d69..abedcdf7 100644 --- a/pkg/sql/parser/pivot.go +++ b/pkg/sql/parser/pivot.go @@ -268,6 +268,9 @@ func (p *Parser) parsePivotClause() (*ast.PivotClause, error) { return nil, p.expectedError(") to close PIVOT IN list") } p.advance() // close IN list ) + if err := p.parsePivotDefault(clause); err != nil { + return nil, err + } if !p.isType(models.TokenTypeRParen) { return nil, p.expectedError(") to close PIVOT clause") diff --git a/pkg/sql/parser/pivot_sample_rows_from_test.go b/pkg/sql/parser/pivot_sample_rows_from_test.go new file mode 100644 index 00000000..7fd809d9 --- /dev/null +++ b/pkg/sql/parser/pivot_sample_rows_from_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser_test + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "reflect" + "testing" +) + +func TestDialectConstructsTableConstructs(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectSnowflake, "SELECT * FROM t PIVOT (SUM(amt) FOR month IN (ANY ORDER BY month) DEFAULT ON NULL (0)) AS p"}, + {keywords.DialectSnowflake, "SELECT * FROM t PIVOT (SUM(amt) FOR month IN ('Jan', 'Feb') DEFAULT ON NULL (0)) AS p"}, + {keywords.DialectClickHouse, "SELECT * FROM hits FINAL SAMPLE 0.1 WHERE x = 1"}, + {keywords.DialectClickHouse, "SELECT * FROM hits SAMPLE 1/10 OFFSET 1/2"}, + {keywords.DialectClickHouse, "SELECT * FROM hits FINAL"}, + {keywords.DialectClickHouse, `SELECT * FROM hits "FINAL"`}, + {keywords.DialectClickHouse, `SELECT * FROM hits AS "FINAL"`}, + {keywords.DialectClickHouse, "SELECT * FROM hits SAMPLE 0.1"}, + {keywords.DialectClickHouse, "SELECT * FROM hits AS h FINAL SAMPLE 0.1"}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM (generate_series(1,3), unnest(ARRAY['a','b'])) WITH ORDINALITY AS r(n, s, i)"}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM (f(x) AS (a int, b text))"}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("want SELECT, got %T", tree.Statements[0]) + } + want := stmt.SQL() + formatted, err := gosqlx.FormatWithDialect(tc.sql, gosqlx.FormatOptions{}, tc.dialect) + if err != nil { + t.Fatal(err) + } + shape := dialectConstructTreeShape(t, tree) + for _, rendered := range []string{want, formatted} { + reparsed, err := gosqlx.ParseWithDialect(rendered, tc.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", rendered, err) + } + defer ast.ReleaseAST(reparsed) + if got := dialectConstructTreeShape(t, reparsed); !reflect.DeepEqual(got, shape) { + t.Errorf("tree changed after rendering %q", rendered) + } + again, ok := reparsed.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("want SELECT, got %T", reparsed.Statements[0]) + } + if got := again.SQL(); got != want { + t.Errorf("round trip = %q, want %q", got, want) + } + } + }) + } +} + +func TestDialectConstructsTableConstructInspection(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + literals, calls int + }{ + {keywords.DialectSnowflake, "SELECT * FROM t PIVOT (SUM(amt) FOR month IN (ANY ORDER BY month) DEFAULT ON NULL (0))", 1, 1}, + {keywords.DialectClickHouse, "SELECT * FROM hits FINAL SAMPLE 1/10 OFFSET 1/2", 4, 0}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM (generate_series(1,3), unnest(ARRAY['a','b'])) WITH ORDINALITY AS r(n,s,i)", 4, 2}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + literals, calls := 0, 0 + ast.Inspect(tree.Statements[0], func(node ast.Node) bool { + switch node.(type) { + case *ast.LiteralValue: + literals++ + case *ast.FunctionCall: + calls++ + } + return true + }) + if literals != tc.literals || calls != tc.calls { + t.Fatalf("visited %d literals, %d calls; want %d, %d", literals, calls, tc.literals, tc.calls) + } + }) + } +} + +func TestDialectConstructsTableConstructRejectMalformed(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectSnowflake, "SELECT * FROM t PIVOT (SUM(amt) FOR month IN ('Jan') DEFAULT ON NULL ())"}, + {keywords.DialectSQLServer, "SELECT * FROM t PIVOT (SUM(amt) FOR month IN (1) DEFAULT ON NULL (0)) p"}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM ()"}, + {keywords.DialectPostgreSQL, `SELECT * FROM ROWS FROM (f(x)) WITH "ORDINALITY"`}, + {keywords.DialectClickHouse, "SELECT * FROM hits FINAL FINAL"}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM (f(x) AS (a, b text))"}, + {keywords.DialectPostgreSQL, "SELECT * FROM ROWS FROM (f(x) AS (a int, b))"}, + {keywords.DialectClickHouse, "SELECT * FROM hits SAMPLE 1/"}, + {keywords.DialectClickHouse, "SELECT * FROM hits SAMPLE 0.1 OFFSET"}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if tree != nil { + defer ast.ReleaseAST(tree) + } + if err == nil { + t.Fatal("expected malformed or unsupported syntax to be rejected") + } + }) + } +} + +func TestDialectConstructsSampleExpressionMutation(t *testing.T) { + tree, err := gosqlx.ParseWithDialect("SELECT * FROM hits FINAL SAMPLE 1/10 OFFSET 1/2", keywords.DialectClickHouse) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + if stmt.Sample == nil { + t.Fatal("missing SAMPLE") + } + stmt.Sample.Size = &ast.LiteralValue{Value: "0.5", Type: "FLOAT"} + stmt.Sample.OffsetSize = &ast.LiteralValue{Value: "0.25", Type: "FLOAT"} + want := "SELECT * FROM hits FINAL SAMPLE 0.5 OFFSET 0.25" + if got := stmt.SQL(); got != want { + t.Errorf("SQL = %q, want %q", got, want) + } + if got := formatter.FormatAST(tree, ast.CompactStyle()); got != want { + t.Errorf("FormatAST = %q, want %q", got, want) + } +} diff --git a/pkg/sql/parser/review_gaps_followup_test.go b/pkg/sql/parser/review_gaps_followup_test.go new file mode 100644 index 00000000..6a654bd1 --- /dev/null +++ b/pkg/sql/parser/review_gaps_followup_test.go @@ -0,0 +1,357 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// assertRoundTrip parses sql, renders it with SQL() and the formatter, and +// asserts that each rendering reparses to the same tree. It returns the +// parsed tree (released by the caller's cleanup). +func assertRoundTrip(t *testing.T, d keywords.SQLDialect, sql string) *ast.AST { + t.Helper() + tree, err := gosqlx.ParseWithDialect(sql, d) + if err != nil { + t.Fatalf("parse %q: %v", sql, err) + } + t.Cleanup(func() { ast.ReleaseAST(tree) }) + outputs := []string{tree.SQL()} + for _, opts := range []gosqlx.FormatOptions{gosqlx.DefaultFormatOptions(), {IndentSize: 0}} { + formatted, err := gosqlx.FormatWithDialect(sql, opts, d) + if err != nil { + t.Fatalf("format %q: %v", sql, err) + } + outputs = append(outputs, formatted) + } + for _, out := range outputs { + other, err := gosqlx.ParseWithDialect(out, d) + if err != nil { + t.Fatalf("reparse %q: %v", out, err) + } + clearDDLSourcePositions(reflect.ValueOf(tree)) + clearDDLSourcePositions(reflect.ValueOf(other)) + same := reflect.DeepEqual(tree.Statements, other.Statements) + ast.ReleaseAST(other) + if !same { + t.Fatalf("tree changed after rendering %q", out) + } + } + return tree +} + +// reachable reports whether target is visited by ast.Inspect from root. +func reachable(root, target ast.Node) bool { + found := false + ast.Inspect(root, func(n ast.Node) bool { + if n == target { + found = true + } + return !found + }) + return found +} + +func TestMySQLPartitionActionsStructured(t *testing.T) { + type check func(t *testing.T, stmt *ast.AlterStatement, op *ast.AlterTableOperation) + names := func(want ...string) check { + return func(t *testing.T, _ *ast.AlterStatement, op *ast.AlterTableOperation) { + var got []string + for _, n := range op.PartitionNames { + got = append(got, n.Name) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("PartitionNames = %v, want %v", got, want) + } + } + } + all := func(t *testing.T, _ *ast.AlterStatement, op *ast.AlterTableOperation) { + if !op.PartitionAll || len(op.PartitionNames) != 0 { + t.Errorf("PartitionAll = %v, names %v", op.PartitionAll, op.PartitionNames) + } + } + count := func(t *testing.T, stmt *ast.AlterStatement, op *ast.AlterTableOperation) { + if op.PartitionCount == nil || !reachable(stmt, op.PartitionCount) { + t.Error("PartitionCount missing or unreachable") + } + } + cases := []struct { + dialect keywords.SQLDialect + sql string + keyword string + ops int + check check + }{ + {keywords.DialectMySQL, "ALTER TABLE t TRUNCATE PARTITION ALL", "TRUNCATE PARTITION", 1, all}, + {keywords.DialectMariaDB, "ALTER TABLE t TRUNCATE PARTITION ALL", "TRUNCATE PARTITION", 1, all}, + {keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION p0, ALGORITHM=INPLACE, LOCK=NONE", "DROP PARTITION", 3, names("p0")}, + {keywords.DialectMySQL, "ALTER TABLE t DROP PARTITION p0, p1, ALGORITHM INPLACE", "DROP PARTITION", 2, names("p0", "p1")}, + {keywords.DialectMySQL, "ALTER TABLE t ADD PARTITION (PARTITION p2 VALUES LESS THAN (2030)), ALGORITHM=INPLACE", "ADD PARTITION", 2, nil}, + {keywords.DialectMariaDB, "ALTER TABLE t DROP PARTITION IF EXISTS p0", "DROP PARTITION", 1, func(t *testing.T, s *ast.AlterStatement, op *ast.AlterTableOperation) { + if !op.IfExists { + t.Error("IfExists not set") + } + names("p0")(t, s, op) + }}, + {keywords.DialectMariaDB, "ALTER TABLE t ADD PARTITION IF NOT EXISTS (PARTITION p VALUES IN (1))", "ADD PARTITION", 1, func(t *testing.T, _ *ast.AlterStatement, op *ast.AlterTableOperation) { + if !op.IfNotExists || len(op.PartitionDefinitions) != 1 { + t.Errorf("IfNotExists = %v, defs %d", op.IfNotExists, len(op.PartitionDefinitions)) + } + }}, + {keywords.DialectMariaDB, "ALTER TABLE t ADD PARTITION (PARTITION p3 CURRENT)", "ADD PARTITION", 1, nil}, + {keywords.DialectMariaDB, "ALTER TABLE t ADD PARTITION (PARTITION p3 HISTORY, PARTITION p4 HISTORY)", "ADD PARTITION", 1, nil}, + {keywords.DialectMySQL, "ALTER TABLE t ADD PARTITION (PARTITION p3)", "ADD PARTITION", 1, nil}, + {keywords.DialectMySQL, "ALTER TABLE t ADD PARTITION PARTITIONS 2", "ADD PARTITION", 1, count}, + {keywords.DialectMySQL, "ALTER TABLE t COALESCE PARTITION 2", "COALESCE PARTITION", 1, count}, + {keywords.DialectMySQL, "ALTER TABLE t EXCHANGE PARTITION p WITH TABLE db.t2", "EXCHANGE PARTITION", 1, func(t *testing.T, s *ast.AlterStatement, op *ast.AlterTableOperation) { + if op.ExchangeTable.Name != "db.t2" || op.Validation != "" { + t.Errorf("ExchangeTable = %q, Validation = %q", op.ExchangeTable.Name, op.Validation) + } + names("p")(t, s, op) + }}, + {keywords.DialectMySQL, "ALTER TABLE t EXCHANGE PARTITION p WITH TABLE t2 WITHOUT VALIDATION", "EXCHANGE PARTITION", 1, func(t *testing.T, _ *ast.AlterStatement, op *ast.AlterTableOperation) { + if op.Validation != "WITHOUT" { + t.Errorf("Validation = %q", op.Validation) + } + }}, + {keywords.DialectMySQL, "ALTER TABLE t EXCHANGE PARTITION p WITH TABLE t2 WITH VALIDATION", "EXCHANGE PARTITION", 1, nil}, + {keywords.DialectMySQL, "ALTER TABLE t ANALYZE PARTITION p0, p1", "ANALYZE PARTITION", 1, names("p0", "p1")}, + {keywords.DialectMySQL, "ALTER TABLE t CHECK PARTITION ALL", "CHECK PARTITION", 1, all}, + {keywords.DialectMySQL, "ALTER TABLE t OPTIMIZE PARTITION p0", "OPTIMIZE PARTITION", 1, names("p0")}, + {keywords.DialectMySQL, "ALTER TABLE t REBUILD PARTITION p0, p1", "REBUILD PARTITION", 1, names("p0", "p1")}, + {keywords.DialectMySQL, "ALTER TABLE t REPAIR PARTITION ALL", "REPAIR PARTITION", 1, all}, + {keywords.DialectMySQL, "ALTER TABLE t DISCARD PARTITION p0, p1 TABLESPACE", "DISCARD PARTITION", 1, names("p0", "p1")}, + {keywords.DialectMySQL, "ALTER TABLE t IMPORT PARTITION ALL TABLESPACE", "IMPORT PARTITION", 1, all}, + {keywords.DialectMySQL, "ALTER TABLE t UPGRADE PARTITIONING", "UPGRADE PARTITIONING", 1, nil}, + {keywords.DialectMySQL, "alter table t reorganize partition p0,p1 into (partition p01 values less than (20))", "REORGANIZE PARTITION", 1, names("p0", "p1")}, + } + for _, tc := range cases { + t.Run(string(tc.dialect)+"/"+tc.sql, func(t *testing.T) { + tree := assertRoundTrip(t, tc.dialect, tc.sql) + stmt, ok := tree.Statements[0].(*ast.AlterStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + if len(stmt.Operations) != tc.ops { + t.Fatalf("%d operations, want %d", len(stmt.Operations), tc.ops) + } + op, ok := stmt.Operations[0].(*ast.AlterTableOperation) + if !ok { + t.Fatalf("got %T", stmt.Operations[0]) + } + if op.Type != ast.AlterPartitioning || op.Keyword != tc.keyword { + t.Fatalf("Type %v Keyword %q, want AlterPartitioning %q", op.Type, op.Keyword, tc.keyword) + } + for _, extra := range stmt.Operations[1:] { + if o, ok := extra.(*ast.AlterTableOperation); !ok || o.Type != ast.AlterTableOther { + t.Errorf("table option parsed as %#v", extra) + } + } + if tc.check != nil { + tc.check(t, stmt, op) + } + }) + } +} + +// MySQL has no IF [NOT] EXISTS on partition actions and no HISTORY / +// CURRENT partitions: these keep the as-written fallback earlier releases +// used rather than a structured (MariaDB) reading. +func TestMySQLPartitionMariaDBOnlyFormsNotStructured(t *testing.T) { + for _, sql := range []string{ + "ALTER TABLE t DROP PARTITION IF EXISTS p0", + "ALTER TABLE t ADD PARTITION IF NOT EXISTS (PARTITION p VALUES IN (1))", + "ALTER TABLE t ADD PARTITION (PARTITION p3 HISTORY)", + } { + t.Run(sql, func(t *testing.T) { + tree := assertRoundTrip(t, keywords.DialectMySQL, sql) + stmt, ok := tree.Statements[0].(*ast.AlterStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + if op, ok := stmt.Operation.(*ast.AlterTableOperation); !ok || op.Type != ast.AlterTableOther { + t.Fatalf("operation = %#v, want AlterTableOther", stmt.Operation) + } + }) + } +} + +func TestClickHouseSampleRejectsNonDecimalNumbers(t *testing.T) { + for _, sql := range []string{ + "SELECT * FROM t SAMPLE 0x10", + "SELECT * FROM t AS x FINAL SAMPLE 0x10", + "SELECT * FROM t SAMPLE 10 OFFSET 0x10", + "SELECT * FROM t SAMPLE 1/0x10", + "SELECT * FROM t SAMPLE 1/10 OFFSET 1/0x2", + "SELECT * FROM t JOIN u SAMPLE 0x10 ON 1", + } { + t.Run(sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectClickHouse) + if err == nil { + out := tree.SQL() + ast.ReleaseAST(tree) + t.Fatalf("expected syntax error, parsed as %q", out) + } + }) + } +} + +func TestClickHouseSampleOnJoinedTables(t *testing.T) { + cases := []struct { + sql string + // stmtSample: SAMPLE kept on SelectStatement.Sample (lone table). + stmtSample bool + // joinSamples: per joined table, whether it carries a SAMPLE. + joinSamples []bool + firstSample bool + }{ + {"SELECT * FROM t JOIN u SAMPLE 0.5 ON 1", false, []bool{true}, false}, + {"SELECT * FROM t x FINAL SAMPLE 0.1 JOIN u y FINAL SAMPLE 0.2 ON x.a = y.a", false, []bool{true}, true}, + {"SELECT * FROM t FINAL JOIN u FINAL SAMPLE 1/10 OFFSET 1/2 USING (a)", false, []bool{true}, false}, + {"SELECT * FROM t LEFT JOIN (SELECT 1 AS a) AS s SAMPLE 0.5 ON 1", false, []bool{true}, false}, + {"SELECT * FROM hits AS h FINAL SAMPLE 0.1 WHERE x = 1", true, nil, false}, + {"SELECT * FROM t SAMPLE 10 ARRAY JOIN arr", true, nil, false}, + {"SELECT * FROM t SAMPLE 1/10 OFFSET 1/2 LEFT ARRAY JOIN arr AS e PREWHERE a = 1", true, nil, false}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree := assertRoundTrip(t, keywords.DialectClickHouse, tc.sql) + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + if (stmt.Sample != nil) != tc.stmtSample { + t.Errorf("statement Sample = %v, want %v", stmt.Sample, tc.stmtSample) + } + if stmt.Sample != nil && !reachable(stmt, stmt.Sample.Size) { + t.Error("statement SAMPLE size unreachable") + } + hasSample := func(ref *ast.TableReference) bool { + return ref.Modifiers != nil && ref.Modifiers.Sample != nil + } + if got := hasSample(&stmt.From[0]); got != tc.firstSample { + t.Errorf("first table Sample = %v, want %v", got, tc.firstSample) + } + if len(stmt.Joins) != len(tc.joinSamples) { + t.Fatalf("%d joins, want %d", len(stmt.Joins), len(tc.joinSamples)) + } + for i, want := range tc.joinSamples { + ref := &stmt.Joins[i].Right + if got := hasSample(ref); got != want { + t.Errorf("join %d Sample = %v, want %v", i, got, want) + } + if want && !reachable(stmt, ref.Modifiers.Sample.Size) { + t.Errorf("join %d SAMPLE size unreachable", i) + } + } + }) + } + for _, sql := range []string{ + "SELECT * FROM t SAMPLE 0.1 FINAL", + "SELECT * FROM t SAMPLE 0.1 SAMPLE 0.2", + "SELECT * FROM t JOIN u SAMPLE 0.1 SAMPLE 0.2 ON 1", + } { + t.Run("reject/"+sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectClickHouse) + if err == nil { + ast.ReleaseAST(tree) + t.Fatal("expected syntax error") + } + }) + } +} + +func TestNestedGroupingSets(t *testing.T) { + cases := []struct { + dialect keywords.SQLDialect + sql string + depth int + }{ + {keywords.DialectPostgreSQL, "SELECT a, b FROM t GROUP BY GROUPING SETS (GROUPING SETS ((a), (b)), c)", 2}, + {keywords.DialectPostgreSQL, "SELECT a FROM t GROUP BY GROUPING SETS (GROUPING SETS (GROUPING SETS (a, ROLLUP(b))), ())", 3}, + {keywords.DialectSnowflake, "SELECT a FROM t GROUP BY grouping sets (grouping sets (a), cube(a, b))", 2}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree := assertRoundTrip(t, tc.dialect, tc.sql) + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("got %T", tree.Statements[0]) + } + depth := 0 + ast.Inspect(stmt, func(n ast.Node) bool { + if _, ok := n.(*ast.GroupingSetsExpression); ok { + depth++ + } + return true + }) + if depth != tc.depth { + t.Errorf("%d GROUPING SETS nodes, want %d", depth, tc.depth) + } + }) + } + deep := "SELECT a FROM t GROUP BY " + strings.Repeat("GROUPING SETS (", 200) + "a" + strings.Repeat(")", 200) + if tree, err := gosqlx.ParseWithDialect(deep, keywords.DialectPostgreSQL); err == nil { + ast.ReleaseAST(tree) + t.Error("expected recursion depth error for 200 nested GROUPING SETS") + } +} + +func TestColumnValueSourceConflictsRejected(t *testing.T) { + for _, sql := range []string{ + "CREATE TABLE t (a int GENERATED ALWAYS AS IDENTITY DEFAULT 5)", + "CREATE TABLE t (a int DEFAULT 5 GENERATED BY DEFAULT AS IDENTITY)", + "CREATE TABLE t (a int GENERATED ALWAYS AS IDENTITY GENERATED BY DEFAULT AS IDENTITY)", + "CREATE TABLE t (a int GENERATED ALWAYS AS IDENTITY NOT NULL GENERATED ALWAYS AS IDENTITY)", + "CREATE TABLE t (a int, b int GENERATED ALWAYS AS (a * 2) STORED GENERATED ALWAYS AS IDENTITY)", + "CREATE TABLE t (a int, b int GENERATED ALWAYS AS (a * 2) STORED DEFAULT 1)", + "ALTER TABLE t ADD COLUMN a int GENERATED ALWAYS AS IDENTITY DEFAULT 1", + } { + t.Run(sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectPostgreSQL) + if err == nil { + ast.ReleaseAST(tree) + t.Fatal("expected syntax error") + } + }) + } + // One value source combined with other constraints in any order is fine. + assertRoundTrip(t, keywords.DialectPostgreSQL, "CREATE TABLE t (a int NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY, b int DEFAULT 1 NOT NULL, c int GENERATED ALWAYS AS (a + 1) STORED NOT NULL)") +} + +func TestIdentityOptionsKeepNumberSign(t *testing.T) { + cases := []struct { + sql string + want string + }{ + {"CREATE TABLE t (a int GENERATED ALWAYS AS IDENTITY (INCREMENT -1))", "(INCREMENT -1)"}, + {"CREATE TABLE t (a int GENERATED BY DEFAULT AS IDENTITY (START WITH -5 INCREMENT BY -1 MINVALUE -100))", "(START WITH -5 INCREMENT BY -1 MINVALUE -100)"}, + {"CREATE TABLE t (a int GENERATED ALWAYS AS IDENTITY (START WITH +3))", "(START WITH +3)"}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + tree := assertRoundTrip(t, keywords.DialectPostgreSQL, tc.sql) + if out := tree.SQL(); !strings.Contains(out, tc.want) { + t.Errorf("SQL() = %q, want it to contain %q", out, tc.want) + } + }) + } +} diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 73dfb0e9..4529fc68 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -114,6 +114,11 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { if len(selectModifiers) > 0 || into != nil { selectStmt.Extras = &ast.SelectExtras{SelectModifiers: selectModifiers, Into: into} } + // The SAMPLE of a lone FROM table is the statement's SAMPLE clause. + if len(tables) == 1 && len(joins) == 0 && tables[0].Modifiers != nil && tables[0].Modifiers.Sample != nil { + selectStmt.Sample = tables[0].Modifiers.Sample + tables[0].Modifiers.Sample = nil + } // No further clause starts at the end of the statement. if p.atSelectEnd() { return selectStmt, nil @@ -133,7 +138,7 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { } // SAMPLE (ClickHouse-specific, specifies sampling rate/size; comes after FROM/FINAL) - if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("SAMPLE") { + if p.dialect == string(keywords.DialectClickHouse) && selectStmt.Sample == nil && p.isTokenMatch("SAMPLE") { if selectStmt.Sample, err = p.parseSampleClause(); err != nil { return nil, err } diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index 149c2c45..374d2a5e 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -100,7 +100,8 @@ func (p *Parser) parseFromClause() (tableName string, tables []ast.TableReferenc // NOTE: FINAL only applies to the first (primary) table reference. In ClickHouse, // FINAL is a per-table modifier; multi-table FROM with FINAL on a non-first // table (e.g. "FROM t1, t2 FINAL") is not supported by this parser. - if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("FINAL") { + if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("FINAL") && + (firstRef.Modifiers == nil || firstRef.Modifiers.Sample == nil) { firstRef.Final = true p.advance() // consume FINAL } @@ -680,7 +681,7 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { clause := &ast.SampleClause{Pos: samplePos} // Read the primary sampling value (numerator / whole number / float) - if !p.isNumericLiteral() { + if !p.isSampleNumber() { return nil, goerrors.ExpectedTokenError( "numeric literal after SAMPLE", p.currentToken.Token.Type.String(), @@ -689,12 +690,13 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { ).WithHint("SAMPLE clause requires a numeric argument (ratio, row count, or N/D fraction)") } clause.Value = p.currentToken.Token.Value + clause.Size = &ast.LiteralValue{Value: clause.Value, Type: numericLiteralType(clause.Value)} p.advance() // Check for fractional form: SAMPLE N / D if p.isType(models.TokenTypeDiv) { p.advance() // consume / - if !p.isNumericLiteral() { + if !p.isSampleNumber() { return nil, goerrors.ExpectedTokenError( "denominator after /", p.currentToken.Token.Type.String(), @@ -703,13 +705,14 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { ).WithHint("SAMPLE N/D fraction requires an integer denominator") } clause.Denominator = p.currentToken.Token.Value + clause.Size = &ast.BinaryExpression{Left: clause.Size, Operator: "/", Right: &ast.LiteralValue{Value: clause.Denominator, Type: numericLiteralType(clause.Denominator)}} p.advance() } // Optional OFFSET N/D if p.isTokenMatch("OFFSET") { p.advance() // Consume OFFSET - if !p.isNumericLiteral() { + if !p.isSampleNumber() { return nil, goerrors.ExpectedTokenError( "numeric literal after SAMPLE ... OFFSET", p.currentToken.Token.Type.String(), @@ -718,10 +721,11 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { ).WithHint("SAMPLE OFFSET requires a numeric argument") } clause.Offset = p.currentToken.Token.Value + clause.OffsetSize = &ast.LiteralValue{Value: clause.Offset, Type: numericLiteralType(clause.Offset)} p.advance() if p.isType(models.TokenTypeDiv) { p.advance() // consume / - if !p.isNumericLiteral() { + if !p.isSampleNumber() { return nil, goerrors.ExpectedTokenError( "denominator after OFFSET /", p.currentToken.Token.Type.String(), @@ -730,6 +734,7 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { ).WithHint("SAMPLE OFFSET N/D fraction requires an integer denominator") } clause.OffsetDenominator = p.currentToken.Token.Value + clause.OffsetSize = &ast.BinaryExpression{Left: clause.OffsetSize, Operator: "/", Right: &ast.LiteralValue{Value: clause.OffsetDenominator, Type: numericLiteralType(clause.OffsetDenominator)}} p.advance() } } @@ -737,6 +742,13 @@ func (p *Parser) parseSampleClause() (*ast.SampleClause, error) { return clause, nil } +// isSampleNumber reports whether the current token is a plain decimal +// number (digits, '.', exponent), the only literal ClickHouse SAMPLE takes; +// hex and other numeric spellings are rejected rather than misread. +func (p *Parser) isSampleNumber() bool { + return p.isNumericLiteral() && ast.IsPlainNumberText(p.currentToken.Token.Value) +} + // parsePrewhereClause parses "PREWHERE " if present (ClickHouse-specific). // PREWHERE is a ClickHouse optimisation that filters data blocks before reading // all columns. It is semantically similar to WHERE but executed earlier in the @@ -827,8 +839,7 @@ func (p *Parser) parseGroupByClause(stmt *ast.SelectStatement) ([]ast.Expression expr, err = p.parseRollup() case p.isType(models.TokenTypeCube): expr, err = p.parseCube() - case p.currentToken.Token.Value == "GROUPING SETS" || - (p.isType(models.TokenTypeGrouping) && strings.EqualFold(p.peekToken().Token.Value, "SETS")): + case p.isGroupingSetsStart(): expr, err = p.parseGroupingSets() default: expr, err = p.parseExpression() diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index f05d73a2..2d23b3a1 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -40,7 +40,14 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } // Check for derived table (subquery in parentheses) - if p.isType(models.TokenTypeLParen) { + if p.isRowsFromStart() { + ref, err := p.parseRowsFrom() + if err != nil { + return tableRef, err + } + tableRef = ref + tableRef.Lateral = isLateral + } else if p.isType(models.TokenTypeLParen) { p.advance() // Consume ( // Check if this is a subquery (starts with SELECT, WITH, VALUES or @@ -285,7 +292,14 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error return p.parseJoinGroup() } - if p.isType(models.TokenTypeLParen) { + if p.isRowsFromStart() { + rows, err := p.parseRowsFrom() + if err != nil { + return ref, err + } + ref = rows + ref.Lateral = isLateral + } else if p.isType(models.TokenTypeLParen) { // Derived table (subquery) p.advance() // Consume ( diff --git a/pkg/sql/parser/table_factor.go b/pkg/sql/parser/table_factor.go index 7d3a292b..83317bd4 100644 --- a/pkg/sql/parser/table_factor.go +++ b/pkg/sql/parser/table_factor.go @@ -185,10 +185,7 @@ func (p *Parser) isTableSampleStart() bool { // isTableSampleStartAt is isTableSampleStart past its inline screen (a matching word is at least as long as SAMPLE). func (p *Parser) isTableSampleStartAt() bool { - if !p.isSampleKeyword() { - return false - } - return !(p.IsClickHouse() && p.peekToken().Token.Type == models.TokenTypeNumber) + return p.isSampleKeyword() && !(p.IsClickHouse() && p.peekToken().Token.Type == models.TokenTypeNumber) } // parseTableSample parses a row-sampling clause; the current token is SAMPLE @@ -362,13 +359,28 @@ func (p *Parser) parseTableModifiersBeforeAlias(ref *ast.TableReference) error { // alias itself: index hints, SQLite INDEXED BY / NOT INDEXED, Oracle // flashback, FOR SYSTEM_TIME. func (p *Parser) isTableModifierAfterAliasStart() bool { - return p.isIndexHintStart() || p.isSQLiteIndexedStart() || p.isOracleFlashbackStart() + return (p.IsClickHouse() && p.isUnquotedWord("FINAL")) || p.isIndexHintStart() || p.isSQLiteIndexedStart() || p.isOracleFlashbackStart() } // parseTableModifiersAfterAlias parses the table modifiers that follow the // alias: TABLESAMPLE, ClickHouse FINAL, MySQL index hints, SQLite INDEXED BY // / NOT INDEXED and SQL Server bare table hints t (NOLOCK). func (p *Parser) parseTableModifiersAfterAlias(ref *ast.TableReference) error { + if p.IsClickHouse() && p.isUnquotedWord("FINAL") { + ref.Final = true + p.advance() + if p.isUnquotedWord("FINAL") { + return p.expectedError("a single FINAL modifier") + } + } + // ClickHouse SAMPLE k [OFFSET m] of this table expression. + if p.IsClickHouse() && p.isUnquotedWord("SAMPLE") && p.peekToken().Token.Type == models.TokenTypeNumber { + sample, err := p.parseSampleClause() + if err != nil { + return err + } + tableModifiers(ref).Sample = sample + } if !hasTableSample(ref) && p.isTableSampleStart() { sample, err := p.parseTableSample() if err != nil { @@ -376,10 +388,6 @@ func (p *Parser) parseTableModifiersAfterAlias(ref *ast.TableReference) error { } tableModifiers(ref).TableSample = sample } - if p.IsClickHouse() && p.isTokenMatch("FINAL") { - ref.Final = true - p.advance() // Consume FINAL - } for p.isIndexHintStart() { hint, err := p.parseIndexHint() if err != nil { @@ -909,6 +917,9 @@ func (p *Parser) parsePivotInSpecial(clause *ast.PivotClause) (bool, error) { return false, p.expectedError(") to close PIVOT IN list") } p.advance() // close IN list ) + if err := p.parsePivotDefault(clause); err != nil { + return false, err + } if !p.isType(models.TokenTypeRParen) { return false, p.expectedError(") to close PIVOT clause") } diff --git a/pkg/sql/parser/table_review_gaps.go b/pkg/sql/parser/table_review_gaps.go new file mode 100644 index 00000000..921e2d9b --- /dev/null +++ b/pkg/sql/parser/table_review_gaps.go @@ -0,0 +1,114 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func (p *Parser) parsePivotDefault(clause *ast.PivotClause) error { + if !p.IsSnowflake() || !p.isType(models.TokenTypeDefault) { + return nil + } + p.advance() + if !p.isType(models.TokenTypeOn) { + return p.expectedError("ON after DEFAULT") + } + p.advance() + if !p.isType(models.TokenTypeNull) { + return p.expectedError("NULL after DEFAULT ON") + } + p.advance() + if !p.isType(models.TokenTypeLParen) { + return p.expectedError("( after DEFAULT ON NULL") + } + p.advance() + value, err := p.parseExpression() + if err != nil { + return err + } + clause.DefaultOnNull = value + if !p.isType(models.TokenTypeRParen) { + return p.expectedError(") after DEFAULT ON NULL expression") + } + p.advance() + return nil +} + +func (p *Parser) isRowsFromStart() bool { + return p.IsPostgreSQL() && p.isType(models.TokenTypeRows) && p.peekToken().Token.Type == models.TokenTypeFrom +} + +func (p *Parser) parseRowsFrom() (ast.TableReference, error) { + ref := ast.TableReference{Modifiers: &ast.TableModifiers{}} + p.advance() + p.advance() // ROWS FROM + if !p.isType(models.TokenTypeLParen) { + return ref, p.expectedError("( after ROWS FROM") + } + p.advance() + for { + name, quote, err := p.parseTableNameQuoted() + if err != nil { + return ref, err + } + if !p.isType(models.TokenTypeLParen) { + return ref, p.expectedError("function call in ROWS FROM") + } + function, err := p.parseFunctionCall(name) + if err != nil { + return ref, err + } + function.NameQuote = quote + item := ast.RowsFromItem{Function: function} + if p.isType(models.TokenTypeAs) { + p.advance() + if !p.isType(models.TokenTypeLParen) { + return ref, p.expectedError("column definitions after AS in ROWS FROM") + } + temp := ast.TableReference{TableFunc: function} + if err := p.parseTableColumnAliases(&temp); err != nil { + return ref, err + } + if temp.ColumnAliases == nil || len(temp.ColumnAliases.Types) != len(temp.ColumnAliases.Names) { + return ref, p.expectedError("typed column definitions in ROWS FROM") + } + for _, typ := range temp.ColumnAliases.Types { + if typ == "" { + return ref, p.expectedError("column type in ROWS FROM") + } + } + item.Columns = temp.ColumnAliases + } + ref.Modifiers.RowsFrom = append(ref.Modifiers.RowsFrom, item) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() + } + if !p.isType(models.TokenTypeRParen) { + return ref, p.expectedError(") after ROWS FROM") + } + p.advance() + if p.isType(models.TokenTypeWith) && strings.EqualFold(p.peekToken().Token.Value, "ORDINALITY") && p.peekToken().Token.Quote == 0 && (p.peekToken().Token.Word == nil || p.peekToken().Token.Word.QuoteStyle == 0) { + p.advance() + p.advance() + ref.WithOrdinality = true + } + return ref, nil +} diff --git a/testdata/realworld/clickhouse/90_review_gaps.sql b/testdata/realworld/clickhouse/90_review_gaps.sql new file mode 100644 index 00000000..f0c92878 --- /dev/null +++ b/testdata/realworld/clickhouse/90_review_gaps.sql @@ -0,0 +1,5 @@ +-- Sample finalized events for a traffic report. +SELECT h.site_id, COUNT(*) FROM hits AS h FINAL SAMPLE 1/10 OFFSET 1/2 WHERE h.status = 200 GROUP BY h.site_id; + +-- Join sampled sessions to sampled finalized hits. +SELECT s.site_id, COUNT(*) FROM sessions AS s SAMPLE 0.1 JOIN hits AS h FINAL SAMPLE 0.1 ON h.session_id = s.id GROUP BY s.site_id; diff --git a/testdata/realworld/mariadb/90_review_gaps.sql b/testdata/realworld/mariadb/90_review_gaps.sql new file mode 100644 index 00000000..d0363e1c --- /dev/null +++ b/testdata/realworld/mariadb/90_review_gaps.sql @@ -0,0 +1,3 @@ +-- Rotate system-versioned history partitions. +ALTER TABLE audit_log DROP PARTITION IF EXISTS p_hist0; +ALTER TABLE audit_log ADD PARTITION IF NOT EXISTS (PARTITION p_hist9 HISTORY); diff --git a/testdata/realworld/mysql/90_review_gaps.sql b/testdata/realworld/mysql/90_review_gaps.sql new file mode 100644 index 00000000..d3234168 --- /dev/null +++ b/testdata/realworld/mysql/90_review_gaps.sql @@ -0,0 +1,8 @@ +-- Roll historical order partitions forward while retaining a catch-all range. +ALTER TABLE order_history PARTITION BY RANGE (YEAR(ordered_at)) (PARTITION p2025 VALUES LESS THAN (2026), PARTITION p_future VALUES LESS THAN MAXVALUE); +ALTER TABLE order_history REORGANIZE PARTITION p_future INTO (PARTITION p2026 VALUES LESS THAN (2027), PARTITION p_future VALUES LESS THAN MAXVALUE); + +-- Purge and compact archived order partitions online. +ALTER TABLE order_history TRUNCATE PARTITION ALL; +ALTER TABLE order_history DROP PARTITION p2019, p2020, ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE order_history EXCHANGE PARTITION p2025 WITH TABLE order_archive WITHOUT VALIDATION; diff --git a/testdata/realworld/postgresql/90_review_gaps.sql b/testdata/realworld/postgresql/90_review_gaps.sql new file mode 100644 index 00000000..16ec69f7 --- /dev/null +++ b/testdata/realworld/postgresql/90_review_gaps.sql @@ -0,0 +1,14 @@ +-- Identity columns can coexist with other constraints in either order. +CREATE TABLE invoice_batches (id bigint GENERATED ALWAYS AS IDENTITY (START WITH 10 INCREMENT BY 5) PRIMARY KEY, sequence_no int GENERATED BY DEFAULT AS IDENTITY NOT NULL, revision int NOT NULL GENERATED ALWAYS AS IDENTITY); + +-- Combine generated calendar rows with labels and retain row ordinality. +SELECT r.day_no, r.label, r.ordinal FROM ROWS FROM (generate_series(1,3), unnest(ARRAY['open','pending','closed'])) WITH ORDINALITY AS r(day_no, label, ordinal); + +-- Select a two-dimensional report cell and a window of daily counts. +SELECT ARRAY[[10,20],[30,40]][1][2] AS report_cell, ARRAY[5,8,13][1:2] AS first_days; + +-- Combine regional subtotals and all-region totals in a revenue report. +SELECT department, region, SUM(amount) FROM monthly_sales GROUP BY GROUPING SETS ((department), ROLLUP(region), CUBE(department, region), ()); + +-- Nest a grouping-set list inside a report's grouping sets. +SELECT department, region, SUM(amount) FROM monthly_sales GROUP BY GROUPING SETS (GROUPING SETS ((department), (region)), ()); diff --git a/testdata/realworld/snowflake/90_review_gaps.sql b/testdata/realworld/snowflake/90_review_gaps.sql new file mode 100644 index 00000000..cdb0888d --- /dev/null +++ b/testdata/realworld/snowflake/90_review_gaps.sql @@ -0,0 +1,5 @@ +-- Monthly revenue pivot with a default for missing sales. +SELECT * FROM monthly_sales PIVOT (SUM(amount) FOR sales_month IN (ANY ORDER BY sales_month) DEFAULT ON NULL (0)) AS revenue; + +-- Department totals with nested grouping operations. +SELECT department, region, SUM(amount) FROM monthly_sales GROUP BY GROUPING SETS ((department), ROLLUP(region), CUBE(department, region), ()); diff --git a/testdata/realworld/sqlserver/90_review_gaps.sql b/testdata/realworld/sqlserver/90_review_gaps.sql new file mode 100644 index 00000000..3aeb44e7 --- /dev/null +++ b/testdata/realworld/sqlserver/90_review_gaps.sql @@ -0,0 +1,2 @@ +-- Copy a bounded batch into an archive table. +INSERT TOP (100) INTO archived_orders (order_id, customer_id) SELECT order_id, customer_id FROM completed_orders;