Skip to content

Commit 594ecaa

Browse files
Merge pull request #432 from QueryaHub/issue/416-clamp-onto-dev
perf(sql): clamp oversized LIMIT / FETCH (#416 onto dev)
2 parents 847ce6c + d27176a commit 594ecaa

4 files changed

Lines changed: 97 additions & 11 deletions

File tree

‎docs/user-guide.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite
4646

4747
### Result row caps (SQL workspaces)
4848

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.
49+
Preferences → **Max rows in results** caps how many rows the grid loads. For **PostgreSQL** and **SQLite**, ad-hoc `SELECT` / `WITH` / `VALUES` are bounded **before** execution:
50+
51+
- No author `LIMIT` → a `LIMIT` equal to the preference is injected.
52+
- Author `LIMIT` / `LIMIT ALL` / `FETCH FIRST n ROWS ONLY` larger than the preference → clamped down to the preference (OFFSET kept when present).
53+
54+
Queries that already use a smaller `LIMIT`, and non-SELECT statements (`INSERT`, `PRAGMA`, …), are left unchanged. The UI may still truncate the displayed grid as a fallback. **MySQL** SQL workspace streams rows and stops at the cap client-side.
5055

5156
For troubleshooting build/run issues, see the main [README.md](../README.md).

‎lib/core/database/sql_limit.dart‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,25 @@ String stripLeadingWhitespaceAndLineComments(String sql) {
1515
}
1616
}
1717

18-
/// Injects a `LIMIT` clause into a read-only query (`SELECT`, `WITH`, `VALUES`)
19-
/// when it does not already contain `LIMIT`.
18+
final _limitAll = RegExp(r'\bLIMIT\s+ALL\b', caseSensitive: false);
19+
final _limitCount = RegExp(
20+
r'\bLIMIT\s+(\d+)(\s+OFFSET\s+\d+)?',
21+
caseSensitive: false,
22+
);
23+
final _fetchFirst = RegExp(
24+
r'\bFETCH\s+(?:FIRST|NEXT)\s+(\d+)\s+ROWS?\s+ONLY\b',
25+
caseSensitive: false,
26+
);
27+
28+
/// Injects or clamps a `LIMIT` on read-only queries (`SELECT`, `WITH`, `VALUES`).
2029
///
21-
/// Existing `LIMIT` is left unchanged (caller may still apply a client-side
22-
/// display cap). Non-select statements are returned as-is.
30+
/// - No `LIMIT` / `FETCH … ONLY` → appends `LIMIT [limit]`.
31+
/// - `LIMIT ALL` → replaced with `LIMIT [limit]`.
32+
/// - `LIMIT n [OFFSET m]` where `n > limit` → clamped to [limit].
33+
/// - `FETCH FIRST/NEXT n ROWS ONLY` where `n > limit` → clamped.
34+
/// - Non-select statements are returned unchanged.
2335
///
24-
/// Trailing semicolons are preserved after the injected clause.
36+
/// Trailing semicolons are preserved after an injected clause.
2537
String injectSqlLimit(String sql, int limit) {
2638
if (limit <= 0) return sql;
2739

@@ -36,9 +48,38 @@ String injectSqlLimit(String sql, int limit) {
3648
return sql;
3749
}
3850

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) {
51+
if (_limitAll.hasMatch(sql)) {
52+
return sql.replaceFirst(_limitAll, 'LIMIT $limit');
53+
}
54+
55+
final limitMatch = _limitCount.firstMatch(sql);
56+
if (limitMatch != null) {
57+
final existing = int.tryParse(limitMatch.group(1)!);
58+
if (existing == null || existing <= limit) {
59+
return sql;
60+
}
61+
final offsetPart = limitMatch.group(2) ?? '';
62+
return sql.replaceFirst(
63+
limitMatch.group(0)!,
64+
'LIMIT $limit$offsetPart',
65+
);
66+
}
67+
68+
final fetchMatch = _fetchFirst.firstMatch(sql);
69+
if (fetchMatch != null) {
70+
final existing = int.tryParse(fetchMatch.group(1)!);
71+
if (existing == null || existing <= limit) {
72+
return sql;
73+
}
74+
return sql.replaceFirst(
75+
fetchMatch.group(0)!,
76+
'FETCH FIRST $limit ROWS ONLY',
77+
);
78+
}
79+
80+
if (RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql) ||
81+
RegExp(r'\bFETCH\b', caseSensitive: false).hasMatch(sql)) {
82+
// Unrecognized LIMIT/FETCH shape — leave unchanged.
4283
return sql;
4384
}
4485

‎test/core/database/postgres_sql_test.dart‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,18 @@ void main() {
9595
'SELECT * FROM users\nLIMIT 5000;;');
9696
});
9797

98-
test('does not append LIMIT if LIMIT already exists', () {
98+
test('does not append LIMIT if LIMIT already within cap', () {
9999
expect(injectSqlLimit('SELECT * FROM users LIMIT 10', 5000),
100100
'SELECT * FROM users LIMIT 10');
101101
expect(injectSqlLimit('SELECT * FROM users limit 10;', 5000),
102102
'SELECT * FROM users limit 10;');
103103
});
104104

105+
test('clamps oversized LIMIT', () {
106+
expect(injectSqlLimit('SELECT * FROM users LIMIT 999999', 5000),
107+
'SELECT * FROM users LIMIT 5000');
108+
});
109+
105110
test('does not modify non-select/non-read queries', () {
106111
expect(injectSqlLimit('INSERT INTO users VALUES (1)', 5000),
107112
'INSERT INTO users VALUES (1)');

‎test/core/database/sql_limit_test.dart‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ void main() {
2525
);
2626
});
2727

28-
test('does not append LIMIT if LIMIT already exists', () {
28+
test('does not append LIMIT if LIMIT already exists and within cap', () {
2929
expect(
3030
injectSqlLimit('SELECT * FROM users LIMIT 10', 5000),
3131
'SELECT * FROM users LIMIT 10',
@@ -36,6 +36,41 @@ void main() {
3636
);
3737
});
3838

39+
test('clamps LIMIT larger than cap', () {
40+
expect(
41+
injectSqlLimit('SELECT * FROM users LIMIT 999999', 5000),
42+
'SELECT * FROM users LIMIT 5000',
43+
);
44+
expect(
45+
injectSqlLimit('SELECT * FROM users LIMIT 100000 OFFSET 20;', 1000),
46+
'SELECT * FROM users LIMIT 1000 OFFSET 20;',
47+
);
48+
});
49+
50+
test('replaces LIMIT ALL with cap', () {
51+
expect(
52+
injectSqlLimit('SELECT * FROM users LIMIT ALL', 5000),
53+
'SELECT * FROM users LIMIT 5000',
54+
);
55+
});
56+
57+
test('clamps FETCH FIRST n ROWS ONLY', () {
58+
expect(
59+
injectSqlLimit(
60+
'SELECT * FROM users FETCH FIRST 100000 ROWS ONLY',
61+
5000,
62+
),
63+
'SELECT * FROM users FETCH FIRST 5000 ROWS ONLY',
64+
);
65+
expect(
66+
injectSqlLimit(
67+
'SELECT * FROM users FETCH FIRST 10 ROWS ONLY',
68+
5000,
69+
),
70+
'SELECT * FROM users FETCH FIRST 10 ROWS ONLY',
71+
);
72+
});
73+
3974
test('does not modify non-select/non-read queries', () {
4075
expect(
4176
injectSqlLimit('INSERT INTO users VALUES (1)', 5000),

0 commit comments

Comments
 (0)