Skip to content

Commit 5ab4a4a

Browse files
Merge pull request #506 from ClickHouse/joe/505-incorrect-pagination-in-cursorfetchall-and-fetchmany
Fix some issues with cursor behavior
2 parents ced1ec4 + 7da9615 commit 5ab4a4a

File tree

3 files changed

+240
-2
lines changed

3 files changed

+240
-2
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# ClickHouse Connect ChangeLog
22

3+
## UNRELEASED
4+
5+
## Improvements
6+
- Added a standalone test file (`tests/unit_tests/test_driver/test_cursor.py`) for testing cursor behavior
7+
8+
## Bug Fixes
9+
- Reset cursor location after performing an execute.
10+
- Fix behavior of `fetchall` to only return rows from the current cursor location.
11+
- Fixes logic of `fetchmany` to respect size parameter.
12+
313
### WARNING -- Breaking change for AsyncClient close()
414
The AsyncClient close() method is now async and should be called as an async function.
515

clickhouse_connect/dbapi/cursor.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ def execute(self, operation: str, parameters=None):
5757
self.data = query_result.result_set
5858
self._rowcount = len(self.data)
5959
self._summary.append(query_result.summary)
60+
61+
# Need to reset cursor _ix after performing an execute
62+
self._ix = 0
63+
6064
if query_result.column_names:
6165
self.names = query_result.column_names
6266
self.types = [x.name for x in query_result.column_types]
@@ -106,9 +110,12 @@ def executemany(self, operation, parameters):
106110
raise ProgrammingError(f'Invalid parameters {parameters} passed to cursor executemany') from ex
107111
self._rowcount = len(self.data)
108112

113+
# Need to reset cursor _ix after performing an execute
114+
self._ix = 0
115+
109116
def fetchall(self):
110117
self.check_valid()
111-
ret = self.data
118+
ret = self.data[self._ix:]
112119
self._ix = self._rowcount
113120
return ret
114121

@@ -122,7 +129,15 @@ def fetchone(self):
122129

