diff --git a/README.md b/README.md index d720112d..f3f7fe29 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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" > ``` --- diff --git a/lib/core/actions/sql_script_format.dart b/lib/core/actions/sql_script_format.dart index 0f9f3f6f..ae21c5d4 100644 --- a/lib/core/actions/sql_script_format.dart +++ b/lib/core/actions/sql_script_format.dart @@ -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', @@ -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); diff --git a/lib/core/database/sql_lexical_scanner.dart b/lib/core/database/sql_lexical_scanner.dart index 96e25774..f36296da 100644 --- a/lib/core/database/sql_lexical_scanner.dart +++ b/lib/core/database/sql_lexical_scanner.dart @@ -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). @@ -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_]*)\$'); @@ -61,7 +61,12 @@ abstract final class SqlLexicalScanner { /// Segments that cover [sql] end to end, in order; neighbouring code is /// one segment. - static List 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 segments(String sql, {bool backslashEscapes = true}) { final out = []; final len = sql.length; var codeStart = 0; @@ -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); @@ -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++; @@ -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; + } } diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 0d4b9148..47d79994 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -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); @@ -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; diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index aab422a1..600c5731 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -149,6 +149,7 @@ class GenericSqlWorkspaceState extends material.State { id: '${widget.sessionPrefix}_tab_1', title: widget.initialTabTitle ?? 'Query 1', initialSql: widget.initialSql, + dialect: widget.dialect, ), ); _appSettingsListener = () { @@ -259,6 +260,7 @@ class GenericSqlWorkspaceState extends material.State { filePath: filePath, initialFraction: _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, + dialect: widget.dialect, ); _sessions.add(session); _activeSessionIndex = _sessions.length - 1; diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 250b9dc8..08c9f9b8 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -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'; @@ -19,6 +21,7 @@ class SqlQueryTabSession { this.filePath, double initialFraction = 0.65, this.isDiagram = false, + this.dialect, material.TextEditingController? customController, }) : controller = customController ?? SyntaxHighlightService.createHighlightController( @@ -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; @@ -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, ); } diff --git a/test/core/actions/sql_script_format_test.dart b/test/core/actions/sql_script_format_test.dart index 058d97e3..aa208ed3 100644 --- a/test/core/actions/sql_script_format_test.dart +++ b/test/core/actions/sql_script_format_test.dart @@ -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', () { @@ -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", + ); + }); + }); } diff --git a/test/core/database/sql_lexical_scanner_test.dart b/test/core/database/sql_lexical_scanner_test.dart index d8ff8b59..91e6277e 100644 --- a/test/core/database/sql_lexical_scanner_test.dart +++ b/test/core/database/sql_lexical_scanner_test.dart @@ -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'"), + ]); + }); + }); } diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart index 76e3519d..682e4fde 100644 --- a/test/core/database/sql_limit_test.dart +++ b/test/core/database/sql_limit_test.dart @@ -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'); + }); + }); }