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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ Download the latest `.deb`, `.AppImage`, `.rpm`, or `.flatpak` from [Releases](h
# Debian / Ubuntu (.deb)
sudo apt install ./Querya-Desktop-*-linux.deb

# Fedora / RHEL (.rpm)
sudo rpm -i Querya-Desktop-*-linux.rpm
# Fedora / RHEL (.rpm; dnf also installs its dependencies, such as libsecret)
sudo dnf install ./Querya-Desktop-*-linux.rpm

# AppImage (standalone executable; Ubuntu 22.04+ requires libfuse2)
# Ubuntu 22.04+: sudo apt install libfuse2 # or libfuse2t64
Expand All @@ -151,11 +151,11 @@ flatpak install --user ./Querya-Desktop-*-linux.flatpak
Download the Windows installer (`.exe`) or portable archive (`.zip`) from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases). Run the setup to automatically register `.sql` and `.sqlite` file associations.

### macOS
Download the universal `.dmg` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases), mount the disk image, and drag **Querya** into your `Applications` folder.
Download the universal `.dmg` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases), mount the disk image, and drag **Querya Desktop** into your `Applications` folder.

> **Unsigned builds:** If Gatekeeper blocks the app after installation, run:
> ```bash
> xattr -cr /Applications/Querya.app
> xattr -cr "/Applications/Querya Desktop.app"
> ```

---
Expand Down
16 changes: 14 additions & 2 deletions lib/core/actions/sql_script_format.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'package:querya_desktop/core/database/sql_lexical_scanner.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart'
show SqlDialect;