123130
def fetchmany(self, size: int = -1):
124131
self.check_valid()
125-
end = self._ix + max(size, self._rowcount - self._ix)
132+
133+
if size < 0:
134+
# Fetch all remaining rows
135+
size = self._rowcount - self._ix
136+
elif size == 0:
137+
# Return empty list for size=0
138+
return []
139+
140+
end = min(self._ix + size, self._rowcount)
126141
ret = self.data[self._ix: end]
127142
self._ix = end
128143
return ret
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from unittest.mock import Mock
2+
import pytest
3+
4+
from clickhouse_connect.dbapi.cursor import Cursor
5+
from clickhouse_connect.driver.exceptions import ProgrammingError
6+
7+
8+
# pylint: disable=protected-access
9+
def create_mock_client(result_data):
10+
"""Helper to create a mock client with query result"""
11+
client = Mock()
12+
query_result = Mock()
13+
query_result.result_set = result_data
14+
query_result.column_names = ["col1", "col2", "col3"]
15+
query_result.column_types = [Mock(name="String")] * 3
16+
query_result.summary = {"rows": len(result_data)}
17+
client.query.return_value = query_result
18+
return client
19+
20+
21+
def test_fetchall_respects_cursor_position():
22+
"""Test that fetchall() returns only unread rows and respects cursor position"""
23+
test_data = [
24+
("row1_col1", "row1_col2", "row1_col3"),
25+
("row2_col1", "row2_col2", "row2_col3"),
26+
("row3_col1", "row3_col2", "row3_col3"),
27+
("row4_col1", "row4_col2", "row4_col3"),
28+
("row5_col1", "row5_col2", "row5_col3"),
29+
]
30+
31+
client = create_mock_client(test_data)
32+
cursor = Cursor(client)
33+
34+
# Execute a query to populate cursor data
35+
cursor.execute("SELECT * FROM test_table")
36+
37+
# Fetch first two rows
38+
row1 = cursor.fetchone()
39+
row2 = cursor.fetchone()
40+
41+
assert row1 == test_data[0]
42+
assert row2 == test_data[1]
43+
assert cursor._ix == 2 # Cursor should be at position 2
44+
45+
# fetchall() should return remaining rows, not all rows
46+
remaining_rows = cursor.fetchall()
47+
48+
# Should only get rows 3, 4, and 5 (indices 2, 3, 4)
49+
expected_remaining = test_data[2:]
50+
assert remaining_rows == expected_remaining
51+
assert len(remaining_rows) == 3
52+
53+
# Cursor should now be at the end
54+
assert cursor._ix == cursor._rowcount
55+
56+
# Another fetchall() should return empty list since all rows consumed
57+
empty_result = cursor.fetchall()
58+
assert empty_result == []
59+
60+
61+
def test_fetchmany_respects_size_parameter():
62+
"""Test that fetchmany() correctly handles the size parameter"""
63+
test_data = [
64+
("row1",),
65+
("row2",),
66+
("row3",),
67+
("row4",),
68+
("row5",),
69+
("row6",),
70+
("row7",),
71+
("row8",),
72+
("row9",),
73+
("row10",),
74+
]
75+
76+
client = create_mock_client(test_data)
77+
cursor = Cursor(client)
78+
cursor.execute("SELECT * FROM test_table")
79+
80+
# Test fetchmany with explicit size
81+
batch1 = cursor.fetchmany(size=3)
82+
assert len(batch1) == 3
83+
assert batch1 == test_data[0:3]
84+
assert cursor._ix == 3
85+
86+
# Test fetchmany with size larger than remaining rows
87+
batch2 = cursor.fetchmany(size=10)
88+
assert len(batch2) == 7 # Only 7 rows remaining
89+
assert batch2 == test_data[3:10]
90+
assert cursor._ix == 10
91+
92+
# Test fetchmany when no rows remain
93+
batch3 = cursor.fetchmany(size=5)
94+
assert batch3 == []
95+
assert cursor._ix == 10
96+
97+
98+
def test_fetchmany_negative_values():
99+
"""Test fetchmany with various negative values"""
100+
test_data = [("row1",), ("row2",), ("row3",), ("row4",), ("row5",)]
101+
102+
client = create_mock_client(test_data)
103+
cursor = Cursor(client)
104+
cursor.execute("SELECT * FROM test_table")
105+
106+
# Advance cursor partway
107+
cursor.fetchone() # Now at index 1
108+
109+
# Any negative value should fetch all remaining
110+
remaining = cursor.fetchmany(-999)
111+
assert len(remaining) == 4
112+
assert remaining == test_data[1:]
113+
114+
115+
def test_fetchmany_w_no_size_parameter_fetches_all_remaining():
116+
"""Test default behavior or fetchmany"""
117+
test_data = [("A", 1), ("B", 2), ("C", 3), ("D", 4), ("E", 5), ("F", 6)]
118+
119+
client = create_mock_client(test_data)
120+
cursor = Cursor(client)
121+
cursor.execute("SELECT * FROM test_table")
122+
123+
# Fetch many (no size parameter)
124+
batch = cursor.fetchmany()
125+
assert batch == test_data
126+
127+
# Reset cursor
128+
cursor.execute("SELECT * FROM test_table")
129+
130+
# Fetch one
131+
row1 = cursor.fetchone()
132+
assert row1 == test_data[0]
133+
134+
# Fetch remaining (fetchmany with no size parameter)
135+
batch = cursor.fetchmany()
136+
assert batch == test_data[1:]
137+
138+
139+
def test_mixed_fetch_operations():
140+
"""Test mixing different fetch operations"""
141+
test_data = [("A", 1), ("B", 2), ("C", 3), ("D", 4), ("E", 5), ("F", 6)]
142+
143+
client = create_mock_client(test_data)
144+
cursor = Cursor(client)
145+
cursor.execute("SELECT * FROM test_table")
146+
147+
# Fetch one
148+
row1 = cursor.fetchone()
149+
assert row1 == test_data[0]
150+
151+
# Fetch many
152+
batch = cursor.fetchmany(2)
153+
assert batch == test_data[1:3]
154+
155+
# Fetch all remaining
156+
remaining = cursor.fetchall()
157+
assert remaining == test_data[3:6]
158+
159+
# All subsequent fetches should return empty/None
160+
assert cursor.fetchone() is None
161+
assert cursor.fetchone() is None # Should continue returning None
162+
assert cursor.fetchmany(10) == []
163+
assert cursor.fetchall() == []
164+
165+
166+
def test_cursor_reset_on_new_execute():
167+
"""Test that cursor position resets on new execute"""
168+
test_data = [("row1",), ("row2",), ("row3",)]
169+
170+
client = create_mock_client(test_data)
171+
cursor = Cursor(client)
172+
173+
# First query
174+
cursor.execute("SELECT * FROM test_table")
175+
cursor.fetchmany(2)
176+
assert cursor._ix == 2
177+
178+
# New query should reset cursor
179+
cursor.execute("SELECT * FROM test_table")
180+
assert cursor._ix == 0
181+
182+
# Should be able to fetch all rows again
183+
all_rows = cursor.fetchall()
184+
assert len(all_rows) == 3
185+
assert all_rows == test_data
186+
187+
188+
def test_check_valid():
189+
"""Test that operations fail when cursor is not valid"""
190+
client = Mock()
191+
cursor = Cursor(client)
192+
193+
# Cursor should be invalid before execute
194+
with pytest.raises(ProgrammingError):
195+
cursor.fetchone()
196+
197+
with pytest.raises(ProgrammingError):
198+
cursor.fetchall()
199+
200+
with pytest.raises(ProgrammingError):
201+
cursor.fetchmany()
202+
203+
204+
def test_empty_result_set():
205+
"""Test cursor behavior with empty result set"""
206+
client = create_mock_client([])
207+
cursor = Cursor(client)
208+
cursor.execute("SELECT * FROM empty_table")
209+
210+
assert cursor.rowcount == 0
211+
assert cursor.fetchone() is None
212+
assert cursor.fetchall() == []
213+
assert cursor.fetchmany(5) == []

0 commit comments

Comments
 (0)