diff --git a/lib/core/database/sql_statement_splitter.dart b/lib/core/database/sql_statement_splitter.dart index f15645fe..5f36951d 100644 --- a/lib/core/database/sql_statement_splitter.dart +++ b/lib/core/database/sql_statement_splitter.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + /// One statement of a script, as offsets into the script. class SqlStatementSpan { const SqlStatementSpan({ @@ -26,6 +28,35 @@ abstract final class SqlStatementSplitter { /// start of the script). One compiled instance for every dollar sign. static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$'); + static const int _chDash = 0x2D; + static const int _chSlash = 0x2F; + static const int _chAsterisk = 0x2A; + static const int _chDollar = 0x24; + static const int _chSingleQuote = 0x27; + static const int _chDoubleQuote = 0x22; + static const int _chBacktick = 0x60; + static const int _chSemicolon = 0x3B; + static const int _chBackslash = 0x5C; + static const int _chLf = 0x0A; + static const int _chCr = 0x0D; + + /// Default script size threshold (64 KB) above which [spansAsync] offloads + /// parsing to a background isolate via [compute]. + static const int defaultIsolateThreshold = 64 * 1024; + + /// Splits [sql] into statement ranges asynchronously. If [sql.length] exceeds + /// [isolateThreshold], parsing runs in a background isolate via [compute] so + /// large scripts do not block the UI thread (#1350). + static Future> spansAsync( + String sql, { + int isolateThreshold = defaultIsolateThreshold, + }) async { + if (sql.length <= isolateThreshold) { + return spans(sql); + } + return compute(spans, sql); + } + static List spans(String sql) { final out = []; final len = sql.length; @@ -39,7 +70,7 @@ abstract final class SqlStatementSplitter { var line = 1; int lineAt(int offset) { while (lineCountedTo < offset) { - if (sql.codeUnitAt(lineCountedTo) == 0x0A) line++; + if (sql.codeUnitAt(lineCountedTo) == _chLf) line++; lineCountedTo++; } return line; @@ -60,20 +91,26 @@ abstract final class SqlStatementSplitter { } while (i < len) { - final c = sql[i]; + final code = sql.codeUnitAt(i); // Line comment: runs to the end of the line. - if (c == '-' && i + 1 < len && sql[i + 1] == '-') { - while (i < len && sql[i] != '\n' && sql[i] != '\r') { + if (code == _chDash && i + 1 < len && sql.codeUnitAt(i + 1) == _chDash) { + while (i < len && + sql.codeUnitAt(i) != _chLf && + sql.codeUnitAt(i) != _chCr) { i++; } continue; } // Block comment. - if (c == '/' && i + 1 < len && sql[i + 1] == '*') { + if (code == _chSlash && + i + 1 < len && + sql.codeUnitAt(i + 1) == _chAsterisk) { i += 2; - while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + while (i + 1 < len && + !(sql.codeUnitAt(i) == _chAsterisk && + sql.codeUnitAt(i + 1) == _chSlash)) { i++; } i = i + 1 < len ? i + 2 : len; @@ -81,7 +118,7 @@ abstract final class SqlStatementSplitter { } // Dollar quote: $tag$ ... $tag$. - if (c == '\$') { + if (code == _chDollar) { final match = _dollarTag.matchAsPrefix(sql, i); if (match != null) { final tag = match.group(0)!; @@ -92,17 +129,18 @@ abstract final class SqlStatementSplitter { } // String literal, with doubled quotes and backslash escapes. - if (c == "'") { + if (code == _chSingleQuote) { i++; while (i < len) { - if (sql[i] == "'") { - if (i + 1 < len && sql[i + 1] == "'") { + final curr = sql.codeUnitAt(i); + if (curr == _chSingleQuote) { + if (i + 1 < len && sql.codeUnitAt(i + 1) == _chSingleQuote) { i += 2; } else { i++; break; } - } else if (sql[i] == '\\' && i + 1 < len) { + } else if (curr == _chBackslash && i + 1 < len) { i += 2; } else { i++; @@ -112,11 +150,13 @@ abstract final class SqlStatementSplitter { } // Quoted identifier: "name" (PostgreSQL) or `name` (MySQL). - if (c == '"' || c == '`') { + if (code == _chDoubleQuote || code == _chBacktick) { + final quoteChar = code; i++; while (i < len) { - if (sql[i] == c) { - if (i + 1 < len && sql[i + 1] == c) { + final curr = sql.codeUnitAt(i); + if (curr == quoteChar) { + if (i + 1 < len && sql.codeUnitAt(i + 1) == quoteChar) { i += 2; } else { i++; @@ -129,7 +169,7 @@ abstract final class SqlStatementSplitter { continue; } - if (c == ';') { + if (code == _chSemicolon) { close(i); i++; segmentStart = i; diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index de069bdc..ede24c15 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -548,13 +548,14 @@ class GenericSqlWorkspaceState extends material.State { final text = session.controller.text; final selected = selection.isValid && !selection.isCollapsed; final scope = selected ? selection.textInside(text) : text; - final spans = _runSpans( + final spans = await _runSpans( scope, statementAtCursor: statementAtCursor, selected: selected, caret: selection.isValid ? selection.baseOffset : scope.length, ); if (spans.isEmpty) return; + if (!mounted) return; final scopeStart = selected ? selection.start : 0; final texts = [for (final s in spans) s.textIn(scope)]; final runSql = statementAtCursor ? texts.single : scope.trim(); @@ -792,12 +793,12 @@ class GenericSqlWorkspaceState extends material.State { /// The ranges of [scope] a run covers. A caret run takes the statement at /// [caret]; a selected caret run is the selection as one statement; a script /// is every statement. - static List _runSpans( + static Future> _runSpans( String scope, { required bool statementAtCursor, required bool selected, required int caret, - }) { + }) async { if (statementAtCursor && selected) { final start = scope.length - scope.trimLeft().length; final end = scope.trimRight().length; @@ -805,7 +806,7 @@ class GenericSqlWorkspaceState extends material.State { ? [SqlStatementSpan(start: start, end: end, line: 1)] : const []; } - final all = SqlStatementSplitter.spans(scope); + final all = await SqlStatementSplitter.spansAsync(scope); if (!statementAtCursor) return all; final at = SqlStatementSplitter.at(all, caret); return at == null ? const [] : [at]; diff --git a/test/core/database/sql_statement_splitter_test.dart b/test/core/database/sql_statement_splitter_test.dart index 6c2922bb..60243d93 100644 --- a/test/core/database/sql_statement_splitter_test.dart +++ b/test/core/database/sql_statement_splitter_test.dart @@ -131,5 +131,38 @@ void main() { ['select \$1', 'select \$2, \$3'], ); }); + + test('spansAsync matches spans synchronously below threshold', () async { + const sql = 'SELECT 1; SELECT 2; SELECT 3;'; + final syncSpans = SqlStatementSplitter.spans(sql); + final asyncSpans = await SqlStatementSplitter.spansAsync( + sql, + isolateThreshold: 1024, + ); + + expect(asyncSpans.length, syncSpans.length); + for (var i = 0; i < syncSpans.length; i++) { + expect(asyncSpans[i].start, syncSpans[i].start); + expect(asyncSpans[i].end, syncSpans[i].end); + expect(asyncSpans[i].line, syncSpans[i].line); + } + }); + + test('spansAsync offloads to isolate above threshold', () async { + final sql = List.generate(200, (i) => 'SELECT $i AS col_$i;').join('\n'); + final syncSpans = SqlStatementSplitter.spans(sql); + // Force isolate execution with a tiny threshold + final asyncSpans = await SqlStatementSplitter.spansAsync( + sql, + isolateThreshold: 64, + ); + + expect(asyncSpans.length, syncSpans.length); + for (var i = 0; i < syncSpans.length; i++) { + expect(asyncSpans[i].start, syncSpans[i].start); + expect(asyncSpans[i].end, syncSpans[i].end); + expect(asyncSpans[i].line, syncSpans[i].line); + } + }); }); }