informix-db/tests/test_dml.py
Ryan Malloy 509af9efa4 Phase 4: parameter binding (SQ_BIND) — int, float, str, bool, None
cur.execute("INSERT INTO t VALUES (?, ?, ?)", (42, "hello", 3.14))
cur.execute("INSERT INTO t VALUES (:1, :2)", (99, "world"))
cur.execute("UPDATE t SET name = ? WHERE id = ?", ("new", 2))
cur.execute("DELETE FROM t WHERE id = ?", (5,))
# all work end-to-end against a real Informix server

Two breakthroughs decoded from JDBC:

1. SQ_BIND PDU shape (chained with SQ_EXECUTE in one PDU, no separate
   round trip):
     [short SQ_ID=4][int SQ_BIND=5][short numparams]
     for each param:
       [short type][short indicator][short prec_or_encLen]
       writePadded(rawbytes)
     [short SQ_EXECUTE=7][short SQ_EOT]

2. Strings are sent as CHAR (type=0) not VARCHAR (type=13). The server
   handles conversion to the actual column type via internal CIDESCRIBE
   — we don't need to do it explicitly.

Per-type encoding (Phase 4 MVP):
  int (32-bit) → IDS INT (type=2), prec=0x0a00 (packed width=10/scale=0),
                  4-byte BE
  int (64-bit) → IDS BIGINT (type=52), prec=0x1300, 8-byte BE
  str          → IDS CHAR (type=0), prec=0, [short len][bytes][pad]
  float        → IDS FLOAT (type=3), prec=0, 8-byte IEEE 754
  bool         → IDS BOOL (type=45), prec=0, 1 byte
  None         → indicator=-1, no data

The integer "precision" field is PACKED — initially looked like a bug
(why would precision be 2560?) until I realized 0x0a00 = (10 << 8) | 0
= packed display-width and scale. Captured this surprise in
DECISION_LOG.md.

Critical fix to execute-path branching: parameterized INSERT also
returns nfields > 0 (server describes the would-be inserted row).
Switched from "branch on nfields" to "branch on SQL keyword" — JDBC
does the same via its IfxStatement / IfxPreparedStatement subclassing.

Numeric paramstyle support: cur.execute("... :1 ...", (val,)) works
by rewriting :N → ? before sending PREPARE. Trivial regex (doesn't
escape strings/comments — Phase 5 can add a proper SQL tokenizer).

Module changes:
  src/informix_db/converters.py:
    + encode_param() dispatcher
    + _encode_int / _encode_bigint / _encode_str / _encode_float / _encode_bool
  src/informix_db/cursors.py:
    + _build_bind_execute_pdu() — chains SQ_BIND + SQ_EXECUTE in one PDU
    + _execute_dml_with_params() — sends bind PDU, drains, releases
    + execute() now accepts parameters; rewrites :N → ?; branches by
      SQL keyword (SELECT vs DML)
    + _NUMERIC_PLACEHOLDER_RE for paramstyle="numeric" support

Tests: 40 unit + 32 integration (8 new parameter tests + 1 updated
smoke) = 72 total, all green, ruff clean. New tests cover:
  - INSERT with ? params
  - INSERT with :N params
  - INT + FLOAT + str round trip via INSERT then SELECT
  - UPDATE with params in SET and WHERE
  - DELETE with parameter in WHERE
  - Unsupported param type (bytes) raises NotImplementedError
  - Parameterized SELECT raises NotSupportedError (Phase 4.x)
  - Dict/named params raise NotSupportedError

Known gaps (Phase 4.x / Phase 5):
  - Parameterized SELECT (needs SQ_BIND before CURNAME+NFETCH)
  - NULL row decoding for VARCHAR (currently surfaces empty string)
  - Proper SQL tokenizer (so :N inside string literals is preserved)
  - bytes/datetime/Decimal parameter types
2026-05-04 10:54:32 -06:00

120 lines
4.5 KiB
Python

