From ac25366bfb35b58da12ebafa7f6478f4a2f63c69 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 05:32:58 +0300 Subject: [PATCH] feat(sql): run the statement at the cursor, splitter with offsets (#1148) SqlStatementSplitter returns statement ranges (start, end, line) and picks the statement at a caret: inside a statement, a caret on a blank line or after the last ; takes the statement above. Semicolons inside string literals, quoted identifiers, comments and PostgreSQL dollar quotes do not end a statement. DestructiveSqlDetector.splitStatements is built on the same scanner. Ctrl/Cmd+Enter, Ctrl/Cmd+R and numpad Enter run the selection or the statement at the cursor. F5 and Ctrl/Cmd+Shift+Enter run the selection or the whole script. --- .../database/destructive_sql_detector.dart | 106 +----------- lib/core/database/sql_statement_splitter.dart | 154 ++++++++++++++++++ .../workspace/generic_sql_workspace.dart | 35 +++- .../database/sql_statement_splitter_test.dart | 95 +++++++++++ 4 files changed, 281 insertions(+), 109 deletions(-) create mode 100644 lib/core/database/sql_statement_splitter.dart create mode 100644 test/core/database/sql_statement_splitter_test.dart diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 9fa29bc5..8684dcdc 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; + /// Categorization of destructive SQL / Mongo / Redis operations that can /// alter or destroy schema/data. enum DestructiveSqlType { @@ -287,107 +289,9 @@ abstract final class DestructiveSqlDetector { /// Splits an SQL query into individual statements on `;`, taking into account /// comments and string literals. - static List splitStatements(String sql) { - final statements = []; - final current = StringBuffer(); - final len = sql.length; - var i = 0; - - while (i < len) { - // Line comment - if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { - while (i < len && sql[i] != '\n' && sql[i] != '\r') { - current.write(sql[i]); - i++; - } - continue; - } - - // Block comment - if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { - current.write('/*'); - i += 2; - while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { - current.write(sql[i]); - i++; - } - if (i + 1 < len) { - current.write('*/'); - i += 2; - } else { - i = len; - } - continue; - } - - // Dollar quotes - if (sql[i] == '\$') { - final match = - RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); - if (match != null) { - final tag = match.group(0)!; - current.write(tag); - i += tag.length; - final closeIdx = sql.indexOf(tag, i); - if (closeIdx != -1) { - current.write(sql.substring(i, closeIdx + tag.length)); - i = closeIdx + tag.length; - } else { - current.write(sql.substring(i)); - i = len; - } - continue; - } - } - - // String literal - if (sql[i] == "'") { - current.write("'"); - i++; - while (i < len) { - if (sql[i] == "'") { - current.write("'"); - if (i + 1 < len && sql[i + 1] == "'") { - current.write("'"); - i += 2; - } else { - i++; - break; - } - } else if (sql[i] == '\\' && i + 1 < len) { - current.write(sql[i]); - current.write(sql[i + 1]); - i += 2; - } else { - current.write(sql[i]); - i++; - } - } - continue; - } - - // Statement delimiter - if (sql[i] == ';') { - final stmt = current.toString().trim(); - if (stmt.isNotEmpty) { - statements.add(stmt); - } - current.clear(); - i++; - continue; - } - - current.write(sql[i]); - i++; - } - - final remaining = current.toString().trim(); - if (remaining.isNotEmpty) { - statements.add(remaining); - } - - return statements; - } + static List splitStatements(String sql) => [ + for (final span in SqlStatementSplitter.spans(sql)) span.textIn(sql), + ]; /// Inspects [sql] and returns any detected destructive operations. static DestructiveSqlInspectionResult inspect(String sql) { diff --git a/lib/core/database/sql_statement_splitter.dart b/lib/core/database/sql_statement_splitter.dart new file mode 100644 index 00000000..8d758ed4 --- /dev/null +++ b/lib/core/database/sql_statement_splitter.dart @@ -0,0 +1,154 @@ +/// One statement of a script, as offsets into the script. +class SqlStatementSpan { + const SqlStatementSpan({ + required this.start, + required this.end, + required this.line, + }); + + /// Offset of the first character of the statement (leading blanks skipped). + final int start; + + /// Offset after its last character (trailing blanks and the `;` excluded). + final int end; + + /// 1-based line of [start]. + final int line; + + String textIn(String sql) => sql.substring(start, end); +} + +/// Splits SQL scripts into statement ranges. Semicolons inside string +/// literals, quoted identifiers, comments and PostgreSQL dollar quotes do not +/// end a statement. +abstract final class SqlStatementSplitter { + static List spans(String sql) { + final out = []; + final len = sql.length; + var i = 0; + var segmentStart = 0; + + void close(int end) { + var s = segmentStart; + var e = end; + while (s < e && _isBlank(sql.codeUnitAt(s))) { + s++; + } + while (e > s && _isBlank(sql.codeUnitAt(e - 1))) { + e--; + } + if (e > s) { + out.add(SqlStatementSpan(start: s, end: e, line: _lineOf(sql, s))); + } + } + + while (i < len) { + final c = sql[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') { + i++; + } + continue; + } + + // Block comment. + if (c == '/' && i + 1 < len && sql[i + 1] == '*') { + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + i++; + } + i = i + 1 < len ? i + 2 : len; + continue; + } + + // Dollar quote: $tag$ ... $tag$. + if (c == '\$') { + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + final close = sql.indexOf(tag, i + tag.length); + i = close == -1 ? len : close + tag.length; + continue; + } + } + + // String literal, with doubled quotes and backslash escapes. + if (c == "'") { + i++; + while (i < len) { + if (sql[i] == "'") { + if (i + 1 < len && sql[i + 1] == "'") { + i += 2; + } else { + i++; + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + i += 2; + } else { + i++; + } + } + continue; + } + + // Quoted identifier: "name" (PostgreSQL) or `name` (MySQL). + if (c == '"' || c == '`') { + i++; + while (i < len) { + if (sql[i] == c) { + if (i + 1 < len && sql[i + 1] == c) { + i += 2; + } else { + i++; + break; + } + } else { + i++; + } + } + continue; + } + + if (c == ';') { + close(i); + i++; + segmentStart = i; + continue; + } + + i++; + } + close(len); + return out; + } + + /// The statement the caret at [offset] belongs to. Between statements (a + /// blank line, or after the last `;`) it is the statement above; before the + /// first statement it is the first one. Null for an empty script. + static SqlStatementSpan? at(List spans, int offset) { + if (spans.isEmpty) return null; + for (final s in spans) { + if (offset >= s.start && offset <= s.end) return s; + } + SqlStatementSpan? above; + for (final s in spans) { + if (s.end <= offset) above = s; + } + return above ?? spans.first; + } + + 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/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 15e7d48b..b8a4b921 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; import 'dart:async'; import 'dart:io'; @@ -399,16 +400,28 @@ class GenericSqlWorkspaceState extends material.State { } } - Future execute([SqlQueryTabSession? targetSession]) async { + /// Runs the selection, else the whole text. With [statementAtCursor] (run + /// statement) only the statement under the caret, see [SqlStatementSplitter]. + Future execute([ + SqlQueryTabSession? targetSession, + bool statementAtCursor = false, + ]) async { final session = targetSession ?? _activeSession; if (session.running) return; final selection = session.controller.selection; + final text = session.controller.text; String userSql; if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(session.controller.text).trim(); + userSql = selection.textInside(text).trim(); + } else if (statementAtCursor) { + final span = SqlStatementSplitter.at( + SqlStatementSplitter.spans(text), + selection.isValid ? selection.baseOffset : text.length, + ); + userSql = span == null ? '' : span.textIn(text); } else { - userSql = session.controller.text.trim(); + userSql = text.trim(); } if (userSql.isEmpty) return; @@ -861,22 +874,28 @@ class GenericSqlWorkspaceState extends material.State { if (!_activeSession.running) unawaited(execute(_activeSession)); }, const material.SingleActivator(LogicalKeyboardKey.enter, control: true): () { - if (!_activeSession.running) unawaited(execute(_activeSession)); + if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, const material.SingleActivator(LogicalKeyboardKey.enter, meta: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession, true)); + }, + const material.SingleActivator(LogicalKeyboardKey.enter, control: true, shift: true): () { if (!_activeSession.running) unawaited(execute(_activeSession)); }, - const material.SingleActivator(LogicalKeyboardKey.numpadEnter, control: true): () { + const material.SingleActivator(LogicalKeyboardKey.enter, meta: true, shift: true): () { if (!_activeSession.running) unawaited(execute(_activeSession)); }, + const material.SingleActivator(LogicalKeyboardKey.numpadEnter, control: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession, true)); + }, const material.SingleActivator(LogicalKeyboardKey.numpadEnter, meta: true): () { - if (!_activeSession.running) unawaited(execute(_activeSession)); + if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, const material.SingleActivator(LogicalKeyboardKey.keyR, control: true): () { - if (!_activeSession.running) unawaited(execute(_activeSession)); + if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, const material.SingleActivator(LogicalKeyboardKey.keyR, meta: true): () { - if (!_activeSession.running) unawaited(execute(_activeSession)); + if (!_activeSession.running) unawaited(execute(_activeSession, true)); }, }, child: material.Focus( diff --git a/test/core/database/sql_statement_splitter_test.dart b/test/core/database/sql_statement_splitter_test.dart new file mode 100644 index 00000000..854d683f --- /dev/null +++ b/test/core/database/sql_statement_splitter_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; + +List texts(String sql) => [ + for (final s in SqlStatementSplitter.spans(sql)) s.textIn(sql), + ]; + +void main() { + group('SqlStatementSplitter.spans', () { + test('splits on semicolons and skips empty statements', () { + expect(texts('select 1;; select 2 ;\n'), ['select 1', 'select 2']); + }); + + test('a semicolon inside quotes or comments does not end a statement', () { + expect(texts("select 'a;b'; select \"c;d\"; -- x; y\nselect 3"), [ + "select 'a;b'", + 'select "c;d"', + '-- x; y\nselect 3', + ]); + expect(texts('select 1 /* ; */; select 2'), [ + 'select 1 /* ; */', + 'select 2', + ]); + }); + + test("doubled quotes and backslash escapes stay inside a literal", () { + expect(texts("select 'it''s;'; select 'x\\';y'"), [ + "select 'it''s;'", + "select 'x\\';y'", + ]); + }); + + test('PostgreSQL dollar quoting keeps its semicolons', () { + expect( + texts('create function f() returns int as \$\$ select 1; \$\$ ' + 'language sql; select 2'), + [ + 'create function f() returns int as \$\$ select 1; \$\$ ' + 'language sql', + 'select 2', + ], + ); + }); + + test('offsets and lines point at the statement text', () { + const sql = 'select 1;\n\n select 2'; + final spans = SqlStatementSplitter.spans(sql); + expect(spans.length, 2); + expect(spans[1].textIn(sql), 'select 2'); + expect(spans[1].line, 3); + expect(spans[0].line, 1); + }); + + test('the destructive detector splits the same way', () { + const sql = "select 'a;b'; drop table t; -- ;\n"; + expect(DestructiveSqlDetector.splitStatements(sql), texts(sql)); + }); + }); + + group('SqlStatementSplitter.at', () { + const sql = 'select 1;\nselect 2;\nselect 3'; + final spans = SqlStatementSplitter.spans(sql); + + test('the caret inside the second of three statements picks it', () { + final offset = sql.indexOf('2') + 1; + expect(SqlStatementSplitter.at(spans, offset)!.textIn(sql), 'select 2'); + }); + + test('a caret on a blank line between statements takes the one above', () { + const blank = 'select 1;\n\nselect 2'; + final b = SqlStatementSplitter.spans(blank); + final offset = blank.indexOf('\n\n') + 1; + expect(SqlStatementSplitter.at(b, offset)!.textIn(blank), 'select 1'); + }); + + test('a caret after the last semicolon takes the last statement', () { + const trailing = 'select 1; select 2;'; + final t = SqlStatementSplitter.spans(trailing); + expect(SqlStatementSplitter.at(t, trailing.length)!.textIn(trailing), + 'select 2'); + }); + + test('a caret before the first statement takes the first one', () { + const leading = ' select 1'; + final l = SqlStatementSplitter.spans(leading); + expect(SqlStatementSplitter.at(l, 0)!.textIn(leading), 'select 1'); + }); + + test('an empty script has no statement', () { + expect(SqlStatementSplitter.at(SqlStatementSplitter.spans(' ; '), 0), + isNull); + }); + }); +}