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: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
identifier delimiters through rendering.

### Changed
- **MySQL / MariaDB `#` line comments**: with the MySQL or MariaDB dialect, `#`
now starts a line comment, like `--`. The tokenizer records it in `Comments`
(re-emitted by the formatter), `SplitStatements` no longer splits on a `;`
inside it, and transpiling to another dialect drops it instead of emitting `#`
(PostgreSQL XOR). Previously `WHERE x = 1 # c` parsed `#` as an operator and
`SELECT a FROM t # c` failed with E2002. Other dialects keep their `#`
meaning (PostgreSQL `#`, `#>`, `#>>`, `#-`; SQL Server `#temp`; Oracle `emp#`).
- Parsed ClickHouse SAMPLE sizes and offsets now use inspectable expression
fields, which take rendering precedence over the retained legacy string fields.
- ClickHouse SAMPLE accepts only decimal numbers (hex such as `0x10` is
Expand Down Expand Up @@ -99,6 +106,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
matching `ScanSQL`. UNION injection checks also run on set operations nested in CTEs
and subqueries. Qualified column comparisons such as `a.id = b.id` are no longer
reported as tautologies.
- `GRANT ... TO [`, `TO [AppUser`, `TO CURRENT_USER(`, MariaDB
`IDENTIFIED VIA ... USING PASSWORD(` and `COPY (` with an unclosed group no
longer panic with `index out of range [-1]`; they return an E2002 error
(expected `]` or `)`) in every dialect.
- Watch now respects `--dialect`, re-validates changes and formats files in place
with the CLI safety checks; MCP parsing accepts a dialect and documents comment preservation.
- Correct transpile dialect help, avoid duplicate lint syntax errors, and rewrite
Expand Down
13 changes: 12 additions & 1 deletion internal/dropcheck/dropcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ var ignorable = map[string]bool{"AS": true, "ASC": true, "INNER": true, "OUTER":

// Words returns the upper-cased identifier-like words of s, skipping
// comments and quoted string literals but keeping quoted identifiers' text.
// For MySQL and MariaDB, '#' also starts a line comment (outside backticks).
// The optional dialect uses the tokenizer's string escape rules; omitted means
// generic SQL. PostgreSQL E-prefixed strings always allow backslash escapes.
func Words(s string, dialect ...string) []string {
Expand All @@ -87,11 +88,21 @@ func Words(s string, dialect ...string) []string {
d = sqldialect.Parse(dialect[0])
}
backslash := d.StringEscape() != sqldialect.StringEscapeNone
mysqlFamily := d == sqldialect.MySQL || d == sqldialect.MariaDB
var words []string
for i := 0; i < len(s); {
c := s[i]
switch {
case c == '-' && i+1 < len(s) && s[i+1] == '-':
case c == '`' && mysqlFamily:
// A backtick identifier: keep its words, but a '#' inside it does
// not start a comment.
end := strings.IndexByte(s[i+1:], '`')
if end < 0 {
end = len(s) - i - 1
}
words = append(words, Words(s[i+1:i+1+end])...)
i += end + 2
case c == '-' && i+1 < len(s) && s[i+1] == '-', c == '#' && mysqlFamily:
for i < len(s) && s[i] != '\n' {
i++
}
Expand Down
28 changes: 28 additions & 0 deletions internal/dropcheck/dropcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,31 @@ func TestDroppedWordsCorpusCases(t *testing.T) {
})
}
}

func TestWordsHashComments(t *testing.T) {
tests := []struct {
name string
dialect string
in string
want []string
}{
{"mysql hash comment skipped", "mysql", "SELECT a # gone\nFROM t", []string{"SELECT", "A", "FROM", "T"}},
{"mariadb hash comment skipped", "mariadb", "SELECT a #gone", []string{"SELECT", "A"}},
{"mysql backtick hash kept", "mysql", "SELECT `a#b` FROM t", []string{"SELECT", "A", "B", "FROM", "T"}},
{"postgresql hash is an operator", "postgresql", "SELECT a # b", []string{"SELECT", "A", "B"}},
{"generic hash is not a comment", "", "SELECT a # b", []string{"SELECT", "A", "B"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []string
if tt.dialect == "" {
got = Words(tt.in)
} else {
got = Words(tt.in, tt.dialect)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Words(%q, %q) = %v, want %v", tt.in, tt.dialect, got, tt.want)
}
})
}
}
110 changes: 110 additions & 0 deletions pkg/gosqlx/hash_comments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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 gosqlx

import (
"strings"
"testing"

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

func TestHashCommentsIntegration(t *testing.T) {
const source = "SELECT a FROM t WHERE x = 1 # c\nAND y = 2"
for _, d := range []keywords.SQLDialect{keywords.DialectMySQL, keywords.DialectMariaDB} {
t.Run(string(d), func(t *testing.T) {
tree, err := ParseWithDialect(source, d)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(tree)
if len(tree.Statements) != 1 {
t.Fatalf("statements = %d", len(tree.Statements))
}
sel, ok := tree.Statements[0].(*ast.SelectStatement)
if !ok {
t.Fatalf("statement = %T", tree.Statements[0])
}
and, ok := sel.Where.(*ast.BinaryExpression)
if !ok || and.Operator != "AND" {
t.Fatalf("WHERE = %#v", sel.Where)
}
for _, side := range []struct {
expr ast.Expression
want string
}{{and.Left, "x = 1"}, {and.Right, "y = 2"}} {
eq, ok := side.expr.(*ast.BinaryExpression)
if !ok || eq.Operator != "=" || eq.SQL() != side.want {
t.Fatalf("predicate = %#v, want %s", side.expr, side.want)
}
}
trailing, err := ParseWithDialect("SELECT a FROM t # trailing", d)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(trailing)
formatted, err := FormatWithDialect("SELECT a FROM t # trailing", FormatOptions{}, d)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(formatted, "# trailing") {
t.Fatalf("comment lost: %q", formatted)
}
reparsed, err := ParseWithDialect(formatted, d)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(reparsed)
if len(reparsed.Statements) != 1 || FormatAST(reparsed) != FormatAST(trailing) {
t.Fatalf("format changed statement: %q", formatted)
}
got, err := Transpile(source, d, keywords.DialectPostgreSQL)
if err != nil {
t.Fatal(err)
}
if got != "SELECT a FROM t WHERE x = 1 AND y = 2" {
t.Fatalf("transpiled = %q", got)
}
})
}
}

func TestSplitStatementsHashComments(t *testing.T) {
for _, d := range []string{"mysql", "mariadb", "postgresql", ""} {
t.Run(d, func(t *testing.T) {
got := SplitStatements("SELECT 1; # a;b\nSELECT 2", d)
want := 3
if d == "mysql" || d == "mariadb" {
want = 2
}
if len(got) != want || got[0] != "SELECT 1" || !strings.Contains(got[len(got)-1], "SELECT 2") {
t.Fatalf("split = %#v", got)
}
})
}
for _, d := range []string{"mysql", "mariadb"} {
for _, source := range []string{
"BEGIN # ignored; END\nSELECT 1; SELECT 2; END; SELECT 3",
"BEGIN # ignored\nNOT # ignored\nATOMIC SELECT 1; SELECT 2; END; SELECT 3",
"DELIMITER $$\nCREATE PROCEDURE p() BEGIN # ignored $$; END\nSELECT 1; END$$\nDELIMITER ;\nSELECT 2;",
"BEGIN SELECT 1; END # ignored\nIF; SELECT 2;",
} {
if got := SplitStatements(source, d); len(got) != 2 {
t.Errorf("%s split %q = %#v", d, source, got)
}
}
}
}
7 changes: 6 additions & 1 deletion pkg/gosqlx/splitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type dialectProfile struct {
supportsEStrings bool
supportsBackslash bool
supportsNestedBlockComments bool
hashComments bool

// Procedural-script features (see splitter_proc.go).
tsql bool // GO batches; IF/WHILE/LOOP are not delimited by END IF etc.
Expand All @@ -108,7 +109,7 @@ func profileForDialect(dialect string) dialectProfile {
supportsNestedBlockComments: true,
}
case "mysql", "mariadb":
return dialectProfile{supportsBackticks: true, delimiter: true}
return dialectProfile{supportsBackticks: true, delimiter: true, hashComments: true}
case "clickhouse":
return dialectProfile{supportsBackticks: true}
case "sqlserver", "mssql", "tsql":
Expand Down Expand Up @@ -286,6 +287,10 @@ func splitCore(src string, p dialectProfile) (out []string, open bool) {
inLine = true
i += 2

case c == '#' && p.hashComments:
inLine = true
i++

case c == '/' && i+1 < len(src) && src[i+1] == '*':
blockCommentDepth = 1
i += 2
Expand Down
22 changes: 14 additions & 8 deletions pkg/gosqlx/splitter_proc.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type procState struct {
on, tsql, oracle bool
depth int
declMask uint64
hashComments bool
// stmtStart is true while the next word begins a statement (after ';',
// BEGIN, THEN, ELSE, LOOP, a label, …). IF/WHILE/FOR/REPEAT only open a
// block there, which keeps "DROP TABLE IF EXISTS" and IF() calls out.
Expand All @@ -66,14 +67,15 @@ type procState struct {

func (s *procState) init(p dialectProfile) {
s.on = !p.noBlocks
s.hashComments = p.hashComments
s.tsql = p.tsql
s.oracle = p.oracle
s.stmtStart = true
}

// reset discards any open block (a batch separator ends them all).
func (s *procState) reset() {
*s = procState{on: s.on, tsql: s.tsql, oracle: s.oracle, stmtStart: true}
*s = procState{on: s.on, tsql: s.tsql, oracle: s.oracle, stmtStart: true, hashComments: s.hashComments}
}

func (s *procState) semicolon() {
Expand Down Expand Up @@ -161,15 +163,15 @@ func (s *procState) word(src string, i, j int) int {
s.stmtStart = true
return j
}
if beginNamesColumn(src, i) || !beginIsBlock(src, j) {
if beginNamesColumn(src, i) || !beginIsBlock(src, j, s.hashComments) {
return j
}
s.push(false)
s.stmtStart = true

case kwEnd:
s.pop()
w, _, end := peekWord(src, j)
w, _, end := peekWordWithHash(src, j, s.hashComments)
var b [12]byte
switch string(foldWord(&b, w)) {
case "try", "catch":
Expand Down Expand Up @@ -252,7 +254,7 @@ func (s *procState) word(src string, i, j int) int {
case kwIsAs:
if s.oracle && s.routineHdr {
s.routineHdr = false
w, _, _ := peekWord(src, j)
w, _, _ := peekWordWithHash(src, j, s.hashComments)
var b [12]byte
switch string(foldWord(&b, w)) {
case "language", "external":
Expand Down Expand Up @@ -328,8 +330,8 @@ func foldWord(buf *[12]byte, w string) []byte {
// beginIsBlock reports whether the BEGIN ending at j opens a block rather
// than starting a transaction (BEGIN; BEGIN TRANSACTION; BEGIN IMMEDIATE …)
// or being a column or alias that happens to be called begin.
func beginIsBlock(src string, j int) bool {
w, c, end := peekWord(src, j)
func beginIsBlock(src string, j int, hashComments bool) bool {
w, c, end := peekWordWithHash(src, j, hashComments)
if w == "" {
// BEGIN :NEW.x := ..., BEGIN <<label>>, BEGIN "x" := ... open a
// PL/SQL block; BEGIN; and BEGIN at the end do not.
Expand All @@ -354,7 +356,7 @@ func beginIsBlock(src string, j int) bool {
case "not":
// BEGIN NOT ATOMIC (MariaDB) is a block; BEGIN NOT DEFERRABLE (PG)
// is a transaction.
w2, _, _ := peekWord(src, end)
w2, _, _ := peekWordWithHash(src, end, hashComments)
var b2 [12]byte
return string(foldWord(&b2, w2)) == "atomic"
}
Expand Down Expand Up @@ -393,13 +395,17 @@ func beginNamesColumn(src string, i int) bool {
// follows (empty when the next token is not a word), the byte found there
// (0 at end of input) and the index just past the word.
func peekWord(src string, j int) (w string, c byte, end int) {
return peekWordWithHash(src, j, false)
}

func peekWordWithHash(src string, j int, hashComments bool) (w string, c byte, end int) {
for j < len(src) {
c = src[j]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v':
j++
continue
case c == '-' && j+1 < len(src) && src[j+1] == '-':
case (c == '-' && j+1 < len(src) && src[j+1] == '-') || (c == '#' && hashComments):
for j < len(src) && src[j] != '\n' {
j++
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/highlight/highlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ func (h *classifier) lastUnclosed(limit int) int {
for i := 0; i < len(s); {
c := s[i]
switch {
case c == '-' && strings.HasPrefix(s[i:], "--"):
case c == '-' && strings.HasPrefix(s[i:], "--") ||
c == '#' && (h.dialect == keywords.DialectMySQL || h.dialect == keywords.DialectMariaDB):
j := strings.IndexByte(s[i:], '\n')
if j < 0 {
return -1
Expand Down
28 changes: 28 additions & 0 deletions pkg/highlight/highlight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ func find(t *testing.T, src string, spans []Span, text string) Category {
return Plain
}

func TestLastUnclosedHashComments(t *testing.T) {
tests := []struct {
name string
sql string
wantMySQL int
wantOther int
}{
{"quote in trailing comment", "SELECT 1 # '", -1, 11},
{"block opener in trailing comment", "SELECT 1 # /*", -1, 11},
{"unclosed quote after comment", "# ' ignored\nSELECT '", 19, -1},
{"hash inside string", "SELECT '#', '", 12, 12},
}
for _, d := range []keywords.SQLDialect{keywords.DialectMySQL, keywords.DialectMariaDB, keywords.DialectPostgreSQL, keywords.DialectGeneric} {
for _, tt := range tests {
t.Run(string(d)+"/"+tt.name, func(t *testing.T) {
want := tt.wantOther
if d == keywords.DialectMySQL || d == keywords.DialectMariaDB {
want = tt.wantMySQL
}
h := classifier{src: tt.sql, dialect: d}
if got := h.lastUnclosed(len(tt.sql)); got != want {
t.Errorf("lastUnclosed(%q) = %d, want %d", tt.sql, got, want)
}
})
}
}
}

func TestSpans_Classification(t *testing.T) {
tests := []struct {
name string
Expand Down
7 changes: 7 additions & 0 deletions pkg/sql/parser/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ func FuzzParse(f *testing.F) {
"MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = s.a",
"SELECT data->>'name' FROM t",
"TRUNCATE TABLE t CASCADE",
"GRANT SELECT ON t TO [",
"GRANT SELECT ON t TO CURRENT_USER(",
"GRANT SELECT ON t TO [AppUser",
"CREATE USER u IDENTIFIED VIA ed25519 USING PASSWORD(",
"GRANT SELECT ON t TO u IDENTIFIED VIA ed25519 USING PASSWORD(",
"COPY (SELECT 1",
"CALL p(",
"",
";;;",
"SELECT",
Expand Down
Loading
Loading