I hand-listed the names a column could shadow: count and index, the two tuple methods. Then I wrote keys(), _asdict() and _fields on the same class and did not go back. A column called "keys" returned a bound method instead of a value, silently, which is precisely the failure shape this driver spent a fortnight removing from its decoders. The fix is not a longer list. The reserved set is now computed from dir(Row), so adding a method later cannot reopen the hole, and the test is parametrized over that computed set so it grows with the class. _fields and _map moved to name-mangled attributes. They are read by repr() and _asdict(), so shadowing the public _fields with a column would have made the machinery report the column value instead of the column names. Mangling keeps the two apart, and there is a test that shadows _fields and checks repr still works. Also exercised, and all clean: value-identical output against the plain tuple path across twenty columns covering every awkward type plus a fully NULL row, which is the strongest available statement that Row is a presentation layer and not a bug; zero-column and 500-column rows; unicode, spaced, digit-leading, empty-string and dunder column names; rows outliving eviction of their class from the bounded cache; the four async fetch routes and the async pool; and twelve threads racing on the class cache, which correctly share one class per shape. 472 tests on 15 and 14.10, 471 on 12.10.
436 lines
16 KiB
Python
436 lines
16 KiB
Python
"""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"
|