Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 5 additions & 101 deletions lib/core/database/destructive_sql_detector.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<String> splitStatements(String sql) {
final statements = <String>[];
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<String> 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) {
Expand Down
154 changes: 154 additions & 0 deletions lib/core/database/sql_statement_splitter.dart
Original file line number Diff line number Diff line change
@@ -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<SqlStatementSpan> spans(String sql) {
final out = <SqlStatementSpan>[];
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<SqlStatementSpan> 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;
}
}
35 changes: 27 additions & 8 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:querya_desktop/core/database/sql_statement_splitter.dart';
import 'dart:async';
import 'dart:io';

Expand Down Expand Up @@ -399,16 +400,28 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
}
}

Future<void> execute([SqlQueryTabSession? targetSession]) async {
/// Runs the selection, else the whole text. With [statementAtCursor] (run
/// statement) only the statement under the caret, see [SqlStatementSplitter].
Future<void> 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;

Expand Down Expand Up @@ -861,22 +874,28 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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(
Expand Down
Loading
Loading