Compare commits
3 Commits
088171325d
...
9fbb97199f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbb97199f | |||
| ce5a582048 | |||
| 805fa58eb2 |
44
CHANGELOG.md
44
CHANGELOG.md
@ -2,6 +2,50 @@
|
||||
|
||||
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.09.03 — A placeholder rewriter that could not see a string literal, and named row access
|
||||
|
||||
### Upgrade if you write string literals containing a colon
|
||||
|
||||
The driver rewrites `:1` placeholders to the `?` the wire protocol takes. That rewrite was a bare regular expression, and a regular expression cannot see a string literal, so it rewrote the inside of one:
|
||||
|
||||
```sql
|
||||
UPDATE jobs SET url = 'http://host:8080/x' WHERE id = ?
|
||||
-- stored as 'http://host?/x'
|
||||
```
|
||||
|
||||
Any `HH:MM` time, any URL with a port, any aspect ratio, any `key:value` string. It wrote wrong data and said nothing about it.
|
||||
|
||||
Two things made it wider than it looks. It was never opt-in: the rewrite runs whenever a statement has parameters, whatever placeholder style you actually used, so writing `?` everywhere and never touching numeric style gave no protection. And it changed the placeholder *count* while the driver still told the server there were `len(params)` of them, leaving the two disagreeing about how many binds exist.
|
||||
|
||||
It is now a single pass that substitutes only outside quotes and comments. The lexical rules are Informix's own, measured against all three servers rather than assumed from standard SQL, and one of them would have been got wrong from habit: a backslash escapes nothing, so `'a\'b'` is an unterminated string and draws `-282`. A scanner written to Postgres reflexes would have desynced there and corrupted everything after it. Block comments do not nest, the first `*/` closes them. Braces are a comment. `::` is stepped over as a unit so a cast can never be read as the start of a placeholder.
|
||||
|
||||
An unterminated quote or comment consumes the rest and substitutes nothing further. Under-substituting hands malformed SQL to the server to reject; guessing would corrupt a literal.
|
||||
|
||||
This was the last place the driver inferred meaning from SQL text instead of handling it properly.
|
||||
|
||||
### Rows that answer to a column name
|
||||
|
||||
Requested by a user running this alongside SQL Server, where both `pyodbc` and `mssql-python` hand back rows addressable three ways at once. The argument is readability on a wide projection: `row[11]` tells a reader nothing, and stays correct only until somebody adds a column in the middle.
|
||||
|
||||
```python
|
||||
conn = informix_db.connect(..., row_factory=informix_db.Row)
|
||||
cur.execute("SELECT tabid, tabname FROM systables")
|
||||
row = cur.fetchone()
|
||||
row[0], row["tabname"], row.tabname
|
||||
```
|
||||
|
||||
Set on the connection, so it is one line for an application rather than per query. A cursor can override it. Pools and the async API forward it unchanged.
|
||||
|
||||
`Row` subclasses `tuple`, so `row == (1, "x")` is still true and code that treats rows as sequences keeps working. Slices degrade to plain tuples, since a slice has no column map.
|
||||
|
||||
**It is opt-in, and here is the number.** On a 20,000-row five-column fetch: 37.2 ms with tuples, 40.8 ms with `Row`, about 9%. Supporting `row["name"]` means `__getitem__` is a Python method rather than C-level tuple indexing, which costs roughly 39 ns on every subscript. Defaulting it on would move the published 1.05-1.15x ratio against IfxPy to roughly 1.15-1.25x. That is a reasonable trade for readable application code and a bad one for a bulk export that never looks at a column by name, so it is a choice rather than a decision made on your behalf.
|
||||
|
||||
Details that were measured rather than assumed. Informix folds unquoted identifiers to lower case, so `SELECT Config_Key` is reachable as `row.config_key`, and the `.lower()` in the workaround people write by hand is already a no-op. Expression columns get server-generated names like `(count(*))` that cannot be attributes, so they are subscript-only. Duplicate names resolve to the first occurrence, matching `pyodbc`. And a column always beats a method of the same name: `tuple` defines `count` and `index`, `Row` adds `keys`, `_asdict` and `_fields`, and any column with one of those names wins, because otherwise it would hand back a bound method in silence. That reserved set is computed from the class rather than hand-listed, which is what caught it: the hand-listed version covered the two `tuple` methods and missed all three of `Row`'s own.
|
||||
|
||||
### Verified
|
||||
|
||||
**472** integration tests on 15.0.1.0.3DE and 14.10.FC7W1DE, **471** on 12.10.FC12W1DE (one skip, no common table expressions before 14.10), up from 457. Both changes have tests that fail against `2026.09.02`.
|
||||
|
||||
## 2026.09.02 — A systematic review, and eleven bugs it found
|
||||
|
||||
No new features. This is the result of going back over the driver looking for the *shape* of past bugs rather than for new symptoms, and it turned up more than expected — including two that silently returned wrong data and one where `rollback()` did nothing at all.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "informix-driver"
|
||||
version = "2026.09.02"
|
||||
version = "2026.09.03"
|
||||
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" }
|
||||
|
||||
@ -50,6 +50,7 @@ from .pool import (
|
||||
PoolTimeoutError,
|
||||
create_pool,
|
||||
)
|
||||
from .rows import Row
|
||||
|
||||
# PEP 249 module-level globals
|
||||
apilevel = "2.0"
|
||||
@ -85,6 +86,7 @@ __all__ = [
|
||||
"PoolClosedError",
|
||||
"PoolTimeoutError",
|
||||
"ProgrammingError",
|
||||
"Row",
|
||||
"RowValue",
|
||||
"ServerCapabilities",
|
||||
"Warning",
|
||||
@ -111,6 +113,7 @@ def connect(
|
||||
client_locale: str = "en_US.8859-1",
|
||||
env: dict[str, str] | None = None,
|
||||
autocommit: bool = False,
|
||||
row_factory: object | None = None,
|
||||
tls: bool | ssl.SSLContext = False,
|
||||
tls_server_hostname: str | None = None,
|
||||
) -> Connection:
|
||||
@ -153,4 +156,5 @@ def connect(
|
||||
client_locale=client_locale,
|
||||
env=env,
|
||||
autocommit=autocommit,
|
||||
row_factory=row_factory,
|
||||
)
|
||||
|
||||
@ -283,6 +283,7 @@ class Connection:
|
||||
client_locale: str = "en_US.8859-1",
|
||||
env: dict[str, str] | None = None,
|
||||
autocommit: bool = False, # honored from Phase 3 onward
|
||||
row_factory: object | None = None,
|
||||
tls: bool | ssl.SSLContext = False,
|
||||
tls_server_hostname: str | None = None,
|
||||
):
|
||||
@ -337,6 +338,10 @@ class Connection:
|
||||
# before the next DML in non-autocommit mode. We default to "no
|
||||
# open txn" — the first DML will trigger SQ_BEGIN.
|
||||
self._in_transaction = False
|
||||
# Default row type for cursors from this connection. None means
|
||||
# plain tuples, which is the zero-cost default; see
|
||||
# informix_db.rows for the opt-in named-access type.
|
||||
self.row_factory = row_factory
|
||||
# Tri-state: True after first successful SQ_BEGIN, False after
|
||||
# an unlogged-DB rejection (-201). None until we've tried.
|
||||
# Used to avoid repeatedly probing on unlogged DBs.
|
||||
|
||||
@ -50,6 +50,7 @@ from .exceptions import (
|
||||
NotSupportedError,
|
||||
ProgrammingError,
|
||||
)
|
||||
from .rows import Row, make_row_class
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .connections import Connection
|
||||
@ -373,6 +374,9 @@ class Cursor:
|
||||
# manipulation. Two-mode cursor; the same surface API works
|
||||
# for both.
|
||||
self._scrollable = scrollable
|
||||
# Inherited from the connection, overridable per cursor. See
|
||||
# informix_db.rows for what this costs and why it is opt-in.
|
||||
self.row_factory = connection.row_factory
|
||||
self._description: list[tuple] | None = None
|
||||
self._columns: list[ColumnInfo] = []
|
||||
self._column_readers: list[tuple] | None = None # Phase 37
|
||||
@ -413,6 +417,8 @@ class Cursor:
|
||||
# DESCRIBE's statement-type field. Decides whether a cursor is
|
||||
# opened -- see _produces_result_set.
|
||||
self._statement_type: int = 0
|
||||
# Per-result-set row class when row_factory is set, else None.
|
||||
self._row_class: type | None = None
|
||||
# Phase 10: smart-LOB read via ``lotofile(col, path, 'client')``.
|
||||
# The server orchestrates a SQ_FILE (98) protocol where it tells
|
||||
# us to "open file X, write these bytes, close". We emulate the
|
||||
@ -514,6 +520,7 @@ class Cursor:
|
||||
self._rowcount = -1
|
||||
self._rows = []
|
||||
self._row_index = -1 # before-first-row
|
||||
self._row_class = None
|
||||
self._statement_type = 0
|
||||
self._statement_already_done = False
|
||||
|
||||
@ -553,6 +560,8 @@ class Cursor:
|
||||
else:
|
||||
self._execute_dml()
|
||||
|
||||
self._row_class = self._resolve_row_class()
|
||||
|
||||
# The statement succeeded. If it was transaction control, the
|
||||
# server's transaction state just changed and the connection has
|
||||
# to know, or commit() and rollback() silently do nothing.
|
||||
@ -566,6 +575,25 @@ class Cursor:
|
||||
if self._description is not None:
|
||||
self._row_index = -1
|
||||
|
||||
def _resolve_row_class(self) -> type | None:
|
||||
"""Pick the class this result set's rows are handed back as.
|
||||
|
||||
``row_factory`` is either :class:`informix_db.Row` (or a subclass),
|
||||
in which case the per-shape class is built and cached from the
|
||||
column names, or any callable taking the name tuple and returning
|
||||
something that takes a values tuple.
|
||||
|
||||
Returns ``None`` when no factory is set, which is the default and
|
||||
keeps plain tuples on the hot path at zero cost.
|
||||
"""
|
||||
factory = self.row_factory
|
||||
if factory is None or self._description is None:
|
||||
return None
|
||||
names = tuple(d[0] for d in self._description)
|
||||
if isinstance(factory, type) and issubclass(factory, Row):
|
||||
return make_row_class(names)
|
||||
return factory(names)
|
||||
|
||||
def _note_transaction_control(self) -> None:
|
||||
"""Sync the connection's transaction flag after a successful execute.
|
||||
|
||||
@ -1387,7 +1415,8 @@ class Cursor:
|
||||
self._row_index = len(self._rows) # past-last
|
||||
return None
|
||||
self._row_index = nxt
|
||||
return self._rows[nxt]
|
||||
row = self._rows[nxt]
|
||||
return self._row_class(row) if self._row_class is not None else row
|
||||
|
||||
def fetchmany(self, size: int | None = None) -> list[tuple]:
|
||||
self._check_open()
|
||||
@ -1418,6 +1447,8 @@ class Cursor:
|
||||
return []
|
||||
start = self._row_index + 1
|
||||
out = self._rows[start:]
|
||||
if self._row_class is not None:
|
||||
out = [self._row_class(r) for r in out]
|
||||
self._row_index = len(self._rows)
|
||||
return list(out)
|
||||
|
||||
@ -1598,7 +1629,7 @@ class Cursor:
|
||||
if scrolltype == 4 or is_last_probe:
|
||||
# SFETCH(LAST) — TUPID == total row count
|
||||
self._scroll_total_rows = self._last_tupid
|
||||
return row
|
||||
return self._row_class(row) if self._row_class is not None else row
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the cursor.
|
||||
|
||||
180
src/informix_db/rows.py
Normal file
180
src/informix_db/rows.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Rows that can be read by position, by column name, or by attribute.
|
||||
|
||||
PEP 249 only requires a sequence, and a sequence is what the driver
|
||||
returns by default. That is fine for ``SELECT a, b`` and steadily worse
|
||||
as the projection grows: ``row[11]`` tells a reader nothing, and stays
|
||||
correct only until somebody adds a column in the middle.
|
||||
|
||||
Opting in with ``row_factory=Row`` gives the shape ``pyodbc`` and
|
||||
``mssql-python`` provide, all three at once::
|
||||
|
||||
conn = informix_db.connect(..., row_factory=informix_db.Row)
|
||||
cur.execute("SELECT tabid, tabname FROM systables")
|
||||
row = cur.fetchone()
|
||||
row[0], row["tabname"], row.tabname
|
||||
|
||||
It is opt-in rather than the default because it is not free, and the
|
||||
driver's whole argument is that pure Python can stay within noise of the
|
||||
C driver on bulk fetch. Measured on a 20,000-row five-column fetch:
|
||||
**37.2 ms with tuples, 40.8 ms with Row**, so about 9%. Defaulting it on
|
||||
would move the published 1.05-1.15x ratio against IfxPy to roughly
|
||||
1.15-1.25x, which is not a trade to make on everybody's behalf.
|
||||
|
||||
Where the 9% goes, against a plain tuple: about 30 ns per row to
|
||||
construct, about 39 ns per ``row[0]`` because supporting ``row["name"]``
|
||||
means ``__getitem__`` is a Python method rather than C-level tuple
|
||||
indexing, and about 13 ns per unpack since CPython's fast path for
|
||||
``a, b = row`` applies to exact tuples and not to subclasses.
|
||||
|
||||
Worth it for readability in application code. Not worth paying in a bulk
|
||||
export that never looks at a column by name, which is exactly why it is
|
||||
a choice.
|
||||
|
||||
``Row`` subclasses ``tuple``, so ``row == (1, "x")`` is still true and
|
||||
existing code keeps working unchanged.
|
||||
|
||||
**A column always beats a method of the same name.** ``tuple`` defines
|
||||
``count`` and ``index``; this class adds ``keys``, ``_asdict`` and
|
||||
``_fields``. A column named any of those would otherwise resolve to the
|
||||
method and hand back a bound method instead of a value, which is exactly
|
||||
the shape of the framing bugs this driver spent a fortnight removing: a
|
||||
plausible-looking wrong answer, in silence. So every such name gets a
|
||||
descriptor and the column wins, and the reserved set is *computed* from
|
||||
the class rather than hand-listed, because hand-listing it is what
|
||||
missed ``keys``, ``_asdict`` and ``_fields`` the first time round. The
|
||||
methods stay reachable through the class: ``tuple.count(row, x)``,
|
||||
``Row.keys(row)``.
|
||||
|
||||
The machinery itself is name-mangled (``__fields`` / ``__map``) so that
|
||||
shadowing ``_fields`` cannot break ``repr`` or ``_asdict``.
|
||||
|
||||
Column names come from ``cursor.description``. Informix folds unquoted
|
||||
identifiers to lower case, so ``SELECT Config_Key`` is reachable as
|
||||
``row.config_key``. Expressions get server-generated names that are not
|
||||
Python identifiers, like ``(count(*))``; those are reachable by
|
||||
subscript but not as attributes. Duplicate names resolve to the first
|
||||
occurrence, matching ``pyodbc``. Dunder names cannot be shadowed and
|
||||
stay subscript-only, which no real schema should notice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from functools import lru_cache
|
||||
from typing import ClassVar
|
||||
|
||||
__all__ = ["Row", "make_row_class"]
|
||||
|
||||
|
||||
class Row(tuple):
|
||||
"""A result row addressable by position, name, or attribute.
|
||||
|
||||
Used as a ``row_factory``. The concrete class handed to each result
|
||||
set is a subclass carrying that query's column names, built by
|
||||
:func:`make_row_class`.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
# Name-mangled to ``_Row__fields`` / ``_Row__map`` so that a column
|
||||
# called "_fields" can be shadowed without breaking the machinery
|
||||
# that reads it. Set on the per-result-set subclass.
|
||||
__fields: ClassVar[tuple[str, ...]] = ()
|
||||
__map: ClassVar[dict[str, int]] = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
# ``key.__class__ is str`` rather than isinstance: this runs on
|
||||
# every subscript.
|
||||
if key.__class__ is str:
|
||||
try:
|
||||
return tuple.__getitem__(self, self.__map[key])
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"no column named {key!r}; this row has "
|
||||
f"{list(self.__fields)}"
|
||||
) from None
|
||||
return tuple.__getitem__(self, key)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Only reached when normal attribute lookup has already failed,
|
||||
# so this costs nothing for names that do not collide.
|
||||
try:
|
||||
return tuple.__getitem__(self, self.__map[name])
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f"no column named {name!r}; this row has "
|
||||
f"{list(self.__fields)}"
|
||||
) from None
|
||||
|
||||
@property
|
||||
def _fields(self) -> tuple[str, ...]:
|
||||
"""Column names, in select order. Mirrors ``namedtuple._fields``."""
|
||||
return self.__fields
|
||||
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
"""Column names, in select order."""
|
||||
return self.__fields
|
||||
|
||||
def _asdict(self) -> dict:
|
||||
"""A plain ``dict`` of the row.
|
||||
|
||||
On duplicate column names the last occurrence wins here, while
|
||||
subscript access gives the first. A dict cannot represent both,
|
||||
and quietly dropping a duplicate is better than raising on a
|
||||
query that is otherwise fine.
|
||||
"""
|
||||
return dict(zip(self.__fields, self, strict=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fields = self.__fields
|
||||
if len(fields) != len(self):
|
||||
return tuple.__repr__(self)
|
||||
body = ", ".join(
|
||||
f"{name}={value!r}"
|
||||
for name, value in zip(fields, self, strict=True)
|
||||
)
|
||||
return f"Row({body})"
|
||||
|
||||
def __reduce__(self):
|
||||
# The per-result-set class is created at runtime and cannot be
|
||||
# pickled by reference, so rebuild it from the field names.
|
||||
return (_rebuild_row, (self.__fields, tuple(self)))
|
||||
|
||||
|
||||
# Every non-dunder attribute a Row already answers to. A column with one
|
||||
# of these names gets a descriptor so the column wins. Computed, not
|
||||
# hand-listed: the hand-listed version covered ``count`` and ``index``
|
||||
# and silently missed ``keys``, ``_asdict`` and ``_fields``.
|
||||
_RESERVED = frozenset(
|
||||
name for name in dir(Row) if not name.startswith("__")
|
||||
) - {"_Row__fields", "_Row__map"}
|
||||
|
||||
|
||||
def _rebuild_row(fields: tuple[str, ...], values: tuple):
|
||||
return make_row_class(fields)(values)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def make_row_class(fields: tuple[str, ...]) -> type[Row]:
|
||||
"""Build (and cache) the row class for one column-name shape.
|
||||
|
||||
Cached because a class per ``execute()`` would put a ``type()`` call
|
||||
on the path of every small query, and applications run the same
|
||||
handful of statement shapes over and over. Keyed on the names alone,
|
||||
so two queries selecting the same columns share a class.
|
||||
"""
|
||||
namespace: dict = {
|
||||
"__slots__": (),
|
||||
"_Row__fields": fields,
|
||||
# First occurrence wins on duplicates, matching pyodbc. Building
|
||||
# the map in reverse and letting earlier entries overwrite later
|
||||
# ones is the shortest way to say that.
|
||||
"_Row__map": {
|
||||
name: i for i, name in reversed(list(enumerate(fields)))
|
||||
},
|
||||
}
|
||||
for i, name in enumerate(fields):
|
||||
if name in _RESERVED:
|
||||
# A column of this name would otherwise resolve to a method.
|
||||
namespace[name] = property(operator.itemgetter(i))
|
||||
return type("Row", (Row,), namespace)
|
||||
@ -214,3 +214,52 @@ async def test_closing_a_connection_stops_its_thread(
|
||||
f"connection threads outlived their connections "
|
||||
f"({before} -> {threading.active_count()})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_factory_reaches_the_async_paths(
|
||||
conn_params: ConnParams,
|
||||
) -> None:
|
||||
"""The async layer wraps the sync cursor, so row_factory should flow
|
||||
through untouched. Each of the four async fetch routes is a separate
|
||||
call site, and async iteration goes through __anext__ rather than
|
||||
__next__."""
|
||||
import informix_db
|
||||
|
||||
conn = await aio.connect(row_factory=informix_db.Row, **_kw(conn_params))
|
||||
try:
|
||||
cur = await conn.cursor()
|
||||
sql = "SELECT FIRST 2 tabid, tabname FROM systables ORDER BY tabid"
|
||||
|
||||
await cur.execute(sql)
|
||||
one = await cur.fetchone()
|
||||
assert one[0] == one["tabid"] == one.tabid
|
||||
|
||||
await cur.execute(sql)
|
||||
assert [r.tabid for r in await cur.fetchall()] == [1, 2]
|
||||
|
||||
await cur.execute(sql)
|
||||
assert all(r.tabname for r in await cur.fetchmany(2))
|
||||
|
||||
await cur.execute(sql)
|
||||
assert [r.tabid async for r in cur] == [1, 2]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_pool_forwards_the_row_factory(
|
||||
conn_params: ConnParams,
|
||||
) -> None:
|
||||
import informix_db
|
||||
|
||||
pool = await aio.create_pool(
|
||||
row_factory=informix_db.Row, min_size=1, max_size=2, **_kw(conn_params)
|
||||
)
|
||||
try:
|
||||
async with pool.connection() as conn:
|
||||
cur = await conn.cursor()
|
||||
await cur.execute("SELECT FIRST 1 tabid FROM systables")
|
||||
assert (await cur.fetchone()).tabid == 1
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
435
tests/test_rows.py
Normal file
435
tests/test_rows.py
Normal file
@ -0,0 +1,435 @@
|
||||
"""Named row access, and the cost of it.
|
||||
|
||||
A field request: `row[11]` tells a reader nothing on a wide projection,
|
||||
and `pyodbc` / `mssql-python` both hand back rows that answer to
|
||||
position, column name, and attribute at once. `row_factory=Row` gives
|
||||
the same three.
|
||||
|
||||
It is opt-in. Defaulting it on would tax the thing this driver is
|
||||
measured against, since supporting `row["name"]` means `__getitem__`
|
||||
becomes a Python method rather than C-level tuple indexing, which costs
|
||||
roughly 39 ns on every subscript. Users who want readable column access
|
||||
in application code should pay that; a bulk export that never looks at a
|
||||
column by name should not.
|
||||
|
||||
`Row` subclasses `tuple`, so `row == (1, "x")` still holds and nothing
|
||||
that already worked stops working. That constraint drove the design: the
|
||||
existing suite compares fetched rows against plain tuples in hundreds of
|
||||
places, and so does everybody's code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import pickle
|
||||
|
||||
import pytest
|
||||
|
||||
import informix_db
|
||||
from informix_db.rows import _RESERVED, Row, make_row_class
|
||||
from tests.conftest import ConnParams
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The type itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row(fields, values):
|
||||
return make_row_class(tuple(fields))(values)
|
||||
|
||||
|
||||
def test_three_ways_to_reach_a_column() -> None:
|
||||
row = _row(("tabid", "tabname"), (1, "systables"))
|
||||
assert row[0] == 1
|
||||
assert row["tabname"] == "systables"
|
||||
assert row.tabname == "systables"
|
||||
|
||||
|
||||
def test_still_equal_to_a_plain_tuple() -> None:
|
||||
"""The compatibility constraint. Existing code and the existing test
|
||||
suite compare fetched rows against tuples everywhere."""
|
||||
row = _row(("a", "b"), (1, "x"))
|
||||
plain = (1, "x")
|
||||
assert row == plain
|
||||
# Both directions: tuple.__eq__ on the left has to accept a subclass
|
||||
# on the right, or `expected == fetched` assertions break.
|
||||
assert plain == row
|
||||
assert list(row) == [1, "x"]
|
||||
assert len(row) == 2
|
||||
a, b = row
|
||||
assert (a, b) == (1, "x")
|
||||
assert row in [(1, "x")]
|
||||
|
||||
|
||||
def test_slice_degrades_to_a_plain_tuple() -> None:
|
||||
"""A slice has no meaningful column mapping, so it should not pretend
|
||||
to be a Row."""
|
||||
row = _row(("a", "b", "c"), (1, 2, 3))
|
||||
assert row[0:2] == (1, 2)
|
||||
assert type(row[0:2]) is tuple
|
||||
|
||||
|
||||
def test_negative_index_still_works() -> None:
|
||||
assert _row(("a", "b"), (1, 2))[-1] == 2
|
||||
|
||||
|
||||
def test_keys_and_asdict() -> None:
|
||||
row = _row(("a", "b"), (1, "x"))
|
||||
assert row.keys() == ("a", "b")
|
||||
assert row._asdict() == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
def test_repr_names_the_columns() -> None:
|
||||
assert repr(_row(("a", "b"), (1, "x"))) == "Row(a=1, b='x')"
|
||||
|
||||
|
||||
def test_pickles() -> None:
|
||||
"""The per-shape class is built at runtime, so it cannot be pickled by
|
||||
reference. Multiprocessing users would hit that immediately."""
|
||||
row = _row(("a", "b"), (1, "x"))
|
||||
restored = pickle.loads(pickle.dumps(row))
|
||||
assert restored == (1, "x")
|
||||
assert restored.b == "x"
|
||||
|
||||
|
||||
def test_missing_column_says_what_is_there() -> None:
|
||||
row = _row(("tabid", "tabname"), (1, "x"))
|
||||
with pytest.raises(KeyError, match="tabid"):
|
||||
_ = row["nope"]
|
||||
with pytest.raises(AttributeError, match="tabid"):
|
||||
_ = row.nope
|
||||
|
||||
|
||||
def test_class_is_cached_per_shape() -> None:
|
||||
"""A type() call per execute() would land on every small query."""
|
||||
assert make_row_class(("a", "b")) is make_row_class(("a", "b"))
|
||||
assert make_row_class(("a", "b")) is not make_row_class(("a", "c"))
|
||||
|
||||
|
||||
def test_duplicate_names_resolve_to_the_first() -> None:
|
||||
"""``SELECT tabid, tabid`` is legal and both columns are named
|
||||
``tabid``. pyodbc gives the first; so do we."""
|
||||
row = _row(("tabid", "tabid"), (1, 2))
|
||||
assert row["tabid"] == 1
|
||||
assert row.tabid == 1
|
||||
assert row[1] == 2, "positional access must still reach the second"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_RESERVED))
|
||||
def test_a_column_always_beats_a_method_of_the_same_name(name: str) -> None:
|
||||
"""Parametrized over the *computed* reserved set rather than a
|
||||
hand-written list, because the hand-written list is what missed
|
||||
``keys``, ``_asdict`` and ``_fields`` on the first attempt. Adding a
|
||||
method to Row later cannot silently reopen the hole: this test grows
|
||||
with it.
|
||||
|
||||
Without the guard these return a bound method, which is the exact
|
||||
failure shape this driver spent a fortnight removing from its
|
||||
decoders: a plausible-looking wrong answer, in silence."""
|
||||
row = _row((name, "other"), (7, 9))
|
||||
assert getattr(row, name) == 7
|
||||
assert row[name] == 7
|
||||
|
||||
|
||||
def test_shadowed_methods_stay_reachable_through_the_base_class() -> None:
|
||||
row = _row(("count", "keys", "_asdict"), (1, 2, 3))
|
||||
assert tuple.count(row, 1) == 1
|
||||
assert Row.keys(row) == ("count", "keys", "_asdict")
|
||||
assert Row._asdict(row) == {"count": 1, "keys": 2, "_asdict": 3}
|
||||
|
||||
|
||||
def test_shadowing_fields_does_not_break_repr_or_asdict() -> None:
|
||||
"""The machinery reads its own field list, so if a column named
|
||||
``_fields`` shadowed it, repr and _asdict would report the column
|
||||
value instead of the names. Mangled attributes keep them separate."""
|
||||
row = _row(("_fields", "x"), ("not the names", 2))
|
||||
assert row._fields == "not the names"
|
||||
assert Row.keys(row) == ("_fields", "x")
|
||||
assert repr(row) == "Row(_fields='not the names', x=2)"
|
||||
|
||||
|
||||
def test_non_identifier_names_are_subscript_only() -> None:
|
||||
"""Informix names expression columns things like ``(count(*))``,
|
||||
which cannot be an attribute."""
|
||||
row = _row(("(count(*))", "ok"), (5, 1))
|
||||
assert row["(count(*))"] == 5
|
||||
assert row.ok == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Against a real server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _connect(conn_params: ConnParams, **kw) -> informix_db.Connection:
|
||||
return informix_db.connect(
|
||||
host=conn_params.host,
|
||||
port=conn_params.port,
|
||||
user=conn_params.user,
|
||||
password=conn_params.password,
|
||||
database=conn_params.database,
|
||||
server=conn_params.server,
|
||||
connect_timeout=10.0,
|
||||
read_timeout=25.0,
|
||||
autocommit=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_default_is_still_a_plain_tuple(conn_params: ConnParams) -> None:
|
||||
"""The opt-in has to be genuinely opt-in. Anyone who does not ask for
|
||||
Row should not pay for it or see any behaviour change."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT FIRST 1 tabid, tabname FROM systables")
|
||||
row = cur.fetchone()
|
||||
assert type(row) is tuple
|
||||
with pytest.raises(TypeError):
|
||||
_ = row["tabname"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_row_factory_on_the_connection(conn_params: ConnParams) -> None:
|
||||
"""One line at connect time, then every cursor from it, which is what
|
||||
'without any additional coding' has to mean in practice."""
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT FIRST 1 tabid, tabname FROM systables ORDER BY tabid"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row[0] == row["tabid"] == row.tabid
|
||||
assert row[1] == row["tabname"] == row.tabname
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_every_fetch_path_returns_rows(conn_params: ConnParams) -> None:
|
||||
"""fetchone, fetchmany, fetchall and iteration each return rows by a
|
||||
different route through the cursor."""
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as conn:
|
||||
sql = "SELECT FIRST 4 tabid, tabname FROM systables ORDER BY tabid"
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(sql)
|
||||
assert cur.fetchone().tabname is not None
|
||||
|
||||
cur.execute(sql)
|
||||
assert all(r.tabname is not None for r in cur.fetchmany(2))
|
||||
|
||||
cur.execute(sql)
|
||||
assert all(r.tabname is not None for r in cur.fetchall())
|
||||
|
||||
cur.execute(sql)
|
||||
assert all(r.tabname is not None for r in cur)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_scrollable_cursor_returns_rows(conn_params: ConnParams) -> None:
|
||||
"""Scrollable cursors return each row straight from the wire rather
|
||||
than from the materialized list, so they are a separate path."""
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as conn:
|
||||
cur = conn.cursor(scrollable=True)
|
||||
cur.execute("SELECT tabid, tabname FROM systables ORDER BY tabid")
|
||||
assert cur.fetch_first().tabname is not None
|
||||
assert cur.fetch_absolute(1).tabname is not None
|
||||
cur.close()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_per_cursor_override(conn_params: ConnParams) -> None:
|
||||
with _connect(conn_params) as conn:
|
||||
named = conn.cursor()
|
||||
named.row_factory = informix_db.Row
|
||||
plain = conn.cursor()
|
||||
sql = "SELECT FIRST 1 tabid FROM systables"
|
||||
named.execute(sql)
|
||||
plain.execute(sql)
|
||||
assert isinstance(named.fetchone(), Row)
|
||||
assert type(plain.fetchone()) is tuple
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_informix_lowercases_so_the_obvious_name_works(
|
||||
conn_params: ConnParams,
|
||||
) -> None:
|
||||
"""Informix folds unquoted identifiers, which is why the .lower() in
|
||||
the workaround people write by hand is a no-op."""
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TEMP TABLE t_rows (Config_Key INT, Cnt INT)")
|
||||
cur.execute("INSERT INTO t_rows VALUES (1, 2)")
|
||||
cur.execute("SELECT Config_Key, Cnt FROM t_rows")
|
||||
row = cur.fetchone()
|
||||
assert row.config_key == 1
|
||||
assert row.cnt == 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_pool_forwards_the_factory(conn_params: ConnParams) -> None:
|
||||
pool = informix_db.create_pool(
|
||||
host=conn_params.host,
|
||||
port=conn_params.port,
|
||||
user=conn_params.user,
|
||||
password=conn_params.password,
|
||||
database=conn_params.database,
|
||||
server=conn_params.server,
|
||||
autocommit=True,
|
||||
row_factory=informix_db.Row,
|
||||
min_size=1,
|
||||
max_size=2,
|
||||
)
|
||||
try:
|
||||
with pool.connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT FIRST 1 tabid FROM systables")
|
||||
assert cur.fetchone().tabid is not None
|
||||
finally:
|
||||
pool.close()
|
||||
|
||||
|
||||
def test_zero_column_row() -> None:
|
||||
row = make_row_class(())(())
|
||||
assert row == ()
|
||||
assert row.keys() == ()
|
||||
assert repr(row) == "Row()"
|
||||
|
||||
|
||||
def test_wide_row() -> None:
|
||||
"""500 columns: the name map is a dict, so this should be flat, but
|
||||
it is the shape most likely to expose an off-by-one."""
|
||||
fields = tuple(f"c{i}" for i in range(500))
|
||||
row = make_row_class(fields)(tuple(range(500)))
|
||||
assert row["c499"] == row.c499 == row[499] == 499
|
||||
assert row["c0"] == row[0] == 0
|
||||
|
||||
|
||||
def test_names_that_cannot_be_attributes_are_subscript_only() -> None:
|
||||
row = _row(("col with space", "1leading_digit", ""), (1, 2, 3))
|
||||
assert row["col with space"] == 1
|
||||
assert row["1leading_digit"] == 2
|
||||
assert row[""] == 3
|
||||
|
||||
|
||||
def test_unicode_column_name() -> None:
|
||||
row = _row(("café", "x"), (1, 2))
|
||||
assert row["café"] == 1
|
||||
assert row.café == 1
|
||||
|
||||
|
||||
def test_dunder_named_column_is_subscript_only() -> None:
|
||||
"""A dunder cannot be shadowed without breaking the object protocol,
|
||||
so the column is reachable by subscript and the attribute keeps its
|
||||
ordinary meaning. No real schema should notice."""
|
||||
row = _row(("__class__", "ok"), (1, 2))
|
||||
assert row["__class__"] == 1
|
||||
assert row.__class__.__name__ == "Row"
|
||||
|
||||
|
||||
def test_row_outlives_eviction_of_its_class_from_the_cache() -> None:
|
||||
"""The class cache is bounded. A row already handed to the caller
|
||||
holds its class alive, so eviction must not affect it."""
|
||||
row = make_row_class(("z1", "z2"))((9, 8))
|
||||
for i in range(300):
|
||||
make_row_class((f"evict{i}", "b"))
|
||||
assert (row.z1, row["z2"], row[0]) == (9, 8, 9)
|
||||
|
||||
|
||||
def test_class_cache_is_bounded() -> None:
|
||||
for i in range(400):
|
||||
make_row_class((f"bounded{i}", "b"))
|
||||
assert make_row_class.cache_info().currsize <= 256
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row must not change a single value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIDE_DDL = """CREATE TABLE t_rowdiff (
|
||||
c_int INT, c_small SMALLINT, c_big BIGINT, c_int8 INT8, c_serial SERIAL,
|
||||
c_float FLOAT, c_smallfloat SMALLFLOAT, c_dec DECIMAL(16,4),
|
||||
c_decu DECIMAL(16), c_money MONEY(12,2), c_char CHAR(10),
|
||||
c_vchar VARCHAR(40), c_nchar NCHAR(8), c_lvar LVARCHAR(200), c_date DATE,
|
||||
c_dt DATETIME YEAR TO FRACTION(5), c_ivl INTERVAL YEAR TO MONTH,
|
||||
c_bool BOOLEAN, c_set SET(INT NOT NULL), c_tail INT)"""
|
||||
|
||||
_WIDE_COLS = (
|
||||
"c_int,c_small,c_big,c_int8,c_serial,c_float,c_smallfloat,c_dec,c_decu,"
|
||||
"c_money,c_char,c_vchar,c_nchar,c_lvar,c_date,c_dt,c_ivl,c_bool,c_set,"
|
||||
"c_tail"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_rows_are_value_identical_to_tuples(conn_params: ConnParams) -> None:
|
||||
"""The strongest statement available: across every awkward type, with
|
||||
a fully populated row and a fully NULL one, wrapping must change
|
||||
nothing. If it does, Row is not a presentation layer, it is a bug."""
|
||||
sql = f"SELECT {_WIDE_COLS} FROM t_rowdiff ORDER BY c_tail"
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
with contextlib.suppress(Exception):
|
||||
cur.execute("DROP TABLE t_rowdiff")
|
||||
cur.execute(_WIDE_DDL)
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO t_rowdiff VALUES (1,2,3,4,0,1.5,2.5,12.34,99,"
|
||||
"9.99,'ch','vc','nc','lv',TODAY,CURRENT,"
|
||||
"INTERVAL(1-2) YEAR TO MONTH,'t',SET{1,2},77)"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO t_rowdiff VALUES (NULL,NULL,NULL,NULL,0,NULL,"
|
||||
"NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,"
|
||||
"NULL,NULL,88)"
|
||||
)
|
||||
cur.execute(sql)
|
||||
plain = cur.fetchall()
|
||||
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as named_conn:
|
||||
named = named_conn.cursor()
|
||||
named.execute(sql)
|
||||
rows = named.fetchall()
|
||||
|
||||
assert rows == plain, "wrapping changed a value"
|
||||
names = _WIDE_COLS.split(",")
|
||||
first = rows[0]
|
||||
for i, name in enumerate(names):
|
||||
assert first[i] == first[name] == getattr(first, name), name
|
||||
assert rows[1]["c_int"] is None
|
||||
assert rows[1].c_tail == 88
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
cur.execute("DROP TABLE t_rowdiff")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_threads_share_one_class_per_shape(conn_params: ConnParams) -> None:
|
||||
"""The cache is process-wide and the pool hands connections to many
|
||||
threads, so two threads running the same query must land on the same
|
||||
class rather than racing to build competing ones."""
|
||||
import threading
|
||||
|
||||
seen: list[type] = []
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
with _connect(conn_params, row_factory=informix_db.Row) as conn:
|
||||
cur = conn.cursor()
|
||||
for _ in range(5):
|
||||
cur.execute(
|
||||
"SELECT FIRST 1 tabid, tabname FROM systables"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row.tabname == row["tabname"] == row[1]
|
||||
seen.append(type(row))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(6)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(60)
|
||||
assert not errors, errors[:2]
|
||||
assert len({id(c) for c in seen}) == 1, "same shape built more than once"
|
||||
Loading…
x
Reference in New Issue
Block a user