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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
in dialects without them.

### Fixed
- 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.
- **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
Expand Down
30 changes: 30 additions & 0 deletions cmd/gosqlx/cmd/format_corpus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ func corpusDialect(rel string) string {
}
}

func TestCorpusDialectSQLServer(t *testing.T) {
for _, path := range []string{"realworld/sqlserver/01.sql", "realworld/mssql/01.sql"} {
if got := corpusDialect(path); got != "sqlserver" {
t.Errorf("corpusDialect(%q) = %q", path, got)
}
}
}

func TestCLI_FormatOracleExecuteLines(t *testing.T) {
const input = "BEGIN\n NULL;\nEND;\n/\nBEGIN\n NULL;\nEND;\n/\nSELECT 3 FROM dual;"
// SQL*Plus execute commands do not add statements to the SQL buffer.
reference := strings.ReplaceAll(input, "\n/\n", "\n")
original, err := parseWithDialect(t, reference, "oracle")
if err != nil {
t.Fatalf("reference script does not parse: %v", err)
}
defer ast.ReleaseAST(original)
if len(original.Statements) != 3 {
t.Fatalf("reference script has %d statements, want 3", len(original.Statements))
}

t.Cleanup(func() { rootCmd.SetArgs(nil) })
path := writeTemp(t, "oracle.sql", input)
result := runCLI(t, nil, "format", "--dialect", "oracle", path)
if result.err != nil {
t.Fatalf("format --dialect oracle failed: %v\nstderr:\n%s", result.err, result.stderr)
}
checkCorpusOutput(t, "Oracle execute lines", "CLI", result.stdout, "oracle", original.SQL(), len(original.Statements))
}

// TestFormatSQL_CorpusRoundTrip formats every parseable testdata/**/*.sql file
// with the CLI formatter in several modes and requires that the output
// re-parses to an equivalent tree and formats idempotently (#599).
Expand Down
4 changes: 3 additions & 1 deletion internal/batch/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ func End(tokens []models.TokenWithSpan, i int) (int, bool) {

// Line reports whether the token at index i is an unquoted GO alone on its
// line, optionally followed on that line by one number token: the shape of a
// sqlcmd batch separator. end is the index of the first token after the line
// sqlcmd batch separator. SQL comments are trivia and may follow GO or its
// count, including a block comment spanning multiple lines (see TextEnd).
// end is the index of the first token after the line
// and valid reports whether the number, if any, is a valid repeat count
// (GO 0 and GO 1.5 have the shape of a separator but an invalid count).
// Tokens without source positions cannot describe a client directive.
Expand Down
72 changes: 72 additions & 0 deletions internal/batch/text.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2026 GoSQLX Authors
// SPDX-License-Identifier: Apache-2.0

package batch

// TextEnd returns the end of a GO directive whose GO word ends at wordEnd.
// The caller must check that GO is the first word on its line. As with Line,
// comments are trivia: both line and block comments may follow GO [count].
// A block comment can span lines; text after its closing delimiter belongs to
// the next line, not the GO line. Repeat counts must be positive whole numbers;
// zero and nonnumeric arguments are rejected rather than silently ignored.
// This is sqlcmd's GO [count] syntax with SQL comments allowed on the line.
func TextEnd(src string, wordEnd int) (int, bool) {
if wordEnd < 0 || wordEnd > len(src) {
return 0, false
}
k := wordEnd
count := false
for k < len(src) {
switch src[k] {
case ' ', '\t', '\r':
k++
case '\n':
return k + 1, true
case '-':
if k+1 >= len(src) || src[k+1] != '-' {
return 0, false
}
for k < len(src) && src[k] != '\n' {
k++
}
case '/':
if k+1 >= len(src) || src[k+1] != '*' {
return 0, false
}
k += 2
depth := 1
crossedLine := false
for k < len(src) && depth > 0 {
if src[k] == '\n' {
crossedLine = true
}
// Match the tokenizer: block comments do not nest.
if k+1 < len(src) && src[k:k+2] == "*/" {
depth--
k += 2
} else {
k++
}
}
if depth != 0 {
return 0, false
}
if crossedLine {
return k, true
}
default:
if count {
return 0, false
}
start := k
for k < len(src) && src[k] >= '0' && src[k] <= '9' {
k++
}
if _, valid := Count(src[start:k]); !valid {
return 0, false
}
count = true
}
}
return k, true
}
45 changes: 45 additions & 0 deletions internal/batch/text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2026 GoSQLX Authors
// SPDX-License-Identifier: Apache-2.0

package batch_test

import (
"testing"

"github.com/ajitpratap0/GoSQLX/internal/batch"
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
"github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer"
)

func TestCommentedLines(t *testing.T) {
for _, tc := range []struct {
text string
want bool
}{
{"GO", true}, {"go", true}, {"GO 2", true}, {"GO 2 -- c", true},
{"GO -- c", true}, {"GO /* c */", true}, {"GO 2 /* c */", true},
{"GO /* multi\nline */", true}, {"GO 0", false}, {"GO x", false},
{"GO 2 /* c */ x", false}, {"GO /* c */ 2", true},
{"GO /* outer /* inner */ x */", false},
} {
for _, ending := range []string{"\n", "\r\n"} {
t.Run(tc.text+ending, func(t *testing.T) {
src := tc.text + ending + "SELECT 1"
_, got := batch.TextEnd(src, 2)
if got != tc.want {
t.Errorf("TextEnd(%q) = %v, want %v", src, got, tc.want)
}
tkz := tokenizer.GetTokenizer()
defer tokenizer.PutTokenizer(tkz)
tkz.SetDialect(keywords.DialectSQLServer)
tokens, err := tkz.TokenizeString(src)
if err != nil {
t.Fatal(err)
}
if _, got := batch.End(tokens, 0); got != tc.want {
t.Errorf("End(%q) = %v, want %v", src, got, tc.want)
}
})
}
}
}
101 changes: 101 additions & 0 deletions internal/dialectaudit/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright 2026 GoSQLX Authors
// SPDX-License-Identifier: Apache-2.0

// Package dialectaudit implements the shared per-statement corpus audit.
package dialectaudit

import (
"fmt"
"strings"

"github.com/ajitpratap0/GoSQLX/internal/dropcheck"
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
)

// Result identifies the failed pipeline stage, or ok/empty for passing input.
type Result struct {
Stage string
Error string
Format string
}

// Check parses, formats, reparses and reformats one statement, then checks for
// dropped words. Callbacks let in-package gosqlx tests use the same pipeline
// as the command without creating an import cycle.
func Check(stmt string, d keywords.SQLDialect, parse func(string, keywords.SQLDialect) (*ast.AST, error), format func(*ast.AST) string) (r Result) {
defer func() {
if p := recover(); p != nil {
r.Stage, r.Error = "panic", fmt.Sprint(p)
}
}()
if Empty(stmt) {
return Result{Stage: "empty"}
}
tree, err := parse(stmt, d)
if err != nil {
return Result{Stage: "parse", Error: err.Error()}
}
defer ast.ReleaseAST(tree)
f1 := format(tree)
r.Format = f1
tree2, err := parse(f1, d)
if err != nil {
r.Stage, r.Error = "reparse", err.Error()
return r
}
defer ast.ReleaseAST(tree2)
if f2 := format(tree2); f2 != f1 {
r.Stage, r.Error = "idempotent", "format(parse(format(x))) != format(x): "+Compact(f2)
return r
}
if lost := dropcheck.DroppedWords(stmt, f1, string(d)); len(lost) > 0 {
r.Stage, r.Error = "drop", "formatter output lost words: "+strings.Join(lost, " ")
return r
}
return Result{Stage: "ok"}
}

// Empty reports whether s consists only of whitespace and complete comments.
// Unterminated block comments remain input so the parser can reject them.
func Empty(s string) bool {
for i := 0; i < len(s); {
switch {
case strings.ContainsRune(" \t\r\n\f\v", rune(s[i])):
i++
case strings.HasPrefix(s[i:], "--"):
if end := strings.IndexByte(s[i:], '\n'); end >= 0 {
i += end + 1
} else {
return true
}
case strings.HasPrefix(s[i:], "/*"):
depth := 1
i += 2
for i < len(s) && depth > 0 {
switch {
case strings.HasPrefix(s[i:], "*/"):
depth--
i += 2
default:
i++
}
}
if depth > 0 {
return false
}
default:
return false
}
}
return true
}

// Compact produces a bounded single-line diagnostic.
func Compact(s string) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > 300 {
s = string(r[:300]) + "…"
}
return s
}
67 changes: 67 additions & 0 deletions internal/dialectaudit/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 GoSQLX Authors
// SPDX-License-Identifier: Apache-2.0

