2 Commits

Author SHA1 Message Date
1c19c71cb6 Phase 7: real transaction semantics on logged databases
Introduces driver-managed transactions that work seamlessly across
logged and unlogged databases. The user calls commit() and rollback()
without needing to know which kind they're hitting — the connection
tracks transaction state internally.

Three protocol facts came out of integration testing:

1. Logged DBs in non-ANSI mode require an explicit SQ_BEGIN before
   the first DML — the server doesn't auto-open a transaction.
   Connection._ensure_transaction() sends SQ_BEGIN lazily and is
   idempotent within an open txn. After commit/rollback, the next
   DML triggers a fresh BEGIN.

2. SQ_RBWORK has a [short savepoint=0] payload before the SQ_EOT
   framing tag — sending SQ_RBWORK alone causes the server to hang
   silently (waiting for the missing 2 bytes). SQ_CMMTWORK has no
   payload. This is the same pattern as the SHORT-vs-INT bug from
   Phase 4.x and the 2-byte length prefix from Phase 6.c — when the
   server hangs, it's an incomplete PDU body.

3. SQ_XACTSTAT (tag 99) is a logged-DB-only message that's
   interleaved with normal responses. Now drained in all four
   response-reading paths: cursor _drain_to_eot, _read_describe_
   response, _read_fetch_response, and connection _drain_to_eot.

For unlogged DBs (e.g., sysmaster), SQ_BEGIN returns -201 and we
cache that result so subsequent DML doesn't re-probe. commit() and
rollback() are silent no-ops in that case — same client code works
across both DB modes.

Tests:
* New tests/test_transactions.py — 10 integration tests covering
  commit visibility, rollback isolation, multi-row rollback, partial
  commit-then-rollback, autocommit behavior, cross-connection
  durability, UPDATE/DELETE rollback, implicit per-statement txn.
* conftest.py auto-creates testdb (logged) for the suite.
* Two old tests rewritten to assert new no-op behavior on unlogged
  DBs (test_commit_rollback_in_unlogged_db_is_noop,
  test_commit_in_unlogged_db_is_noop).

Total: 53 unit + 98 integration = 151 tests.

The Phase 3 "gate test" (test_rollback_hides_insert) — a rolled-back
INSERT must be invisible to subsequent SELECTs in the same session —
now passes against a real logged database for the first time.
2026-05-04 12:54:02 -06:00
d04000dfc3 Phase 5.a: real error messages with PEP 249 exception classification
Before:
  ProgrammingError: server returned SQ_ERR sqlcode=-201 isamcode=0

After:
  ProgrammingError: [-201] A syntax error has occurred (offset 1)
  ProgrammingError: [-206] The specified table is not in the database
                    (near 'no_such_table') [ISAM -111] (offset 27)
  IntegrityError:   [-268] Cannot insert duplicate value -
                    violates UNIQUE constraint (near 'on table u')
                    [ISAM -100] (offset 23)
  IntegrityError:   [-391] Cannot insert NULL value into a NOT NULL column
                    (near 't.id') (offset 23)
  OperationalError: [-255] Not in transaction

PEP 249 exception classes mapped from sqlcode:
  -201, -206, -217, -286, -310, ... → ProgrammingError
  -239, -268, -291, -292, -391, -703 → IntegrityError
  -255, -256, -407, -440, -908, ...   → OperationalError
  -329, -349, -510                    → NotSupportedError
  others                              → DatabaseError (safe fallback)

SQ_ERR wire decode (per IfxSqli.receiveError line 2717):
  [short sqlcode][short isamcode][int offset]
  [short near_token_len][bytes name][optional pad][short SQ_EOT]

The "near" token is the object name where the error occurred (table or
column name for "not found" errors); empty for most syntax errors.

Structured fields attached to every Informix error for programmatic
inspection:
  e.sqlcode      — Informix error code (e.g. -206)
  e.isamcode     — ISAM/RSAM-level error (e.g. -111 = "table not found")
  e.offset       — character offset in the SQL where the error occurred
  e.near         — object name in the "near 'XYZ'" clause (or "")

Connection state survives errors: a failed query doesn't poison the
session — subsequent execute() calls work normally. Verified by
test_connection_survives_query_error.

Built-in error catalog of ~50 most common Informix sqlcodes shipped
in src/informix_db/_errcodes.py. Users can extend at runtime with
register_error_text(code, text). Unknown codes get a generic
"Informix error <N>" with structured fields still populated.

Module changes:
  src/informix_db/_errcodes.py (new) — error catalog + exception
    classification + register_error_text()
  src/informix_db/cursors.py — _raise_sq_err now uses the catalog
  src/informix_db/connections.py — same upgrade for the connection-side
    SQ_ERR path (catches commit/rollback errors etc.)

Tests: 40 unit + 63 integration (8 new error tests) = 103 total, all
green, ruff clean. Tests cover:
  - syntax error → ProgrammingError(-201)
  - table not found → ProgrammingError(-206) with near='no_such_table'
  - column not found → ProgrammingError(-217)
  - UNIQUE violation → IntegrityError(-268)
  - NOT NULL violation → IntegrityError(-391)
  - commit on unlogged DB → OperationalError(-255)
  - connection survives errors (subsequent queries work)
  - all errors carry structured sqlcode/isamcode/offset/near attrs
2026-05-04 11:59:03 -06:00