Opt-in via conn.cursor(scrollable=True). Opens the cursor with
SQ_SCROLL (24) before SQ_OPEN (6), keeps it open server-side, and
sends SQ_SFETCH (23) per scroll call instead of materializing the
result set up-front.
User-facing API is identical to Phase 17's in-memory scroll
(fetch_first/last/prior/absolute/relative, scroll, rownumber).
Only the internal mechanism differs:
| feature | default | scrollable=True
|-------------------|------------------|------------------
| memory | all rows | one row at a time
| round-trips/fetch | 0 (after NFETCH) | 1 per call
| cursor lifetime | closed after exec| open until close()
| best for | sequential iter | random access on
| huge result sets
Wire format (verified against JDBC ScrollProbe capture):
* SQ_SFETCH: [short SQ_ID=4][int 23][short scrolltype]
[int target][int bufSize=4096][short SQ_EOT]
scrolltype: 1=NEXT, 4=LAST, 6=ABSOLUTE
* SQ_SCROLL (24): emitted between CURNAME and SQ_OPEN
* SQ_TUPID (25): response tag with 1-indexed row position;
authoritative source for client-side position tracking
Position tracking uses the server's SQ_TUPID rather than client-
computed indexes. Total row count discovered lazily via SFETCH(LAST)
when negative absolute indexing requires it; cached in
_scroll_total_rows.
Trap on the way: initial SFETCH used SHORT for bufSize → server
hung silently. Same SHORT-vs-INT diagnostic pattern as Phase 4.x's
CURNAME+NFETCH. Captured JDBC trace, byte-diffed against ours,
found the mismatch (bufSize is INT in modern Informix per
isXPSVER8_40 / is2GBFetchBufferSupported).
Tests: 14 integration tests in test_scroll_cursor_server.py
covering lifecycle, sequential fetch, fetch_first/last/prior/
absolute/relative, negative indexing, scroll, empty result sets,
past-end, and random-access on a 100-row result set.
Total: 69 unit + 191 integration = 260 tests.
240 lines
8.8 KiB
Python
240 lines
8.8 KiB
Python
"""Phase 18 integration tests — server-side scrollable cursor.
|
|
|
|
When ``conn.cursor(scrollable=True)`` is set, the cursor opens with
|
|
``SQ_SCROLL`` (tag 24) before ``SQ_OPEN``, doesn't materialize the
|
|
result set, and uses ``SQ_SFETCH`` (tag 23) for each fetch. The
|
|
server-side cursor stays open across scroll operations and is
|
|
closed by ``cursor.close()``.
|
|
|
|
The user-facing API surface (``fetch_first``, ``fetch_last``,
|
|
``fetch_prior``, ``fetch_absolute``, ``fetch_relative``, ``scroll``,
|
|
``rownumber``) is identical to the in-memory scroll mode (Phase 17).
|
|
The internal mechanism is what changes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import informix_db
|
|
from tests.conftest import ConnParams
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _connect(params: ConnParams) -> informix_db.Connection:
|
|
return informix_db.connect(
|
|
host=params.host,
|
|
port=params.port,
|
|
user=params.user,
|
|
password=params.password,
|
|
database=params.database,
|
|
server=params.server,
|
|
autocommit=True,
|
|
)
|
|
|
|
|
|
# -------- Cursor lifecycle --------
|
|
|
|
|
|
def test_scrollable_cursor_opens_and_closes(conn_params: ConnParams) -> None:
|
|
"""A scrollable cursor reports its server-side state correctly."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
assert cur._scrollable is True
|
|
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
|
|
assert cur._server_cursor_open is True
|
|
cur.close()
|
|
assert cur._server_cursor_open is False
|
|
assert cur.closed is True
|
|
|
|
|
|
def test_scrollable_default_off(conn_params: ConnParams) -> None:
|
|
"""``conn.cursor()`` without args still produces a non-scrollable cursor."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
assert cur._scrollable is False
|
|
|
|
|
|
# -------- Forward sequential --------
|
|
|
|
|
|
def test_scrollable_sequential_fetchone(conn_params: ConnParams) -> None:
|
|
"""``fetchone`` advances through rows when scrollable=True."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
|
|
rows = []
|
|
while (row := cur.fetchone()) is not None:
|
|
rows.append(row[0])
|
|
assert rows == [1, 2, 3, 4, 5]
|
|
cur.close()
|
|
|
|
|
|
def test_scrollable_fetchall(conn_params: ConnParams) -> None:
|
|
"""``fetchall`` drains all rows from current position to end."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
|
|
rows = cur.fetchall()
|
|
assert [r[0] for r in rows] == [1, 2, 3, 4, 5]
|
|
cur.close()
|
|
|
|
|
|
# -------- Scroll API --------
|
|
|
|
|
|
def test_fetch_first_via_sfetch(conn_params: ConnParams) -> None:
|
|
"""``fetch_first`` sends SFETCH(ABSOLUTE, 1)."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
|
|
# Advance a few rows
|
|
cur.fetchone()
|
|
cur.fetchone()
|
|
# Reset
|
|
first = cur.fetch_first()
|
|
assert first == (1,)
|
|
assert cur.rownumber == 0
|
|
cur.close()
|
|
|
|
|
|
def test_fetch_last_caches_total_rows(conn_params: ConnParams) -> None:
|
|
"""``fetch_last`` populates ``_scroll_total_rows`` from the SFETCH(LAST) TUPID."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 7 tabid FROM systables ORDER BY tabid")
|
|
last = cur.fetch_last()
|
|
assert last is not None
|
|
assert cur._scroll_total_rows == 7
|
|
cur.close()
|
|
|
|
|
|
def test_fetch_prior_walks_backward(conn_params: ConnParams) -> None:
|
|
"""Sequential ``fetch_prior`` from the last row walks back to the first."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 4 tabid FROM systables ORDER BY tabid")
|
|
cur.fetch_last()
|
|
# last gave row 4; fetch_prior walks 3, 2, 1
|
|
assert cur.fetch_prior() == (3,)
|
|
assert cur.fetch_prior() == (2,)
|
|
assert cur.fetch_prior() == (1,)
|
|
assert cur.fetch_prior() is None
|
|
cur.close()
|
|
|
|
|
|
def test_fetch_absolute_random_access(conn_params: ConnParams) -> None:
|
|
"""``fetch_absolute(n)`` jumps to row ``n`` (0-indexed)."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 10 tabid FROM systables ORDER BY tabid")
|
|
# Random access in arbitrary order
|
|
assert cur.fetch_absolute(0) == (1,)
|
|
assert cur.fetch_absolute(9) == (10,)
|
|
assert cur.fetch_absolute(4) == (5,)
|
|
assert cur.fetch_absolute(2) == (3,)
|
|
assert cur.rownumber == 2
|
|
cur.close()
|
|
|
|
|
|
def test_fetch_absolute_negative(conn_params: ConnParams) -> None:
|
|
"""Negative absolute indexes count from the end (Python-style)."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
|
|
# Without prior fetch_last, abs(-1) probes via SFETCH(LAST)
|
|
assert cur.fetch_absolute(-1) == (5,)
|
|
assert cur.fetch_absolute(-2) == (4,)
|
|
assert cur._scroll_total_rows == 5
|
|
cur.close()
|
|
|
|
|
|
def test_fetch_relative(conn_params: ConnParams) -> None:
|
|
"""``fetch_relative(n)`` moves ``n`` rows from the current position."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 8 tabid FROM systables ORDER BY tabid")
|
|
cur.fetch_first()
|
|
# Currently at row 0 (tabid=1); jump to position 4
|
|
assert cur.fetch_relative(4) == (5,)
|
|
# Jump back 3
|
|
assert cur.fetch_relative(-3) == (2,)
|
|
cur.close()
|
|
|
|
|
|
def test_scroll_relative_and_absolute(conn_params: ConnParams) -> None:
|
|
"""The PEP 249 ``scroll`` method works in both modes."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 6 tabid FROM systables ORDER BY tabid")
|
|
cur.fetchone() # row 0 (tabid=1)
|
|
cur.scroll(2, mode="relative") # to row 2
|
|
# rownumber tracks via TUPID; for scroll(no-fetch), our local
|
|
# _row_index moves but no SFETCH happens until next fetchone
|
|
assert cur.rownumber == 2
|
|
# Verify the position by fetching at the new position
|
|
cur.scroll(4, mode="absolute") # absolute index 4 (1-indexed → row 4 in API)
|
|
# absolute 4 in PEP 249 maps to _row_index = 3 (row at tabid=4)
|
|
assert cur.rownumber == 3
|
|
cur.close()
|
|
|
|
|
|
# -------- End-of-cursor / empty result set --------
|
|
|
|
|
|
def test_scrollable_empty_result_set(conn_params: ConnParams) -> None:
|
|
"""Scroll methods on empty result return None gracefully."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT tabid FROM systables WHERE tabid = -999")
|
|
assert cur.fetch_first() is None
|
|
assert cur.fetch_last() is None
|
|
assert cur.fetch_absolute(0) is None
|
|
assert cur.fetchone() is None
|
|
cur.close()
|
|
|
|
|
|
def test_scrollable_past_end_returns_none(conn_params: ConnParams) -> None:
|
|
"""Fetching past the end returns None rather than wrapping."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
|
|
cur.fetch_last()
|
|
# We're at the last row; one more fetchone exceeds end
|
|
assert cur.fetchone() is None
|
|
cur.close()
|
|
|
|
|
|
# -------- Mixed: 1000-row scrollable workload --------
|
|
|
|
|
|
def test_scrollable_random_access(conn_params: ConnParams) -> None:
|
|
"""Random-access into a moderate-size result set without OOM.
|
|
|
|
Doesn't assume contiguous tabids (systables has gaps); instead,
|
|
cross-checks scrollable-cursor results against a non-scrollable
|
|
materialized fetch.
|
|
"""
|
|
with _connect(conn_params) as conn:
|
|
# Reference: pull the first 100 rows once, materialized
|
|
ref_cur = conn.cursor()
|
|
ref_cur.execute("SELECT FIRST 100 tabid FROM systables ORDER BY tabid")
|
|
reference = ref_cur.fetchall()
|
|
ref_cur.close()
|
|
assert len(reference) >= 50 # systables has plenty of rows
|
|
|
|
# Now hit the same query through a scrollable cursor and
|
|
# verify random-access matches the reference.
|
|
cur = conn.cursor(scrollable=True)
|
|
cur.execute("SELECT FIRST 100 tabid FROM systables ORDER BY tabid")
|
|
# Random sampling
|
|
for idx in (0, 1, 5, 25, len(reference) - 1):
|
|
assert cur.fetch_absolute(idx) == reference[idx]
|
|
# Walk backward from the middle
|
|
mid = len(reference) // 2
|
|
cur.fetch_absolute(mid)
|
|
for offset in range(1, 5):
|
|
assert cur.fetch_prior() == reference[mid - offset]
|
|
cur.close()
|