diff --git a/lib/core/database/sql_statement_splitter.dart b/lib/core/database/sql_statement_splitter.dart index 8d758ed4..f15645fe 100644 --- a/lib/core/database/sql_statement_splitter.dart +++ b/lib/core/database/sql_statement_splitter.dart @@ -22,12 +22,29 @@ class SqlStatementSpan { /// literals, quoted identifiers, comments and PostgreSQL dollar quotes do not /// end a statement. abstract final class SqlStatementSplitter { + /// `$tag$` or `$$`, matched at an offset (no `^`: it would only match at the + /// start of the script). One compiled instance for every dollar sign. + static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$'); + static List spans(String sql) { final out = []; final len = sql.length; var i = 0; var segmentStart = 0; + // Statement starts only move forward, so the line of each one is found by + // counting newlines from the previous start instead of from the top of the + // script: one pass in total (#1350). + var lineCountedTo = 0; + var line = 1; + int lineAt(int offset) { + while (lineCountedTo < offset) { + if (sql.codeUnitAt(lineCountedTo) == 0x0A) line++; + lineCountedTo++; + } + return line; + } + void close(int end) { var s = segmentStart; var e = end; @@ -38,7 +55,7 @@ abstract final class SqlStatementSplitter { e--; } if (e > s) { - out.add(SqlStatementSpan(start: s, end: e, line: _lineOf(sql, s))); + out.add(SqlStatementSpan(start: s, end: e, line: lineAt(s))); } } @@ -65,8 +82,7 @@ abstract final class SqlStatementSplitter { // Dollar quote: $tag$ ... $tag$. if (c == '\$') { - final match = - RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + final match = _dollarTag.matchAsPrefix(sql, i); if (match != null) { final tag = match.group(0)!; final close = sql.indexOf(tag, i + tag.length); @@ -143,12 +159,4 @@ abstract final class SqlStatementSplitter { static bool _isBlank(int c) => c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0B || c == 0x0C; - - static int _lineOf(String sql, int offset) { - var line = 1; - for (var i = 0; i < offset; i++) { - if (sql.codeUnitAt(i) == 0x0A) line++; - } - return line; - } } diff --git a/test/core/database/sql_statement_splitter_test.dart b/test/core/database/sql_statement_splitter_test.dart index 854d683f..6c2922bb 100644 --- a/test/core/database/sql_statement_splitter_test.dart +++ b/test/core/database/sql_statement_splitter_test.dart @@ -92,4 +92,44 @@ void main() { isNull); }); }); + + group('SqlStatementSplitter line numbers and dollar quotes (#1350)', () { + test('lines stay exact across many statements', () { + const count = 5000; + final sql = List.generate(count, (i) => 'select $i;').join('\n'); + final spans = SqlStatementSplitter.spans(sql); + + expect(spans, hasLength(count)); + for (var i = 0; i < count; i++) { + expect(spans[i].line, i + 1, reason: 'statement $i'); + } + }); + + test('multi-line statements and blank lines count every newline', () { + const sql = 'select\n 1;\n\n\nselect 2;\nselect\n3'; + final spans = SqlStatementSplitter.spans(sql); + + expect(spans.map((s) => s.line), [1, 5, 6]); + }); + + test('a dollar quote far into the script is recognised', () { + final padding = 'select 1;\n' * 50; + final sql = '${padding}do \$body\$ begin perform 1; end \$body\$; select 2'; + final spans = SqlStatementSplitter.spans(sql); + + expect(spans, hasLength(52)); + expect( + spans[50].textIn(sql), + 'do \$body\$ begin perform 1; end \$body\$', + ); + expect(spans[51].textIn(sql), 'select 2'); + }); + + test('numbered parameters are not dollar quotes', () { + expect( + texts('select \$1; select \$2, \$3'), + ['select \$1', 'select \$2, \$3'], + ); + }); + }); }