package dialectaudit

import (
"errors"
"testing"

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

func TestEmpty(t *testing.T) {
for _, tc := range []struct {
sql string
want bool
}{
{"", true}, {" -- line\n /* block\n comment */ -- tail", true},
{"/* outer /* inner */ rest */", false}, {"/* unfinished", false},
{"/* comment */ SELECT 1", false}, {"'-- literal'", false}, {";", false},
} {
if got := Empty(tc.sql); got != tc.want {
t.Errorf("Empty(%q) = %v, want %v", tc.sql, got, tc.want)
}
}
}

func TestCheckStages(t *testing.T) {
for _, stage := range []string{"ok", "empty", "parse", "reparse", "idempotent", "drop", "panic"} {
t.Run(stage, func(t *testing.T) {
calls := 0
parse := func(string, keywords.SQLDialect) (*ast.AST, error) {
calls++
if stage == "panic" {
panic("test panic")
}
if stage == "parse" || stage == "reparse" && calls == 2 {
return nil, errors.New("test error")
}
return nil, nil
}
formats := 0
format := func(*ast.AST) string {
formats++
if stage == "idempotent" && formats == 2 {
return "SELECT y"
}
return "SELECT x"
}
sql := "SELECT x"
if stage == "drop" {
sql = "SELECT x WHERE x"
}
if stage == "empty" {
sql = "/* comments only */"
}
got := Check(sql, keywords.DialectPostgreSQL, parse, format)
if got.Stage != stage {
t.Fatalf("stage = %q, want %q: %s", got.Stage, stage, got.Error)
}
if stage == "empty" && calls != 0 {
t.Fatal("parsed empty statement")
}
})
}
}
Loading
Loading