-
Notifications
You must be signed in to change notification settings - Fork 5
feat(mysql): expose executable comment recognition #398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
+3
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With a multibyte connection character set whose trailing bytes may include 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 | ||
| } | ||
| } | ||
| } | ||
| 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)) | ||
| } | ||
| } |
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
fujiabao89 marked this conversation as resolved.
Comment on lines
1949
to
+1950
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. |
||
| 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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the session enables
ANSI_QUOTESwithoutNO_BACKSLASH_ESCAPES, double quotes delimit identifiers and a backslash does not escape the following quote. For example, inSELECT "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 applicableANSI_QUOTESmode to the options and scan double-quoted identifiers with identifier escaping, or otherwise conservatively detect this boundary.Useful? React with 👍 / 👎.