From e32bc114106d6cf12bcc20031fafcef4f9e3ad99 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 23:09:45 +0300 Subject: [PATCH] perf(editor): single-pass line numbers and one RegExp in SqlStatementSplitter Statement lines are counted incrementally from the previous statement start instead of rescanning from the top of the script, and dollar quote tags are matched with one static RegExp at the current offset instead of copying the rest of the script into a substring on every dollar sign. Part of #1350. --- lib/core/database/sql_statement_splitter.dart | 30 +++++++++----- .../database/sql_statement_splitter_test.dart | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) 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'], + ); + }); + }); }