Docs: put named row access where someone leaving IfxPy will find it

The API reference gained the Row section with the release, which is the
right home for the details and the wrong place to discover the feature
exists. Three gaps closed.

The IfxPy migration guide is the page for people in exactly the position
that prompted this: IfxPy is positional-only, so those codebases grow a
description/zip helper that yields a dict and still no attribute access.
That page now shows the helper it replaces alongside the one-liner, and
notes the .lower() in the hand-written version is already a no-op since
Informix folds unquoted identifiers.

Quickstart gains one sentence, next to the paramstyle note where someone
is already thinking about how rows come back.

The phase log records 2026.09.03, including where the testing actually
found the shadowed-names bug: not in the differential test across twenty
awkward types, which found nothing, but in six lines of name bookkeeping
that were correct when written and stopped being correct an hour later.

Verified live by content, and the #rows anchor the new cross-links point
at resolves.
This commit is contained in:
Ryan Malloy 2026-09-03 16:06:34 -06:00
parent acc21b8b81
commit c85aaf2ecb
3 changed files with 69 additions and 0 deletions

View File

@ -96,6 +96,40 @@ The cheapest fix was also the most valuable. Asserting that a row decoder lands
Test count went from 241 to 457 across three server versions over this stretch.
### 2026.09.03
Two more, one of each kind.
The `:N` placeholder rewriter was a bare regular expression, so it
rewrote the inside of string literals: `'http://host:8080/x'` was stored
as `'http://host?/x'`. Same family as the statement-classification bug
above, and the last place the driver inferred meaning from SQL text
instead of handling it. Replaced with a scanner using Informix's own
lexical rules, measured rather than assumed. One of those rules 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 desyncs there and corrupts everything after it.
`row_factory=Row` adds column-name and attribute access, on request from
a user coming from `pyodbc`. Opt-in, because it costs about 9% on bulk
fetch and that is not a trade to make for someone who never reads a
column by name.
Testing the second one found the pattern again, this time in code
written an hour earlier. The names a column could shadow were
hand-listed as `count` and `index`, the two `tuple` methods; then `keys`,
`_asdict` and `_fields` were added to the same class and the list was
never revisited. A column called `keys` returned a bound method, in
silence. The fix was to compute the set from the class rather than
lengthen the list, and to parametrize the test over that computed set so
it grows with the class.
The interesting part is where testing found it. A differential test
across twenty columns covering every awkward type, populated and fully
NULL, found nothing. The bug was in six lines of name bookkeeping that
looked obviously correct, and had been correct when written. Anything
that must stay in sync with a class by hand eventually will not.
## Notable architectural pivots
The decision log calls out four moments where the obvious choice would have been wrong:

View File

@ -48,6 +48,36 @@ The exception hierarchy is identical: `Error`, `Warning`, `InterfaceError`, `Dat
- **Type-safe annotations**: `informix-driver` ships with `py.typed`
- **Python 3.12+ support**
- **Pipelined `executemany`**: 1.6× faster than IfxPy's per-row implementation
- **Rows addressable by name**: `row["col"]` and `row.col`, not just `row[0]`
## Reading rows by name
IfxPy gives you positional access and nothing else, which is why most
IfxPy codebases grow a helper like this:
```python
cols = [c[0].lower() for c in cur.description]
row_dict = dict(zip(cols, cur.fetchone()))
```
That gets you a dict and still no attribute access. Ask for `Row`
instead and all three work at once:
```python
conn = informix_db.connect(..., row_factory=informix_db.Row)
cur.execute("SELECT config_key, config_value FROM settings")
row = cur.fetchone()
row[0], row["config_key"], row.config_key
```
Set on the connection, so it applies to every cursor from it. The
`.lower()` in the hand-written version is already a no-op, incidentally:
Informix folds unquoted identifiers to lower case.
It is opt-in because it costs about 9% on bulk fetch. See
[the API reference](/reference/api/#rows) for the numbers and the
edge cases.
## Migrating incrementally

View File

@ -121,6 +121,11 @@ with informix_db.connect(host="127.0.0.1", port=9088, user="informix",
`?` and `:1` both work. Informix's native paramstyle is `numeric`, but `?` is supported as a synonym.
Rows come back as plain tuples. If you would rather read them by column
name, pass `row_factory=informix_db.Row` to `connect()` and you get
`row["name"]` and `row.name` alongside `row[0]`. See
[the API reference](/reference/api/#rows).
## 5. Use the connection pool
For real applications, prefer the pool: