informix-db/tests/test_scroll_cursor_server.py
Ryan Malloy a42dc5c5de Phase 18: server-side scrollable cursors via SQ_SFETCH (v2026.05.04.2)
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.
2026-05-04 16:41:25 -06:00

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()