Cursor.execute now branches on DESCRIBE response's nfields:
- nfields > 0 → SELECT path (cursor lifecycle: CURNAME+NFETCH+...)
- nfields == 0 → DDL/DML path (just SQ_EXECUTE then SQ_RELEASE)
Examples that work end-to-end against the dev container:
cur.execute('CREATE TEMP TABLE t (id INTEGER, name VARCHAR(50))')
cur.execute("INSERT INTO t VALUES (1, 'hello')") # rowcount=1
cur.execute("UPDATE t SET name = 'new' WHERE id = 1")
cur.execute('DELETE FROM t WHERE id = 1')
Plus full mix: CREATE → 5 INSERTs → SELECT WHERE → DELETE WHERE → SELECT
(see tests/test_dml.py::test_full_dml_cycle_in_one_connection).
Three protocol findings during this push, documented in DECISION_LOG.md:
1. SQ_INSERTDONE (=94) is METADATA, not execution. It arrives in BOTH
the DESCRIBE response (PREPARE phase) AND the EXECUTE response for
literal-value INSERTs. The PREPARE-phase SQ_INSERTDONE carries the
serial values that WILL be assigned IF you execute. The EXECUTE-
phase SQ_INSERTDONE confirms execution. My initial assumption was
"PREPARE-phase INSERTDONE means already-executed" — wrong. Skipping
SQ_EXECUTE made the row not persist (SELECT returned []). Lesson:
optimization-looking responses may not be what they look like —
always verify with a follow-up SELECT.
2. SQ_INSERTDONE wire format: 18 bytes (10 byte longint serial8 + 8
byte bigint bigserial). Per IfxSqli.receiveInsertDone line 2347.
We read-and-discard for now; Phase 5+ surfaces as Cursor.lastrowid.
3. Transactions: commit() and rollback() are 2-byte messages.
SQ_CMMTWORK=19 + SQ_EOT for commit; SQ_RBWORK=20 + SQ_EOT for
rollback. Server responds with SQ_DONE+SQ_EOT in logged databases,
or SQ_ERR sqlcode=-255 ("Not in transaction") in unlogged databases
like sysmaster. Wire machinery is implemented; full transaction
testing needs a logged DB (use ``stores_demo`` from the dev image).
Module changes:
src/informix_db/cursors.py:
- execute() branches on nfields (SELECT path vs DDL/DML path)
- new _execute_dml() does just EXECUTE + RELEASE
- new _build_execute_pdu() emits the 8-byte SQ_ID(EXECUTE)+EOT
- _read_describe_response() and _drain_to_eot() handle SQ_INSERTDONE
src/informix_db/connections.py:
- commit() / rollback() now functional — send the SQ_CMMTWORK /
SQ_RBWORK PDU and drain the response
Tests: 40 unit + 24 integration (6 new DML tests) = 64 total, all
green, ruff clean. New tests cover:
- CREATE TEMP TABLE
- INSERT (rowcount=1, persists, SELECT shows it)
- UPDATE WHERE (specific row changed)
- DELETE WHERE (specific row removed)
- Full mixed cycle (CREATE + 5 INSERTs + SELECT + DELETE + SELECT)
- commit() in unlogged DB raises OperationalError sqlcode=-255
Captured wire artifacts kept for future debugging:
docs/CAPTURES/16-py-insert-literal.socat.log
docs/CAPTURES/17-py-insert-select.socat.log
118 lines
4.5 KiB
Python
118 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:
|
|
with pytest.raises(informix_db.OperationalError, match="-255"):
|
|
conn.commit()
|