diff --git a/docs/user-guide.md b/docs/user-guide.md index 12997878..bba0f411 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,4 +44,8 @@ Preferences (except secrets) live in the same local SQLite file as connection me High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite include rich object trees and SQL workspaces (with SQLite utilizing local `.db` files); Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. +### Result row caps (SQL workspaces) + +Preferences → **Max rows in results** caps how many rows the grid loads. For **PostgreSQL** and **SQLite**, ad-hoc `SELECT` / `WITH` / `VALUES` without an author `LIMIT` get a `LIMIT` injected before execution so the engine does not materialize an unbounded result. Queries that already include `LIMIT`, and non-SELECT statements (`INSERT`, `PRAGMA`, …), are left unchanged; the UI may still truncate the displayed grid as a fallback. + For troubleshooting build/run issues, see the main [README.md](../README.md). diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index 8f9de124..0eff57f6 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -1,19 +1,9 @@ // Helpers for ad-hoc SQL workspace (transactions, stripping comments). -/// Removes leading whitespace and `--` line comments (not `/* */`). -String stripLeadingWhitespaceAndLineComments(String sql) { - var s = sql.trimLeft(); - while (true) { - if (s.isEmpty) return s; - if (s.startsWith('--')) { - final nl = s.indexOf('\n'); - if (nl == -1) return ''; - s = s.substring(nl + 1).trimLeft(); - continue; - } - return s; - } -} +import 'sql_limit.dart'; + +export 'sql_limit.dart' + show injectSqlLimit, stripLeadingWhitespaceAndLineComments; /// True if the first statement looks like explicit transaction control, so we /// should not prepend `BEGIN` when autocommit is off. @@ -36,40 +26,3 @@ bool shouldSkipImplicitBegin(String sql) { return false; } - -/// Injects a `LIMIT` clause to a read-only query (SELECT, WITH, VALUES) -/// if it does not already contain a `LIMIT` clause. -String injectSqlLimit(String sql, int limit) { - final cleanSql = stripLeadingWhitespaceAndLineComments(sql); - final upper = cleanSql.toUpperCase(); - - final isSelect = upper.startsWith('SELECT') || - upper.startsWith('WITH') || - upper.startsWith('VALUES'); - - if (!isSelect) { - return sql; - } - - // Check if it already has a LIMIT clause - final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); - if (hasLimit) { - return sql; - } - - // Strip trailing whitespace and semicolons to build the body - var body = sql.trimRight(); - var suffix = ''; - - while (true) { - if (body.isEmpty) break; - if (body.endsWith(';')) { - body = body.substring(0, body.length - 1).trimRight(); - suffix = ';$suffix'; - continue; - } - break; - } - - return '$body\nLIMIT $limit$suffix'; -} diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart new file mode 100644 index 00000000..45a81a8c --- /dev/null +++ b/lib/core/database/sql_limit.dart @@ -0,0 +1,59 @@ +// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, …). + +/// Removes leading whitespace and `--` line comments (not `/* */`). +String stripLeadingWhitespaceAndLineComments(String sql) { + var s = sql.trimLeft(); + while (true) { + if (s.isEmpty) return s; + if (s.startsWith('--')) { + final nl = s.indexOf('\n'); + if (nl == -1) return ''; + s = s.substring(nl + 1).trimLeft(); + continue; + } + return s; + } +} + +/// Injects a `LIMIT` clause into a read-only query (`SELECT`, `WITH`, `VALUES`) +/// when it does not already contain `LIMIT`. +/// +/// Existing `LIMIT` is left unchanged (caller may still apply a client-side +/// display cap). Non-select statements are returned as-is. +/// +/// Trailing semicolons are preserved after the injected clause. +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'); + + if (!isSelect) { + return sql; + } + + // Already bounded by the author (may still exceed UI cap — see clamp issue). + final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); + if (hasLimit) { + return sql; + } + + var body = sql.trimRight(); + var suffix = ''; + + while (true) { + if (body.isEmpty) break; + if (body.endsWith(';')) { + body = body.substring(0, body.length - 1).trimRight(); + suffix = ';$suffix'; + continue; + } + break; + } + + return '$body\nLIMIT $limit$suffix'; +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 718076b7..64f034a1 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -7,6 +7,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -156,7 +157,11 @@ class _SqliteSqlWorkspaceState extends material.State { return; } - final results = await conn.execute(userSql); + // Bound SELECT/WITH/VALUES at the engine before materializing rows. + // Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT. + final cap = _resultMaxRows; + final sql = injectSqlLimit(userSql, cap); + final results = await conn.execute(sql); if (!mounted) return; @@ -165,9 +170,9 @@ class _SqliteSqlWorkspaceState extends material.State { cols.addAll(results.first.keys); } - final cap = _resultMaxRows; final truncated = results.length > cap; final limitCount = truncated ? cap : results.length; + final injectedLimit = sql != userSql; final rawRows = results.take(limitCount).map((row) { return cols.map((col) => row[col]).toList(); @@ -184,10 +189,10 @@ class _SqliteSqlWorkspaceState extends material.State { _affectedRows = null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed.'; + } else if (truncated || (injectedLimit && results.length >= cap)) { + _statusLine = 'Showing first $cap row(s) (result capped).'; } else { - _statusLine = truncated - ? 'Showing first $cap row(s) (result capped).' - : '${results.length} row(s).'; + _statusLine = '${results.length} row(s).'; } _running = false; }); diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart new file mode 100644 index 00000000..dd3468f5 --- /dev/null +++ b/test/core/database/sql_limit_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; + +void main() { + group('injectSqlLimit', () { + test('appends LIMIT to select query without limit', () { + expect( + injectSqlLimit('SELECT * FROM users', 5000), + 'SELECT * FROM users\nLIMIT 5000', + ); + }); + + test('handles trailing semicolons', () { + expect( + injectSqlLimit('SELECT * FROM users;', 5000), + 'SELECT * FROM users\nLIMIT 5000;', + ); + expect( + injectSqlLimit('SELECT * FROM users; ', 5000), + 'SELECT * FROM users\nLIMIT 5000;', + ); + expect( + injectSqlLimit('SELECT * FROM users;;', 5000), + 'SELECT * FROM users\nLIMIT 5000;;', + ); + }); + + test('does not append LIMIT if LIMIT already exists', () { + expect( + injectSqlLimit('SELECT * FROM users LIMIT 10', 5000), + 'SELECT * FROM users LIMIT 10', + ); + expect( + injectSqlLimit('SELECT * FROM users limit 10;', 5000), + 'SELECT * FROM users limit 10;', + ); + }); + + test('does not modify non-select/non-read queries', () { + expect( + injectSqlLimit('INSERT INTO users VALUES (1)', 5000), + 'INSERT INTO users VALUES (1)', + ); + expect( + injectSqlLimit('UPDATE users SET x = 1', 5000), + 'UPDATE users SET x = 1', + ); + expect( + injectSqlLimit('PRAGMA table_info(users)', 5000), + 'PRAGMA table_info(users)', + ); + }); + + test('appends LIMIT to WITH and VALUES', () { + expect( + injectSqlLimit( + 'WITH t AS (SELECT * FROM users) SELECT * FROM t;', + 5000, + ), + 'WITH t AS (SELECT * FROM users) SELECT * FROM t\nLIMIT 5000;', + ); + expect( + injectSqlLimit('VALUES (1), (2), (3)', 2), + 'VALUES (1), (2), (3)\nLIMIT 2', + ); + }); + + test('ignores non-positive limit', () { + expect(injectSqlLimit('SELECT 1', 0), 'SELECT 1'); + expect(injectSqlLimit('SELECT 1', -1), 'SELECT 1'); + }); + + test('skips leading line comments when detecting SELECT', () { + expect( + injectSqlLimit('-- comment\nSELECT * FROM t', 100), + '-- comment\nSELECT * FROM t\nLIMIT 100', + ); + }); + }); +}