"""Phase 3 integration tests — DDL + DML.
Tests CREATE TEMP TABLE, INSERT, UPDATE, DELETE end-to-end with row counts
verified via subsequent SELECTs.
"""
from __future__ import annotations
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def _connect(conn_params: ConnParams) -> 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=10.0,
)
def test_create_temp_table(conn_params: ConnParams) -> None:
"""DDL: CREATE TEMP TABLE returns rowcount=0 and no description."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_phase3_a (id INTEGER, name VARCHAR(50))")
assert cur.description is None
assert cur.rowcount == 0
def test_insert_then_select_persists(conn_params: ConnParams) -> None:
"""DML: INSERT actually persists; SELECT returns inserted rows."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_phase3_b (id INTEGER, name VARCHAR(50))")
cur.execute("INSERT INTO t_phase3_b VALUES (1, 'alpha')")
assert cur.rowcount == 1
cur.execute("INSERT INTO t_phase3_b VALUES (2, 'beta')")
assert cur.rowcount == 1
cur.execute("INSERT INTO t_phase3_b VALUES (3, 'gamma')")
assert cur.rowcount == 1
cur.execute("SELECT id, name FROM t_phase3_b ORDER BY id")
rows = cur.fetchall()
assert rows == [(1, "alpha"), (2, "beta"), (3, "gamma")]
def test_update_with_where(conn_params: ConnParams) -> None:
"""UPDATE with WHERE clause changes rowcount-many rows."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_phase3_c (id INTEGER, name VARCHAR(50))")
cur.execute("INSERT INTO t_phase3_c VALUES (1, 'old')")
cur.execute("INSERT INTO t_phase3_c VALUES (2, 'old')")
cur.execute("INSERT INTO t_phase3_c VALUES (3, 'old')")
cur.execute("UPDATE t_phase3_c SET name = 'new' WHERE id = 2")
# rowcount semantics: at least the affected row count
# (Phase 3.x will refine — for now we check the SELECT)
cur.execute("SELECT id, name FROM t_phase3_c ORDER BY id")
rows = cur.fetchall()
assert rows == [(1, "old"), (2, "new"), (3, "old")]
def test_delete_with_where(conn_params: ConnParams) -> None:
"""DELETE with WHERE removes the matched row."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_phase3_d (id INTEGER, name VARCHAR(50))")
cur.execute("INSERT INTO t_phase3_d VALUES (1, 'keep')")
cur.execute("INSERT INTO t_phase3_d VALUES (2, 'delete')")
cur.execute("INSERT INTO t_phase3_d VALUES (3, 'keep')")
cur.execute("DELETE FROM t_phase3_d WHERE id = 2")
cur.execute("SELECT id, name FROM t_phase3_d ORDER BY id")
rows = cur.fetchall()
assert rows == [(1, "keep"), (3, "keep")]
def test_full_dml_cycle_in_one_connection(conn_params: ConnParams) -> None:
"""Mix DDL, multiple DML, and SELECT on one connection — proves session state."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_phase3_e (id INTEGER, val INTEGER)")
for i in range(1, 6):
cur.execute(f"INSERT INTO t_phase3_e VALUES ({i}, {i * 10})")
cur.execute("SELECT val FROM t_phase3_e WHERE id > 2 ORDER BY id")
rows = cur.fetchall()
assert rows == [(30,), (40,), (50,)]
cur.execute("DELETE FROM t_phase3_e WHERE id <= 2")
cur.execute("SELECT id FROM t_phase3_e ORDER BY id")
rows = cur.fetchall()
assert rows == [(3,), (4,), (5,)]
def test_commit_rollback_in_unlogged_db_raises(conn_params: ConnParams) -> None:
"""commit() and rollback() fail with OperationalError in sysmaster (no logging).
Confirms the commit/rollback wire machinery works — it sends the right
PDU and parses the SQ_ERR response. To actually test transactions,
point integration at a logged database (e.g. ``stores_demo``).
"""
with (
_connect(conn_params) as conn,
pytest.raises(informix_db.OperationalError, match="-255"),
):
conn.commit()