Phase 17: scroll cursor API (v2026.05.04.1)

Adds scroll/random-access methods on Cursor:
* scroll(value, mode='relative'|'absolute') — PEP 249 compatible
* fetch_first() / fetch_last() — jump to result-set ends
* fetch_prior() — step backward (SQL-standard: from past-end yields
  the last row, matching JDBC ResultSet.previous() semantics)
* fetch_absolute(n) — 0-indexed jump; negative n indexes from end
* fetch_relative(n) — n-step from current position
* rownumber property — current 0-indexed position

Implementation: replaced _row_iter (single-pass iterator) with
_row_index (random-access index) on the cursor. The result set
is already materialized in _rows during execute(); scroll just
repositions the index. No new wire protocol needed.

For server-side scroll over genuinely huge result sets, SQ_SFETCH
(tag 23) would be needed — JDBC has executeScrollFetch (line 3908)
but we only need it if someone hits the in-memory materialization
ceiling. Phase 18 if so.

Out-of-range scroll raises IndexError per PEP 249. Invalid mode
strings raise ProgrammingError. fetchall() now correctly returns
only the rows from the current position to end (not all rows).

14 new integration tests in test_scroll_cursor.py covering:
* fetchone advancing rownumber sequentially
* fetch_first reset
* fetch_last
* fetch_prior including the past-end-to-last-row semantics
* fetch_absolute with positive and negative indexes
* fetch_relative
* PEP 249 scroll(value, mode='relative'/'absolute')
* IndexError on out-of-range
* ProgrammingError on bad mode
* Empty-result-set edge cases
* fetchall after partial iteration

Total: 69 unit + 177 integration = 246 tests.
This commit is contained in:
Ryan Malloy 2026-05-04 15:51:24 -06:00
parent 0c856372a6
commit 461c62c8d3
4 changed files with 379 additions and 12 deletions

View File

@ -2,6 +2,24 @@
All notable changes to `informix-db`. Versioning is [CalVer](https://calver.org/) — `YYYY.MM.DD` for date-based releases, `YYYY.MM.DD.N` for same-day post-releases per PEP 440. All notable changes to `informix-db`. Versioning is [CalVer](https://calver.org/) — `YYYY.MM.DD` for date-based releases, `YYYY.MM.DD.N` for same-day post-releases per PEP 440.
## 2026.05.04.1 — Scroll cursors
### Added
- **Scroll cursor API** on `Cursor` (Phase 17):
- `cur.scroll(value, mode='relative'|'absolute')` — PEP 249 compatible
- `cur.fetch_first()` / `cur.fetch_last()` — jump to ends
- `cur.fetch_prior()` — backward step (SQL-standard semantics: from past-end yields the last row)
- `cur.fetch_absolute(n)` — 0-indexed jump; negative `n` indexes from the end
- `cur.fetch_relative(n)` — n-step from current position
- `cur.rownumber` — current 0-indexed position (None if before-first or no result set)
In-memory implementation — no new wire-protocol; the existing materialized result set in `cur._rows` is now indexed rather than iterated. For server-side scroll over huge result sets, `SQ_SFETCH` (tag 23) would be needed — Phase 18 if anyone hits the in-memory ceiling.
### Tests
14 new integration tests in `test_scroll_cursor.py`. Total: **69 unit + 177 integration = 246 tests**.
## 2026.05.04 — Library completion ## 2026.05.04 — Library completion
The Phase 0 ambition — first pure-Python Informix SQLI driver — reaches feature completeness. Adds async, TLS, connection pool, smart-LOBs, fast-path RPC, composite UDTs. The Phase 0 ambition — first pure-Python Informix SQLI driver — reaches feature completeness. Adds async, TLS, connection pool, smart-LOBs, fast-path RPC, composite UDTs.

View File

@ -1,6 +1,6 @@
[project] [project]
name = "informix-db" name = "informix-db"
version = "2026.05.04" version = "2026.05.04.1"
description = "Pure-Python driver for IBM Informix IDS — speaks the SQLI wire protocol over raw sockets. No CSDK, no JVM, no native libraries." description = "Pure-Python driver for IBM Informix IDS — speaks the SQLI wire protocol over raw sockets. No CSDK, no JVM, no native libraries."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }

View File

