diff --git a/src/grate.oracle/Infrastructure/OracleStatementSplitter.cs b/src/grate.oracle/Infrastructure/OracleStatementSplitter.cs
index b04692d6..88d110a6 100644
--- a/src/grate.oracle/Infrastructure/OracleStatementSplitter.cs
+++ b/src/grate.oracle/Infrastructure/OracleStatementSplitter.cs
@@ -1,11 +1,201 @@
+using System.Text.RegularExpressions;
using grate.Infrastructure;
namespace grate.Oracle.Infrastructure;
-public class OracleStatementSplitter : RegexStatementSplitter
+///
+/// Splits Oracle scripts on semicolons and SQL*Plus slash commands without treating
+/// separators in comments or quoted literals as batch separators.
+///
+public partial class OracleStatementSplitter : IStatementSplitter
{
- protected override string StringsRegex => @"(?'[^']*')";
- protected override string DashCommentsRegex => @"(?--.*$)";
- protected override string StarCommentsRegex => @"(?/\*[\S\s]*?\*/)";
- protected override string SeparatorRegex => @"(?^|\s)(?/)(?\s|;|$)";
+ private enum TokenType
+ {
+ BlockStart,
+ DeclareBlockStart,
+ BlockEnd,
+ Semicolon,
+ SQLPLUSEXECUTE,
+ StringLiteral,
+ OpenCustomQuotedLiteral,
+ MultiLineCommentStart,
+ MultiLineCommentEnd,
+ SingleLineComment,
+ NewLine
+ }
+
+ private readonly record struct Token(
+ TokenType Type,
+ int Index,
+ int Length,
+ string CustomQuoteDelimiter = "");
+
+ private const string OpenCustomQuotedLiteralPattern =
+ "(?\\b[qQ]'(?[^'\\s]))";
+ private const string StringLiteralPattern = "(?')";
+ private const string MultiLineCommentStartPattern = "(?/\\*)";
+ private const string MultiLineCommentEndPattern = "(?\\*/)";
+ private const string SingleLineCommentPattern = "(?--)";
+ private const string BlockStartPattern = "(?\\bBEGIN\\b)";
+ private const string DeclareBlockStartPattern = "(?\\bDECLARE\\b)";
+
+ // Note that this captures ; as well, so that we avoid capturing END IF; etc.
+ private const string BlockEndPattern = "(?\\bEND;)";
+ private const string SemicolonPattern = "(?;)";
+ private const string SqlPlusExecutePattern = "(?^[ \\t]*/[ \\t]*(?=\\r?$))";
+ private const string NewLinePattern = "(?\\r\\n|\\r|\\n)";
+
+ [GeneratedRegex(
+ OpenCustomQuotedLiteralPattern + "|" +
+ StringLiteralPattern + "|" +
+ MultiLineCommentStartPattern + "|" +
+ MultiLineCommentEndPattern + "|" +
+ SingleLineCommentPattern + "|" +
+ BlockStartPattern + "|" +
+ DeclareBlockStartPattern + "|" +
+ BlockEndPattern + "|" +
+ SemicolonPattern + "|" +
+ SqlPlusExecutePattern + "|" +
+ NewLinePattern,
+ RegexOptions.Multiline | RegexOptions.IgnoreCase)]
+ private static partial Regex TokenPattern();
+
+ [GeneratedRegex(@"\S")]
+ private static partial Regex SignificantTextPattern();
+
+ public IEnumerable Split(string statement) =>
+ BreakIntoBatches(statement).Where(batch => SignificantTextPattern().IsMatch(batch));
+
+ private static IEnumerable Tokenize(string sql)
+ {
+ for (var match = TokenPattern().Match(sql); match.Success; match = match.NextMatch())
+ {
+ var index = match.Index;
+ var tokenGroup = match.Groups
+ .Cast()
+ .Single(group => group.Success && group.Name is not "0" and not "CustomQuoteDelimiter");
+ var tokenType = Enum.Parse(tokenGroup.Name);
+ string CustomQuoteDelimiter = "";
+ if (tokenType == TokenType.OpenCustomQuotedLiteral)
+ {
+ CustomQuoteDelimiter = match.Groups["CustomQuoteDelimiter"].Value;
+ }
+ yield return new Token(tokenType, index, match.Length, CustomQuoteDelimiter);
+ }
+ }
+
+ private static IEnumerable BreakIntoBatches(string sql)
+ {
+ using var tokens = Tokenize(sql).GetEnumerator();
+ var cutIndex = 0;
+ var blockDepth = 0;
+ var inDeclareBlock = false;
+ var token = default(Token);
+
+ bool NextToken()
+ {
+ if (!tokens.MoveNext())
+ {
+ return false;
+ }
+ token = tokens.Current;
+ return true;
+ }
+
+ while(NextToken())
+ {
+ switch (token.Type)
+ {
+ case TokenType.DeclareBlockStart:
+ blockDepth++;
+ inDeclareBlock = true;
+ break;
+ case TokenType.BlockStart:
+ // a declare block is eventually followed by a BEGIN
+ // so we need to check if we were preceded by a DECLARE
+ if (inDeclareBlock)
+ {
+ inDeclareBlock = false;
+ } else {
+ blockDepth++;
+ }
+ break;
+
+ case TokenType.BlockEnd:
+ blockDepth--;
+ if (blockDepth == 0)
+ {
+ yield return sql[cutIndex..(token.Index + token.Length)];
+ cutIndex = token.Index + token.Length;
+ }
+ break;
+
+ case TokenType.StringLiteral:
+ while(NextToken() && token.Type != TokenType.StringLiteral)
+ {
+
+ }
+ break;
+ case TokenType.OpenCustomQuotedLiteral:
+ var closingDelimiter = GetClosingCustomQuoteDelimiter(token.CustomQuoteDelimiter);
+ while(NextToken()) {
+ // Custom Delimiters come just before the ' string
+ // literal so check for that.
+ if (token.Type == TokenType.StringLiteral &&
+ sql[..token.Index].EndsWith(closingDelimiter)
+ ) {
+ // end of string literal
+ break;
+ }
+ }
+ break;
+
+ case TokenType.MultiLineCommentStart:
+ var depth = 1;
+ while (depth > 0 && NextToken())
+ {
+ depth += token.Type switch
+ {
+ TokenType.MultiLineCommentStart => 1,
+ TokenType.MultiLineCommentEnd => -1,
+ _ => 0
+ };
+ }
+ break;
+
+ case TokenType.SingleLineComment:
+ while (NextToken() && token.Type != TokenType.NewLine)
+ {
+ }
+ break;
+
+ case TokenType.Semicolon:
+ // We want to include the semicolon in the batch
+ if (blockDepth > 0)
+ {
+ break;
+ }
+
+ yield return sql[cutIndex..(token.Index + token.Length)];
+ cutIndex = token.Index + token.Length;
+ break;
+
+ case TokenType.SQLPLUSEXECUTE:
+ yield return sql[cutIndex..token.Index];
+ cutIndex = token.Index + token.Length;
+ break;
+ }
+ }
+
+ yield return sql[cutIndex..];
+ }
+
+ private static string GetClosingCustomQuoteDelimiter(string delimiter) => delimiter switch
+ {
+ "[" => "]",
+ "{" => "}",
+ "(" => ")",
+ "<" => ">",
+ _ => delimiter
+ };
}
diff --git a/unittests/Oracle/Basic_tests/OracleStatementSplitter_.cs b/unittests/Oracle/Basic_tests/OracleStatementSplitter_.cs
index c5534b7b..aaf6d494 100644
--- a/unittests/Oracle/Basic_tests/OracleStatementSplitter_.cs
+++ b/unittests/Oracle/Basic_tests/OracleStatementSplitter_.cs
@@ -9,7 +9,7 @@ namespace Oracle.Basic_tests;
public class OracleStatementSplitter_
{
- private const string Symbols_to_check = "`~!@#$%^&*()-_+=,.;:'\"[]\\/?<>";
+ private const string Symbols_to_check = "`~!@#$%^&*()-_+=,.:'\"[]\\/?<>";
private const string Words_to_check = "abcdefghijklmnopqrstuvwzyz0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ";
// ReSharper disable once InconsistentNaming
@@ -32,7 +32,7 @@ public void full_statement_without_issue()
var result = _splitter.Split(sql_to_match).ToList();
Assert.NotEmpty(result);
Assert.True(result.Count > 1, "Should split into multiple statements");
- Assert.Equal(result, OracleSplitterContext.FullSplitter.PLSqlStatementScrubbed);
+ Assert.Equal(OracleSplitterContext.FullSplitter.PLSqlStatementScrubbed, result);
}
[Fact]
@@ -50,7 +50,7 @@ public void slash_with_tab()
string sql_to_match = @" /" + "\t";
_testOutput.WriteLine(sql_to_match);
var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal(["\t"],result);
+ Assert.Empty(result);
}
[Fact]
@@ -84,22 +84,14 @@ public void slash_with_new_line()
[Theory]
[InlineData("\n", "LF")]
- public void slash_with_one_new_line_after_double_dash_comments_lf(string line_ending, string _)
- {
- string sql_to_match = $"--{line_ending}/{line_ending}";
- _testOutput.WriteLine(sql_to_match);
- var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal(["--" + line_ending], result);
- }
-
- [Theory]
[InlineData("\r\n", "CRLF")]
- public void slash_with_one_new_line_after_double_dash_comments_crlf(string line_ending, string _)
+
+ public void slash_with_one_new_line_after_double_dash_comments(string line_ending, string _)
{
string sql_to_match = $"--{line_ending}/{line_ending}";
_testOutput.WriteLine(sql_to_match);
var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal(["--" + line_ending, line_ending], result);
+ Assert.Equal(["--" + line_ending], result);
}
[Fact]
@@ -161,7 +153,7 @@ public void slash_with_symbols_and_words_before()
";
_testOutput.WriteLine(sql_to_match);
var result = _splitter.Split(sql_to_match).ToList();
- Assert.Single(result);
+ Assert.Equal([sql_to_match], result);
}
[Fact]
@@ -192,26 +184,6 @@ public void slash_with_words_after_on_the_same_line_including_symbols()
Assert.Single(result);
}
- [Fact]
- public void slash_with_words_before_and_after_on_the_same_line()
- {
- string sql_to_match = Words_to_check + @" / " + Words_to_check;
- _testOutput.WriteLine(sql_to_match);
- var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal([Words_to_check + " "," " + Words_to_check], result);
- }
-
- [Fact]
- public void slash_with_words_before_and_after_on_the_same_line_including_symbols()
- {
- string sql_to_match = Words_to_check + Symbols_to_check.Replace("'", "").Replace("\"", "") +
- " / BOB" + Symbols_to_check;
- _testOutput.WriteLine(sql_to_match);
- var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal([ Words_to_check + Symbols_to_check.Replace("'", "").Replace("\"", "") +
- " ", " BOB" + Symbols_to_check], result);
- }
-
[Fact]
public void slash_after_double_dash_comment_with_single_quote_and_single_quote_after_slash()
{
@@ -234,12 +206,39 @@ public void slash_with_comment_after()
}
[Fact]
- public void slash_with_semicolon_directly_after()
+ public void slash_in_a_single_quoted_literal_with_an_escaped_quote_is_not_a_separator()
{
- string sql_to_match = "jalla /;";
- _testOutput.WriteLine(sql_to_match);
+ const string sql_to_match = "select 'can''t / here' from dual";
+
+ var result = _splitter.Split(sql_to_match).ToList();
+
+ Assert.Equal([sql_to_match], result);
+ }
+
+ [Theory]
+ [InlineData("q'[/]'", "slash in bracket quoted literal")]
+ [InlineData("q'{ / }'", "slash in brace quoted literal")]
+ [InlineData("q'< / >'", "slash in angle quoted literal")]
+ [InlineData("q'! / !'", "slash in delimiter quoted literal")]
+ public void slash_in_an_oracle_alternative_quoted_literal_is_not_a_separator(
+ string literal,
+ string _)
+ {
+ var sql_to_match = $"select {literal} from dual";
+
var result = _splitter.Split(sql_to_match).ToList();
- Assert.Equal(["jalla ", ";"], result);
+
+ Assert.Equal([sql_to_match], result);
+ }
+
+ [Fact]
+ public void slash_only_with_crlf_is_not_a_batch()
+ {
+ const string sql_to_match = " \r\n/\r\n";
+
+ var result = _splitter.Split(sql_to_match).ToList();
+
+ Assert.Empty(result);
}
}
@@ -254,6 +253,34 @@ public should_not_replace_on(ITestOutputHelper testOutput)
_testOutput = testOutput;
_splitter = new OracleStatementSplitter();
}
+
+ [Fact]
+ public void slash_with_semicolon_directly_after()
+ {
+ string sql_to_match = "jalla /;";
+ _testOutput.WriteLine(sql_to_match);
+ var result = _splitter.Split(sql_to_match).ToList();
+ Assert.Equal(["jalla /;"], result);
+ }
+
+ [Fact]
+ public void slash_with_words_before_and_after_on_the_same_line()
+ {
+ string sql_to_match = Words_to_check + @" / " + Words_to_check;
+ _testOutput.WriteLine(sql_to_match);
+ var result = _splitter.Split(sql_to_match).ToList();
+ Assert.Equal([sql_to_match], result);
+ }
+
+ [Fact]
+ public void slash_with_words_before_and_after_on_the_same_line_including_symbols()
+ {
+ string sql_to_match = Words_to_check + Symbols_to_check.Replace("'", "").Replace("\"", "") +
+ " / BOB" + Symbols_to_check;
+ _testOutput.WriteLine(sql_to_match);
+ var result = _splitter.Split(sql_to_match).ToList();
+ Assert.Equal([sql_to_match], result);
+ }
[Fact]
public void slash_when_slash_is_the_last_part_of_the_last_word_on_a_line()
@@ -482,10 +509,185 @@ public void slash_inside_of_comments_with_symbols_after_on_different_lines()
}
}
+ public class sqlplus_compatibility
+ {
+ private readonly OracleStatementSplitter _splitter = new();
+
+ [Fact]
+ public void standalone_slash_executes_a_plsql_buffer()
+ {
+ const string sql = "BEGIN\n NULL;\nEND;\n/\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal(["BEGIN\n NULL;\nEND;"], result);
+ }
+
+ [Fact]
+ public void semicolons_inside_a_plsql_buffer_do_not_split_the_buffer()
+ {
+ const string sql = "BEGIN\n NULL;\n NULL;\nEND;\n/\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(sql[..^3], result[0]);
+ }
+
+ [Fact]
+ public void semicolons_inside_a_declare_block_do_not_split_the_buffer()
+ {
+ const string sql = "DECLARE\n value NUMBER := 1;\nBEGIN\n value := value + 1;\nEND;";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(sql, result[0]);
+ }
+
+ [Theory]
+ [InlineData("begin\n NULL;\nend;")]
+ [InlineData("BeGiN\n NULL;\nEnD;")]
+ public void begin_and_end_are_case_insensitive(string sql)
+ {
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(sql, result[0]);
+ }
+
+ [Theory]
+ [InlineData("declare\n value NUMBER := 1;\nbegin\n NULL;\nend;")]
+ [InlineData("DeClArE\n value NUMBER := 1;\nBeGiN\n NULL;\nEnD;")]
+ public void declare_blocks_are_case_insensitive(string sql)
+ {
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(sql, result[0]);
+ }
+
+ [Fact]
+ public void semicolons_after_a_declare_block_do_split_the_buffer()
+ {
+ const string sql = "DECLARE\n value NUMBER := 1;\nBEGIN\n value := value + 1;\nEND;\nSELECT 1;";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains("\nSELECT 1;", result);
+ }
+
+ [Fact]
+ public void end_if_semicolon_does_not_split_a_plsql_buffer()
+ {
+ const string sql = "BEGIN\n IF 1 = 1 THEN\n NULL;\n END IF;\nEND;\n/\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Contains("END IF;", result[0]);
+ }
+
+ [Fact]
+ public void end_loop_semicolon_does_not_split_a_plsql_buffer()
+ {
+ const string sql = "BEGIN\n LOOP\n NULL;\n EXIT;\n END LOOP;\nEND;\n/\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ Assert.Contains("END LOOP;", result[0]);
+ }
+
+ [Fact]
+ public void slash_in_a_division_expression_is_not_a_batch_separator()
+ {
+ const string sql = "SELECT 10 / 2 FROM dual;";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal([sql], result);
+ }
+
+ [Fact]
+ public void inline_slash_is_not_a_batch_separator()
+ {
+ const string sql = "SELECT 1 / 2 FROM dual;";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal([sql], result);
+ }
+
+ [Fact]
+ public void standalone_slash_with_trailing_spaces_executes_at_eof()
+ {
+ const string sql = "BEGIN\n NULL;\nEND;\n/ ";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal(["BEGIN\n NULL;\nEND;"], result);
+ }
+
+ [Fact]
+ public void repeated_standalone_slashes_do_not_create_empty_batches()
+ {
+ const string sql = "BEGIN\n NULL;\nEND;\n/\n/\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public void standalone_slash_works_with_crlf_line_endings()
+ {
+ const string sql = "BEGIN\r\n NULL;\r\nEND;\r\n/\r\n";
+
+ var result = _splitter.Split(sql).ToList();
+
+ Assert.Equal(["BEGIN\r\n NULL;\r\nEND;"], result);
+ }
+ }
+
public class Split
{
private readonly OracleStatementSplitter _splitter = new();
+ [Fact]
+ public void Splits_and_removes_semicolon()
+ {
+ const string sql = "\nSELECT * FROM v$version WHERE banner LIKE 'Oracle%';\nSELECT 1\n";
+
+ var batches = _splitter.Split(sql).ToArray();
+
+ Assert.Equal(2, batches.Length);
+ Assert.EndsWith(";", batches[0]);
+ }
+
+ [Fact]
+ public void Splits_and_removes_slashes_and_semicolon()
+ {
+ const string sql = "\nSELECT * FROM v$version WHERE banner LIKE 'Oracle%';\n/\nSELECT 1\n";
+
+ var batches = _splitter.Split(sql).ToArray();
+
+ Assert.Equal(2, batches.Length);
+ Assert.EndsWith(";", batches[0]);
+ }
+
+ [Fact]
+ public void Splits_and_removes_indented_slashes_and_semicolon()
+ {
+ const string sql = "\n CREATE TABLE table_one (\n col NUMBER\n );\n /\n\n CREATE TABLE table_two (\n col NUMBER\n )\n";
+
+ var batches = _splitter.Split(sql).ToArray();
+
+ Assert.Equal(2, batches.Length);
+ Assert.EndsWith(";", batches[0]);
+ }
+
[Fact]
public void Splits_and_removes_GO_statements()
{
diff --git a/unittests/Oracle/TestInfrastructure/OracleSplitterContext.cs b/unittests/Oracle/TestInfrastructure/OracleSplitterContext.cs
index c6adf78a..64444c82 100644
--- a/unittests/Oracle/TestInfrastructure/OracleSplitterContext.cs
+++ b/unittests/Oracle/TestInfrastructure/OracleSplitterContext.cs
@@ -20,7 +20,7 @@ public static class FullSplitter
BOB3 /
---`~!@#$%^&*()-_+=,.;:'""[]\/?<> /
+--`~!@#$%^&*()-_+=,.:'""[]\/?<> /
BOB5
/
@@ -45,7 +45,7 @@ public static class FullSplitter
BOB9
--- `~!@#$%^&*()-_+=,.;:'""[]\/?<>
+-- `~!@#$%^&*()-_+=,.:'""[]\/?<>
/
BOB10/
@@ -109,12 +109,12 @@ INSERT [dbo].[Foo] ([Bar]) VALUES (N'/ speed racer, / speed racer, / speed racer
-- /
-BOB3 ", @"
+BOB3 /
---`~!@#$%^&*()-_+=,.;:'""[]\/?<> /
+--`~!@#$%^&*()-_+=,.:'""[]\/?<> /
BOB5
- ", @"
+", @"
BOB6
", @"
@@ -136,7 +136,7 @@ INSERT [dbo].[Foo] ([Bar]) VALUES (N'/ speed racer, / speed racer, / speed racer
BOB9
--- `~!@#$%^&*()-_+=,.;:'""[]\/?<>
+-- `~!@#$%^&*()-_+=,.:'""[]\/?<>
", @"
BOB10/
@@ -164,8 +164,7 @@ yeppsasd decimal(20, 6) NULL,
uhuhhh datetime NULL,
slsald varchar(15) NULL,
uhasdf varchar(15) NULL,
- daf_asdfasdf DECIMAL(20,6) NULL;
-", @"
+ daf_asdfasdf DECIMAL(20,6) NULL;", @"
EXEC @ReturnCode = msdb.dbo.sp_add_jobstep @job_id=@jobId, @step_name=N'Daily job',
@step_id=1,