Skip to content
Open
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
31 changes: 31 additions & 0 deletions mysql/parser/executable_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package parser

// ExecutableCommentOptions configures SQL modes that affect lexical comment
// recognition.
type ExecutableCommentOptions struct {
// NoBackslashEscapes must match the session's NO_BACKSLASH_ESCAPES SQL
// mode. When true, backslashes in string literals are ordinary bytes.
NoBackslashEscapes bool
Comment on lines +5 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for ANSI_QUOTES when scanning double quotes

When the session enables ANSI_QUOTES without NO_BACKSLASH_ESCAPES, double quotes delimit identifiers and a backslash does not escape the following quote. For example, in SELECT "x\" /*!50000 + 1 */, MySQL closes the identifier before the comment and recognizes the executable opener, while this helper treats \" as part of a string and returns false. Add the applicable ANSI_QUOTES mode to the options and scan double-quoted identifiers with identifier escaping, or otherwise conservatively detect this boundary.

Useful? React with 👍 / 👎.

Comment on lines +3 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make string scanning aware of the connection character set

With a multibyte connection character set whose trailing bytes may include 0x5c, the byte-oriented string scanner can mistake part of a character for a backslash escape and hide an active opener. For example, under sjis, the bytes in SELECT '\x95\x5c' /*!50000 DROP TABLE t*/ encode one character followed by the closing quote, so MySQL sees the executable comment; this lexer consumes that quote as escaped and returns false. Pass the connection character set into detection or conservatively handle backslashes that can be multibyte trailing bytes.

Useful? React with 👍 / 👎.

}

// ContainsExecutableComment reports whether sql contains a MySQL executable
// comment (/*! ... */). Recognition is performed by the MySQL lexer, so text
// inside string literals, quoted identifiers, ordinary comments, optimizer
// hints, and line comments is not reported.
//
// Unknown version prefixes and unterminated executable comments return true.
func ContainsExecutableComment(sql string, options ExecutableCommentOptions) bool {
lexer := NewLexer(sql)
lexer.noBackslashEscapes = options.NoBackslashEscapes
lexer.stopAtExecutableComment = true

for {
tok := lexer.NextToken()
if lexer.hasExecutableComment {
return true
}
if tok.Type == tokEOF {
return false
}
}
}
20 changes: 20 additions & 0 deletions mysql/parser/executable_comment_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package parser

import (
"strings"
"testing"
)

func TestExecutableCommentDetectionStopsBeforeSplice(t *testing.T) {
lexer := NewLexer(strings.Repeat("/*! */", 1000))
lexer.stopAtExecutableComment = true

lexer.NextToken()

if !lexer.hasExecutableComment {
t.Fatal("lexer did not detect executable comment")
}
if len(lexer.spliceGaps) != 0 {
t.Fatalf("detection-only lexer performed %d splices, want 0", len(lexer.spliceGaps))
}
}
140 changes: 140 additions & 0 deletions mysql/parser/executable_comment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package parser_test

import (
"testing"

"github.com/bytebase/omni/mysql/parser"
)

func TestContainsExecutableComment(t *testing.T) {
tests := []struct {
name string
sql string
want bool
}{
{
name: "ddl",
sql: "/*!50000 DROP TABLE t*/ SELECT 1",
want: true,
},
{
name: "alter",
sql: "/*!50000 ALTER TABLE t ADD COLUMN x INT*/ SELECT 1",
want: true,
},
{
name: "delete",
sql: "/*!50000 DELETE FROM t*/ SELECT 1",
want: true,
},
{
name: "insert",
sql: "/*!50000 INSERT INTO t VALUES (1)*/ SELECT 1",
want: true,
},
{
name: "unknown_version",
sql: "/*!99999 SELECT 1*/",
want: true,
},
{
name: "expression",
sql: "SELECT /*!80100 42*/ FROM dual",
want: true,
},
{
name: "no_version",
sql: "SELECT /*! 1 + 1 */ FROM dual",
want: true,
},
{
name: "multiline",
sql: "/*!50000\nDROP TABLE t;\n*/ SELECT 1",
want: true,
},
{
name: "multiple_statements_in_comment",
sql: "/*!50000 DROP TABLE t; DELETE FROM t2*/ SELECT 1",
want: true,
},
{
name: "cte_dml",
sql: "/*!50000 WITH cte AS (SELECT 1) DELETE FROM t*/ SELECT 1",
want: true,
},
{
name: "nested_comment",
sql: "SELECT /*!80100 1 /* nested */ + 2 */",
want: true,
},
{
name: "select_list",
sql: "SELECT a, /*!80100 b, */ c FROM t",
want: true,
},
{
name: "invalid_version_format",
sql: "/*!version SELECT 1*/",
want: true,
},
{
name: "unterminated",
sql: "/*!50000 SELECT 1",
want: true,
},
{
name: "string_literal",
sql: "SELECT '/*!50000 harmless*/' AS str",
want: false,
},
{
name: "quoted_identifier",
sql: "SELECT `/*!50000` AS quoted",
want: false,
},
{
name: "ordinary_block_comment",
sql: "SELECT 1 /* benign comment */",
want: false,
},
{
name: "optimizer_hint",
sql: "SELECT /*+ optimizer_hint */ 1",
want: false,
},
{
name: "line_comment",
sql: "-- /*!50000 line comment\nSELECT 1",
want: false,
},
{
name: "line_comment_form_feed",
sql: "--\f/*!50000 DROP TABLE t*/",
want: false,
},
{
name: "line_comment_vertical_tab",
sql: "--\v/*!50000 DROP TABLE t*/",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parser.ContainsExecutableComment(tt.sql, parser.ExecutableCommentOptions{}); got != tt.want {
t.Fatalf("ContainsExecutableComment(%q) = %v, want %v", tt.sql, got, tt.want)
}
})
}
}

