diff --git a/mysql/parser/executable_comment.go b/mysql/parser/executable_comment.go new file mode 100644 index 00000000..ae9cd316 --- /dev/null +++ b/mysql/parser/executable_comment.go @@ -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 +} + +// 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 + } + } +} diff --git a/mysql/parser/executable_comment_internal_test.go b/mysql/parser/executable_comment_internal_test.go new file mode 100644 index 00000000..317ad819 --- /dev/null +++ b/mysql/parser/executable_comment_internal_test.go @@ -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)) + } +} diff --git a/mysql/parser/executable_comment_test.go b/mysql/parser/executable_comment_test.go new file mode 100644 index 00000000..6e72b5ca --- /dev/null +++ b/mysql/parser/executable_comment_test.go @@ -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) + } +} diff --git a/mysql/parser/lexer.go b/mysql/parser/lexer.go index 6ff2b1c2..a33f19b5 100644 --- a/mysql/parser/lexer.go +++ b/mysql/parser/lexer.go @@ -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 @@ -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++ @@ -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 + if l.stopAtExecutableComment { + return + } // Skip /*! innerStart := l.pos + 3 // Skip optional version number (digits) @@ -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] @@ -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