const _kSqlKeywords = {
'ADD',
Expand Down Expand Up @@ -67,9 +69,19 @@ final _kSqlWord = RegExp(r"[A-Za-z_][A-Za-z0-9_]*");
/// Only code is touched: string literals, quoted identifiers, comments and
/// dollar-quoted bodies are kept as written (#1566). Keywords keep their
/// length, so every offset into [sql] is the same offset into the result.
String formatSqlScript(String sql) {
///
/// [dialect] decides whether `\'` escapes a quote: it does in MySQL (and when
/// the dialect is unknown), not in PostgreSQL or SQLite, where reading it as
/// an escape would take the code after `'C:\'` for part of the literal and
/// the next literal for code.
String formatSqlScript(String sql, {SqlDialect? dialect}) {
final backslashEscapes =
dialect != SqlDialect.postgres && dialect != SqlDialect.sqlite;
final out = StringBuffer();
for (final segment in SqlLexicalScanner.segments(sql)) {
for (final segment in SqlLexicalScanner.segments(
sql,
backslashEscapes: backslashEscapes,
)) {
final text = sql.substring(segment.start, segment.end);
if (segment.kind != SqlSegmentKind.code) {
out.write(text);
Expand Down
32 changes: 27 additions & 5 deletions lib/core/database/sql_lexical_scanner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ enum SqlSegmentKind {
/// Keywords, identifiers, operators, numbers and blanks.
code,

/// `'...'`, with `''` and backslash escapes.
/// `'...'`, with `''` (and backslash escapes where the dialect has them).
string,

/// `"name"` (PostgreSQL, standard SQL) or `` `name` `` (MySQL).
Expand Down Expand Up @@ -43,8 +43,8 @@ class SqlSegment {
}

/// Splits SQL into code and the parts that are not code (literals, quoted
/// identifiers, comments, dollar quotes), with the same rules as
/// `SqlStatementSplitter`. An unterminated literal or comment runs to the end.
/// identifiers, comments, dollar quotes). An unterminated literal or comment
/// runs to the end.
abstract final class SqlLexicalScanner {
static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$');

Expand All @@ -61,7 +61,12 @@ abstract final class SqlLexicalScanner {

/// Segments that cover [sql] end to end, in order; neighbouring code is
/// one segment.
static List<SqlSegment> segments(String sql) {
///
/// [backslashEscapes]: whether `\'` inside `'...'` is an escaped quote, as
/// in MySQL. In PostgreSQL and SQLite a backslash is an ordinary character
/// and `'C:\'` is a whole literal; only PostgreSQL's `E'...'` strings
/// escape with it, so those are honoured either way.
static List<SqlSegment> segments(String sql, {bool backslashEscapes = true}) {
final out = <SqlSegment>[];
final len = sql.length;
var codeStart = 0;
Expand Down Expand Up @@ -113,6 +118,7 @@ abstract final class SqlLexicalScanner {
}

if (code == _chSingleQuote) {
final escapes = backslashEscapes || _isEscapeStringPrefix(sql, i);
var j = i + 1;
while (j < len) {
final curr = sql.codeUnitAt(j);
Expand All @@ -123,7 +129,7 @@ abstract final class SqlLexicalScanner {
j++;
break;
}
} else if (curr == _chBackslash && j + 1 < len) {
} else if (escapes && curr == _chBackslash && j + 1 < len) {
j += 2;
} else {
j++;
Expand Down Expand Up @@ -160,4 +166,20 @@ abstract final class SqlLexicalScanner {
}
return out;
}

/// Whether the quote at [quote] opens a PostgreSQL escape string: `E'` or
/// `e'`, the `E` not being the end of a longer word (`TYPE'...'`).
static bool _isEscapeStringPrefix(String sql, int quote) {
if (quote < 1) return false;
final e = sql.codeUnitAt(quote - 1);
if (e != 0x45 && e != 0x65) return false;
if (quote < 2) return true;
final before = sql.codeUnitAt(quote - 2);
final word = (before >= 0x30 && before <= 0x39) ||
(before >= 0x41 && before <= 0x5A) ||
(before >= 0x61 && before <= 0x7A) ||
before == 0x5F ||
before == 0x24;
return !word;
}
}
24 changes: 18 additions & 6 deletions lib/core/database/sql_limit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ int _skipLeadingCommentsOffset(String sql) {
}
}

/// Whether [sql] has [upperKeyword] at [at], ignoring ASCII case. Like the
/// `startsWith` it replaces, it does not require a word boundary after it.
bool _hasKeywordAt(String sql, int at, String upperKeyword) {
if (at + upperKeyword.length > sql.length) return false;
for (var k = 0; k < upperKeyword.length; k++) {
var c = sql.codeUnitAt(at + k);
if (c >= 0x61 && c <= 0x7A) c -= 0x20;
if (c != upperKeyword.codeUnitAt(k)) return false;
}
return true;
}

/// Removes leading whitespace, `--` line comments and `/* */` block comments.
String stripLeadingWhitespaceAndLineComments(String sql) {
final start = _skipLeadingCommentsOffset(sql);
Expand Down Expand Up @@ -207,12 +219,12 @@ Match? _outerMatch(RegExp pattern, String sql) {
String injectSqlLimit(String sql, int limit) {
if (limit <= 0) return sql;

final cleanSql = stripLeadingWhitespaceAndLineComments(sql);
final upper = cleanSql.toUpperCase();

final isSelect = upper.startsWith('SELECT') ||
upper.startsWith('WITH') ||
upper.startsWith('VALUES');
// The keyword is compared in place: neither the comment-free tail nor an
// upper-cased copy of a large script is made just to read its first word.
final start = _skipLeadingCommentsOffset(sql);
final isSelect = _hasKeywordAt(sql, start, 'SELECT') ||
_hasKeywordAt(sql, start, 'WITH') ||
_hasKeywordAt(sql, start, 'VALUES');

if (!isSelect) {
return sql;
Expand Down
2 changes: 2 additions & 0 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
id: '${widget.sessionPrefix}_tab_1',
title: widget.initialTabTitle ?? 'Query 1',
initialSql: widget.initialSql,
dialect: widget.dialect,
),
);
_appSettingsListener = () {
Expand Down Expand Up @@ -259,6 +260,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
filePath: filePath,
initialFraction:
_sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65,
dialect: widget.dialect,
);
_sessions.add(session);
_activeSessionIndex = _sessions.length - 1;
Expand Down
8 changes: 7 additions & 1 deletion lib/features/workspace/sql_query_tab_session.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/actions/sql_script_format.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart'
show SqlDialect;
import 'package:querya_desktop/core/database/table_schema_meta.dart';
import 'package:querya_desktop/core/editor/querya_code_language.dart';
import 'package:querya_desktop/core/editor/syntax_highlight_service.dart';
Expand All @@ -19,6 +21,7 @@ class SqlQueryTabSession {
this.filePath,
double initialFraction = 0.65,
this.isDiagram = false,
this.dialect,
material.TextEditingController? customController,
}) : controller = customController ??
SyntaxHighlightService.createHighlightController(
Expand All @@ -38,6 +41,9 @@ class SqlQueryTabSession {

/// True for the ER diagram tab (no editor / result grid).
final bool isDiagram;

/// The connection's SQL dialect, for Format SQL's lexical rules.
final SqlDialect? dialect;
String title;
String? filePath;
final material.TextEditingController controller;
Expand Down Expand Up @@ -104,7 +110,7 @@ class SqlQueryTabSession {
void formatSql() {
final value = controller.value;
controller.value = value.copyWith(
text: formatSqlScript(value.text),
text: formatSqlScript(value.text, dialect: dialect),
composing: material.TextRange.empty,
);
}
Expand Down
28 changes: 28 additions & 0 deletions test/core/actions/sql_script_format_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/actions/sql_script_format.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart'
show SqlDialect;

void main() {
test('uppercases SQL keywords and leaves identifiers', () {
Expand Down Expand Up @@ -66,4 +68,30 @@ void main() {
const sql = "select a, \"b\" from t -- x\nwhere c = 'and' and d is null";
expect(formatSqlScript(sql).length, sql.length);
});

group('backslashes follow the dialect', () {
test(r"PostgreSQL: a literal ending in \ does not swallow the next one", () {
expect(
formatSqlScript(r"select 'C:\', 'not null' from t",
dialect: SqlDialect.postgres),
r"SELECT 'C:\', 'not null' FROM t",
);
});

test('SQLite: the same', () {
expect(
formatSqlScript(r"select 'C:\' as p where x = 'and'",
dialect: SqlDialect.sqlite),
r"SELECT 'C:\' AS p WHERE x = 'and'",
);
});

test(r"MySQL: \' is an escaped quote inside the literal", () {
expect(
formatSqlScript(r"select 'it\'s or not' from t",
dialect: SqlDialect.mysql),
r"SELECT 'it\'s or not' FROM t",
);
});
});
}
42 changes: 42 additions & 0 deletions test/core/database/sql_lexical_scanner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,46 @@ void main() {
expect(at, sql.length);
});
});

group('backslashes (PostgreSQL / SQLite)', () {
List<(SqlSegmentKind, String)> scanStd(String sql) => [
for (final s
in SqlLexicalScanner.segments(sql, backslashEscapes: false))
(s.kind, sql.substring(s.start, s.end)),
];

test(r"a backslash before the quote does not escape it: 'C:\'", () {
expect(scanStd(r"select 'C:\', 'not null'"), [
(SqlSegmentKind.code, 'select '),
(SqlSegmentKind.string, r"'C:\'"),
(SqlSegmentKind.code, ', '),
(SqlSegmentKind.string, "'not null'"),
]);
});

test(r"E'...' strings still escape with a backslash", () {
expect(scanStd(r"select E'it\'s', e'a\'b'"), [
(SqlSegmentKind.code, 'select E'),
(SqlSegmentKind.string, r"'it\'s'"),
(SqlSegmentKind.code, ', e'),
(SqlSegmentKind.string, r"'a\'b'"),
]);
});

test('an E that ends a longer word is not a prefix', () {
expect(scanStd(r"select type'x\', 'y'"), [
(SqlSegmentKind.code, 'select type'),
(SqlSegmentKind.string, r"'x\'"),
(SqlSegmentKind.code, ', '),
(SqlSegmentKind.string, "'y'"),
]);
});

test('MySQL rules (the default) still escape', () {
expect(_scan(r"select 'it\'s'"), [
(SqlSegmentKind.code, 'select '),
(SqlSegmentKind.string, r"'it\'s'"),
]);
});
});
}
19 changes: 19 additions & 0 deletions test/core/database/sql_limit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,23 @@ void main() {
});
});
});

group('statement kind after leading comments (#1515)', () {
test('a long comment header still finds the SELECT, any case', () {
final header = List.filled(200, '-- generated\n').join();
expect(
injectSqlLimit('$header/* x */\n sElEcT * FROM t', 10),
'$header/* x */\n sElEcT * FROM t\nLIMIT 10',
);
});

test('WITH and VALUES count, other statements are left alone', () {
expect(injectSqlLimit('with a as (select 1) select * from a', 5),
endsWith('LIMIT 5'));
expect(injectSqlLimit('values (1), (2)', 5), endsWith('LIMIT 5'));
expect(injectSqlLimit('-- c\nupdate t set a = 1', 5),
'-- c\nupdate t set a = 1');
expect(injectSqlLimit('-- only a comment', 5), '-- only a comment');
});
});
}
Loading