From 5bea6dd1a056cdd6f78d51b735c2a939267bb21b Mon Sep 17 00:00:00 2001 From: rebelice Date: Tue, 28 Jul 2026 19:17:09 +0900 Subject: [PATCH] oracle: match Oracle's non-nested block comment semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oracle block comments do not nest: the first */ terminates the comment regardless of /* sequences inside it. Both the lexer and the splitter's comment scanner counted nesting depth (PostgreSQL semantics, where nesting is genuine scan.l behavior), so a commented-out code region containing /* left the scanner in comment state through EOF. The statement terminator and the standalone SQL*Plus slash after it were never recognized, and the slash was sent to Oracle as part of the statement — PLS-00103, invalid package body (BYT-9963, 232KB customer package with the marker at body-relative line 4584). Engine-verified on 11gR2 in both directions: SELECT 1 /* a /* b */ FROM dual -- succeeds SELECT 2 /* a /* b */ */ FROM dual -- ORA-00936 (legal only if nested) Fix both scan sites to terminate at the first */ so the lexer and the splitter stay byte-identical. Hints (/*+ ... */) follow the same rule, keeping tokHINT. Fences: both truth directions, hint termination, unterminated forms, /* markers in strings and line comments, division, the minimal customer shape, and the spec+body package pair; the real customer file now splits into 2 segments with no retained slash. Fixes BYT-9963. Co-Authored-By: Claude Fable 5 --- oracle/parser/lexer.go | 25 +++--- oracle/parser/nonnested_comment_test.go | 103 ++++++++++++++++++++++++ oracle/parser/split.go | 22 +++-- 3 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 oracle/parser/nonnested_comment_test.go diff --git a/oracle/parser/lexer.go b/oracle/parser/lexer.go index 248dbe4f..c4df5269 100644 --- a/oracle/parser/lexer.go +++ b/oracle/parser/lexer.go @@ -233,29 +233,26 @@ func (l *Lexer) lexBlockCommentOrHint() Token { l.pos++ // skip + } + // Oracle block comments do not nest: the first */ terminates the + // comment regardless of any /* sequences inside it. Engine-verified in + // both directions on 11gR2: SELECT 1 /* a /* b */ FROM dual succeeds, + // while appending a second */ is ORA-00936 — the shape that would be + // legal under nesting semantics. (PostgreSQL nests; this is a genuine + // per-engine divergence, so the PG lexer must keep its counting.) var buf strings.Builder - depth := 1 + terminated := false - for l.pos < len(l.input) && depth > 0 { - if l.input[l.pos] == '/' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '*' { - depth++ - buf.WriteString("/*") - l.pos += 2 - continue - } + for l.pos < len(l.input) { if l.input[l.pos] == '*' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '/' { - depth-- - if depth > 0 { - buf.WriteString("*/") - } l.pos += 2 - continue + terminated = true + break } buf.WriteByte(l.input[l.pos]) l.pos++ } - if depth > 0 { + if !terminated { l.Err = fmt.Errorf("unterminated block comment") return Token{Type: tokEOF, Loc: l.start} } diff --git a/oracle/parser/nonnested_comment_test.go b/oracle/parser/nonnested_comment_test.go new file mode 100644 index 00000000..c5aac28f --- /dev/null +++ b/oracle/parser/nonnested_comment_test.go @@ -0,0 +1,103 @@ +package parser + +import ( + "strings" + "testing" +) + +// Oracle block comments do not nest: the first */ terminates the comment +// regardless of /* sequences inside it. Engine-verified on 11gR2 in both +// directions (BYT-9963): +// +// SELECT 1 /* a /* b */ FROM dual -- succeeds (comment ends at first */) +// SELECT 2 /* a /* b */ */ FROM dual -- ORA-00936 (legal only under nesting) +// +// PostgreSQL nests block comments; that is a genuine per-engine divergence, +// so these fences are Oracle-only and the PG lexer keeps its counting. + +func TestLexerNonNestedBlockComment(t *testing.T) { + tests := []struct { + name string + sql string + wantErr bool + }{ + {"innerOpenIgnored", "SELECT 1 /* a /* b */ FROM dual", false}, + {"strayCloseAfterComment", "SELECT 2 /* a /* b */ */ FROM dual", true}, + {"plainComment", "SELECT 1 /* plain */ FROM dual", false}, + {"unterminated", "SELECT 1 /* never closed", true}, + {"unterminatedWithInnerOpen", "SELECT 1 /* a /* b", true}, + {"hintEndsAtFirstClose", "SELECT /*+ FULL(t) /* x */ c FROM t", false}, + {"division", "SELECT 4 / 2 FROM dual", false}, + {"openMarkerInString", "SELECT '/*' FROM dual", false}, + {"closeMarkerInString", "SELECT '*/' FROM dual", false}, + {"openMarkerInLineComment", "SELECT 1 FROM dual -- /*\n", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.sql) + if tt.wantErr && err == nil { + t.Fatalf("want error, got none") + } + if !tt.wantErr && err != nil { + t.Fatalf("want success, got %v", err) + } + }) + } +} + +// The customer shape (BYT-9963, MBBank): a commented-out code region that +// itself contains a /* marker, inside a package body terminated by a +// standalone SQL*Plus slash. The slash must be recognized as a delimiter, +// not retained inside the SQL segment. +func TestSplitNonNestedCommentSlashDelimiter(t *testing.T) { + sql := "create or replace procedure p as\nbegin\n /* outer /* inner */\n null;\nend;\n/\n" + segs := Split(sql) + + // The standalone slash is a pure delimiter: it must not appear in any + // segment (see TestSplitSlashDelimiter for the established contract). + var sqlSegs int + for _, s := range segs { + if s.Kind == SegmentSQLPlusCommand { + continue + } + sqlSegs++ + if strings.Contains(s.Text, "\n/") || strings.HasSuffix(strings.TrimSpace(s.Text), "/") { + t.Fatalf("SQL segment retains slash delimiter: %q", s.Text) + } + } + if sqlSegs != 1 { + t.Fatalf("got %d SQL segments, want 1 (all: %#v)", sqlSegs, segs) + } +} + +// Two-unit script in the customer file's structure: spec, slash, body whose +// nested-marker comment must not swallow the terminator, slash. +func TestSplitCustomerShapePackagePair(t *testing.T) { + sql := strings.Join([]string{ + "create or replace package pkg as\n procedure q;\nend pkg;", + "/", + "create or replace package body pkg as\n procedure q as\n begin\n /*old := 1;\n /*\n if x then\n y;\n end if;*/\n null;\n end;\nend pkg;", + "/", + "", + }, "\n") + segs := Split(sql) + + var sqlTexts []string + for _, s := range segs { + if s.Kind == SegmentSQLPlusCommand { + continue + } + sqlTexts = append(sqlTexts, s.Text) + } + if len(sqlTexts) != 2 { + t.Fatalf("got %d SQL segments, want 2", len(sqlTexts)) + } + for i, txt := range sqlTexts { + if strings.HasSuffix(strings.TrimSpace(txt), "/") { + t.Fatalf("SQL segment %d retains trailing slash", i) + } + } + if !strings.Contains(sqlTexts[1], "end pkg;") { + t.Fatalf("body segment truncated: %q", sqlTexts[1][:80]) + } +} diff --git a/oracle/parser/split.go b/oracle/parser/split.go index 88df85db..0142722a 100644 --- a/oracle/parser/split.go +++ b/oracle/parser/split.go @@ -764,20 +764,18 @@ func splitSkipLineComment(sql string, i int) int { return i } +// splitSkipBlockComment advances past an Oracle block comment. Oracle block +// comments do not nest — the first */ terminates the comment (engine-verified; +// see lexBlockCommentOrHint) — so the scan must stay byte-identical to the +// lexer's rule or the splitter and parser would disagree on statement +// boundaries around comments containing a stray /*. func splitSkipBlockComment(sql string, i int) (int, bool) { i += 2 - depth := 1 - for i < len(sql) && depth > 0 { - switch { - case sql[i] == '/' && i+1 < len(sql) && sql[i+1] == '*': - depth++ - i += 2 - case sql[i] == '*' && i+1 < len(sql) && sql[i+1] == '/': - depth-- - i += 2 - default: - i++ + for i < len(sql) { + if sql[i] == '*' && i+1 < len(sql) && sql[i+1] == '/' { + return i + 2, true } + i++ } - return i, depth == 0 + return i, false }