Skip to content

Commit 5a4ffe0

Browse files
Merge pull request #429 from QueryaHub/issue/415-sqlite-inject-limit
perf(sqlite): inject LIMIT before materializing SQL results (#415)
2 parents 59ea3bb + 30af284 commit 5a4ffe0

5 files changed

Lines changed: 157 additions & 56 deletions

File tree

‎docs/user-guide.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,8 @@ Preferences (except secrets) live in the same local SQLite file as connection me
4444

4545
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.
4646

47+
### Result row caps (SQL workspaces)
48+
49+
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.
50+
4751
For troubleshooting build/run issues, see the main [README.md](../README.md).
Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
// Helpers for ad-hoc SQL workspace (transactions, stripping comments).
22

3-
/// Removes leading whitespace and `--` line comments (not `/* */`).
4-
String stripLeadingWhitespaceAndLineComments(String sql) {
5-
var s = sql.trimLeft();
6-
while (true) {
7-
if (s.isEmpty) return s;
8-
if (s.startsWith('--')) {
9-
final nl = s.indexOf('\n');
10-
if (nl == -1) return '';
11-
s = s.substring(nl + 1).trimLeft();
12-
continue;
13-
}
14-
return s;
15-
}
16-
}
3+
import 'sql_limit.dart';
4+
5+
export 'sql_limit.dart'
6+
show injectSqlLimit, stripLeadingWhitespaceAndLineComments;
177

188
/// True if the first statement looks like explicit transaction control, so we
199
/// should not prepend `BEGIN` when autocommit is off.
@@ -36,40 +26,3 @@ bool shouldSkipImplicitBegin(String sql) {
3626

3727
return false;
3828
}
39-
40-
/// Injects a `LIMIT` clause to a read-only query (SELECT, WITH, VALUES)
41-
/// if it does not already contain a `LIMIT` clause.
42-
String injectSqlLimit(String sql, int limit) {
43-
final cleanSql = stripLeadingWhitespaceAndLineComments(sql);
44-
final upper = cleanSql.toUpperCase();
45-
46-
final isSelect = upper.startsWith('SELECT') ||
47-
upper.startsWith('WITH') ||
48-
upper.startsWith('VALUES');
49-
50-
if (!isSelect) {
51-
return sql;
52-
}
53-
54-
// Check if it already has a LIMIT clause
55-
final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql);
56-
if (hasLimit) {
57-
return sql;
58-
}
59-
60-
// Strip trailing whitespace and semicolons to build the body
61-
var body = sql.trimRight();
62-
var suffix = '';
63-
64-
while (true) {
65-
if (body.isEmpty) break;
66-
if (body.endsWith(';')) {
67-
body = body.substring(0, body.length - 1).trimRight();
68-
suffix = ';$suffix';
69-
continue;
70-
}
71-
break;
72-
}
73-
74-
return '$body\nLIMIT $limit$suffix';
75-
}

‎lib/core/database/sql_limit.dart‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, …).
2+
3+
/// Removes leading whitespace and `--` line comments (not `/* */`).
4+
String stripLeadingWhitespaceAndLineComments(String sql) {
5+
var s = sql.trimLeft();
6+
while (true) {
7+
if (s.isEmpty) return s;
8+
if (s.startsWith('--')) {
9+
final nl = s.indexOf('\n');
10+
if (nl == -1) return '';
11+
s = s.substring(nl + 1).trimLeft();
12+
continue;
13+
}
14+
return s;
15+
}
16+
}
17+
18+
/// Injects a `LIMIT` clause into a read-only query (`SELECT`, `WITH`, `VALUES`)
19+
/// when it does not already contain `LIMIT`.
20+
///
21+
/// Existing `LIMIT` is left unchanged (caller may still apply a client-side
22+
/// display cap). Non-select statements are returned as-is.
23+
///
24+
/// Trailing semicolons are preserved after the injected clause.
25+
String injectSqlLimit(String sql, int limit) {
26+
if (limit <= 0) return sql;
27+
28+
final cleanSql = stripLeadingWhitespaceAndLineComments(sql);
29+
final upper = cleanSql.toUpperCase();
30+
31+
final isSelect = upper.startsWith('SELECT') ||
32+
upper.startsWith('WITH') ||
33+
upper.startsWith('VALUES');
34+
35+
if (!isSelect) {
36+
return sql;
37+
}
38+
39+
// Already bounded by the author (may still exceed UI cap — see clamp issue).
40+
final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql);
41+
if (hasLimit) {
42+
return sql;
43+
}
44+
45+
var body = sql.trimRight();
46+
var suffix = '';
47+
48+
while (true) {
49+
if (body.isEmpty) break;
50+
if (body.endsWith(';')) {
51+
body = body.substring(0, body.length - 1).trimRight();
52+
suffix = ';$suffix';
53+
continue;
54+
}
55+
break;
56+
}
57+
58+
return '$body\nLIMIT $limit$suffix';
59+
}

