diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fff2ae..86de4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 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. diff --git a/pyproject.toml b/pyproject.toml index bdd1ea4..50deb31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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." readme = "README.md" license = { text = "MIT" } diff --git a/src/informix_db/cursors.py b/src/informix_db/cursors.py index ccc5b42..c603f74 100644 --- a/src/informix_db/cursors.py +++ b/src/informix_db/cursors.py @@ -83,7 +83,13 @@ class Cursor: self._columns: list[ColumnInfo] = [] self._rowcount: int = -1 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 — # Informix optimizes literal-value INSERTs by executing during # PREPARE. In that case we skip SQ_EXECUTE and go straight to RELEASE. @@ -152,7 +158,7 @@ class Cursor: self._columns = [] self._rowcount = -1 self._rows = [] - self._row_iter = None + self._row_index = -1 # before-first-row self._statement_already_done = False # On a logged DB in non-autocommit mode, the server requires an @@ -183,8 +189,13 @@ class Cursor: else: 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: - self._row_iter = iter(self._rows) + self._row_index = -1 def _execute_select_with_params(self, params: tuple) -> None: """Parameterized SELECT: SQ_BIND → CURNAME+NFETCH → drain → CLOSE+RELEASE. @@ -646,7 +657,7 @@ class Cursor: self._columns = [] self._rowcount = -1 self._rows = [] - self._row_iter = None + self._row_index = -1 self._statement_already_done = False # Logged-DB transaction guard — same as execute(). Idempotent @@ -672,10 +683,16 @@ class Cursor: self._rowcount = total_rowcount def fetchone(self) -> tuple | None: + """Return the row at ``_row_index + 1`` and advance, or None at EOF.""" self._check_open() - if self._row_iter is None: + if self._description is None or not self._rows: 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]: self._check_open() @@ -689,16 +706,115 @@ class Cursor: return out def fetchall(self) -> list[tuple]: + """Return all remaining rows from the current position to the end.""" self._check_open() - if self._row_iter is None: + if self._description is None or not self._rows: return [] - out = list(self._row_iter) - self._row_iter = iter([]) - return out + start = self._row_index + 1 + out = self._rows[start:] + 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: self._closed = True - self._row_iter = None + self._row_index = len(self._rows) # mark exhausted def __iter__(self) -> Iterator[tuple]: return self diff --git a/tests/test_scroll_cursor.py b/tests/test_scroll_cursor.py new file mode 100644 index 0000000..815d51e --- /dev/null +++ b/tests/test_scroll_cursor.py @@ -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]