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