From 4622f040b8b9bcba985602993e53981b54741add Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Tue, 27 Oct 2026 14:16:15 +0530 Subject: [PATCH 1/2] fix(formatter,transform): complete lower-case output, keep comments by GO, handle TOP/FETCH - format --no-uppercase (KeywordLower) lower-cases every keyword, operator word and data type, not only clause keywords; identifiers, quoted names and literals are untouched. Rendering stays linear in expression depth. Raw JSON_TABLE/XMLTABLE clause text keeps its source case (documented). - Comments in CRLF input no longer keep '\r'. - Comments before or after a GO line stay on that side of it. - STRAIGHT_JOIN ... ON a = b, t3 no longer renders a space before ','. - transform.AddTableAlias renames qualifiers in SELECT, scope-aware. - SetLimit/SetOffset/RemoveLimit/RemoveOffset handle SQL Server TOP and FETCH FIRST/NEXT; PERCENT and WITH TIES are rejected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 11 +- cmd/gosqlx/cmd/format.go | 2 +- cmd/gosqlx/cmd/format_comment_starts_test.go | 3 + .../format_keyword_case_line_endings_test.go | 42 +++ cmd/gosqlx/cmd/formatter.go | 13 +- docs/CLI_GUIDE.md | 5 +- .../comment_line_endings_batch_test.go | 57 +++ pkg/formatter/comments.go | 12 +- pkg/formatter/expression_scaling_test.go | 160 ++++++++ pkg/formatter/keyword_case_complete_test.go | 167 ++++++++ pkg/formatter/render.go | 166 ++++---- pkg/formatter/render_case_helpers.go | 357 ++++++++++++++++++ pkg/formatter/render_dml.go | 24 +- pkg/formatter/render_expression_case.go | 338 +++++++++++++++++ pkg/formatter/render_select_ext.go | 32 +- pkg/formatter/select_ext_render_test.go | 2 +- pkg/sql/ast/select_ext_test.go | 2 +- pkg/sql/ast/sql.go | 4 +- pkg/sql/ast/sql_dml.go | 4 +- pkg/sql/ast/table_factor.go | 4 +- pkg/transform/limit.go | 62 ++- pkg/transform/pagination_regression_test.go | 138 +++++++ pkg/transform/tables.go | 16 +- pkg/transform/tables_alias_select.go | 36 ++ pkg/transform/tables_alias_select_test.go | 149 ++++++++ pkg/transform/tables_dml.go | 113 +++--- 26 files changed, 1739 insertions(+), 180 deletions(-) create mode 100644 cmd/gosqlx/cmd/format_keyword_case_line_endings_test.go create mode 100644 pkg/formatter/comment_line_endings_batch_test.go create mode 100644 pkg/formatter/expression_scaling_test.go create mode 100644 pkg/formatter/keyword_case_complete_test.go create mode 100644 pkg/formatter/render_case_helpers.go create mode 100644 pkg/formatter/render_expression_case.go create mode 100644 pkg/transform/pagination_regression_test.go create mode 100644 pkg/transform/tables_alias_select.go create mode 100644 pkg/transform/tables_alias_select_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 508e16cd..5d58bdd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in dialects without them. ### Fixed +- Formatter lower-case mode covers expression and built-in function keywords; + comments use LF endings and keep SQL Server batch boundaries. Fix MySQL join + comma spacing, SELECT alias qualifiers, and TOP/FETCH limit transforms. +- Lower-case expression formatting avoids repeated subtree rendering and copying. + Raw `JSON_TABLE`/`XMLTABLE` clauses retain their source case in either mode. - **AST walkers** (`ast.Inspect`, `Walk`, the security scanner, linter and table extraction) reach SELECT `TOP` and ClickHouse `ARRAY JOIN`, the ENGINE/ORDER BY/PARTITION BY/TTL/SETTINGS clauses of `CREATE TABLE` and @@ -143,9 +148,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 formatting no longer drop comments either. New `formatter.JoinWithComments`, `gosqlx.FormatWithDialect` and `gosqlx.WithComments()`. - **CLI**: `lint` exits 1 on syntax errors and `--auto-fix` judges the fixed - text; `format --no-uppercase` lower-cases clause keywords (it used to upper-case - them; operators such as `AND`, `IN`, `IS NULL`, `DESC` and function names - are still written in upper case); `postgres`, `pg`, `mssql`, `tsql` and `sqlite3` are accepted by every + text; `format --no-uppercase` lower-cases keywords, keyword operators + and built-in function keywords (raw `JSON_TABLE`/`XMLTABLE` clauses retain source + case); `postgres`, `pg`, `mssql`, `tsql` and `sqlite3` are accepted by every `--dialect` flag; errors are printed once and usage only for flag and argument errors; `validate --quiet` exits silently; `validate` names stdin ``, validates `GO`/`DELIMITER` scripts and files over the token diff --git a/cmd/gosqlx/cmd/format.go b/cmd/gosqlx/cmd/format.go index ac6246e7..6f8b07f4 100644 --- a/cmd/gosqlx/cmd/format.go +++ b/cmd/gosqlx/cmd/format.go @@ -265,7 +265,7 @@ func init() { // Negation flag. It has its own variable: sharing formatUppercase made // --no-uppercase set uppercase to true. - formatCmd.Flags().BoolVar(&formatNoUppercase, "no-uppercase", false, "write keywords in lower case (opposite of --uppercase)") + formatCmd.Flags().BoolVar(&formatNoUppercase, "no-uppercase", false, "write keywords in lower case, including keyword operators and built-in function keywords; raw JSON_TABLE/XMLTABLE clauses retain source case (opposite of --uppercase)") } // resolveUppercaseFlags folds --no-uppercase into formatUppercase. diff --git a/cmd/gosqlx/cmd/format_comment_starts_test.go b/cmd/gosqlx/cmd/format_comment_starts_test.go index 76aebea2..61212513 100644 --- a/cmd/gosqlx/cmd/format_comment_starts_test.go +++ b/cmd/gosqlx/cmd/format_comment_starts_test.go @@ -19,6 +19,9 @@ func TestFormatCommentsStayWithTheirStatements(t *testing.T) { // order lists pieces of the output in the order they must appear. order []string }{ + {"sqlserver", + "SELECT 1\n-- before go\nGO\n-- after go\nSELECT 2", + []string{"SELECT 1", "-- before go", "GO", "-- after go", "SELECT 2"}}, {"sqlserver", "-- A\nSELECT 1\nSELECT 2;\n-- B: the proc\nCREATE PROC p AS BEGIN SELECT 1; END;\n", []string{"-- A", "SELECT 1", "SELECT 2", "-- B: the proc", "CREATE PROC"}}, diff --git a/cmd/gosqlx/cmd/format_keyword_case_line_endings_test.go b/cmd/gosqlx/cmd/format_keyword_case_line_endings_test.go new file mode 100644 index 00000000..7cb1cf0a --- /dev/null +++ b/cmd/gosqlx/cmd/format_keyword_case_line_endings_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 GoSQLX Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestCLIFormatCRLFInPlace(t *testing.T) { + path := writeTemp(t, "crlf.sql", "-- leading\r\nselect /* multi\r\nline */ 1 -- trailing\r\n") + res := runCLI(t, nil, "format", "-i", path) + if res.err != nil { + t.Fatalf("format: %v: %s", res.err, res.stderr) + } + out, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "\r") { + t.Fatalf("mixed endings: %q", out) + } + for _, comment := range []string{"-- leading", "/* multi\nline */", "-- trailing"} { + if !strings.Contains(string(out), comment) { + t.Fatalf("lost %q in %q", comment, out) + } + } +} + +func TestCLIFormatNoUppercaseExpressions(t *testing.T) { + res := runCLI(t, str("select a from t where b is null and c in (1) order by a desc"), "format", "--no-uppercase") + if res.err != nil { + t.Fatalf("format: %v: %s", res.err, res.stderr) + } + for _, part := range []string{"where b is null and c in (1)", "order by a desc"} { + if !strings.Contains(res.stdout, part) { + t.Fatalf("missing %q: %q", part, res.stdout) + } + } +} diff --git a/cmd/gosqlx/cmd/formatter.go b/cmd/gosqlx/cmd/formatter.go index a89d197e..bab6052e 100644 --- a/cmd/gosqlx/cmd/formatter.go +++ b/cmd/gosqlx/cmd/formatter.go @@ -320,6 +320,13 @@ func (f *Formatter) formatSQLText(sql string) (string, error) { return formatted, nil } +func normalizedComment(text string) string { + if strings.HasPrefix(text, "--") { + text = strings.TrimSuffix(text, "\r") + } + return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") +} + // verifyComments requires the formatted output to contain exactly the // comments of the input, so format -i can never delete or invent one. func verifyComments(input []models.Comment, formatted, dialect string) error { @@ -333,13 +340,13 @@ func verifyComments(input []models.Comment, formatted, dialect string) error { } pool := make(map[string]int, len(input)) for _, c := range tkz.Comments { - pool[c.Text]++ + pool[normalizedComment(c.Text)]++ } for _, c := range input { - if pool[c.Text] == 0 { + if pool[normalizedComment(c.Text)] == 0 { return fmt.Errorf("formatted output loses the comment %q (formatter bug; input left unchanged)", c.Text) } - pool[c.Text]-- + pool[normalizedComment(c.Text)]-- } for text, n := range pool { if n > 0 { diff --git a/docs/CLI_GUIDE.md b/docs/CLI_GUIDE.md index 88cbbc34..08103db7 100644 --- a/docs/CLI_GUIDE.md +++ b/docs/CLI_GUIDE.md @@ -206,11 +206,14 @@ gosqlx format [file...] [flags] | `--dialect` | string | postgresql | SQL dialect (generic, postgresql, mysql, mariadb, sqlserver, oracle, sqlite, snowflake, bigquery, redshift, clickhouse); unknown names are rejected | | `--indent` | int | 2 | Indentation size in spaces (0-8) | | `--uppercase` | bool | true | Uppercase SQL keywords | -| `--no-uppercase` | bool | false | Write keywords in lower case (cannot be combined with `--uppercase`) | +| `--no-uppercase` | bool | false | Write all keywords, including keyword operators and built-in function keywords, in lower case (cannot be combined with `--uppercase`) | | `--compact` | bool | false | Compact format (minimal whitespace) | | `--check` | bool | false | Check if formatting is needed (CI mode) | | `--max-line` | int | 80 | Maximum line length (0-500) | +Dialect clauses stored verbatim inside `JSON_TABLE` and `XMLTABLE` retain their +source case, including their `COLUMNS` definitions, to preserve names and literals. + #### Examples ```bash diff --git a/pkg/formatter/comment_line_endings_batch_test.go b/pkg/formatter/comment_line_endings_batch_test.go new file mode 100644 index 00000000..e3c4c42a --- /dev/null +++ b/pkg/formatter/comment_line_endings_batch_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 GoSQLX Authors +// SPDX-License-Identifier: Apache-2.0 + +package formatter + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestCommentsCRLF(t *testing.T) { + for _, input := range []string{"-- leading\r\nSELECT 1 -- trailing\r\n", "SELECT /* multi\r\nline */ 1", "SELECT /*+ hint\r\ntext */ 1"} { + out, err := renderJoinDialect(t, input, keywords.DialectPostgreSQL, CommentLayout{Gap: "\n", Terminator: ";"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "\r") { + t.Fatalf("CR retained in output: %q", out) + } + } +} + +func TestCommentsCRLFHoisted(t *testing.T) { + comments := []models.Comment{{Text: "-- leading\r"}, {Text: "/* multi\r\nline */"}} + out, err := JoinWithComments([]string{"SELECT 1"}, nil, comments, CommentLayout{}) + if err != nil { + t.Fatal(err) + } + if out != "-- leading\n/* multi\nline */\nSELECT 1" { + t.Fatalf("hoisted comments = %q", out) + } +} + +func TestCommentsStayOnTheirSideOfGO(t *testing.T) { + for _, input := range []string{ + "SELECT 1\n-- before go\nGO\n-- after go\nSELECT 2", + "SELECT 1\n-- before go\nGO 2 -- on go\n-- after go\nSELECT 2", + } { + for _, gap := range []string{"\n", " "} { + out, err := renderJoinDialect(t, input, keywords.DialectSQLServer, CommentLayout{Gap: gap, Terminator: ";"}) + if err != nil { + t.Fatal(err) + } + rest := out + for _, piece := range []string{"SELECT 1", "-- before go", "GO", "-- after go", "SELECT 2"} { + i := strings.Index(rest, piece) + if i < 0 { + t.Fatalf("missing or misplaced %q in %q", piece, out) + } + rest = rest[i+len(piece):] + } + } + } +} diff --git a/pkg/formatter/comments.go b/pkg/formatter/comments.go index 2a299bad..ff783b50 100644 --- a/pkg/formatter/comments.go +++ b/pkg/formatter/comments.go @@ -124,6 +124,7 @@ func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments [] var tail []string for ci := range comments { c := comments[ci] + c.Text = normalizeCommentText(c.Text) sig := isSignificantComment(c.Text) idx, role := locateComment(items, c) switch role { @@ -233,6 +234,15 @@ func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments [] return sb.String(), nil } +// normalizeCommentText uses LF endings, including the CR a line comment can +// retain from a source CRLF terminator. +func normalizeCommentText(text string) string { + if strings.HasPrefix(text, "--") { + text = strings.TrimSuffix(text, "\r") + } + return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") +} + func hasExecutable(comments []string) bool { for _, c := range comments { if isExecutable(c) { @@ -288,7 +298,7 @@ func joinHoisted(stmts []string, comments []models.Comment, layout CommentLayout if isSignificantComment(c.Text) { return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) } - sb.WriteString(c.Text) + sb.WriteString(normalizeCommentText(c.Text)) sb.WriteString("\n") } sb.WriteString(joinPlain(stmts, layout)) diff --git a/pkg/formatter/expression_scaling_test.go b/pkg/formatter/expression_scaling_test.go new file mode 100644 index 00000000..887bf59f --- /dev/null +++ b/pkg/formatter/expression_scaling_test.go @@ -0,0 +1,160 @@ +// 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 formatter_test + +import ( + "fmt" + "runtime" + "strings" + "testing" + "time" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// Fourfold expression growth must stay below the sixteenfold cost of repeatedly +// rendering or scanning every descendant. Build ASTs outside the measurement so +// parser limits and parsing time do not conceal formatter regressions. +func TestFormatASTExpressionLinearGrowth(t *testing.T) { + for _, shape := range []struct { + name string + small int + build func(int, bool) (*ast.AST, string) + }{ + {"left_deep_binary", 512, scalingBinaryExpression}, + {"nested_parentheses_case_function", 256, scalingNestedExpression}, + {"long_marker_identifier", 4096, scalingMarkerIdentifier}, + {"many_marker_literals", 256, scalingMarkerLiterals}, + } { + t.Run(shape.name, func(t *testing.T) { + for _, mode := range []struct { + name string + lower bool + }{ + {"upper", false}, + {"lower", true}, + } { + t.Run(mode.name, func(t *testing.T) { + opts := ast.CompactStyle() + if mode.lower { + opts.KeywordCase = ast.KeywordLower + } + small, smallWant := shape.build(shape.small, mode.lower) + large, largeWant := shape.build(4*shape.small, mode.lower) + for _, tc := range []struct { + tree *ast.AST + want string + }{{small, smallWant}, {large, largeWant}} { + if got := formatter.FormatAST(tc.tree, opts); got != tc.want { + t.Fatalf("formatted expression differs: got length %d, want %d", len(got), len(tc.want)) + } + } + smallTime := expressionFormatDuration(small, opts) + largeTime := expressionFormatDuration(large, opts) + t.Logf("n=%d: %v; n=%d: %v; ratio %.2f", shape.small, smallTime, 4*shape.small, largeTime, float64(largeTime)/float64(smallTime)) + if largeTime > 10*smallTime { + t.Fatalf("fourfold expression growth took %v -> %v, exceeding 10x", smallTime, largeTime) + } + }) + } + }) + } +} + +func scalingBinaryExpression(n int, lower bool) (*ast.AST, string) { + var expr ast.Expression = &ast.Identifier{Name: "a0"} + var want strings.Builder + if lower { + want.WriteString("select ") + } else { + want.WriteString("SELECT ") + } + want.WriteString("a0") + for i := 1; i < n; i++ { + name := fmt.Sprintf("a%d", i) + expr = &ast.BinaryExpression{Left: expr, Operator: "+", Right: &ast.Identifier{Name: name}} + want.WriteString(" + ") + want.WriteString(name) + } + return &ast.AST{Statements: []ast.Statement{&ast.SelectStatement{Columns: []ast.Expression{expr}}}}, want.String() +} + +func scalingNestedExpression(n int, lower bool) (*ast.AST, string) { + var expr ast.Expression = &ast.Identifier{Name: "MixedName"} + for i := 0; i < n; i++ { + expr = &ast.ParenthesizedExpression{Expr: &ast.CaseExpression{ + WhenClauses: []ast.WhenClause{{Condition: &ast.Identifier{Name: "Flag"}, Result: &ast.FunctionCall{Name: "ABS", Arguments: []ast.Expression{expr}}}}, + ElseClause: &ast.Identifier{Name: "Fallback"}, + }} + } + prefix, suffix, selectSQL := "(CASE WHEN Flag THEN ABS(", ") ELSE Fallback END)", "SELECT " + if lower { + prefix, suffix, selectSQL = "(case when Flag then abs(", ") else Fallback end)", "select " + } + want := selectSQL + strings.Repeat(prefix, n) + "MixedName" + strings.Repeat(suffix, n) + return &ast.AST{Statements: []ast.Statement{&ast.SelectStatement{Columns: []ast.Expression{expr}}}}, want +} + +func scalingMarkerIdentifier(n int, lower bool) (*ast.AST, string) { + name := "gosqlx_case_slot_" + strings.Repeat("_", n) + prefix := "SELECT " + if lower { + prefix = "select " + } + return &ast.AST{Statements: []ast.Statement{&ast.SelectStatement{Columns: []ast.Expression{&ast.Identifier{Name: name}}}}}, prefix + name +} + +func scalingMarkerLiterals(n int, lower bool) (*ast.AST, string) { + args := make([]ast.Expression, n) + var want strings.Builder + if lower { + want.WriteString("select coalesce(") + } else { + want.WriteString("SELECT COALESCE(") + } + for i := range args { + value := fmt.Sprintf("gosqlx_case_slot_%d_0_", i) + args[i] = &ast.LiteralValue{Type: "string", Value: value} + if i > 0 { + want.WriteString(", ") + } + want.WriteString("'") + want.WriteString(value) + want.WriteString("'") + } + want.WriteString(")") + expr := &ast.FunctionCall{Name: "COALESCE", Arguments: args} + return &ast.AST{Statements: []ast.Statement{&ast.SelectStatement{Columns: []ast.Expression{expr}}}}, want.String() +} + +// Use the minimum of repeated equal-size batches to limit scheduler and GC +// noise. The assertion compares input sizes, never an absolute time budget. +func expressionFormatDuration(tree *ast.AST, opts ast.FormatOptions) time.Duration { + var best time.Duration + for sample := 0; sample < 3; sample++ { + runtime.GC() + start := time.Now() + for batch := 0; batch < 2; batch++ { + out := formatter.FormatAST(tree, opts) + runtime.KeepAlive(out) + } + elapsed := time.Since(start) + if best == 0 || elapsed < best { + best = elapsed + } + } + return best +} diff --git a/pkg/formatter/keyword_case_complete_test.go b/pkg/formatter/keyword_case_complete_test.go new file mode 100644 index 00000000..016e6b82 --- /dev/null +++ b/pkg/formatter/keyword_case_complete_test.go @@ -0,0 +1,167 @@ +// 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 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 TestKeywordLowerIncludesExpressions(t *testing.T) { + cases := []string{ + "SELECT a FROM t WHERE b IS NULL AND c IN (1) ORDER BY a DESC", + "SELECT DISTINCT CASE WHEN a BETWEEN 1 AND 2 THEN CAST(b AS INTEGER) ELSE NULL END AS result FROM t WHERE NOT EXISTS (SELECT 1 FROM u WHERE u.a LIKE 'A%')", + "SELECT COUNT(DISTINCT a), SUM(b) OVER (PARTITION BY c ORDER BY a DESC NULLS FIRST ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t", + "SELECT a FROM t LEFT OUTER JOIN u ON t.a = u.a WHERE a NOT IN (1) UNION ALL SELECT a FROM v ORDER BY a DESC", + "SELECT a FROM t WINDOW win AS (PARTITION BY b ORDER BY a DESC NULLS FIRST)", + "SELECT CAST(CASE WHEN a IS NOT NULL THEN a ELSE 0 END AS DECIMAL(10, 2)) FROM t WHERE a NOT BETWEEN 1 AND 2", + "SELECT ARRAY[CASE WHEN a IS NULL THEN NULL ELSE a END], TRUE AND NOT FALSE, INTERVAL '1' DAY FROM t", + "SELECT (CASE WHEN a IS NULL THEN b ELSE c END).field FROM t", + } + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + for _, input := range cases { + t.Run(input, func(t *testing.T) { + got := formatWithDialect(t, input, keywords.DialectPostgreSQL, opts) + // All identifiers and literal text in these cases are already lower case, + // except the intentionally preserved string literal. + want := strings.ReplaceAll(strings.ToLower(got), "'a%'", "'A%'") + if got != want { + t.Fatalf("keywords remain upper case: %s", got) + } + if again := formatWithDialect(t, got, keywords.DialectPostgreSQL, opts); again != got { + t.Fatalf("not a fixpoint: %q -> %q", got, again) + } + }) + } +} + +func TestKeywordLowerPreservesIdentifiers(t *testing.T) { + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + input := `SELECT MixedCase, "QuotedName", t.VALUE AS Result, UserFunction(MixedCase) FROM TableName t WHERE MixedCase IS NULL ORDER BY MixedCase DESC` + got := formatWithDialect(t, input, keywords.DialectPostgreSQL, opts) + for _, name := range []string{"MixedCase", `"QuotedName"`, "t.VALUE", "Result", "UserFunction", "TableName"} { + if !strings.Contains(got, name) { + t.Errorf("identifier %q changed: %s", name, got) + } + } +} + +func TestKeywordLowerPreservesExpressionSyntaxAndValues(t *testing.T) { + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + cases := []struct{ input, want string }{ + {"SELECT - -1, ~ -1, -(-a) FROM t", "select - -1, ~ -1, -(-a) from t"}, + {"SELECT CAST(a AS MyDomain), CAST(a AS MySchema.NUMERIC), CAST(a AS INTEGER) FROM t", "select cast(a as MyDomain), cast(a as MySchema.NUMERIC), cast(a as integer) from t"}, + {"SELECT EXTRACT(YEAR FROM CreatedAt), SUBSTRING(Name FROM 1 FOR 3) FROM MyTable", "select extract(year from CreatedAt), substring(Name from 1 for 3) from MyTable"}, + {"SELECT a FROM t WHERE a LIKE 'X' ESCAPE EscapeValue", "select a from t where a like 'X' escape EscapeValue"}, + {"SELECT a FROM t WHERE c = SOME (SELECT z FROM w)", "select a from t where c = some (select z from w)"}, + {"SELECT gosqlx_case_slot_0_ FROM t ORDER BY gosqlx_case_slot_0_ DESC", "select gosqlx_case_slot_0_ from t order by gosqlx_case_slot_0_ desc"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.input, keywords.DialectPostgreSQL) + if err != nil { + t.Fatal(err) + } + before := tree.SQL() + if got := formatter.FormatAST(tree, opts); got != tc.want { + t.Fatalf("got %q; want %q", got, tc.want) + } + if after := tree.SQL(); after != before { + t.Fatalf("formatting mutated AST: %q -> %q", before, after) + } + if again := formatWithDialect(t, tc.want, keywords.DialectPostgreSQL, opts); again != tc.want { + t.Fatalf("not a fixpoint: %q", again) + } + }) + } +} + +func TestKeywordLowerPreservesPostfixGrouping(t *testing.T) { + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + operand := &ast.BinaryExpression{Left: &ast.Identifier{Name: "a"}, Operator: "+", Right: &ast.Identifier{Name: "b"}} + cases := []struct { + expr ast.Expression + want string + }{ + {&ast.AtTimeZoneExpression{Expr: operand, Zone: &ast.LiteralValue{Value: "UTC", Type: "STRING"}}, "(a + b) at time zone 'UTC'"}, + {&ast.CollateExpression{Expr: operand, Collation: "C", Quoted: true}, `(a + b) collate "C"`}, + {&ast.VariantPath{Root: operand, Segments: []ast.VariantPathSegment{{Name: "Key"}}}, `(a + b):"Key"`}, + {&ast.BinaryExpression{Left: &ast.LiteralValue{Type: "STRING", Value: "operator(custom.=)"}, Right: &ast.Identifier{Name: "a"}, CustomOp: &ast.CustomBinaryOperator{Parts: []string{"Custom", "="}}}, "'operator(custom.=)' operator(Custom.=) a"}, + } + for _, tc := range cases { + before := tc.expr.TokenLiteral() + if got := formatter.FormatExpression(tc.expr, opts); got != tc.want { + t.Errorf("got %q; want %q", got, tc.want) + } + if tc.expr.TokenLiteral() != before { + t.Fatal("formatting mutated expression") + } + } +} + +func TestKeywordLowerNestedDialectExpressions(t *testing.T) { + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + cases := []struct { + input string + dialect keywords.SQLDialect + want string + }{ + {"SELECT {'key': CASE WHEN a IS NULL THEN 0 ELSE 1 END} FROM t", keywords.DialectSnowflake, "select {'key': case when a is null then 0 else 1 end} from t"}, + {"SELECT JSON_OBJECT(KEY 'Key' VALUE CASE WHEN a IS NULL THEN 0 ELSE 1 END) FROM t", keywords.DialectOracle, "select JSON_OBJECT(key 'Key' value case when a is null then 0 else 1 end) from t"}, + {"SELECT * EXCLUDE (KeepCase) REPLACE (CASE WHEN a IS NULL THEN 0 ELSE 1 END AS OtherCase) FROM t", keywords.DialectSnowflake, "select * exclude (KeepCase) replace (case when a is null then 0 else 1 end as OtherCase) from t"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := formatWithDialect(t, tc.input, tc.dialect, opts) + if got != tc.want { + t.Fatalf("got %q; want %q", got, tc.want) + } + if again := formatWithDialect(t, got, tc.dialect, opts); again != got { + t.Fatalf("not a fixpoint: %q", again) + } + }) + } +} + +func TestStraightJoinCommaSpacing(t *testing.T) { + input := "SELECT * FROM t1 STRAIGHT_JOIN t2 ON t1.a = t2.a, t3" + opts := ast.CompactStyle() + got := formatWithDialect(t, input, keywords.DialectMySQL, opts) + if got != input { + t.Fatalf("FormatAST = %q; want %q", got, input) + } + tree, err := gosqlx.ParseWithDialect(input, keywords.DialectMySQL) + if err != nil { + t.Fatal(err) + } + if got := tree.SQL(); got != input { + t.Fatalf("SQL = %q; want %q", got, input) + } + lower := opts + lower.KeywordCase = ast.KeywordLower + if got := formatter.FormatAST(tree, lower); got != strings.ToLower(input) { + t.Fatalf("lower = %q", got) + } +} diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 95b3a4fb..366c0a86 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -27,9 +27,10 @@ import ( // nodeFormatter holds state during a single rendering pass. type nodeFormatter struct { - opts ast.FormatOptions - sb *strings.Builder - depth int + opts ast.FormatOptions + sb *strings.Builder + depth int + expressionCase *expressionCase } func newNodeFormatter(opts ast.FormatOptions) *nodeFormatter { @@ -210,6 +211,12 @@ func FormatExpression(e ast.Expression, opts ast.FormatOptions) string { if e == nil { return "" } + if opts.KeywordCase == ast.KeywordLower { + f := newNodeFormatter(opts) + c := newExpressionCase(f, exprSQL(e)) + f.expressionCase = c + return c.restore(c.formatWith(f, e)) + } switch v := e.(type) { case *ast.CaseExpression: return renderCase(v, opts) @@ -222,6 +229,9 @@ func FormatExpression(e ast.Expression, opts ast.FormatOptions) string { case *ast.SubqueryExpression: return renderSubquery(v, opts) default: + if opts.KeywordCase == ast.KeywordLower { + return newNodeFormatter(opts).exprSQL(e) + } return exprSQL(e) } } @@ -255,7 +265,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { if len(s.DistinctOnColumns) > 0 { sb.WriteString(f.kw("DISTINCT ON")) sb.WriteString(" (") - sb.WriteString(exprListSQL(s.DistinctOnColumns)) + sb.WriteString(f.exprListSQL(s.DistinctOnColumns)) sb.WriteString(") ") } else if s.Distinct { sb.WriteString(f.kw("DISTINCT")) @@ -265,7 +275,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { // MySQL select modifiers and TOP, between SELECT [DISTINCT] and columns. writeSelectPrologue(sb, s, f) - sb.WriteString(exprListSQL(s.Columns)) + sb.WriteString(f.exprListSQL(s.Columns)) if into := selectExtras(s).Into; into != nil && !into.Trailing { sb.WriteString(" ") @@ -285,7 +295,9 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { for _, j := range s.Joins { j := j - sb.WriteString(f.clauseSep()) + if !j.Comma { + sb.WriteString(f.clauseSep()) + } sb.WriteString(joinSQL(&j, f)) } @@ -334,7 +346,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(" ") wins := make([]string, len(s.Windows)) for i := range s.Windows { - wins[i] = ast.QuoteNameWith(s.Windows[i].Name, f.nq(s.Windows[i].NameQuote)) + " AS (" + windowSpecSQL(&s.Windows[i]) + ")" + wins[i] = ast.QuoteNameWith(s.Windows[i].Name, f.nq(s.Windows[i].NameQuote)) + " " + f.kw("AS") + " (" + f.windowSpecSQL(&s.Windows[i]) + ")" } sb.WriteString(strings.Join(wins, ", ")) } @@ -347,7 +359,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(f.kw("ORDER BY")) } sb.WriteString(" ") - sb.WriteString(orderBySQL(s.OrderBy)) + sb.WriteString(f.orderBySQL(s.OrderBy)) } if interp := selectExtras(s).Interpolate; interp != nil { sb.WriteString(interpolateSQL(interp, f)) @@ -366,7 +378,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { } if s.For != nil { - sb.WriteString(forSQL(s.For)) + sb.WriteString(forSQL(s.For, f)) } // FOR JSON/XML, SETTINGS, OPTION (...), FORMAT @@ -429,7 +441,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sets := make([]string, len(i.Columns)) for k, col := range i.Columns { - sets[k] = ast.ColumnRefSQL(col) + " = " + exprSQL(i.Values[0][k]) + sets[k] = ast.ColumnRefSQL(col) + " = " + f.exprSQL(i.Values[0][k]) } sb.WriteString(strings.Join(sets, ", ")) } else if i.Query != nil { @@ -443,7 +455,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { for idx, row := range i.Values { vals := make([]string, len(row)) for j, v := range row { - vals[j] = exprSQL(v) + vals[j] = f.exprSQL(v) } rows[idx] = "(" + strings.Join(vals, ", ") + ")" } @@ -455,7 +467,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { writeInsertAfterSource(sb, f, i) if i.OnConflict != nil { - sb.WriteString(onConflictSQL(i.OnConflict)) + sb.WriteString(onConflictSQL(i.OnConflict, f)) } if i.OnDuplicateKey != nil && len(i.OnDuplicateKey.Updates) > 0 { @@ -464,7 +476,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(" ") upds := make([]string, len(i.OnDuplicateKey.Updates)) for k, u := range i.OnDuplicateKey.Updates { - upds[k] = ast.ColumnRefSQL(u.Column) + " = " + exprSQL(u.Value) + upds[k] = ast.ColumnRefSQL(u.Column) + " = " + f.exprSQL(u.Value) } sb.WriteString(strings.Join(upds, ", ")) } @@ -505,7 +517,7 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string { sb.WriteString(" ") upds := make([]string, len(u.Assignments)) for i, upd := range u.Assignments { - upds[i] = ast.ColumnRefSQL(upd.Column) + " = " + exprSQL(upd.Value) + upds[i] = ast.ColumnRefSQL(upd.Column) + " = " + f.exprSQL(upd.Value) } sb.WriteString(strings.Join(upds, ", ")) writeDMLOutput(sb, f, x.Output, x.OutputInto) @@ -564,7 +576,7 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sb.WriteString(f.kw("BEFORE SYSTEM_TIME")) sb.WriteString(" ") - sb.WriteString(exprSQL(d.BeforeSystemTime)) + sb.WriteString(f.exprSQL(d.BeforeSystemTime)) } writeDMLOutput(sb, f, x.Output, x.OutputInto) @@ -692,7 +704,7 @@ func renderSetOperationBody(s *ast.SetOperation, opts ast.FormatOptions) string sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("ORDER BY")) sb.WriteString(" ") - sb.WriteString(orderBySQL(view.OrderBy)) + sb.WriteString(f.orderBySQL(view.OrderBy)) } writeLimitOffsetClauses(sb, view, f) if view.Fetch != nil { @@ -821,7 +833,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("ON")) sb.WriteString(" ") - sb.WriteString(exprSQL(m.OnCondition)) + sb.WriteString(f.exprSQL(m.OnCondition)) for _, when := range m.WhenClauses { sb.WriteString(f.clauseSep()) @@ -844,7 +856,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sb.WriteString(f.kw("AND")) sb.WriteString(" ") - sb.WriteString(exprSQL(when.Condition)) + sb.WriteString(f.exprSQL(when.Condition)) } sb.WriteString(" ") sb.WriteString(f.kw("THEN")) @@ -859,7 +871,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sets := make([]string, len(when.Action.SetClauses)) for i, sc := range when.Action.SetClauses { - sets[i] = ast.QuoteQualifiedNameWith(sc.Column, f.nq(sc.ColumnQuote)) + " = " + exprSQL(sc.Value) + sets[i] = ast.QuoteQualifiedNameWith(sc.Column, f.nq(sc.ColumnQuote)) + " = " + f.exprSQL(sc.Value) } sb.WriteString(strings.Join(sets, ", ")) case "DELETE": @@ -881,7 +893,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(" (") vals := make([]string, len(when.Action.Values)) for i, v := range when.Action.Values { - vals[i] = exprSQL(v) + vals[i] = f.exprSQL(v) } sb.WriteString(strings.Join(vals, ", ")) sb.WriteString(")") @@ -904,7 +916,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { // ─── expression renderers ───────────────────────────────────────────────────── -func renderCase(c *ast.CaseExpression, opts ast.FormatOptions) string { +func renderCase(c *ast.CaseExpression, opts ast.FormatOptions, context ...*expressionCase) string { if c == nil { return "" } @@ -914,23 +926,23 @@ func renderCase(c *ast.CaseExpression, opts ast.FormatOptions) string { sb.WriteString(f.kw("CASE")) if c.Value != nil { sb.WriteString(" ") - sb.WriteString(FormatExpression(c.Value, opts)) + sb.WriteString(formatExpressionChild(c.Value, opts, context...)) } for _, when := range c.WhenClauses { sb.WriteString(" ") sb.WriteString(f.kw("WHEN")) sb.WriteString(" ") - sb.WriteString(FormatExpression(when.Condition, opts)) + sb.WriteString(formatExpressionChild(when.Condition, opts, context...)) sb.WriteString(" ") sb.WriteString(f.kw("THEN")) sb.WriteString(" ") - sb.WriteString(FormatExpression(when.Result, opts)) + sb.WriteString(formatExpressionChild(when.Result, opts, context...)) } if c.ElseClause != nil { sb.WriteString(" ") sb.WriteString(f.kw("ELSE")) sb.WriteString(" ") - sb.WriteString(FormatExpression(c.ElseClause, opts)) + sb.WriteString(formatExpressionChild(c.ElseClause, opts, context...)) } sb.WriteString(" ") sb.WriteString(f.kw("END")) @@ -938,14 +950,14 @@ func renderCase(c *ast.CaseExpression, opts ast.FormatOptions) string { return f.result() } -func renderBetween(b *ast.BetweenExpression, opts ast.FormatOptions) string { +func renderBetween(b *ast.BetweenExpression, opts ast.FormatOptions, context ...*expressionCase) string { if b == nil { return "" } f := newNodeFormatter(opts) sb := f.sb - sb.WriteString(FormatExpression(b.Expr, opts)) + sb.WriteString(formatExpressionChild(b.Expr, opts, context...)) sb.WriteString(" ") if b.Not { sb.WriteString(f.kw("NOT")) @@ -953,23 +965,23 @@ func renderBetween(b *ast.BetweenExpression, opts ast.FormatOptions) string { } sb.WriteString(f.kw("BETWEEN")) sb.WriteString(" ") - sb.WriteString(FormatExpression(b.Lower, opts)) + sb.WriteString(formatExpressionChild(b.Lower, opts, context...)) sb.WriteString(" ") sb.WriteString(f.kw("AND")) sb.WriteString(" ") - sb.WriteString(FormatExpression(b.Upper, opts)) + sb.WriteString(formatExpressionChild(b.Upper, opts, context...)) return f.result() } -func renderIn(i *ast.InExpression, opts ast.FormatOptions) string { +func renderIn(i *ast.InExpression, opts ast.FormatOptions, context ...*expressionCase) string { if i == nil { return "" } f := newNodeFormatter(opts) sb := f.sb - sb.WriteString(FormatExpression(i.Expr, opts)) + sb.WriteString(formatExpressionChild(i.Expr, opts, context...)) sb.WriteString(" ") if i.Global { sb.WriteString(f.kw("GLOBAL")) @@ -983,7 +995,16 @@ func renderIn(i *ast.InExpression, opts ast.FormatOptions) string { if i.Bare && i.Subquery == nil { // SQLite "a IN tbl": no parentheses around the table reference. sb.WriteString(" ") - sb.WriteString(exprListSQL(i.List)) + if len(context) == 0 { + sb.WriteString(f.exprListSQL(i.List)) + } else { + for idx, e := range i.List { + if idx > 0 { + sb.WriteString(", ") + } + sb.WriteString(formatExpressionChild(e, opts, context...)) + } + } return f.result() } sb.WriteString(" (") @@ -992,7 +1013,7 @@ func renderIn(i *ast.InExpression, opts ast.FormatOptions) string { } else { parts := make([]string, len(i.List)) for idx, e := range i.List { - parts[idx] = FormatExpression(e, opts) + parts[idx] = formatExpressionChild(e, opts, context...) } sb.WriteString(strings.Join(parts, ", ")) } @@ -1020,6 +1041,9 @@ func renderSubquery(s *ast.SubqueryExpression, opts ast.FormatOptions) string { if s == nil { return "" } + if opts.KeywordCase == ast.KeywordLower && s.Bare { + return FormatStatement(s.Subquery, nestedOpts(opts)) + } f := newNodeFormatter(opts) sb := f.sb @@ -1139,7 +1163,9 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString(tableRefSQL(&g.Tables[i], f)) } for i := range g.Joins { - sb.WriteString(" ") + if !g.Joins[i].Comma { + sb.WriteString(" ") + } sb.WriteString(joinSQL(&g.Joins[i], f)) } sb.WriteString(")") @@ -1151,17 +1177,17 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { } if t.Subquery != nil { sb.WriteString("(") - sb.WriteString(stmtSQL(t.Subquery)) + sb.WriteString(f.tableSubquerySQL(t.Subquery)) sb.WriteString(")") } else if t.TableFunc != nil { - sb.WriteString(t.TableFunc.SQL()) + sb.WriteString(f.exprSQL(t.TableFunc)) if t.WithOrdinality { sb.WriteString(" ") sb.WriteString(f.kw("WITH ORDINALITY")) } } else if t.SetSubquery != nil { sb.WriteString("(") - sb.WriteString(stmtSQL(t.SetSubquery)) + sb.WriteString(f.tableSubquerySQL(t.SetSubquery)) sb.WriteString(")") } else if t.DialectModifiers().Values != nil { sb.WriteString("(") @@ -1258,7 +1284,7 @@ func joinSQL(j *ast.JoinClause, f *nodeFormatter) string { sb.WriteString(" ") sb.WriteString(f.kw("MATCH_CONDITION")) sb.WriteString(" (") - sb.WriteString(exprSQL(j.MatchCondition)) + sb.WriteString(f.exprSQL(j.MatchCondition)) sb.WriteString(")") } if len(j.Using) > 0 { @@ -1269,14 +1295,14 @@ func joinSQL(j *ast.JoinClause, f *nodeFormatter) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(exprSQL(u)) + sb.WriteString(f.exprSQL(u)) } sb.WriteString(")") } else if j.Condition != nil { sb.WriteString(" ") sb.WriteString(f.kw("ON")) sb.WriteString(" ") - sb.WriteString(exprSQL(j.Condition)) + sb.WriteString(f.exprSQL(j.Condition)) } return sb.String() } @@ -1357,67 +1383,67 @@ func fetchSQL(fc *ast.FetchClause, f *nodeFormatter) string { } // forSQL renders a FOR UPDATE/SHARE clause. -func forSQL(f *ast.ForClause) string { +func forSQL(lock *ast.ForClause, f *nodeFormatter) string { var sb strings.Builder - if f.LockInShareMode { - sb.WriteString(" LOCK IN SHARE MODE") - if f.NoWait { - sb.WriteString(" NOWAIT") + if lock.LockInShareMode { + sb.WriteString(f.kw(" LOCK IN SHARE MODE")) + if lock.NoWait { + sb.WriteString(f.kw(" NOWAIT")) } - if f.Wait != nil { - sb.WriteString(" WAIT ") - sb.WriteString(exprSQL(f.Wait)) + if lock.Wait != nil { + sb.WriteString(f.kw(" WAIT ")) + sb.WriteString(f.exprSQL(lock.Wait)) } return sb.String() } - sb.WriteString(" FOR ") - sb.WriteString(ast.LockTypeText(f.LockType)) - if len(f.Tables) > 0 { - sb.WriteString(" OF ") - sb.WriteString(quoteQualifiedNames(f.Tables)) + sb.WriteString(f.kw(" FOR ")) + sb.WriteString(f.kw(ast.LockTypeText(lock.LockType))) + if len(lock.Tables) > 0 { + sb.WriteString(f.kw(" OF ")) + sb.WriteString(quoteQualifiedNames(lock.Tables)) } - if f.NoWait { - sb.WriteString(" NOWAIT") + if lock.NoWait { + sb.WriteString(f.kw(" NOWAIT")) } - if f.SkipLocked { - sb.WriteString(" SKIP LOCKED") + if lock.SkipLocked { + sb.WriteString(f.kw(" SKIP LOCKED")) } - if f.Wait != nil { - sb.WriteString(" WAIT ") - sb.WriteString(exprSQL(f.Wait)) + if lock.Wait != nil { + sb.WriteString(f.kw(" WAIT ")) + sb.WriteString(f.exprSQL(lock.Wait)) } return sb.String() } // onConflictSQL renders an ON CONFLICT clause. -func onConflictSQL(oc *ast.OnConflict) string { +func onConflictSQL(oc *ast.OnConflict, f *nodeFormatter) string { var sb strings.Builder - sb.WriteString(" ON CONFLICT") + sb.WriteString(f.kw(" ON CONFLICT")) if len(oc.Target) > 0 { sb.WriteString(" (") sb.WriteString(ast.ColumnRefListSQL(oc.Target)) sb.WriteString(")") } if oc.TargetWhere != nil { - sb.WriteString(" WHERE ") - sb.WriteString(exprSQL(oc.TargetWhere)) + sb.WriteString(f.kw(" WHERE ")) + sb.WriteString(f.exprSQL(oc.TargetWhere)) } if oc.Constraint != "" { - sb.WriteString(" ON CONSTRAINT ") + sb.WriteString(f.kw(" ON CONSTRAINT ")) sb.WriteString(ast.QuoteColumnName(oc.Constraint)) } if oc.Action.DoNothing { - sb.WriteString(" DO NOTHING") + sb.WriteString(f.kw(" DO NOTHING")) } else if len(oc.Action.DoUpdate) > 0 { - sb.WriteString(" DO UPDATE SET ") + sb.WriteString(f.kw(" DO UPDATE SET ")) upds := make([]string, len(oc.Action.DoUpdate)) for i, u := range oc.Action.DoUpdate { - upds[i] = ast.ColumnRefSQL(u.Column) + " = " + exprSQL(u.Value) + upds[i] = ast.ColumnRefSQL(u.Column) + " = " + f.exprSQL(u.Value) } sb.WriteString(strings.Join(upds, ", ")) if oc.Action.Where != nil { - sb.WriteString(" WHERE ") - sb.WriteString(exprSQL(oc.Action.Where)) + sb.WriteString(f.kw(" WHERE ")) + sb.WriteString(f.exprSQL(oc.Action.Where)) } } return sb.String() diff --git a/pkg/formatter/render_case_helpers.go b/pkg/formatter/render_case_helpers.go new file mode 100644 index 00000000..97e247a9 --- /dev/null +++ b/pkg/formatter/render_case_helpers.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 formatter + +import ( + "strconv" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// expressionCase preserves SQL()'s operator spelling and parentheses while +// casing syntax separately from operands. Slots contain names or expression +// fragments referencing earlier slots. Only the outermost call expands them. +type expressionCase struct { + f *nodeFormatter + prefix string + slots []string +} + +func newExpressionCase(f *nodeFormatter, source string) *expressionCase { + const base = "gosqlx_case_slot_" + // Inspect the source once per root, including quoted names and literals. + // A numeric suffix keeps the marker short even for hostile identifiers + // containing very long runs of underscores or existing slot-like names. + used := make(map[int]bool) + for { + i := strings.Index(source, base) + if i < 0 { + break + } + source = source[i+len(base):] + end := 0 + for end < len(source) && source[end] >= '0' && source[end] <= '9' { + end++ + } + if end < len(source) && source[end] == '_' { + if id, err := strconv.Atoi(source[:end]); err == nil { + used[id] = true + } + } + source = source[end:] + } + id := 0 + for used[id] { + id++ + } + return &expressionCase{f: f, prefix: base + strconv.Itoa(id) + "_"} +} + +func (c *expressionCase) slot(sql string) string { + name := c.prefix + strconv.Itoa(len(c.slots)) + "_" + c.slots = append(c.slots, sql) + return name +} + +func (c *expressionCase) expr(e ast.Expression) ast.Expression { + if e == nil { + return nil + } + opts := c.f.opts + opts.NewlinePerClause = false + f := *c.f + f.opts = opts + sql := c.formatWith(&f, e) + prefix := "" + // SQL() separates adjacent symbolic operators using the operand's first + // byte. Keep that byte visible while hiding the remainder from casing. + if len(sql) > 0 && strings.ContainsRune("+-*/<>=~!@#%^&|?`", rune(sql[0])) { + prefix, sql = sql[:1], sql[1:] + } + return &ast.LiteralValue{Type: "placeholder", Value: prefix + c.slot(sql)} +} + +func (c *expressionCase) exprs(exprs []ast.Expression) []ast.Expression { + out := make([]ast.Expression, len(exprs)) + for i, e := range exprs { + out[i] = c.expr(e) + } + return out +} + +func (c *expressionCase) orders(orders []ast.OrderByExpression) []ast.OrderByExpression { + out := append([]ast.OrderByExpression(nil), orders...) + for i := range out { + out[i].Expression = c.expr(out[i].Expression) + if fill := out[i].WithFill; fill != nil { + copy := *fill + copy.From = c.expr(fill.From) + copy.To = c.expr(fill.To) + copy.Step = c.expr(fill.Step) + out[i].WithFill = © + } + } + return out +} + +func (c *expressionCase) restore(sql string) string { + var out strings.Builder + var pending []string + for { + i := strings.Index(sql, c.prefix) + if i < 0 { + out.WriteString(sql) + if len(pending) == 0 { + return out.String() + } + sql = pending[len(pending)-1] + pending = pending[:len(pending)-1] + continue + } + out.WriteString(sql[:i]) + tail := sql[i+len(c.prefix):] + end := 0 + for end < len(tail) && tail[end] >= '0' && tail[end] <= '9' { + end++ + } + id, err := strconv.Atoi(tail[:end]) + if err != nil || end == len(tail) || tail[end] != '_' || id >= len(c.slots) { + out.WriteString(c.prefix) + sql = tail + continue + } + pending = append(pending, tail[end+1:]) + sql = c.slots[id] + } +} + +func (c *expressionCase) result(e ast.Expression) string { + return lowerSyntax(exprSQL(e)) +} + +func (c *expressionCase) formatWith(f *nodeFormatter, e ast.Expression) string { + switch v := e.(type) { + case *ast.CaseExpression: + return renderCase(v, f.opts, c) + case *ast.BetweenExpression: + return renderBetween(v, f.opts, c) + case *ast.InExpression: + return renderIn(v, f.opts, c) + case *ast.ExistsExpression: + return renderExists(v, f.opts) + case *ast.SubqueryExpression: + return renderSubquery(v, f.opts) + default: + return f.exprSQL(e) + } +} + +func formatExpressionChild(e ast.Expression, opts ast.FormatOptions, context ...*expressionCase) string { + if len(context) == 0 { + return FormatExpression(e, opts) + } + f := *context[0].f + f.opts = opts + return context[0].slot(context[0].formatWith(&f, e)) +} + +// lowerSyntax operates on AST-rendered syntax whose operands and names have +// been replaced by slots. Delimited names and string literals stay intact. +func lowerSyntax(sql string) string { + var out strings.Builder + for i := 0; i < len(sql); { + ch := sql[i] + if ch == '\'' || ch == '"' || ch == '`' || ch == '[' { + end := ch + if ch == '[' { + end = ']' + } + start := i + i++ + for i < len(sql) { + if sql[i] == end { + i++ + if i < len(sql) && sql[i] == end { + i++ + continue + } + break + } + if sql[i] == '\\' && ch == '\'' && i+1 < len(sql) { + i += 2 + } else { + i++ + } + } + out.WriteString(sql[start:i]) + continue + } + if ch >= 'A' && ch <= 'Z' { + ch += 'a' - 'A' + } + out.WriteByte(ch) + i++ + } + return out.String() +} + +var expressionKeywords = keywords.New(keywords.DialectGeneric, true) + +func (f *nodeFormatter) exprListSQL(exprs []ast.Expression) string { + if f.opts.KeywordCase != ast.KeywordLower { + return exprListSQL(exprs) + } + parts := make([]string, len(exprs)) + opts := f.opts + opts.NewlinePerClause = false + for i, e := range exprs { + if f.expressionCase != nil { + parts[i] = formatExpressionChild(e, opts, f.expressionCase) + } else { + parts[i] = FormatExpression(e, opts) + } + } + return strings.Join(parts, ", ") +} + +func (f *nodeFormatter) orderBySQL(orders []ast.OrderByExpression) string { + if f.opts.KeywordCase != ast.KeywordLower { + return orderBySQL(orders) + } + if f.expressionCase == nil { + scoped := *f + c := newExpressionCase(&scoped, orderBySQL(orders)) + scoped.expressionCase = c + return c.restore(scoped.orderBySQL(orders)) + } + return lowerSyntax(orderBySQL(f.expressionCase.orders(orders))) +} + +func (f *nodeFormatter) windowSpecSQL(w *ast.WindowSpec) string { + if f.opts.KeywordCase != ast.KeywordLower { + return windowSpecSQL(w) + } + if f.expressionCase == nil { + scoped := *f + c := newExpressionCase(&scoped, windowSpecSQL(w)) + scoped.expressionCase = c + return c.restore(scoped.windowSpecSQL(w)) + } + var parts []string + if w.BaseWindow != "" { + parts = append(parts, ast.QuoteNameWith(w.BaseWindow, w.BaseWindowQuote)) + } + if len(w.PartitionBy) > 0 { + parts = append(parts, "partition by "+f.exprListSQL(w.PartitionBy)) + } + if len(w.OrderBy) > 0 { + parts = append(parts, "order by "+f.orderBySQL(w.OrderBy)) + } + if w.FrameClause != nil { + c := f.expressionCase + copy := *w.FrameClause + copy.Start.Value = c.expr(copy.Start.Value) + if copy.End != nil { + end := *copy.End + end.Value = c.expr(end.Value) + copy.End = &end + } + parts = append(parts, lowerSyntax(windowFrameSQL(©))) + } + return strings.Join(parts, " ") +} + +func (f *nodeFormatter) tableSubquerySQL(s ast.Statement) string { + if f.opts.KeywordCase != ast.KeywordLower { + return stmtSQL(s) + } + opts := nestedOpts(f.opts) + opts.NewlinePerClause = false + return FormatStatement(s, opts) +} + +func lowerDataType(typ string) string { + // A qualified type names a user-defined binding even when its last + // component happens to have the spelling of a built-in type. + name := typ + if i := strings.IndexAny(name, "([< "); i >= 0 { + name = name[:i] + } + if strings.Contains(name, ".") { + return typ + } + var out strings.Builder + for i := 0; i < len(typ); { + start := i + for i < len(typ) && (typ[i] >= 'a' && typ[i] <= 'z' || typ[i] >= 'A' && typ[i] <= 'Z' || typ[i] == '_' || typ[i] >= '0' && typ[i] <= '9') { + i++ + } + if i > start { + word := typ[start:i] + if expressionKeywords.IsKeyword(word) || builtinTypeWord(word) { + word = strings.ToLower(word) + } + out.WriteString(word) + } else if typ[i] == '\'' || typ[i] == '"' || typ[i] == '`' { + end := typ[i] + i++ + for i < len(typ) { + if typ[i] == end { + i++ + if i < len(typ) && typ[i] == end { + i++ + continue + } + break + } + i++ + } + out.WriteString(typ[start:i]) + } else { + out.WriteByte(typ[i]) + i++ + } + } + return out.String() +} + +func builtinTypeWord(word string) bool { + switch strings.ToUpper(word) { + case "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT", "INT2", "INT4", "INT8", "SERIAL", "BIGSERIAL", "SMALLSERIAL", "NUMERIC", "DECIMAL", "DEC", "NUMBER", "FLOAT", "REAL", "DOUBLE", "PRECISION", "MONEY", "SMALLMONEY", "BOOLEAN", "BOOL", "BIT", "CHAR", "CHARACTER", "NCHAR", "VARCHAR", "NVARCHAR", "VARCHAR2", "NVARCHAR2", "VARYING", "TEXT", "NTEXT", "CLOB", "NCLOB", "BLOB", "BINARY", "VARBINARY", "BYTEA", "DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", "JSON", "JSONB", "XML", "UUID", "UNSIGNED", "SIGNED", "ZONE", "WITH", "WITHOUT": + return true + } + return false +} + +func builtinFunctionName(name string) bool { + switch strings.ToUpper(name) { + case "ABS", "AVG", "COUNT", "SUM", "MIN", "MAX", "COALESCE", "NULLIF", "LOWER", "UPPER", "LENGTH", "CHAR_LENGTH", "CHARACTER_LENGTH", "OCTET_LENGTH", "SUBSTR", "SUBSTRING", "POSITION", "TRIM", "LTRIM", "RTRIM", "CONCAT", "ROUND", "CEIL", "CEILING", "FLOOR", "MOD", "POWER", "SQRT", "EXTRACT", "GROUP_CONCAT", "STRING_AGG", "ARRAY_AGG", "MULTISET": + return true + } + return false +} + +func (c *expressionCase) postfixExpr(e ast.Expression) ast.Expression { + slotted := c.expr(e) + switch e.(type) { + case *ast.BinaryExpression, *ast.BetweenExpression, *ast.InExpression, + *ast.AnyExpression, *ast.AllExpression, *ast.AnyAllExpression, *ast.TernaryExpression: + return &ast.ParenthesizedExpression{Expr: slotted} + default: + return slotted + } +} diff --git a/pkg/formatter/render_dml.go b/pkg/formatter/render_dml.go index 7eeb7ad1..56a61a16 100644 --- a/pkg/formatter/render_dml.go +++ b/pkg/formatter/render_dml.go @@ -37,7 +37,7 @@ func writeDMLTop(sb *strings.Builder, f *nodeFormatter, top *ast.TopClause) { } sb.WriteString(f.kw("TOP")) sb.WriteString(" (") - sb.WriteString(exprSQL(top.Count)) + sb.WriteString(f.exprSQL(top.Count)) sb.WriteString(") ") if top.IsPercent { sb.WriteString(f.kw("PERCENT")) @@ -72,7 +72,9 @@ func writeDMLTargets(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFiel // writeDMLJoins writes the JOIN clauses of a multi-table UPDATE / DELETE. func writeDMLJoins(sb *strings.Builder, f *nodeFormatter, joins []ast.JoinClause) { for i := range joins { - sb.WriteString(f.clauseSep()) + if !joins[i].Comma { + sb.WriteString(f.clauseSep()) + } sb.WriteString(joinSQL(&joins[i], f)) } } @@ -85,7 +87,7 @@ func writeDMLOutput(sb *strings.Builder, f *nodeFormatter, output []ast.Expressi sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("OUTPUT")) sb.WriteString(" ") - sb.WriteString(exprListSQL(output)) + sb.WriteString(f.exprListSQL(output)) if into != nil && into.Target != "" { sb.WriteString(" ") sb.WriteString(f.kw("INTO")) @@ -105,13 +107,13 @@ func writeDMLOrderLimit(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetF sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("ORDER BY")) sb.WriteString(" ") - sb.WriteString(orderBySQL(d.OrderBy)) + sb.WriteString(f.orderBySQL(d.OrderBy)) } if d.Limit != nil { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("LIMIT")) sb.WriteString(" ") - sb.WriteString(exprSQL(d.Limit)) + sb.WriteString(f.exprSQL(d.Limit)) } } @@ -123,12 +125,12 @@ func writeReturning(sb *strings.Builder, f *nodeFormatter, returning, into []ast sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("RETURNING")) sb.WriteString(" ") - sb.WriteString(exprListSQL(returning)) + sb.WriteString(f.exprListSQL(returning)) if len(into) > 0 { sb.WriteString(" ") sb.WriteString(f.kw("INTO")) sb.WriteString(" ") - sb.WriteString(exprListSQL(into)) + sb.WriteString(f.exprListSQL(into)) } } @@ -142,7 +144,7 @@ func writeInsertHead(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStateme if i.DialectExtras().TableFunction != nil { sb.WriteString(f.kw("FUNCTION")) sb.WriteString(" ") - sb.WriteString(exprSQL(i.DialectExtras().TableFunction)) + sb.WriteString(f.exprSQL(i.DialectExtras().TableFunction)) return } sb.WriteString(ast.QuoteQualifiedNameWith(i.TableName, f.nq(i.DialectExtras().TableNameQuote))) @@ -198,7 +200,7 @@ func writeInsertTail(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStateme } if l.Tag != nil { sb.WriteString(" (") - sb.WriteString(exprSQL(l.Tag)) + sb.WriteString(f.exprSQL(l.Tag)) sb.WriteString(")") } if lim := ast.LogErrorsRejectLimitText(l.RejectLimit); lim != "" { @@ -241,12 +243,12 @@ func writeMergeActionWhere(sb *strings.Builder, f *nodeFormatter, a *ast.MergeAc sb.WriteString(" ") sb.WriteString(f.kw("WHERE")) sb.WriteString(" ") - sb.WriteString(exprSQL(a.Where)) + sb.WriteString(f.exprSQL(a.Where)) } if a.DeleteWhere != nil { sb.WriteString(" ") sb.WriteString(f.kw("DELETE WHERE")) sb.WriteString(" ") - sb.WriteString(exprSQL(a.DeleteWhere)) + sb.WriteString(f.exprSQL(a.DeleteWhere)) } } diff --git a/pkg/formatter/render_expression_case.go b/pkg/formatter/render_expression_case.go new file mode 100644 index 00000000..53446cf7 --- /dev/null +++ b/pkg/formatter/render_expression_case.go @@ -0,0 +1,338 @@ +// 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 formatter + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func (f *nodeFormatter) exprSQL(e ast.Expression) string { + if e == nil { + return "" + } + if f.opts.KeywordCase != ast.KeywordLower { + return exprSQL(e) + } + if f.expressionCase == nil { + scoped := *f + scoped.opts.NewlinePerClause = false + c := newExpressionCase(&scoped, exprSQL(e)) + scoped.expressionCase = c + return c.restore(c.formatWith(&scoped, e)) + } + c := f.expressionCase + switch v := e.(type) { + case *ast.CaseExpression, *ast.BetweenExpression, *ast.InExpression, + *ast.ExistsExpression, *ast.SubqueryExpression: + scoped := *f + scoped.opts.NewlinePerClause = false + return c.formatWith(&scoped, e) + case *ast.Identifier, *ast.Ident: + return exprSQL(e) + case *ast.LiteralValue: + if v.Value == nil || strings.EqualFold(v.Type, "NULL") || strings.EqualFold(v.Type, "BOOLEAN") || strings.EqualFold(v.Type, "BOOL") { + return lowerSyntax(v.SQL()) + } + return v.SQL() + case *ast.BinaryExpression: + copy := *v + copy.Left = c.expr(v.Left) + copy.Right = c.expr(v.Right) + if v.Extras != nil { + x := *v.Extras + x.Escape = c.expr(x.Escape) + copy.Extras = &x + } + sql := lowerSyntax(copy.SQL()) + if v.CustomOp != nil { + op := v.CustomOp.String() + // OPERATOR is syntax; its schema and operator parts are names. + cased := "operator" + strings.TrimPrefix(op, "OPERATOR") + sql = strings.ReplaceAll(sql, lowerSyntax(op), cased) + } + return sql + case *ast.UnaryExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + return c.result(©) + case *ast.AliasedExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + copy.Alias = c.slot(ast.QuoteNameWith(v.Alias, v.AliasQuote)) + copy.AliasQuote = 0 + return c.result(©) + case *ast.CastExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + if !v.TypeAsString { + copy.Type = c.slot(lowerDataType(v.Type)) + } + return c.result(©) + case *ast.ParenthesizedExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + return c.result(©) + case *ast.FunctionCall: + copy := *v + nameSQL := (&ast.FunctionCall{Name: v.Name, NameQuote: v.NameQuote}).SQL() + nameSQL = strings.TrimSuffix(nameSQL, "()") + if v.NameQuote == 0 && (expressionKeywords.IsKeyword(v.Name) || builtinFunctionName(v.Name)) { + nameSQL = strings.ToLower(nameSQL) + } + copy.Name = c.slot(nameSQL) + copy.NameQuote = 0 + copy.Arguments = make([]ast.Expression, len(v.Arguments)) + // KeywordArgument affects argument separators, so retain its node type. + for i, arg := range v.Arguments { + if k, ok := arg.(*ast.KeywordArgument); ok { + item := *k + item.Value = c.expr(k.Value) + if k.TypeName != "" { + item.TypeName = c.slot(lowerDataType(k.TypeName)) + } + copy.Arguments[i] = &item + } else { + copy.Arguments[i] = c.expr(arg) + } + } + copy.Parameters = c.exprs(v.Parameters) + copy.Filter = c.expr(v.Filter) + copy.OrderBy = c.orders(v.OrderBy) + copy.WithinGroup = c.orders(v.WithinGroup) + if v.Extras != nil { + x := *v.Extras + if x.Keep != nil { + keep := *x.Keep + keep.OrderBy = c.orders(keep.OrderBy) + x.Keep = &keep + } + copy.Extras = &x + } + if v.Over != nil { + w := *v.Over + if w.Name != "" && w.BaseWindow == "" && len(w.PartitionBy) == 0 && len(w.OrderBy) == 0 && w.FrameClause == nil { + copy.Over = &ast.WindowSpec{Name: c.slot(ast.QuoteNameWith(w.Name, w.NameQuote))} + } else { + if w.BaseWindow == "" { + w.BaseWindow, w.BaseWindowQuote = w.Name, w.NameQuote + } + copy.Over = &ast.WindowSpec{Name: c.slot("(" + f.windowSpecSQL(&w) + ")")} + } + } + return c.result(©) + case *ast.NamedArgument: + copy := *v + copy.Name = c.slot(ast.QuoteName(v.Name)) + copy.Value = c.expr(v.Value) + return c.result(©) + case *ast.KeywordArgument: + copy := *v + copy.Value = c.expr(v.Value) + if v.TypeName != "" { + copy.TypeName = c.slot(lowerDataType(v.TypeName)) + } + return c.result(©) + case *ast.ListExpression: + copy := *v + copy.Values = c.exprs(v.Values) + return c.result(©) + case *ast.TupleExpression: + copy := *v + copy.Expressions = c.exprs(v.Expressions) + return c.result(©) + case *ast.ArrayConstructorExpression: + if v.Subquery != nil { + return "array(" + f.tableSubquerySQL(v.Subquery) + ")" + } + if v.Query != nil { + return "array(" + f.tableSubquerySQL(v.Query) + ")" + } + copy := *v + copy.Elements = c.exprs(v.Elements) + return c.result(©) + case *ast.ArraySubscriptExpression: + copy := *v + copy.Array = c.expr(v.Array) + copy.Indices = c.exprs(v.Indices) + return c.result(©) + case *ast.ArraySliceExpression: + copy := *v + copy.Array = c.expr(v.Array) + copy.Start = c.expr(v.Start) + copy.End = c.expr(v.End) + return c.result(©) + case *ast.ExtractExpression: + copy := *v + copy.Source = c.expr(v.Source) + return c.result(©) + case *ast.PositionExpression: + copy := *v + copy.Substr = c.expr(v.Substr) + copy.Str = c.expr(v.Str) + return c.result(©) + case *ast.SubstringExpression: + copy := *v + copy.Str = c.expr(v.Str) + copy.Start = c.expr(v.Start) + copy.Length = c.expr(v.Length) + return c.result(©) + case *ast.IntervalExpression: + copy := *v + copy.ValueExpr = c.expr(v.ValueExpr) + return c.result(©) + case *ast.TypedLiteral: + copy := *v + copy.TypeName = c.slot(lowerDataType(v.TypeName)) + return c.result(©) + case *ast.AtTimeZoneExpression: + copy := *v + copy.Expr = c.postfixExpr(v.Expr) + copy.Zone = c.postfixExpr(v.Zone) + return c.result(©) + case *ast.CollateExpression: + // The suffix is a collation name or literal, not keyword syntax. + copy := *v + copy.Expr = c.postfixExpr(v.Expr) + rendered := copy.SQL() + return strings.Replace(rendered, " COLLATE ", " collate ", 1) + case *ast.AnyAllExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + copy.Array = c.expr(v.Array) + if sq, ok := v.Array.(*ast.SubqueryExpression); ok && sq != nil { + copy.Array = &ast.SubqueryExpression{Subquery: &ast.UtilityStatement{Text: c.slot(f.tableSubquerySQL(sq.Subquery))}} + } + return c.result(©) + case *ast.FieldAccessExpression: + copy := *v + copy.Args = c.exprs(v.Args) + marker := c.slot("") + copy.Expr = &ast.Identifier{Name: marker} + suffix := strings.TrimPrefix(copy.SQL(), "("+marker+")") + root := exprSQL(c.expr(v.Expr)) + switch v.Expr.(type) { + case *ast.ParenthesizedExpression, *ast.SubqueryExpression, *ast.TupleExpression, + *ast.FieldAccessExpression, *ast.ArraySubscriptExpression, *ast.ArraySliceExpression: + default: + root = "(" + root + ")" + } + return root + suffix + case *ast.AnyExpression: + return exprSQL(c.expr(v.Expr)) + " " + lowerSyntax(v.Operator) + " any (" + f.tableSubquerySQL(v.Subquery) + ")" + case *ast.AllExpression: + return exprSQL(c.expr(v.Expr)) + " " + lowerSyntax(v.Operator) + " all (" + f.tableSubquerySQL(v.Subquery) + ")" + case *ast.TernaryExpression: + copy := *v + copy.Cond = c.expr(v.Cond) + copy.Then = c.expr(v.Then) + copy.Else = c.expr(v.Else) + return c.result(©) + case *ast.RollupExpression: + copy := *v + copy.Expressions = c.exprs(v.Expressions) + return c.result(©) + case *ast.CubeExpression: + copy := *v + 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]) + } + return c.result(©) + case *ast.OuterJoinMarker: + copy := *v + copy.Expr = c.expr(v.Expr) + return c.result(©) + case *ast.MatchAgainstExpression: + copy := *v + copy.Columns = c.exprs(v.Columns) + copy.Query = c.expr(v.Query) + return c.result(©) + case *ast.NextValueForExpression: + copy := *v + copy.Sequence = c.slot(ast.QuoteQualifiedName(v.Sequence)) + return c.result(©) + case *ast.AssignmentExpression: + copy := *v + copy.Target = c.expr(v.Target) + copy.Value = c.expr(v.Value) + return c.result(©) + case *ast.ConnectByRootExpression: + copy := *v + copy.Expr = c.expr(v.Expr) + return c.result(©) + case *ast.VariantPath: + copy := *v + copy.Segments = append([]ast.VariantPathSegment(nil), v.Segments...) + for i := range copy.Segments { + copy.Segments[i].Index = c.expr(v.Segments[i].Index) + } + copy.Root = c.expr(v.Root) + switch v.Root.(type) { + case *ast.BinaryExpression, *ast.CastExpression, *ast.CaseExpression, + *ast.BetweenExpression, *ast.InExpression: + copy.Root = &ast.ParenthesizedExpression{Expr: copy.Root} + } + return copy.SQL() + case *ast.ObjectLiteral: + copy := *v + copy.Entries = append([]ast.ObjectEntry(nil), v.Entries...) + for i := range copy.Entries { + copy.Entries[i].Key = c.expr(copy.Entries[i].Key) + copy.Entries[i].Value = c.expr(copy.Entries[i].Value) + } + return c.result(©) + case *ast.JSONObjectEntry: + copy := *v + copy.Key = c.expr(v.Key) + copy.Value = c.expr(v.Value) + return c.result(©) + case *ast.ClauseArgument: + copy := *v + copy.Head = c.expr(v.Head) + // The parser retains this clause as raw text, including names. + return copy.SQL() + case *ast.DefaultExpr: + return "default" + case *ast.StarExpression: + copy := *v + copy.Star = c.expr(v.Star) + copy.Ilike = c.expr(v.Ilike) + copy.Apply = c.exprs(v.Apply) + copy.Exclude = make([]string, len(v.Exclude)) + for i, name := range v.Exclude { + copy.Exclude[i] = c.slot(ast.QuoteQualifiedName(name)) + } + copy.Replace = make([]*ast.AliasedExpression, len(v.Replace)) + for i, item := range v.Replace { + if item != nil { + copy.Replace[i] = &ast.AliasedExpression{Expr: c.expr(item.Expr), Alias: c.slot(ast.QuoteName(item.Alias))} + } + } + copy.Rename = append([]ast.StarRename(nil), v.Rename...) + for i := range copy.Rename { + copy.Rename[i].Column = c.slot(ast.QuoteName(copy.Rename[i].Column)) + copy.Rename[i].Alias = c.slot(ast.QuoteName(copy.Rename[i].Alias)) + } + return c.result(©) + default: + return exprSQL(e) + } +} diff --git a/pkg/formatter/render_select_ext.go b/pkg/formatter/render_select_ext.go index 9598cd15..89faa537 100644 --- a/pkg/formatter/render_select_ext.go +++ b/pkg/formatter/render_select_ext.go @@ -79,11 +79,11 @@ func selectIntoSQL(in *ast.SelectInto, f *nodeFormatter) string { sb.WriteString(" ") switch in.Kind { case ast.SelectIntoVariables: - sb.WriteString(exprListSQL(in.Targets)) + sb.WriteString(f.exprListSQL(in.Targets)) case ast.SelectIntoOutfile, ast.SelectIntoDumpfile: sb.WriteString(f.kw(in.Kind)) sb.WriteString(" ") - sb.WriteString(exprSQL(in.File)) + sb.WriteString(f.exprSQL(in.File)) if in.Kind == ast.SelectIntoOutfile { if in.CharacterSet != "" { sb.WriteString(" ") @@ -123,7 +123,7 @@ func writeIntoFileOptions(sb *strings.Builder, clause string, opts []ast.IntoFil sb.WriteString(" ") sb.WriteString(f.kw(kw)) sb.WriteString(" ") - sb.WriteString(exprSQL(o.Value)) + sb.WriteString(f.exprSQL(o.Value)) } } @@ -140,7 +140,7 @@ func arrayJoinSQL(a *ast.ArrayJoinClause, f *nodeFormatter) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(exprSQL(e.Expr)) + sb.WriteString(f.exprSQL(e.Expr)) if e.Alias != "" { sb.WriteString(" ") sb.WriteString(f.kw("AS")) @@ -164,7 +164,7 @@ func groupBySQL(s *ast.SelectStatement, f *nodeFormatter) string { if m.All { sb.WriteString(f.kw("ALL")) } else { - sb.WriteString(exprListSQL(s.GroupBy)) + sb.WriteString(f.exprListSQL(s.GroupBy)) } switch { case m.WithRollup: @@ -216,17 +216,17 @@ func writeLimitOffsetClauses(sb *strings.Builder, s *ast.SelectStatement, f *nod sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("LIMIT")) sb.WriteString(" ") - sb.WriteString(exprSQL(lb.Count)) + sb.WriteString(f.exprSQL(lb.Count)) if lb.Offset != nil { sb.WriteString(" ") sb.WriteString(f.kw("OFFSET")) sb.WriteString(" ") - sb.WriteString(exprSQL(lb.Offset)) + sb.WriteString(f.exprSQL(lb.Offset)) } sb.WriteString(" ") sb.WriteString(f.kw("BY")) sb.WriteString(" ") - sb.WriteString(exprListSQL(lb.Exprs)) + sb.WriteString(f.exprListSQL(lb.Exprs)) } if s.HasLimit() { sb.WriteString(f.clauseSep()) @@ -236,7 +236,7 @@ func writeLimitOffsetClauses(sb *strings.Builder, s *ast.SelectStatement, f *nod case s.Limit != nil: sb.WriteString(strconv.Itoa(*s.Limit)) case s.DialectExtras().LimitExpr != nil: - sb.WriteString(exprSQL(s.DialectExtras().LimitExpr)) + sb.WriteString(f.exprSQL(s.DialectExtras().LimitExpr)) default: sb.WriteString(f.kw("ALL")) } @@ -252,7 +252,7 @@ func writeLimitOffsetClauses(sb *strings.Builder, s *ast.SelectStatement, f *nod if s.Offset != nil { sb.WriteString(strconv.Itoa(*s.Offset)) } else { - sb.WriteString(exprSQL(s.DialectExtras().OffsetExpr)) + sb.WriteString(f.exprSQL(s.DialectExtras().OffsetExpr)) } if s.DialectExtras().OffsetRows || s.Fetch != nil { sb.WriteString(" ") @@ -263,7 +263,7 @@ func writeLimitOffsetClauses(sb *strings.Builder, s *ast.SelectStatement, f *nod sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("ROWS EXAMINED")) sb.WriteString(" ") - sb.WriteString(exprSQL(x.RowsExamined)) + sb.WriteString(f.exprSQL(x.RowsExamined)) } } @@ -284,7 +284,7 @@ func forOutputSQL(fo *ast.ForOutputClause, f *nodeFormatter) string { sb.WriteString(f.kw(mode)) if fo.ModeArg != nil { sb.WriteString("(") - sb.WriteString(exprSQL(fo.ModeArg)) + sb.WriteString(f.exprSQL(fo.ModeArg)) sb.WriteString(")") } } @@ -297,7 +297,7 @@ func forOutputSQL(fo *ast.ForOutputClause, f *nodeFormatter) string { sb.WriteString(f.kw(name)) if o.Arg != nil { sb.WriteString("(") - sb.WriteString(exprSQL(o.Arg)) + sb.WriteString(f.exprSQL(o.Arg)) sb.WriteString(")") } } @@ -327,7 +327,7 @@ func queryHintsSQL(hints []ast.QueryHint, f *nodeFormatter) string { } else { sb.WriteString(" ") } - sb.WriteString(exprSQL(h.Value)) + sb.WriteString(f.exprSQL(h.Value)) } if h.HasArgs { sb.WriteString(" (") @@ -335,7 +335,7 @@ func queryHintsSQL(hints []ast.QueryHint, f *nodeFormatter) string { if j > 0 { sb.WriteString(", ") } - sb.WriteString(exprSQL(a.Expr)) + sb.WriteString(f.exprSQL(a.Expr)) if a.Unknown { sb.WriteString(" ") sb.WriteString(f.kw("UNKNOWN")) @@ -401,7 +401,7 @@ func interpolateSQL(c *ast.InterpolateClause, f *nodeFormatter) string { sb.WriteString(" ") sb.WriteString(f.kw("AS")) sb.WriteString(" ") - sb.WriteString(exprSQL(it.Expr)) + sb.WriteString(f.exprSQL(it.Expr)) } } sb.WriteString(")") diff --git a/pkg/formatter/select_ext_render_test.go b/pkg/formatter/select_ext_render_test.go index 393078a0..8bf538cb 100644 --- a/pkg/formatter/select_ext_render_test.go +++ b/pkg/formatter/select_ext_render_test.go @@ -48,7 +48,7 @@ func TestRender_SelectBodyClauses(t *testing.T) { {"mysql modifiers", keywords.DialectMySQL, "SELECT SQL_CALC_FOUND_ROWS * FROM t", "SELECT SQL_CALC_FOUND_ROWS * FROM t"}, {"mysql straight join", keywords.DialectMySQL, "SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id", "SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id"}, {"mysql join without on", keywords.DialectMySQL, "SELECT * FROM a JOIN b", "SELECT * FROM a INNER JOIN b"}, - {"mysql comma after join", keywords.DialectMySQL, "SELECT * FROM a LEFT JOIN b ON a.id = b.id, t3", "SELECT * FROM a LEFT JOIN b ON a.id = b.id , t3"}, + {"mysql comma after join", keywords.DialectMySQL, "SELECT * FROM a LEFT JOIN b ON a.id = b.id, t3", "SELECT * FROM a LEFT JOIN b ON a.id = b.id, t3"}, {"mysql lock in share mode", keywords.DialectMySQL, "SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE", "SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE"}, {"mysql into vars", keywords.DialectMySQL, "SELECT a, b INTO @x, @y FROM t", "SELECT a, b INTO @x, @y FROM t"}, {"mysql outfile", keywords.DialectMySQL, "SELECT * FROM o INTO OUTFILE '/tmp/o.csv' FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'", "SELECT * FROM o INTO OUTFILE '/tmp/o.csv' FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'"}, diff --git a/pkg/sql/ast/select_ext_test.go b/pkg/sql/ast/select_ext_test.go index d3c052c0..5cf19968 100644 --- a/pkg/sql/ast/select_ext_test.go +++ b/pkg/sql/ast/select_ext_test.go @@ -106,7 +106,7 @@ func TestSelectExt_SQL(t *testing.T) { {Type: "OUTER APPLY", Right: TableReference{Name: "f"}}, {Type: "INNER", Strictness: "ASOF", Right: TableReference{Name: "g"}, MatchCondition: id("m"), Condition: id("y")}, } - }, "SELECT a FROM t GLOBAL ALL JOIN b USING (k) LEFT MERGE JOIN c ON x , d STRAIGHT_JOIN e OUTER APPLY f ASOF JOIN g MATCH_CONDITION (m) ON y"}, + }, "SELECT a FROM t GLOBAL ALL JOIN b USING (k) LEFT MERGE JOIN c ON x, d STRAIGHT_JOIN e OUTER APPLY f ASOF JOIN g MATCH_CONDITION (m) ON y"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 61dd926b..955da45f 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1297,7 +1297,9 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { } for i := range s.Joins { - buf.WriteByte(' ') + if !s.Joins[i].Comma { + buf.WriteByte(' ') + } writeJoin(buf, &s.Joins[i]) } diff --git a/pkg/sql/ast/sql_dml.go b/pkg/sql/ast/sql_dml.go index e2624096..0bbd9287 100644 --- a/pkg/sql/ast/sql_dml.go +++ b/pkg/sql/ast/sql_dml.go @@ -86,7 +86,9 @@ func (d *DMLTargetFields) writeDMLTargets(buf *bytes.Buffer, name string, nameQ // writeDMLJoins writes " JOIN ..." for every join. func writeDMLJoins(buf *bytes.Buffer, joins []JoinClause) { for i := range joins { - buf.WriteByte(' ') + if !joins[i].Comma { + buf.WriteByte(' ') + } writeJoin(buf, &joins[i]) } } diff --git a/pkg/sql/ast/table_factor.go b/pkg/sql/ast/table_factor.go index e1a56459..e32f75ee 100644 --- a/pkg/sql/ast/table_factor.go +++ b/pkg/sql/ast/table_factor.go @@ -212,7 +212,9 @@ func writeJoinGroup(buf *bytes.Buffer, g *JoinGroup) { buf.WriteByte('(') writeTableRefList(buf, g.Tables) for i := range g.Joins { - buf.WriteByte(' ') + if !g.Joins[i].Comma { + buf.WriteByte(' ') + } writeJoin(buf, &g.Joins[i]) } buf.WriteByte(')') diff --git a/pkg/transform/limit.go b/pkg/transform/limit.go index 43c0000d..a0f19d9a 100644 --- a/pkg/transform/limit.go +++ b/pkg/transform/limit.go @@ -16,12 +16,13 @@ package transform import ( "fmt" + "strconv" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) -// SetLimit returns a Rule that sets (or replaces) the LIMIT clause of a SELECT -// statement. Any existing LIMIT value is overwritten. +// SetLimit returns a Rule that sets or replaces the row limit of a SELECT, +// preserving an existing TOP or FETCH clause. PERCENT and WITH TIES are rejected. // // Parameters: // - n: Number of rows to return; must be >= 0. Returns an error for negative values. @@ -36,7 +37,21 @@ func SetLimit(n int) Rule { if err != nil { return err } - sel.Limit = &n + if err := checkPaginationModifiers(sel, "SetLimit"); err != nil { + return err + } + switch { + case sel.Top != nil: + sel.Top.Count = &ast.LiteralValue{Value: strconv.Itoa(n), Type: "INTEGER"} + sel.Limit = nil + case sel.Fetch != nil: + value := int64(n) + sel.Fetch.FetchValue = &value + sel.Fetch.FetchExpr = nil + sel.Limit = nil + default: + sel.Limit = &n + } if sel.Extras != nil { sel.Extras.LimitExpr = nil sel.Extras.LimitAll = false @@ -46,7 +61,8 @@ func SetLimit(n int) Rule { } // SetOffset returns a Rule that sets (or replaces) the OFFSET clause of a SELECT -// statement. Use together with SetLimit to implement pagination. +// statement. Use together with SetLimit to implement pagination. TOP and +// FETCH PERCENT or WITH TIES clauses are rejected. // // Parameters: // - n: Number of rows to skip; must be >= 0. Returns an error for negative values. @@ -61,7 +77,16 @@ func SetOffset(n int) Rule { if err != nil { return err } + if err := checkPaginationModifiers(sel, "SetOffset"); err != nil { + return err + } + if sel.Top != nil { + return fmt.Errorf("SetOffset: cannot combine OFFSET with TOP") + } sel.Offset = &n + if sel.Fetch != nil { + sel.Fetch.OffsetValue = nil + } if sel.Extras != nil { sel.Extras.OffsetExpr = nil } @@ -69,8 +94,8 @@ func SetOffset(n int) Rule { }) } -// RemoveLimit returns a Rule that removes the LIMIT clause from a SELECT statement, -// allowing the query to return an unbounded number of rows. +// RemoveLimit returns a Rule that removes LIMIT, TOP, or FETCH from a SELECT, +// allowing an unbounded number of rows. PERCENT and WITH TIES are rejected. // // Returns ErrUnsupportedStatement for non-SELECT statements. func RemoveLimit() Rule { @@ -79,7 +104,18 @@ func RemoveLimit() Rule { if err != nil { return err } + if err := checkPaginationModifiers(sel, "RemoveLimit"); err != nil { + return err + } + // Preserve the offset of ASTs constructed with the legacy FETCH field. + if sel.Fetch != nil && sel.Fetch.OffsetValue != nil && sel.Offset == nil && sel.DialectExtras().OffsetExpr == nil { + offset := int(*sel.Fetch.OffsetValue) + sel.Offset = &offset + sel.EnsureExtras().OffsetRows = true + } sel.Limit = nil + sel.Top = nil + sel.Fetch = nil if sel.Extras != nil { sel.Extras.LimitExpr = nil sel.Extras.LimitAll = false @@ -100,6 +136,9 @@ func RemoveOffset() Rule { return err } sel.Offset = nil + if sel.Fetch != nil { + sel.Fetch.OffsetValue = nil + } if sel.Extras != nil { sel.Extras.OffsetExpr = nil sel.Extras.OffsetRows = false @@ -107,3 +146,14 @@ func RemoveOffset() Rule { return nil }) } + +// Percentages and ties change the meaning of a row count; reject them before mutation. +func checkPaginationModifiers(sel *ast.SelectStatement, operation string) error { + if sel.Top != nil && (sel.Top.IsPercent || sel.Top.WithTies) { + return fmt.Errorf("%s: TOP PERCENT or WITH TIES is not supported", operation) + } + if sel.Fetch != nil && (sel.Fetch.IsPercent || sel.Fetch.WithTies) { + return fmt.Errorf("%s: FETCH PERCENT or WITH TIES is not supported", operation) + } + return nil +} diff --git a/pkg/transform/pagination_regression_test.go b/pkg/transform/pagination_regression_test.go new file mode 100644 index 00000000..d948c66e --- /dev/null +++ b/pkg/transform/pagination_regression_test.go @@ -0,0 +1,138 @@ +// 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 transform + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestPaginationPreservesClauseKind(t *testing.T) { + tests := []struct { + name, sql string + rule Rule + want string + }{ + {"top", "SELECT TOP 5 * FROM a", SetLimit(3), "SELECT TOP 3 * FROM a"}, + {"parenthesized top", "SELECT TOP (5) * FROM a", SetLimit(3), "SELECT TOP (3) * FROM a"}, + {"fetch first", "SELECT * FROM a FETCH FIRST 5 ROWS ONLY", SetLimit(3), "SELECT * FROM a FETCH FIRST 3 ROWS ONLY"}, + {"fetch next", "SELECT * FROM a ORDER BY id OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY", SetLimit(3), "SELECT * FROM a ORDER BY id OFFSET 2 ROWS FETCH NEXT 3 ROWS ONLY"}, + {"remove top", "SELECT TOP 5 * FROM a", RemoveLimit(), "SELECT * FROM a"}, + {"remove fetch", "SELECT * FROM a FETCH FIRST 5 ROWS ONLY", RemoveLimit(), "SELECT * FROM a"}, + {"remove fetch retain offset", "SELECT * FROM a ORDER BY id OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY", RemoveLimit(), "SELECT * FROM a ORDER BY id OFFSET 2 ROWS"}, + {"offset fetch", "SELECT * FROM a ORDER BY id OFFSET 2 ROWS FETCH NEXT 5 ROWS ONLY", SetOffset(3), "SELECT * FROM a ORDER BY id OFFSET 3 ROWS FETCH NEXT 5 ROWS ONLY"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := ParseSQLWithDialect(tt.sql, keywords.DialectSQLServer) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt := tree.Statements[0] + if err := Apply(stmt, tt.rule); err != nil { + t.Fatal(err) + } + for name, out := range map[string]string{"SQL": selectSQL(t, stmt), "FormatAST": strings.TrimSuffix(format(stmt), ";")} { + if out != tt.want { + t.Errorf("%s = %q, want %q", name, out, tt.want) + } + reparsed, err := ParseSQLWithDialect(out, keywords.DialectSQLServer) + if err != nil { + t.Fatalf("reparse: %v", err) + } + defer ast.ReleaseAST(reparsed) + if got := selectSQL(t, reparsed.Statements[0]); got != selectSQL(t, stmt) { + t.Errorf("round trip = %q, want %q", got, selectSQL(t, stmt)) + } + } + }) + } +} + +func TestPaginationRejectsAmbiguousModifiers(t *testing.T) { + for _, sql := range []string{"SELECT TOP 5 PERCENT * FROM a", "SELECT TOP 5 WITH TIES * FROM a ORDER BY id", "SELECT * FROM a FETCH FIRST 5 PERCENT ROWS ONLY", "SELECT * FROM a ORDER BY id FETCH FIRST 5 ROWS WITH TIES"} { + for name, rule := range map[string]Rule{"limit": SetLimit(3), "remove": RemoveLimit(), "offset": SetOffset(3)} { + t.Run(sql+name, func(t *testing.T) { + tree, err := ParseSQLWithDialect(sql, keywords.DialectSQLServer) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt := tree.Statements[0] + before := selectSQL(t, stmt) + if err := Apply(stmt, rule); err == nil { + t.Fatal("expected error") + } + if got := selectSQL(t, stmt); got != before { + t.Errorf("error mutated statement: %s", got) + } + }) + } + } +} + +func selectSQL(t *testing.T, stmt ast.Statement) string { + t.Helper() + sel, ok := stmt.(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SELECT, got %T", stmt) + } + return sel.SQL() +} + +func TestSetOffsetRejectsTop(t *testing.T) { + tree, err := ParseSQLWithDialect("SELECT TOP 5 * FROM a", keywords.DialectSQLServer) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt := tree.Statements[0] + before := selectSQL(t, stmt) + if err := Apply(stmt, SetOffset(3)); err == nil { + t.Fatal("expected error for TOP with OFFSET") + } + if got := selectSQL(t, stmt); got != before { + t.Errorf("error mutated statement: %s", got) + } +} + +func TestPaginationLegacyFetchOffset(t *testing.T) { + for name, tt := range map[string]struct { + rule Rule + want string + }{ + "set": {SetOffset(3), "SELECT * FROM a OFFSET 3 ROWS FETCH NEXT 5 ROWS ONLY"}, + "remove offset": {RemoveOffset(), "SELECT * FROM a FETCH NEXT 5 ROWS ONLY"}, + "remove limit": {RemoveLimit(), "SELECT * FROM a OFFSET 2 ROWS"}, + } { + t.Run(name, func(t *testing.T) { + offset, count := int64(2), int64(5) + sel := &ast.SelectStatement{Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, From: []ast.TableReference{{Name: "a"}}, Fetch: &ast.FetchClause{OffsetValue: &offset, FetchValue: &count, FetchType: "NEXT"}} + if err := Apply(sel, tt.rule); err != nil { + t.Fatal(err) + } + if got := sel.SQL(); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + if got := strings.TrimSuffix(format(sel), ";"); got != tt.want { + t.Errorf("formatter got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/transform/tables.go b/pkg/transform/tables.go index c0ac1358..a84c2c25 100644 --- a/pkg/transform/tables.go +++ b/pkg/transform/tables.go @@ -69,13 +69,13 @@ func ReplaceTable(oldName, newName string) Rule { // AddTableAlias returns a Rule that assigns an alias to a specific table in a // SELECT, UPDATE, or DELETE statement. For SELECT statements the alias is applied -// to the matching entry in the FROM clause. For UPDATE and DELETE it is applied -// to the matching table wherever the statement names it (the target, further +// to the matching FROM or JOIN entry and its column qualifiers. For UPDATE and +// DELETE it is applied wherever the statement names the table (the target, further // targets, JOINs, FROM / USING lists) and the column qualifiers that named the // table are renamed to the alias, because a table with an alias can no longer // be referred to by its name. In DELETE t FROM t JOIN ... the target is renamed -// to the alias as well. A table that appears more than once in an UPDATE or -// DELETE (a self join) is an error, since one alias cannot name both. +// to the alias as well. A table that appears more than once in the same scope +// (a self join) is an error, since one alias cannot name both. // // Parameters: // - tableName: The table to alias (case-insensitive match) @@ -86,13 +86,7 @@ func AddTableAlias(tableName, alias string) Rule { return RuleFunc(func(stmt ast.Statement) error { switch s := stmt.(type) { case *ast.SelectStatement: - for i := range s.From { - if strings.EqualFold(s.From[i].Name, tableName) { - s.From[i].Alias = alias - s.From[i].AliasQuote = replacedNameQuote(s.From[i].AliasQuote) - } - } - return nil + return addAliasToSelect(s, tableName, alias) case *ast.UpdateStatement: return addAliasToUpdate(s, tableName, alias) case *ast.DeleteStatement: diff --git a/pkg/transform/tables_alias_select.go b/pkg/transform/tables_alias_select.go new file mode 100644 index 00000000..ce446739 --- /dev/null +++ b/pkg/transform/tables_alias_select.go @@ -0,0 +1,36 @@ +// 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 transform + +import "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + +// addAliasToSelect shares DML's binding checks and scope-aware qualifier rewrite. +func addAliasToSelect(s *ast.SelectStatement, tableName, alias string) error { + refs := dmlAliasTargets{tableName: tableName, alias: alias} + refs.list(s.From) + refs.joins(s.Joins) + if err := refs.check(); err != nil { + return err + } + if err := refs.checkCaptures(s); err != nil { + return err + } + refs.apply() + // Only nested SELECT nodes establish a new scope for this rewrite. + for _, child := range s.Children() { + rewriteAliasQualifiers(child, refs.matched, alias) + } + return nil +} diff --git a/pkg/transform/tables_alias_select_test.go b/pkg/transform/tables_alias_select_test.go new file mode 100644 index 00000000..9ba34e24 --- /dev/null +++ b/pkg/transform/tables_alias_select_test.go @@ -0,0 +1,149 @@ +// 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 transform + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func TestAddTableAliasSelectQualifiers(t *testing.T) { + tests := []struct { + name, sql string + want, reject []string + }{ + {"where", "SELECT users.id FROM users WHERE users.id = 1", []string{"SELECT u.id", "FROM users u", "WHERE u.id = 1"}, []string{"users.id"}}, + {"previous alias", "SELECT old.id FROM users old WHERE old.id = 1", []string{"u.id", "FROM users u"}, []string{"old.id"}}, + {"shadowed subquery", "SELECT users.id FROM users WHERE EXISTS (SELECT users.id FROM users WHERE users.id = 2)", []string{"SELECT u.id FROM users u", "SELECT users.id FROM users WHERE users.id = 2"}, nil}, + {"correlated subquery", "SELECT users.id FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)", []string{"orders.user_id = u.id"}, []string{"users.id"}}, + {"join", "SELECT users.id FROM orders JOIN users ON users.id = orders.user_id", []string{"JOIN users u ON u.id = orders.user_id"}, []string{"users.id"}}, + {"group having", "SELECT users.id FROM users GROUP BY users.id HAVING users.id > 0 ORDER BY users.id", []string{"GROUP BY u.id", "HAVING u.id", "ORDER BY u.id"}, []string{"users.id"}}, + {"window", "SELECT SUM(users.id) OVER (PARTITION BY users.id ORDER BY users.id) FROM users", []string{"SUM(u.id) OVER (PARTITION BY u.id ORDER BY u.id)"}, []string{"users.id"}}, + {"named window", "SELECT SUM(users.id) OVER w FROM users WINDOW w AS (PARTITION BY users.id ORDER BY users.id)", []string{"PARTITION BY u.id ORDER BY u.id"}, []string{"users.id"}}, + {"partial shadow", "SELECT old.id FROM users old WHERE EXISTS (SELECT 1 FROM users WHERE users.id = old.id)", []string{"users.id = u.id"}, []string{"old.id"}}, + {"lateral join", "SELECT users.id FROM users JOIN LATERAL (SELECT users.id) x ON true", []string{"LATERAL (SELECT u.id)"}, []string{"users.id"}}, + {"lateral from", "SELECT users.id FROM users, LATERAL (SELECT users.id) x", []string{"LATERAL (SELECT u.id)"}, []string{"users.id"}}, + {"lateral shadow", "SELECT users.id FROM users JOIN LATERAL (SELECT users.id FROM users) x ON true", []string{"LATERAL (SELECT users.id FROM users)"}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := ParseSQL(tt.sql) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt := tree.Statements[0] + if err := Apply(stmt, AddTableAlias("users", "u")); err != nil { + t.Fatal(err) + } + out := selectSQL(t, stmt) + for _, s := range tt.want { + if !strings.Contains(out, s) { + t.Errorf("want %q in %q", s, out) + } + } + for _, s := range tt.reject { + if strings.Contains(out, s) { + t.Errorf("unexpected %q in %q", s, out) + } + } + }) + } +} + +func TestAddTableAliasSelectRejectsSelfJoin(t *testing.T) { + tree, err := ParseSQL("SELECT a.id FROM users a JOIN users b ON a.id = b.id") + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + stmt := tree.Statements[0] + before := selectSQL(t, stmt) + if err := Apply(stmt, AddTableAlias("users", "u")); err == nil { + t.Fatal("expected ambiguous alias error") + } + if got := selectSQL(t, stmt); got != before { + t.Errorf("error mutated statement: %s", got) + } +} + +func TestAddTableAliasDMLPartialShadow(t *testing.T) { + for _, sql := range []string{ + "UPDATE users old SET id = 1 WHERE EXISTS (SELECT 1 FROM users WHERE users.id = old.id)", + "DELETE FROM users old WHERE EXISTS (SELECT 1 FROM users WHERE users.id = old.id)", + } { + t.Run(sql, func(t *testing.T) { + tree, err := ParseSQL(sql) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + if err := Apply(tree.Statements[0], AddTableAlias("users", "u")); err != nil { + t.Fatal(err) + } + out := FormatSQL(tree.Statements[0]) + if !strings.Contains(out, "users.id = u.id") || strings.Contains(out, "old.id") { + t.Errorf("incorrect scoped alias: %s", out) + } + }) + } +} + +func TestAddTableAliasRejectsCorrelatedCapture(t *testing.T) { + for _, sql := range []string{ + "SELECT users.id FROM users WHERE EXISTS (SELECT 1 FROM orders u WHERE u.user_id = users.id)", + "SELECT users.id FROM users JOIN LATERAL (SELECT users.id FROM orders u) x ON true", + "UPDATE users SET id = 1 WHERE EXISTS (SELECT 1 FROM orders u WHERE u.user_id = users.id)", + "DELETE FROM users WHERE EXISTS (SELECT 1 FROM orders u WHERE u.user_id = users.id)", + } { + t.Run(sql, func(t *testing.T) { + tree, err := ParseSQL(sql) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + before := tree.SQL() + if err := Apply(tree.Statements[0], AddTableAlias("users", "u")); err == nil { + t.Fatal("expected correlated qualifier capture error") + } + if got := tree.SQL(); got != before { + t.Errorf("error mutated statement: %s", got) + } + }) + } +} + +func TestAddTableAliasAllowsIndependentNestedAlias(t *testing.T) { + for _, sql := range []string{ + "SELECT users.id FROM users WHERE EXISTS (SELECT 1 FROM orders u WHERE u.user_id = 1)", + "SELECT users.id FROM users WHERE EXISTS (SELECT users.id FROM users JOIN orders u ON users.id = u.user_id)", + } { + t.Run(sql, func(t *testing.T) { + tree, err := ParseSQL(sql) + if err != nil { + t.Fatal(err) + } + defer ast.ReleaseAST(tree) + if err := Apply(tree.Statements[0], AddTableAlias("users", "u")); err != nil { + t.Fatal(err) + } + if got := tree.SQL(); !strings.Contains(got, "SELECT u.id FROM users u") { + t.Errorf("outer qualifier was not renamed: %s", got) + } + }) + } +} diff --git a/pkg/transform/tables_dml.go b/pkg/transform/tables_dml.go index ada35d5f..fbddebf1 100644 --- a/pkg/transform/tables_dml.go +++ b/pkg/transform/tables_dml.go @@ -91,6 +91,9 @@ func addAliasToUpdate(s *ast.UpdateStatement, tableName, alias string) error { if err := refs.check(); err != nil { return err } + if err := refs.checkCaptures(s); err != nil { + return err + } refs.apply() rewrite := refs.qualifierRewriter() for i := range s.Assignments { @@ -125,6 +128,9 @@ func addAliasToDelete(s *ast.DeleteStatement, tableName, alias string) error { if err := refs.check(); err != nil { return err } + if err := refs.checkCaptures(s); err != nil { + return err + } refs.apply() if multi { renameDeleteTargets(s, refs.matched, alias) @@ -297,66 +303,69 @@ func (t *dmlAliasTargets) apply() { // names refer to the subquery's table, not the aliased one, and are left // alone. func (t *dmlAliasTargets) qualifierRewriter() func(ast.Expression) ast.Expression { - names := t.matched return func(expr ast.Expression) ast.Expression { - shadowed := shadowedIdentifiers(expr, names) - return walkExpr(expr, func(e ast.Expression) ast.Expression { - if id, ok := e.(*ast.Identifier); ok && id.Table != "" && !shadowed[id] { - for _, n := range names { - if strings.EqualFold(id.Table, n) { - id.Table = t.alias - id.Quotes = replacedQualifierQuote(id.Quotes) - break - } - } - } - return e + rewriteAliasQualifiers(expr, t.matched, t.alias) + return expr + } +} + +// rewriteAliasQualifiers follows AST children, including window clauses. +// Each nested SELECT shadows only the names it binds, leaving other +// correlated qualifiers visible. +func rewriteAliasQualifiers(node ast.Node, names []string, alias string) { + walkAliasQualifiers(node, names, alias, false, func(id *ast.Identifier, _ bool) { + id.Table = alias + id.Quotes = replacedQualifierQuote(id.Quotes) + }) +} + +// checkCaptures rejects aliases that would redirect a correlated reference +// to a different table introduced by a nested query. +func (t *dmlAliasTargets) checkCaptures(stmt ast.Statement) error { + captured := false + for _, child := range stmt.Children() { + walkAliasQualifiers(child, t.matched, t.alias, false, func(_ *ast.Identifier, shadowed bool) { + captured = captured || shadowed }) } + if captured { + return fmt.Errorf("transform AddTableAlias: alias %q would capture a correlated qualifier", t.alias) + } + return nil } -// shadowedIdentifiers returns the identifiers of expr that sit inside a -// subquery whose FROM or JOIN list introduces one of names, so that a -// qualifier with that name resolves to the subquery's own table. -func shadowedIdentifiers(expr ast.Expression, names []string) map[*ast.Identifier]bool { - var shadowed map[*ast.Identifier]bool - mark := func(e ast.Expression) ast.Expression { - if id, ok := e.(*ast.Identifier); ok { - if shadowed == nil { - shadowed = make(map[*ast.Identifier]bool) +func walkAliasQualifiers(node ast.Node, names []string, alias string, captured bool, visit func(*ast.Identifier, bool)) { + if node == nil || len(names) == 0 { + return + } + switch n := node.(type) { + case *ast.Identifier: + for _, name := range names { + if strings.EqualFold(n.Table, name) { + visit(n, captured) + return } - shadowed[id] = true } - return e - } - walkExpr(expr, func(e ast.Expression) ast.Expression { - if sel := exprSubquery(e); sel != nil && selectIntroduces(sel, names) { - walkStmtExprs(sel, mark) + case *ast.SelectStatement: + visible := make([]string, 0, len(names)) + for _, name := range names { + if !selectIntroduces(n, []string{name}) { + visible = append(visible, name) + } } - return e - }) - return shadowed -} - -// exprSubquery returns the SELECT held by a subquery-bearing expression. -func exprSubquery(e ast.Expression) *ast.SelectStatement { - var stmt ast.Statement - switch x := e.(type) { - case *ast.SubqueryExpression: - stmt = x.Subquery - case *ast.ExistsExpression: - stmt = x.Subquery - case *ast.InExpression: - stmt = x.Subquery - case *ast.AnyExpression: - stmt = x.Subquery - case *ast.AllExpression: - stmt = x.Subquery - case *ast.ArrayConstructorExpression: - stmt = x.Subquery - } - sel, _ := stmt.(*ast.SelectStatement) - return sel + names = visible + captured = captured || selectIntroduces(n, []string{alias}) + } + for _, child := range node.Children() { + if ref, ok := node.(*ast.TableReference); ok && !ref.Lateral { + switch child.(type) { + case *ast.SelectStatement, *ast.SetOperation: + // Only LATERAL derived tables can use this query's bindings. + continue + } + } + walkAliasQualifiers(child, names, alias, captured, visit) + } } // selectIntroduces reports whether the FROM or JOIN list of sel introduces From 5e2e07657c214e4cd44b2530893f3c4704c93cbc Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Wed, 28 Oct 2026 00:36:34 +0530 Subject: [PATCH 2/2] test(formatter): time scaling over 20ms samples and a 16x size step The 4x/10x check failed on CI: Windows timed the small case at 0s and a race run measured 12x on a 10ms sample. Locally every shape grows 15-22x for 16x more input; quadratic growth would be ~256x. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/expression_scaling_test.go | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pkg/formatter/expression_scaling_test.go b/pkg/formatter/expression_scaling_test.go index 887bf59f..29d7630b 100644 --- a/pkg/formatter/expression_scaling_test.go +++ b/pkg/formatter/expression_scaling_test.go @@ -25,8 +25,9 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) -// Fourfold expression growth must stay below the sixteenfold cost of repeatedly -// rendering or scanning every descendant. Build ASTs outside the measurement so +// Sixteenfold expression growth must stay far below the 256-fold cost of +// repeatedly rendering or scanning every descendant: linear work grows ~16x, +// and the 64x bound leaves room for noisy shared CI runners. Build ASTs outside the measurement so // parser limits and parsing time do not conceal formatter regressions. func TestFormatASTExpressionLinearGrowth(t *testing.T) { for _, shape := range []struct { @@ -53,7 +54,7 @@ func TestFormatASTExpressionLinearGrowth(t *testing.T) { opts.KeywordCase = ast.KeywordLower } small, smallWant := shape.build(shape.small, mode.lower) - large, largeWant := shape.build(4*shape.small, mode.lower) + large, largeWant := shape.build(16*shape.small, mode.lower) for _, tc := range []struct { tree *ast.AST want string @@ -64,9 +65,9 @@ func TestFormatASTExpressionLinearGrowth(t *testing.T) { } smallTime := expressionFormatDuration(small, opts) largeTime := expressionFormatDuration(large, opts) - t.Logf("n=%d: %v; n=%d: %v; ratio %.2f", shape.small, smallTime, 4*shape.small, largeTime, float64(largeTime)/float64(smallTime)) - if largeTime > 10*smallTime { - t.Fatalf("fourfold expression growth took %v -> %v, exceeding 10x", smallTime, largeTime) + t.Logf("n=%d: %v; n=%d: %v; ratio %.2f", shape.small, smallTime, 16*shape.small, largeTime, float64(largeTime)/float64(smallTime)) + if largeTime > 64*smallTime { + t.Fatalf("sixteenfold expression growth took %v -> %v, exceeding 64x", smallTime, largeTime) } }) } @@ -143,17 +144,21 @@ func scalingMarkerLiterals(n int, lower bool) (*ast.AST, string) { // Use the minimum of repeated equal-size batches to limit scheduler and GC // noise. The assertion compares input sizes, never an absolute time budget. func expressionFormatDuration(tree *ast.AST, opts ast.FormatOptions) time.Duration { + // Repeat until a sample spans at least 20ms so coarse timers (Windows) + // and scheduler noise cannot dominate, then keep the best per-call time. var best time.Duration for sample := 0; sample < 3; sample++ { runtime.GC() + calls := 0 start := time.Now() - for batch := 0; batch < 2; batch++ { + for time.Since(start) < 20*time.Millisecond { out := formatter.FormatAST(tree, opts) runtime.KeepAlive(out) + calls++ } - elapsed := time.Since(start) - if best == 0 || elapsed < best { - best = elapsed + perCall := time.Since(start) / time.Duration(calls) + if best == 0 || perCall < best { + best = perCall } } return best