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,