@ -83,7 +83,13 @@ class Cursor:
self._columns: list[ColumnInfo] = [] self._columns: list[ColumnInfo] = []
self._rowcount: int = -1 self._rowcount: int = -1
self._rows: list[tuple] = [] self._rows: list[tuple] = []
self._row_iter: Iterator[tuple] | None = None # Phase 17: index-based row access enables scroll cursors. The
# cursor materializes all rows on execute() (current behavior),
# then ``fetchone`` / ``scroll`` / ``fetch_*`` move ``_row_index``
# through them. Default position is "before first row" (-1)
# so the first ``fetchone()`` returns rows[0]. Set to
# ``len(_rows)`` after the last row is exhausted.
self._row_index: int = -1
# Set if the DESCRIBE response already includes SQ_INSERTDONE — # Set if the DESCRIBE response already includes SQ_INSERTDONE —
# Informix optimizes literal-value INSERTs by executing during # Informix optimizes literal-value INSERTs by executing during
# PREPARE. In that case we skip SQ_EXECUTE and go straight to RELEASE. # PREPARE. In that case we skip SQ_EXECUTE and go straight to RELEASE.
@ -152,7 +158,7 @@ class Cursor:
self._columns = [] self._columns = []
self._rowcount = -1 self._rowcount = -1
self._rows = [] self._rows = []
self._row_iter = None self._row_index = -1 # before-first-row
self._statement_already_done = False self._statement_already_done = False
# On a logged DB in non-autocommit mode, the server requires an # On a logged DB in non-autocommit mode, the server requires an
@ -183,8 +189,13 @@ class Cursor:
else: else:
self._execute_dml() self._execute_dml()
# SELECT path: position cursor before the first row so the next
# ``fetchone()`` returns ``rows[0]``. DML paths leave _row_index
# at -1 too (no rows to iterate).
# (No-op now — the reset above already set _row_index = -1 — but
# left explicit for symmetry with prior _row_iter logic.)
if self._description is not None: if self._description is not None:
self._row_iter = iter(self._rows) self._row_index = -1
def _execute_select_with_params(self, params: tuple) -> None: def _execute_select_with_params(self, params: tuple) -> None:
"""Parameterized SELECT: SQ_BIND → CURNAME+NFETCH → drain → CLOSE+RELEASE. """Parameterized SELECT: SQ_BIND → CURNAME+NFETCH → drain → CLOSE+RELEASE.
@ -646,7 +657,7 @@ class Cursor:
self._columns = [] self._columns = []
self._rowcount = -1 self._rowcount = -1
self._rows = [] self._rows = []
self._row_iter = None self._row_index = -1
self._statement_already_done = False self._statement_already_done = False
# Logged-DB transaction guard — same as execute(). Idempotent # Logged-DB transaction guard — same as execute(). Idempotent
@ -672,10 +683,16 @@ class Cursor:
self._rowcount = total_rowcount self._rowcount = total_rowcount
def fetchone(self) -> tuple | None: def fetchone(self) -> tuple | None:
"""Return the row at ``_row_index + 1`` and advance, or None at EOF."""
self._check_open() self._check_open()
if self._row_iter is None: if self._description is None or not self._rows:
return None return None
return next(self._row_iter, None) nxt = self._row_index + 1
if nxt >= len(self._rows):
self._row_index = len(self._rows) # past-last
return None
self._row_index = nxt
return self._rows[nxt]
def fetchmany(self, size: int | None = None) -> list[tuple]: def fetchmany(self, size: int | None = None) -> list[tuple]:
self._check_open() self._check_open()
@ -689,16 +706,115 @@ class Cursor:
return out return out
def fetchall(self) -> list[tuple]: def fetchall(self) -> list[tuple]:
"""Return all remaining rows from the current position to the end."""
self._check_open() self._check_open()
if self._row_iter is None: if self._description is None or not self._rows:
return [] return []
out = list(self._row_iter) start = self._row_index + 1
self._row_iter = iter([]) out = self._rows[start:]
return out self._row_index = len(self._rows)
return list(out)
# -- Phase 17: scroll cursor API --------------------------------------
def scroll(self, value: int, mode: str = "relative") -> None:
"""Move the cursor position. PEP 249-compatible.
``mode='relative'`` (default): move ``value`` rows forward
(negative = backward). ``mode='absolute'``: jump to row ``value``
(0-indexed; the next ``fetchone()`` returns ``rows[value]``).
Raises :class:`IndexError` if the target position falls outside
the available result set (per PEP 249).
Note: this is *in-memory* scroll the cursor materializes all
rows on ``execute()`` and ``scroll()`` simply repositions the
index. For true server-side scrollable cursors over huge
result sets, see Phase 18.
"""
self._check_open()
if self._description is None:
raise ProgrammingError("no result set; call execute() first")
if mode == "relative":
target = self._row_index + value
elif mode == "absolute":
target = value - 1 # absolute(0) = before first; absolute(N) = at row N-1
else:
raise ProgrammingError(
f"scroll mode must be 'relative' or 'absolute', got {mode!r}"
)
if target < -1 or target >= len(self._rows):
raise IndexError(
f"scroll target out of range: position {target} "
f"vs. result set of {len(self._rows)} rows"
)
self._row_index = target
def fetch_first(self) -> tuple | None:
"""Reset to before-first then fetch row 0."""
self._check_open()
self._row_index = -1
return self.fetchone()
def fetch_last(self) -> tuple | None:
"""Position at the last row and return it (None if empty)."""
self._check_open()
if not self._rows:
return None
self._row_index = len(self._rows) - 1
return self._rows[self._row_index]
def fetch_prior(self) -> tuple | None:
"""Move backward one row and return it (None if before-first)."""
self._check_open()
prev = self._row_index - 1
if prev < 0:
self._row_index = -1
return None
self._row_index = prev
return self._rows[prev]
def fetch_absolute(self, n: int) -> tuple | None:
"""Position at row ``n`` (0-indexed) and return it.
Negative ``n`` indexes from the end (Python-style):
``fetch_absolute(-1)`` returns the last row.
"""
self._check_open()
if not self._rows:
return None
if n < 0:
n = len(self._rows) + n
if n < 0 or n >= len(self._rows):
return None
self._row_index = n
return self._rows[n]
def fetch_relative(self, n: int) -> tuple | None:
"""Move ``n`` rows from the current position and return that row.
``n=1`` is equivalent to ``fetchone``, ``n=-1`` to ``fetch_prior``.
Returns None if the target falls outside the result set.
"""
self._check_open()
if not self._rows:
return None
target = self._row_index + n
if target < 0 or target >= len(self._rows):
return None
self._row_index = target
return self._rows[target]
@property
def rownumber(self) -> int | None:
"""Current 0-indexed row position, or None if no result set / before-first."""
if self._description is None or self._row_index < 0:
return None
return self._row_index
def close(self) -> None: def close(self) -> None:
self._closed = True self._closed = True
self._row_iter = None self._row_index = len(self._rows) # mark exhausted
def __iter__(self) -> Iterator[tuple]: def __iter__(self) -> Iterator[tuple]:
return self return self