‎lib/features/sqlite/sqlite_sql_workspace.dart‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:file_selector/file_selector.dart';
77
import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart';
88
import 'package:querya_desktop/core/actions/sql_editor_actions.dart';
99
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
10+
import 'package:querya_desktop/core/database/sql_limit.dart';
1011
import 'package:querya_desktop/core/database/sqlite_service.dart';
1112
import 'package:querya_desktop/core/layout/vertical_split_pane.dart';
1213
import 'package:querya_desktop/core/storage/app_settings.dart';
@@ -156,7 +157,11 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
156157
return;
157158
}
158159

159-
final results = await conn.execute(userSql);
160+
// Bound SELECT/WITH/VALUES at the engine before materializing rows.
161+
// Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT.
162+
final cap = _resultMaxRows;
163+
final sql = injectSqlLimit(userSql, cap);
164+
final results = await conn.execute(sql);
160165

161166
if (!mounted) return;
162167

@@ -165,9 +170,9 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
165170
cols.addAll(results.first.keys);
166171
}
167172

168-
final cap = _resultMaxRows;
169173
final truncated = results.length > cap;
170174
final limitCount = truncated ? cap : results.length;
175+
final injectedLimit = sql != userSql;
171176

172177
final rawRows = results.take(limitCount).map((row) {
173178
return cols.map((col) => row[col]).toList();
@@ -184,10 +189,10 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
184189
_affectedRows = null;
185190
if (cols.isEmpty && outRows.isEmpty) {
186191
_statusLine = 'Command completed.';
192+
} else if (truncated || (injectedLimit && results.length >= cap)) {
193+
_statusLine = 'Showing first $cap row(s) (result capped).';
187194
} else {
188-
_statusLine = truncated
189-
? 'Showing first $cap row(s) (result capped).'
190-
: '${results.length} row(s).';
195+
_statusLine = '${results.length} row(s).';
191196
}
192197
_running = false;
193198
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:querya_desktop/core/database/sql_limit.dart';
3+
4+
void main() {
5+
group('injectSqlLimit', () {
6+
test('appends LIMIT to select query without limit', () {
7+
expect(
8+
injectSqlLimit('SELECT * FROM users', 5000),
9+
'SELECT * FROM users\nLIMIT 5000',
10+
);
11+
});
12+
13+
test('handles trailing semicolons', () {
14+
expect(
15+
injectSqlLimit('SELECT * FROM users;', 5000),
16+
'SELECT * FROM users\nLIMIT 5000;',
17+
);
18+
expect(
19+
injectSqlLimit('SELECT * FROM users; ', 5000),
20+
'SELECT * FROM users\nLIMIT 5000;',
21+
);
22+
expect(
23+
injectSqlLimit('SELECT * FROM users;;', 5000),
24+
'SELECT * FROM users\nLIMIT 5000;;',
25+
);
26+
});
27+
28+
test('does not append LIMIT if LIMIT already exists', () {
29+
expect(
30+
injectSqlLimit('SELECT * FROM users LIMIT 10', 5000),
31+
'SELECT * FROM users LIMIT 10',
32+
);
33+
expect(
34+
injectSqlLimit('SELECT * FROM users limit 10;', 5000),
35+
'SELECT * FROM users limit 10;',
36+
);
37+
});
38+
39+
test('does not modify non-select/non-read queries', () {
40+
expect(
41+
injectSqlLimit('INSERT INTO users VALUES (1)', 5000),
42+
'INSERT INTO users VALUES (1)',
43+
);
44+
expect(
45+
injectSqlLimit('UPDATE users SET x = 1', 5000),
46+
'UPDATE users SET x = 1',
47+
);
48+
expect(
49+
injectSqlLimit('PRAGMA table_info(users)', 5000),
50+
'PRAGMA table_info(users)',
51+
);
52+
});
53+
54+
test('appends LIMIT to WITH and VALUES', () {
55+
expect(
56+
injectSqlLimit(
57+
'WITH t AS (SELECT * FROM users) SELECT * FROM t;',
58+
5000,
59+
),
60+
'WITH t AS (SELECT * FROM users) SELECT * FROM t\nLIMIT 5000;',
61+
);
62+
expect(
63+
injectSqlLimit('VALUES (1), (2), (3)', 2),
64+
'VALUES (1), (2), (3)\nLIMIT 2',
65+
);
66+
});
67+
68+
test('ignores non-positive limit', () {
69+
expect(injectSqlLimit('SELECT 1', 0), 'SELECT 1');
70+
expect(injectSqlLimit('SELECT 1', -1), 'SELECT 1');
71+
});
72+
73+
test('skips leading line comments when detecting SELECT', () {
74+
expect(
75+
injectSqlLimit('-- comment\nSELECT * FROM t', 100),
76+
'-- comment\nSELECT * FROM t\nLIMIT 100',
77+
);
78+
});
79+
});
80+
}

0 commit comments

Comments
 (0)