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
4 changes: 4 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
55 changes: 4 additions & 51 deletions lib/core/database/postgres_sql.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';
}
59 changes: 59 additions & 0 deletions lib/core/database/sql_limit.dart
Original file line number Diff line number Diff line change
@@ -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';
}
15 changes: 10 additions & 5 deletions lib/features/sqlite/sqlite_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -156,7 +157,11 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
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;

Expand All @@ -165,9 +170,9 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
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();
Expand All @@ -184,10 +189,10 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
_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;
});
Expand Down
80 changes: 80 additions & 0 deletions test/core/database/sql_limit_test.dart
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
}
Loading