Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Statement splitting follows dialect string escapes, parenthesized bodies,
commented SQL Server GO lines and Oracle SQL*Plus execute lines; corpus and
CLI audits share a pipeline and detect repeated dropped words.
Expand Down Expand Up @@ -146,9 +151,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
`<stdin>`, validates `GO`/`DELIMITER` scripts and files over the token
Expand Down
2 changes: 1 addition & 1 deletion cmd/gosqlx/cmd/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions cmd/gosqlx/cmd/format_comment_starts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
42 changes: 42 additions & 0 deletions cmd/gosqlx/cmd/format_keyword_case_line_endings_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
13 changes: 10 additions & 3 deletions cmd/gosqlx/cmd/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion docs/CLI_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions pkg/formatter/comment_line_endings_batch_test.go
Original file line number Diff line number Diff line change
@@ -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):]
}
}
}
}
12 changes: 11 additions & 1 deletion pkg/formatter/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down
165 changes: 165 additions & 0 deletions pkg/formatter/expression_scaling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package formatter_test

import (
"fmt"
"runtime"
"strings"
"testing"
"time"

"github.com/ajitpratap0/GoSQLX/pkg/formatter"
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
)

// 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 {
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(16*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, 16*shape.small, largeTime, float64(largeTime)/float64(smallTime))
if largeTime > 64*smallTime {
t.Fatalf("sixteenfold expression growth took %v -> %v, exceeding 64x", 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 {
// 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 time.Since(start) < 20*time.Millisecond {
out := formatter.FormatAST(tree, opts)
runtime.KeepAlive(out)
calls++
}
perCall := time.Since(start) / time.Duration(calls)
if best == 0 || perCall < best {
best = perCall
}
}
return best
}
Loading
Loading