func TestContainsExecutableCommentNoBackslashEscapes(t *testing.T) {
sql := "SELECT 'x\\' /*!50000 + 1 */"

if got := parser.ContainsExecutableComment(sql, parser.ExecutableCommentOptions{}); got {
t.Fatalf("ContainsExecutableComment(%q, default mode) = true, want false", sql)
}
if got := parser.ContainsExecutableComment(sql, parser.ExecutableCommentOptions{NoBackslashEscapes: true}); !got {
t.Fatalf("ContainsExecutableComment(%q, NO_BACKSLASH_ESCAPES) = false, want true", sql)
}
}
29 changes: 25 additions & 4 deletions mysql/parser/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,17 @@ type Lexer struct {
prevTokenEnd int // end position of the previously emitted token (for adjacency checks)
baseOffset int // added to all token Loc values for absolute positioning

// hasExecutableComment records whether lexing encountered MySQL's
// executable-comment opener (/*!). It is set even when the comment is
// unterminated or its version prefix is unknown so callers can fail closed.
hasExecutableComment bool
// stopAtExecutableComment lets the public detection helper return at the
// first opener without splicing the remaining input.
stopAtExecutableComment bool
// noBackslashEscapes mirrors MySQL's NO_BACKSLASH_ESCAPES SQL mode for
// string-boundary recognition.
noBackslashEscapes bool

// errMsg/errPos record the first lexing error (unterminated comment, string,
// or quoted identifier). A malformed token that runs to EOF without its closing
// delimiter must never index past the buffer; instead the scan stops at EOF and
Expand Down Expand Up @@ -1907,10 +1918,10 @@ func (l *Lexer) skipWhitespaceAndComments() {
continue
}

// Line comment: -- must be followed by a space, tab, newline, or end-of-input (per MySQL spec).
// Line comment: -- must be followed by whitespace/control or end-of-input.
if ch == '-' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '-' {
// Check third character: must be space, tab, newline, or end of input.
if l.pos+2 >= len(l.input) || l.input[l.pos+2] == ' ' || l.input[l.pos+2] == '\t' || l.input[l.pos+2] == '\n' || l.input[l.pos+2] == '\r' {
// The third character must be whitespace/control or end-of-input.
if l.pos+2 >= len(l.input) || isMySQLSpaceOrControl(l.input[l.pos+2]) {
l.pos += 2
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
l.pos++
Expand All @@ -1936,6 +1947,10 @@ func (l *Lexer) skipWhitespaceAndComments() {
// MySQL conditional comments: /*!NNNNN ... */ or /*! ... */
// These should be parsed as SQL, not skipped.
if l.pos+2 < len(l.input) && l.input[l.pos+2] == '!' {
l.hasExecutableComment = true
Comment thread
fujiabao89 marked this conversation as resolved.
Comment on lines 1949 to +1950

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop regular comments at MySQL's first terminator

Because this flag is set only after the ordinary-comment skipper exposes an opener, the skip logic's generic nesting behavior can hide a server-visible executable comment. MySQL regular block comments do not generally nest, so in /* outer /* inner */ /*!50000 DROP TABLE t*/ the first */ ends the ordinary comment and MySQL processes the following executable comment; this lexer instead increments its nesting depth for the inner /*, consumes through the executable comment's closing delimiter, and returns false. The detection path should scan ordinary comments using MySQL's actual termination rules.

Useful? React with 👍 / 👎.

if l.stopAtExecutableComment {
return
}
// Skip /*!
innerStart := l.pos + 3
// Skip optional version number (digits)
Expand Down Expand Up @@ -2105,7 +2120,7 @@ func (l *Lexer) scanString(quote byte) Token {
closed = true
break
}
} else if ch == '\\' {
} else if ch == '\\' && !l.noBackslashEscapes {
l.pos++
if l.pos < len(l.input) {
esc := l.input[l.pos]
Expand Down Expand Up @@ -2150,6 +2165,12 @@ func (l *Lexer) scanString(quote byte) Token {
return Token{Type: tokSCONST, Str: sb.String(), Loc: start}
}

// isMySQLSpaceOrControl reports the ASCII bytes MySQL accepts after "--" to
// introduce a line comment.
func isMySQLSpaceOrControl(ch byte) bool {
return ch <= ' ' || ch == 0x7f
}

func (l *Lexer) scanNumber() Token {
start := l.pos

Expand Down