233
tests/test_scroll_cursor.py Normal file
View File

@ -0,0 +1,233 @@
"""Phase 17 integration tests — scroll cursor API.
The cursor materializes all rows on ``execute()`` (current behavior),
and Phase 17 adds index-based scroll methods on top of that:
``scroll()``, ``fetch_first()``, ``fetch_last()``, ``fetch_prior()``,
``fetch_absolute()``, ``fetch_relative()``, plus a ``rownumber`` property.
No new wire protocol pure-Python feature on top of the existing
materialized result set.
"""
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,
)
# -------- Basic scroll API --------
def test_fetchone_advances_index(conn_params: ConnParams) -> None:
"""Sequential ``fetchone`` advances ``rownumber`` 0, 1, 2, ..."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 4 tabname FROM systables ORDER BY tabid")
assert cur.rownumber is None # before-first
for expected_idx in range(4):
row = cur.fetchone()
assert row is not None
assert cur.rownumber == expected_idx
# After last row
assert cur.fetchone() is None
def test_fetch_first_resets_position(conn_params: ConnParams) -> None:
"""``fetch_first`` repositions to row 0 regardless of current position."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabname FROM systables ORDER BY tabid")
# Advance a few rows
cur.fetchone()
cur.fetchone()
cur.fetchone()
assert cur.rownumber == 2
# Reset
first = cur.fetch_first()
assert first is not None
assert cur.rownumber == 0
def test_fetch_last(conn_params: ConnParams) -> None:
"""``fetch_last`` jumps to the final row."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabname FROM systables ORDER BY tabid")
last = cur.fetch_last()
assert last is not None
assert cur.rownumber == 4
# No more rows after
assert cur.fetchone() is None
def test_fetch_prior(conn_params: ConnParams) -> None:
"""``fetch_prior`` moves backward one row.
Semantics match SQL-standard FETCH PRIOR: from "past-end" (after
fetchone returned None), the first fetch_prior returns the *last*
row. From row N, fetch_prior returns row N-1.
"""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabname FROM systables ORDER BY tabid")
rows = []
# Fetch all forward
while (row := cur.fetchone()) is not None:
rows.append(row)
# Now scroll back — 5 fetch_priors take us from past-end to row 0
for expected_idx in (4, 3, 2, 1, 0):
row = cur.fetch_prior()
assert row == rows[expected_idx]
assert cur.rownumber == expected_idx
# One more prior → before-first → None
assert cur.fetch_prior() is None
assert cur.rownumber is None
def test_fetch_absolute(conn_params: ConnParams) -> None:
"""``fetch_absolute(n)`` jumps to row ``n`` (0-indexed)."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute(
"SELECT FIRST 5 tabid FROM systables ORDER BY tabid"
)
# Pre-collect all rows for comparison
all_rows = cur.fetchall()
cur.fetch_first() # rewind via the pre-fetch path
# Re-execute since fetchall consumed
cur.execute(
"SELECT FIRST 5 tabid FROM systables ORDER BY tabid"
)
# Jump around
assert cur.fetch_absolute(0) == all_rows[0]
assert cur.fetch_absolute(4) == all_rows[4]
assert cur.fetch_absolute(2) == all_rows[2]
assert cur.rownumber == 2
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()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
rows = cur.fetchall()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
assert cur.fetch_absolute(-1) == rows[-1]
assert cur.rownumber == 4
assert cur.fetch_absolute(-2) == rows[-2]
def test_fetch_relative(conn_params: ConnParams) -> None:
"""``fetch_relative(n)`` moves ``n`` rows from current position."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
cur.fetchone() # at row 0
cur.fetchone() # at row 1
# Jump forward 2
cur.fetch_relative(2)
assert cur.rownumber == 3
# Jump back 3
cur.fetch_relative(-3)
assert cur.rownumber == 0
# -------- PEP 249 scroll() --------
def test_scroll_relative(conn_params: ConnParams) -> None:
"""``scroll(value)`` defaults to relative mode."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
cur.fetchone() # row 0
cur.scroll(2) # relative +2 → row 2
assert cur.rownumber == 2
cur.scroll(-1) # relative -1 → row 1
assert cur.rownumber == 1
def test_scroll_absolute(conn_params: ConnParams) -> None:
"""``scroll(value, mode='absolute')`` jumps to row N (1-indexed per PEP 249)."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
cur.scroll(3, mode="absolute") # to row 3 (1-indexed)
assert cur.rownumber == 2 # 0-indexed: position 2
# The next fetchone advances to position 3
cur.fetchone()
assert cur.rownumber == 3
def test_scroll_out_of_range_raises(conn_params: ConnParams) -> None:
"""``scroll`` past the result set raises IndexError per PEP 249."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
with pytest.raises(IndexError):
cur.scroll(100)
with pytest.raises(IndexError):
cur.scroll(-5)
def test_scroll_invalid_mode_raises(conn_params: ConnParams) -> None:
"""Unknown scroll mode raises ProgrammingError."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
with pytest.raises(informix_db.ProgrammingError, match="scroll mode"):
cur.scroll(1, mode="forward") # type: ignore[arg-type]
# -------- Edge cases --------
def test_scroll_on_empty_result_set(conn_params: ConnParams) -> None:
"""Scroll methods on empty result return None gracefully."""
with _connect(conn_params) as conn:
cur = conn.cursor()
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.fetch_relative(1) is None
assert cur.fetch_prior() is None
def test_fetchall_after_partial_iteration(conn_params: ConnParams) -> None:
"""``fetchall()`` returns only remaining rows after partial iteration."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
cur.fetchone() # consume row 0
cur.fetchone() # consume row 1
remaining = cur.fetchall()
assert len(remaining) == 3 # rows 2, 3, 4
def test_fetchall_then_fetch_first_restarts(conn_params: ConnParams) -> None:
"""After ``fetchall``, ``fetch_first`` repositions back to row 0."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
all_rows = cur.fetchall()
assert len(all_rows) == 3
# Cursor now at past-end. Rewind.
first = cur.fetch_first()
assert first == all_rows[0]