informix-db/tests/test_smoke.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

136 lines
4.3 KiB
Python

"""Phase 1 integration smoke tests — connect, auth-fail, network-fail.
Marked ``integration`` so the default ``pytest`` invocation skips them.
Run with ``pytest -m integration`` after ``docker compose up`` (or with
the container already running).
"""
from __future__ import annotations
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def test_connect_and_close(conn_params: ConnParams) -> None:
"""The happy path: connect with valid creds, then close cleanly."""
conn = 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,
)
assert conn.closed is False
conn.close()
assert conn.closed is True
def test_close_is_idempotent(conn_params: ConnParams) -> None:
"""``close()`` must be safely callable multiple times."""
conn = 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,
)
conn.close()
conn.close() # must not raise
assert conn.closed is True
def test_context_manager(conn_params: ConnParams) -> None:
"""``with`` block closes on exit."""
with 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,
) as conn:
assert conn.closed is False
assert conn.closed is True
def test_bad_password_raises_operational_error(conn_params: ConnParams) -> None:
"""Auth failure must be ``OperationalError``, not a generic socket error."""
with pytest.raises(informix_db.OperationalError):
informix_db.connect(
host=conn_params.host,
port=conn_params.port,
user=conn_params.user,
password="definitely-the-wrong-password",
database=conn_params.database,
server=conn_params.server,
connect_timeout=5.0,
read_timeout=5.0,
)
def test_bad_host_raises_operational_error(conn_params: ConnParams) -> None:
"""Network-level failure must also be ``OperationalError``."""
# Pick an unused port on loopback.
with pytest.raises(informix_db.OperationalError, match="cannot connect"):
informix_db.connect(
host="127.0.0.1",
port=1, # IANA-reserved, nothing listens
user="x",
password="x",
database="x",
server="x",
connect_timeout=2.0,
)
def test_cursor_returns_cursor_object(conn_params: ConnParams) -> None:
"""Phase 2: ``cursor()`` returns a Cursor; SELECT execution is partial work-in-progress."""
with 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,
) as conn:
cur = conn.cursor()
assert cur is not None
assert cur.description is None # nothing executed yet
assert cur.rowcount == -1
assert cur.fetchone() is None
cur.close()
assert cur.closed is True
def test_cursor_with_parameters_for_dml_works(conn_params: ConnParams) -> None:
"""Phase 4: parameter binding works for DML.
Parameterized SELECT lands in Phase 4.x — see ``tests/test_params.py``.
"""
with 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,
) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_smoke_p (id INTEGER)")
cur.execute("INSERT INTO t_smoke_p VALUES (?)", (42,))
assert cur.rowcount == 1
cur.execute("SELECT id FROM t_smoke_p")
assert cur.fetchall() == [(42,)]