Skip to content

Commit edc847b

Browse files
authored
Merge pull request #163 from Maxteabag/worktree-fix-limit-bug-132
Fix stacked result tables clipping last row
2 parents 43809c4 + ae7583e commit edc847b

File tree

2 files changed

+335
-2
lines changed

2 files changed

+335
-2
lines changed

sqlit/shared/ui/widgets_stacked_results.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class ResultSection(Collapsible):
8585
ResultSection DataTable {
8686
/* Height is set dynamically based on row count */
8787
margin-right: 1;
88+
scrollbar-gutter: stable;
8889
}
8990
"""
9091

@@ -214,8 +215,11 @@ def _build_result_table_from_rows(
214215
self, columns: list[str], rows: list[tuple], index: int
215216
) -> SqlitDataTable:
216217
"""Build a DataTable for a QueryResult without Arrow conversion."""
217-
# Calculate height: 1 for header + number of rows, capped at 15
218-
table_height = min(1 + len(rows), 15)
218+
# Calculate height: 1 for header + rows + 1 for horizontal scrollbar
219+
# The extra line is needed because when the table content is wider
220+
# than the viewport, a horizontal scrollbar appears at the bottom
221+
# and consumes 1 line of vertical space (fixes #132).
222+
table_height = min(2 + len(rows), 16)
219223

220224
table = SqlitDataTable(
221225
id=f"result-table-{index}",
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""Integration test for multi-statement LIMIT enforcement.
2+
3+
Regression test for https://github.com/fredrikaverpil/sqlit/issues/132:
4+
When running multiple queries with different LIMIT clauses via "Run All",
5+
each result table should have the correct number of rows.
6+
7+
Tests against real MySQL (via Docker) and SQLite to catch driver-specific issues.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import sqlite3
13+
14+
import pytest
15+
16+
from sqlit.domains.query.app.multi_statement import MultiStatementExecutor
17+
from sqlit.domains.query.app.query_service import (
18+
KeywordQueryAnalyzer,
19+
NonQueryResult,
20+
QueryKind,
21+
QueryResult,
22+
)
23+
24+
25+
class CursorBasedExecutor:
26+
"""Executor using CursorBasedAdapter's execute_query/execute_non_query logic.
27+
28+
This mirrors how MultiStatementExecutor calls TransactionExecutor,
29+
which calls _execute_on_connection, which calls adapter.execute_query.
30+
The logic is copied verbatim from CursorBasedAdapter to test the exact
31+
same code path against real database connections.
32+
"""
33+
34+
def __init__(self, conn) -> None:
35+
self._conn = conn
36+
self._analyzer = KeywordQueryAnalyzer()
37+
38+
def execute(self, sql: str, max_rows: int | None = None) -> QueryResult | NonQueryResult:
39+
if self._analyzer.classify(sql) == QueryKind.RETURNS_ROWS:
40+
# Verbatim from CursorBasedAdapter.execute_query
41+
cursor = self._conn.cursor()
42+
cursor.execute(sql)
43+
if cursor.description:
44+
columns = [col[0] for col in cursor.description]
45+
if max_rows is not None:
46+
rows = cursor.fetchmany(max_rows + 1)
47+
truncated = len(rows) > max_rows
48+
if truncated:
49+
rows = rows[:max_rows]
50+
else:
51+
rows = cursor.fetchall()
52+
truncated = False
53+
return QueryResult(
54+
columns=columns,
55+
rows=[tuple(row) for row in rows],
56+
row_count=len(rows),
57+
truncated=truncated,
58+
)
59+
return QueryResult(columns=[], rows=[], row_count=0, truncated=False)
60+
else:
61+
# Verbatim from CursorBasedAdapter.execute_non_query
62+
cursor = self._conn.cursor()
63+
cursor.execute(sql)
64+
rowcount = int(cursor.rowcount)
65+
self._conn.commit()
66+
return NonQueryResult(rows_affected=rowcount)
67+
68+
69+
# ---------------------------------------------------------------------------
70+
# MySQL tests
71+
# ---------------------------------------------------------------------------
72+
73+
def _mysql_connect():
74+
"""Connect to MySQL test instance, skip if unavailable."""
75+
try:
76+
import pymysql
77+
except ImportError:
78+
pytest.skip("PyMySQL not installed")
79+
80+
from tests.fixtures.mysql import MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD
81+
82+
try:
83+
conn = pymysql.connect(
84+
host=MYSQL_HOST,
85+
port=MYSQL_PORT,
86+
user=MYSQL_USER,
87+
password=MYSQL_PASSWORD,
88+
connect_timeout=5,
89+
autocommit=True,
90+
charset="utf8mb4",
91+
)
92+
except Exception as e:
93+
pytest.skip(f"MySQL not available: {e}")
94+
return conn
95+
96+
97+
@pytest.fixture
98+
def mysql_limit_db():
99+
"""Create a MySQL test database with enough rows for LIMIT testing."""
100+
conn = _mysql_connect()
101+
cursor = conn.cursor()
102+
cursor.execute("CREATE DATABASE IF NOT EXISTS test_limit_bug")
103+
cursor.execute("USE test_limit_bug")
104+
cursor.execute("DROP TABLE IF EXISTS users")
105+
cursor.execute("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))")
106+
for i in range(1, 21):
107+
cursor.execute("INSERT INTO users (id, name) VALUES (%s, %s)", (i, f"user_{i}"))
108+
conn.commit()
109+
yield conn
110+
cursor = conn.cursor()
111+
cursor.execute("DROP DATABASE IF EXISTS test_limit_bug")
112+
conn.close()
113+
114+
115+
class TestMySQLMultiStatementLimits:
116+
"""Test LIMIT enforcement against real MySQL via CursorBasedAdapter."""
117+
118+
def test_issue_132_limit_2_and_3(self, mysql_limit_db) -> None:
119+
"""Exact reproduction of issue #132: LIMIT 2 and LIMIT 3 on same table."""
120+
conn = mysql_limit_db
121+
executor = CursorBasedExecutor(conn)
122+
multi = MultiStatementExecutor(executor)
123+
124+
result = multi.execute(
125+
"SELECT * FROM users LIMIT 2; SELECT * FROM users LIMIT 3;",
126+
max_rows=100000,
127+
)
128+
129+
assert result.completed is True
130+
assert len(result.results) == 2
131+
132+
r1 = result.results[0].result
133+
r2 = result.results[1].result
134+
135+
assert isinstance(r1, QueryResult)
136+
assert isinstance(r2, QueryResult)
137+
138+
assert r1.row_count == 2, (
139+
f"Issue #132: LIMIT 2 should return 2 rows, got {r1.row_count}"
140+
)
141+
assert r2.row_count == 3, (
142+
f"Issue #132: LIMIT 3 should return 3 rows, got {r2.row_count}"
143+
)
144+
145+
def test_limits_5_and_1(self, mysql_limit_db) -> None:
146+
"""LIMIT 5 then LIMIT 1 should return 5 and 1 rows."""
147+
conn = mysql_limit_db
148+
executor = CursorBasedExecutor(conn)
149+
multi = MultiStatementExecutor(executor)
150+
151+
result = multi.execute(
152+
"SELECT * FROM users LIMIT 5; SELECT * FROM users LIMIT 1;",
153+
max_rows=100000,
154+
)
155+
156+
assert result.completed is True
157+
assert result.results[0].result.row_count == 5
158+
assert result.results[1].result.row_count == 1
159+
160+
def test_three_different_limits(self, mysql_limit_db) -> None:
161+
"""Three queries with LIMIT 1, 3, 7."""
162+
conn = mysql_limit_db
163+
executor = CursorBasedExecutor(conn)
164+
multi = MultiStatementExecutor(executor)
165+
166+
result = multi.execute(
167+
"SELECT * FROM users LIMIT 1; SELECT * FROM users LIMIT 3; SELECT * FROM users LIMIT 7;",
168+
max_rows=100000,
169+
)
170+
171+
assert result.completed is True
172+
assert len(result.results) == 3
173+
assert result.results[0].result.row_count == 1
174+
assert result.results[1].result.row_count == 3
175+
assert result.results[2].result.row_count == 7
176+
177+
def test_correct_data_not_mixed(self, mysql_limit_db) -> None:
178+
"""Verify row data is correct, not mixed between results."""
179+
conn = mysql_limit_db
180+
executor = CursorBasedExecutor(conn)
181+
multi = MultiStatementExecutor(executor)
182+
183+
result = multi.execute(
184+
"SELECT * FROM users WHERE id <= 2; SELECT * FROM users WHERE id > 18;",
185+
max_rows=100000,
186+
)
187+
188+
assert result.completed is True
189+
r1_ids = [row[0] for row in result.results[0].result.rows]
190+
r2_ids = [row[0] for row in result.results[1].result.rows]
191+
192+
assert r1_ids == [1, 2], f"Expected [1, 2], got {r1_ids}"
193+
assert r2_ids == [19, 20], f"Expected [19, 20], got {r2_ids}"
194+
195+
196+
class TestMySQLMultiStatementViaTransactionExecutor:
197+
"""Test using the actual TransactionExecutor — the real TUI code path."""
198+
199+
def test_issue_132_via_transaction_executor(self, mysql_limit_db) -> None:
200+
"""Reproduce #132 using the full TransactionExecutor + MultiStatementExecutor path."""
201+
from sqlit.domains.connections.providers.registry import get_provider
202+
from sqlit.domains.query.app.transaction import TransactionExecutor
203+
from tests.fixtures.mysql import (
204+
MYSQL_DATABASE,
205+
MYSQL_HOST,
206+
MYSQL_PASSWORD,
207+
MYSQL_PORT,
208+
MYSQL_USER,
209+
)
210+
from tests.helpers import ConnectionConfig
211+
212+
config = ConnectionConfig(
213+
name="test-limit-bug",
214+
db_type="mysql",
215+
server=MYSQL_HOST,
216+
port=str(MYSQL_PORT),
217+
database="test_limit_bug",
218+
username=MYSQL_USER,
219+
password=MYSQL_PASSWORD,
220+
)
221+
provider = get_provider("mysql")
222+
executor = TransactionExecutor(config=config, provider=provider)
223+
224+
try:
225+
multi = MultiStatementExecutor(executor)
226+
result = multi.execute(
227+
"SELECT * FROM users LIMIT 2; SELECT * FROM users LIMIT 3;",
228+
max_rows=100000,
229+
)
230+
231+
assert result.completed is True
232+
assert len(result.results) == 2
233+
234+
r1 = result.results[0].result
235+
r2 = result.results[1].result
236+
237+
assert isinstance(r1, QueryResult)
238+
assert isinstance(r2, QueryResult)
239+
240+
assert r1.row_count == 2, (
241+
f"Issue #132: LIMIT 2 should return 2 rows, got {r1.row_count}"
242+
)
243+
assert r2.row_count == 3, (
244+
f"Issue #132: LIMIT 3 should return 3 rows, got {r2.row_count}"
245+
)
246+
finally:
247+
executor.close()
248+
249+
def test_three_limits_via_transaction_executor(self, mysql_limit_db) -> None:
250+
"""Three different LIMITs through TransactionExecutor."""
251+
from sqlit.domains.connections.providers.registry import get_provider
252+
from sqlit.domains.query.app.transaction import TransactionExecutor
253+
from tests.fixtures.mysql import (
254+
MYSQL_HOST,
255+
MYSQL_PASSWORD,
256+
MYSQL_PORT,
257+
MYSQL_USER,
258+
)
259+
from tests.helpers import ConnectionConfig
260+
261+
config = ConnectionConfig(
262+
name="test-limit-bug",
263+
db_type="mysql",
264+
server=MYSQL_HOST,
265+
port=str(MYSQL_PORT),
266+
database="test_limit_bug",
267+
username=MYSQL_USER,
268+
password=MYSQL_PASSWORD,
269+
)
270+
provider = get_provider("mysql")
271+
executor = TransactionExecutor(config=config, provider=provider)
272+
273+
try:
274+
multi = MultiStatementExecutor(executor)
275+
result = multi.execute(
276+
"SELECT * FROM users LIMIT 1; SELECT * FROM users LIMIT 3; SELECT * FROM users LIMIT 7;",
277+
max_rows=100000,
278+
)
279+
280+
assert result.completed is True
281+
assert len(result.results) == 3
282+
assert result.results[0].result.row_count == 1
283+
assert result.results[1].result.row_count == 3
284+
assert result.results[2].result.row_count == 7
285+
finally:
286+
executor.close()
287+
288+
289+
# ---------------------------------------------------------------------------
290+
# SQLite baseline tests (same logic, should always pass)
291+
# ---------------------------------------------------------------------------
292+
293+
@pytest.fixture
294+
def sqlite_limit_db():
295+
"""Create a SQLite test database with enough rows for LIMIT testing."""
296+
conn = sqlite3.connect(":memory:")
297+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
298+
for i in range(1, 21):
299+
conn.execute("INSERT INTO users (id, name) VALUES (?, ?)", (i, f"user_{i}"))
300+
conn.commit()
301+
return conn
302+
303+
304+
class TestSQLiteMultiStatementLimits:
305+
"""Baseline: same tests against SQLite to confirm the core logic is correct."""
306+
307+
def test_issue_132_limit_2_and_3(self, sqlite_limit_db) -> None:
308+
executor = CursorBasedExecutor(sqlite_limit_db)
309+
multi = MultiStatementExecutor(executor)
310+
311+
result = multi.execute(
312+
"SELECT * FROM users LIMIT 2; SELECT * FROM users LIMIT 3;",
313+
max_rows=100000,
314+
)
315+
316+
assert result.results[0].result.row_count == 2
317+
assert result.results[1].result.row_count == 3
318+
319+
def test_limits_5_and_1(self, sqlite_limit_db) -> None:
320+
executor = CursorBasedExecutor(sqlite_limit_db)
321+
multi = MultiStatementExecutor(executor)
322+
323+
result = multi.execute(
324+
"SELECT * FROM users LIMIT 5; SELECT * FROM users LIMIT 1;",
325+
max_rows=100000,
326+
)
327+
328+
assert result.results[0].result.row_count == 5
329+
assert result.results[1].result.row_count == 1

0 commit comments

Comments
 (0)