From 92c4fdbcbf0f25d7ad603508cb13cd1cd6d4890b Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 4 May 2026 08:02:48 -0600 Subject: [PATCH] Phase 3: DDL + DML + commit/rollback wire machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CAPTURES/16-py-insert-literal.socat.log | 44 +++++++ docs/CAPTURES/17-py-insert-select.socat.log | 70 +++++++++++ docs/DECISION_LOG.md | 35 ++++++ src/informix_db/connections.py | 10 +- src/informix_db/cursors.py | 75 ++++++++++-- tests/test_dml.py | 117 +++++++++++++++++++ 6 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 docs/CAPTURES/16-py-insert-literal.socat.log create mode 100644 docs/CAPTURES/17-py-insert-select.socat.log create mode 100644 tests/test_dml.py diff --git a/docs/CAPTURES/16-py-insert-literal.socat.log b/docs/CAPTURES/16-py-insert-literal.socat.log new file mode 100644 index 0000000..044793b --- /dev/null +++ b/docs/CAPTURES/16-py-insert-literal.socat.log @@ -0,0 +1,44 @@ +2026/05/04 07:57:28 socat[381123] N listening on AF=2 0.0.0.0:9090 +2026/05/04 07:57:28 socat[381123] N accepting connection from AF=2 127.0.0.1:48632 on AF=2 127.0.0.1:9090 +2026/05/04 07:57:28 socat[381123] N opening connection to 127.0.0.1:9088 +2026/05/04 07:57:28 socat[381123] N opening connection to AF=2 127.0.0.1:9088 +2026/05/04 07:57:28 socat[381123] N successfully connected from local address AF=2 127.0.0.1:42552 +2026/05/04 07:57:28 socat[381123] N successfully connected to 127.0.0.1:9088 +2026/05/04 07:57:28 socat[381123] N starting data transfer loop with FDs [6,6] and [5,5] +> 2026/05/04 07:57:28.587152 length=384 from=0 to=383 + 01 80 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 05 d0 d0 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 20 00 00 00 00 00 00 00 00 00 16 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 38 31 31 33 36 00 00 7f +< 2026/05/04 07:57:28.599101 length=276 from=0 to=275 + 01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f +> 2026/05/04 07:57:28.599363 length=14 from=384 to=397 + 00 7e 00 08 ff fc 7f fc 3c 8c aa 97 00 0c +< 2026/05/04 07:57:28.599425 length=16 from=276 to=291 + 00 7e 00 09 bd be 9f fe 7f b7 ff ef ff 00 00 0c +> 2026/05/04 07:57:28.599458 length=48 from=398 to=445 + 00 51 00 06 00 26 00 0c 00 04 00 06 44 42 54 45 4d 50 00 04 2f 74 6d 70 00 0b 53 55 42 51 43 41 43 48 45 53 5a 00 00 02 31 30 00 00 00 00 00 0c +< 2026/05/04 07:57:28.599533 length=2 from=292 to=293 + 00 0c +> 2026/05/04 07:57:28.599551 length=18 from=446 to=463 + 00 24 00 09 73 79 73 6d 61 73 74 65 72 00 00 00 00 0c +< 2026/05/04 07:57:28.599721 length=28 from=294 to=321 + 00 0f 00 15 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:57:28.599774 length=66 from=464 to=529 + 00 02 00 00 00 00 00 33 43 52 45 41 54 45 20 54 45 4d 50 20 54 41 42 4c 45 20 74 31 20 28 69 64 20 49 4e 54 45 47 45 52 2c 20 6e 61 6d 65 20 56 41 52 43 48 41 52 28 35 30 29 29 00 00 16 00 31 00 0c +< 2026/05/04 07:57:28.599929 length=46 from=322 to=367 + 00 08 00 2d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0f 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:57:28.599996 length=8 from=530 to=537 + 00 04 00 00 00 07 00 0c +< 2026/05/04 07:57:28.605271 length=28 from=368 to=395 + 00 0f 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:57:28.605354 length=8 from=538 to=545 + 00 04 00 00 00 0b 00 0c +< 2026/05/04 07:57:28.605429 length=2 from=396 to=397 + 00 0c +> 2026/05/04 07:57:28.605451 length=48 from=546 to=593 + 00 02 00 00 00 00 00 22 49 4e 53 45 52 54 20 49 4e 54 4f 20 74 31 20 56 41 4c 55 45 53 20 28 31 2c 20 27 68 65 6c 6c 6f 27 29 00 16 00 31 00 0c +< 2026/05/04 07:57:28.605604 length=66 from=398 to=463 + 00 08 00 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 5e 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 02 00 0c +> 2026/05/04 07:57:28.605680 length=2 from=594 to=595 + 00 38 +2026/05/04 07:57:28 socat[381123] N socket 1 (fd 6) is at EOF +2026/05/04 07:57:28 socat[381123] N socket 2 (fd 5) is at EOF +2026/05/04 07:57:28 socat[381123] N exiting with status 0 diff --git a/docs/CAPTURES/17-py-insert-select.socat.log b/docs/CAPTURES/17-py-insert-select.socat.log new file mode 100644 index 0000000..21e0bbf --- /dev/null +++ b/docs/CAPTURES/17-py-insert-select.socat.log @@ -0,0 +1,70 @@ +2026/05/04 07:59:51 socat[386534] N listening on AF=2 0.0.0.0:9090 +2026/05/04 07:59:51 socat[386534] N accepting connection from AF=2 127.0.0.1:43524 on AF=2 127.0.0.1:9090 +2026/05/04 07:59:51 socat[386534] N opening connection to 127.0.0.1:9088 +2026/05/04 07:59:51 socat[386534] N opening connection to AF=2 127.0.0.1:9088 +2026/05/04 07:59:51 socat[386534] N successfully connected from local address AF=2 127.0.0.1:36412 +2026/05/04 07:59:51 socat[386534] N successfully connected to 127.0.0.1:9088 +2026/05/04 07:59:51 socat[386534] N starting data transfer loop with FDs [6,6] and [5,5] +> 2026/05/04 07:59:51.858392 length=384 from=0 to=383 + 01 80 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 05 e5 f2 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 20 00 00 00 00 00 00 00 00 00 16 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 38 36 35 34 36 00 00 7f +< 2026/05/04 07:59:51.870350 length=276 from=0 to=275 + 01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f +> 2026/05/04 07:59:51.870669 length=14 from=384 to=397 + 00 7e 00 08 ff fc 7f fc 3c 8c aa 97 00 0c +< 2026/05/04 07:59:51.870750 length=16 from=276 to=291 + 00 7e 00 09 bd be 9f fe 7f b7 ff ef ff 00 00 0c +> 2026/05/04 07:59:51.870820 length=48 from=398 to=445 + 00 51 00 06 00 26 00 0c 00 04 00 06 44 42 54 45 4d 50 00 04 2f 74 6d 70 00 0b 53 55 42 51 43 41 43 48 45 53 5a 00 00 02 31 30 00 00 00 00 00 0c +< 2026/05/04 07:59:51.870908 length=2 from=292 to=293 + 00 0c +> 2026/05/04 07:59:51.870956 length=18 from=446 to=463 + 00 24 00 09 73 79 73 6d 61 73 74 65 72 00 00 00 00 0c +< 2026/05/04 07:59:51.871136 length=28 from=294 to=321 + 00 0f 00 15 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:59:51.871182 length=66 from=464 to=529 + 00 02 00 00 00 00 00 33 43 52 45 41 54 45 20 54 45 4d 50 20 54 41 42 4c 45 20 74 31 20 28 69 64 20 49 4e 54 45 47 45 52 2c 20 6e 61 6d 65 20 56 41 52 43 48 41 52 28 35 30 29 29 00 00 16 00 31 00 0c +< 2026/05/04 07:59:51.871313 length=46 from=322 to=367 + 00 08 00 2d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0f 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:59:51.871374 length=8 from=530 to=537 + 00 04 00 00 00 07 00 0c +< 2026/05/04 07:59:51.880065 length=28 from=368 to=395 + 00 0f 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c +> 2026/05/04 07:59:51.880164 length=8 from=538 to=545 + 00 04 00 00 00 0b 00 0c +< 2026/05/04 07:59:51.880243 length=2 from=396 to=397 + 00 0c +> 2026/05/04 07:59:51.880282 length=48 from=546 to=593 + 00 02 00 00 00 00 00 22 49 4e 53 45 52 54 20 49 4e 54 4f 20 74 31 20 56 41 4c 55 45 53 20 28 31 2c 20 27 68 65 6c 6c 6f 27 29 00 16 00 31 00 0c +< 2026/05/04 07:59:51.880440 length=66 from=398 to=463 + 00 08 00 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 5e 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 02 00 0c +> 2026/05/04 07:59:51.880669 length=8 from=594 to=601 + 00 04 00 00 00 0b 00 0c +< 2026/05/04 07:59:51.880736 length=2 from=464 to=465 + 00 0c +> 2026/05/04 07:59:51.880755 length=32 from=602 to=633 + 00 02 00 00 00 00 00 11 53 45 4c 45 43 54 20 69 64 20 46 52 4f 4d 20 74 31 00 00 16 00 31 00 0c +< 2026/05/04 07:59:51.880847 length=80 from=466 to=545 + 00 08 00 02 00 00 00 00 00 00 00 04 00 01 00 00 00 03 00 00 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 69 64 00 00 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 02 00 0c +> 2026/05/04 07:59:51.880961 length=42 from=634 to=675 + 00 04 00 00 00 03 00 12 5f 69 66 78 63 30 30 30 30 30 30 30 30 30 30 30 30 31 00 06 00 04 00 00 00 09 00 00 10 00 00 00 00 0c +< 2026/05/04 07:59:51.881050 length=28 from=546 to=573 + 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 02 00 0c +> 2026/05/04 07:59:51.881090 length=14 from=676 to=689 + 00 04 00 00 00 09 00 00 10 00 00 00 00 0c +< 2026/05/04 07:59:51.881130 length=28 from=574 to=601 + 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 02 00 0c +> 2026/05/04 07:59:51.881169 length=8 from=690 to=697 + 00 04 00 00 00 0a 00 0c +< 2026/05/04 07:59:51.881207 length=2 from=602 to=603 + 00 0c +> 2026/05/04 07:59:51.881222 length=8 from=698 to=705 + 00 04 00 00 00 0b 00 0c +< 2026/05/04 07:59:51.881259 length=2 from=604 to=605 + 00 0c +> 2026/05/04 07:59:51.881282 length=2 from=706 to=707 + 00 38 +< 2026/05/04 07:59:51.884736 length=2 from=606 to=607 + 00 38 +2026/05/04 07:59:51 socat[386534] N socket 1 (fd 6) is at EOF +2026/05/04 07:59:51 socat[386534] N socket 2 (fd 5) is at EOF +2026/05/04 07:59:51 socat[386534] N exiting with status 0 diff --git a/docs/DECISION_LOG.md b/docs/DECISION_LOG.md index b7fa69d..71c88bf 100644 --- a/docs/DECISION_LOG.md +++ b/docs/DECISION_LOG.md @@ -184,6 +184,41 @@ DATETIME / INTERVAL / DECIMAL / NUMERIC / MONEY remain in Phase 6+ — their enc --- +## 2026-05-04 — DML / DDL execution path: SQ_PREPARE + SQ_EXECUTE + SQ_RELEASE + +**Status**: active +**Decision**: For statements that don't return rows (CREATE, INSERT, UPDATE, DELETE, DROP), Cursor.execute branches on ``nfields == 0`` in the DESCRIBE response. SELECT path is the cursor lifecycle (CURNAME+NFETCH+...); DDL/DML path is just SQ_EXECUTE then SQ_RELEASE. +**Why**: JDBC uses SQ_PREPARE for everything; for non-SELECT it just doesn't open a cursor. Per IfxSqli.sendExecute (line 1075): non-prepared-statement execute is a bare ``[short SQ_ID=4][int SQ_EXECUTE=7][short SQ_EOT]`` (8 bytes). + +--- + +## 2026-05-04 — SQ_INSERTDONE (=94) is execution metadata, NOT execution + +**Status**: active +**Decision**: SQ_INSERTDONE arrives in BOTH the DESCRIBE response (PREPARE phase) AND the EXECUTE response for literal-value INSERTs. It carries the auto-generated serial values that WILL be / WERE inserted. Don't interpret SQ_INSERTDONE in the DESCRIBE response as "row was inserted" — it's just metadata. Always send SQ_EXECUTE. +**Why this was a debugging trap**: when I first saw SQ_INSERTDONE in the PREPARE response for ``INSERT INTO t1 VALUES (1, 'hello')``, I assumed Informix optimizes literal INSERTs by executing during PREPARE and added a "skip SQ_EXECUTE" branch. Result: SELECT returned 0 rows. The data wasn't actually inserted; the SQ_INSERTDONE in PREPARE was just "here are the serials that WILL be assigned when you execute". After reverting to "always send SQ_EXECUTE", the row persists. Lesson: optimization-looking responses may not be what they look like — always verify with a follow-up SELECT. + +--- + +## 2026-05-04 — SQ_INSERTDONE wire format + +**Status**: active +**Decision**: Per IfxSqli.receiveInsertDone (line 2347), the SQ_INSERTDONE payload is 18 bytes for modern (bigint-supported) servers: +- 10 bytes: serial8 inserted (Informix's variable-numeric LONGINT encoding) +- 8 bytes: bigserial inserted (regular 64-bit long, big-endian) + +For now we read-and-discard. Phase 5+ will surface these as ``Cursor.lastrowid`` / similar. + +--- + +## 2026-05-04 — Transactions: commit/rollback are 2-byte messages + +**Status**: active +**Decision**: ``Connection.commit()`` sends ``[short SQ_CMMTWORK=19][short SQ_EOT=12]`` (4 bytes). ``Connection.rollback()`` sends ``[short SQ_RBWORK=20][short SQ_EOT=12]``. Server responds with SQ_DONE+SQ_EOT (in logged databases) or SQ_ERR sqlcode=-255 ("Not in transaction") in unlogged databases like sysmaster. +**How to apply**: integration tests for transactions need a LOGGED database. The Informix Developer Edition image ships with ``stores_demo`` (logged) — point integration tests at that for commit/rollback verification. + +--- + ## (template — copy below this line for new entries) ``` diff --git a/src/informix_db/connections.py b/src/informix_db/connections.py index ce17537..d16702f 100644 --- a/src/informix_db/connections.py +++ b/src/informix_db/connections.py @@ -148,14 +148,20 @@ class Connection: self._sock.write_all(pdu) def commit(self) -> None: - """No-op in Phase 1 (transactions land in Phase 3).""" + """Commit the current transaction (SQ_CMMTWORK).""" if self._closed: raise InterfaceError("connection is closed") + # PDU: [short SQ_CMMTWORK=19][short SQ_EOT=12] + self._sock.write_all(struct.pack("!hh", MessageType.SQ_CMMTWORK, MessageType.SQ_EOT)) + self._drain_to_eot() def rollback(self) -> None: - """No-op in Phase 1 (transactions land in Phase 3).""" + """Roll back the current transaction (SQ_RBWORK).""" if self._closed: raise InterfaceError("connection is closed") + # PDU: [short SQ_RBWORK=20][short SQ_EOT=12] + self._sock.write_all(struct.pack("!hh", MessageType.SQ_RBWORK, MessageType.SQ_EOT)) + self._drain_to_eot() def close(self) -> None: """Send SQ_EXIT and tear down the socket. Idempotent.""" diff --git a/src/informix_db/cursors.py b/src/informix_db/cursors.py index a445e43..0982ddc 100644 --- a/src/informix_db/cursors.py +++ b/src/informix_db/cursors.py @@ -66,6 +66,10 @@ class Cursor: self._rowcount: int = -1 self._rows: list[tuple] = [] self._row_iter: Iterator[tuple] | None = None + # Set if the DESCRIBE response already includes SQ_INSERTDONE — + # Informix optimizes literal-value INSERTs by executing during + # PREPARE. In that case we skip SQ_EXECUTE and go straight to RELEASE. + self._statement_already_done = False # -- PEP 249 attributes ------------------------------------------------ @@ -101,29 +105,57 @@ class Cursor: self._rowcount = -1 self._rows = [] self._row_iter = None + self._statement_already_done = False # Step 1: PREPARE — send SQL, receive column descriptors. self._conn._send_pdu(self._build_prepare_pdu(operation)) self._read_describe_response() - # Step 2: open a cursor and fetch the first batch. + if self._columns: + # SELECT path: open a cursor and fetch all rows. + self._execute_select() + else: + # DDL/DML path: just SQ_EXECUTE + SQ_RELEASE. + self._execute_dml() + + if self._description is not None: + self._row_iter = iter(self._rows) + + def _execute_select(self) -> None: + """Run the SELECT cursor lifecycle: CURNAME+NFETCH → drain → CLOSE → RELEASE.""" cursor_name = _generate_cursor_name() self._conn._send_pdu(self._build_curname_nfetch_pdu(cursor_name)) self._read_fetch_response() - # Step 3: drain — fetch again to confirm no more rows. + # Drain — fetch again to confirm no more rows. # (JDBC always does this; the second fetch returns DONE only.) self._conn._send_pdu(self._build_nfetch_pdu()) - self._read_fetch_response() # appends rows if any; usually empty + self._read_fetch_response() - # Step 4: close the cursor and release the prepared statement. self._conn._send_pdu(self._build_close_pdu()) self._drain_to_eot() self._conn._send_pdu(self._build_release_pdu()) self._drain_to_eot() - if self._description is not None: - self._row_iter = iter(self._rows) + def _execute_dml(self) -> None: + """Run the DDL/DML path: SQ_EXECUTE → SQ_RELEASE. + + For statements that don't return rows (CREATE, INSERT, UPDATE, + DELETE, DROP), the server's DESCRIBE response has ``nfields=0``. + We don't open a cursor — just execute the prepared statement and + release it. Per JDBC's executeExecute path for non-prepared + statements (line 1075 of IfxSqli.sendExecute). + + Note: when the DESCRIBE response includes SQ_INSERTDONE for a + literal-value INSERT, that's METADATA about the would-be insert + (auto-generated serial values), NOT the actual execution. We + still need SQ_EXECUTE to make the row persist. Lesson: don't + let the optimization-looking response confuse you. + """ + self._conn._send_pdu(self._build_execute_pdu()) + self._drain_to_eot() # reads DONE + COST + EOT, populates rowcount + self._conn._send_pdu(self._build_release_pdu()) + self._drain_to_eot() def executemany(self, operation: str, seq_of_parameters: Any) -> None: raise NotSupportedError("executemany lands in Phase 4 (needs parameter binding)") @@ -245,6 +277,18 @@ class Cursor: writer.write_short(MessageType.SQ_EOT) return buf.getvalue() + def _build_execute_pdu(self) -> bytes: + """SQ_ID(EXECUTE=7) + SQ_EOT — runs the most-recently-prepared statement. + + From JDBC capture msg[21] in 02-dml-cycle.socat.log: 8 bytes, + ``00 04 00 00 00 07 00 0c``. + """ + writer, buf = make_pdu_writer() + writer.write_short(MessageType.SQ_ID) + writer.write_int(MessageType.SQ_EXECUTE) # action = 7 + writer.write_short(MessageType.SQ_EOT) + return buf.getvalue() + def _build_close_pdu(self) -> bytes: """SQ_ID(CLOSE) + SQ_EOT.""" writer, buf = make_pdu_writer() @@ -264,7 +308,7 @@ class Cursor: # -- response readers ------------------------------------------------- def _read_describe_response(self) -> None: - """Read DESCRIBE + DONE + COST + EOT after a PREPARE.""" + """Read DESCRIBE (+ optional SQ_INSERTDONE) + DONE + COST + EOT after PREPARE.""" reader = _SocketReader(self._conn._sock) while True: tag = reader.read_short() @@ -275,6 +319,14 @@ class Cursor: self._description = ( [c.to_description_tuple() for c in self._columns] if self._columns else None ) + elif tag == 94: # SQ_INSERTDONE — Informix optimization: literal + # INSERT executed during PREPARE. Payload is: + # readLongInt (10 bytes) — serial8 inserted + # readLongBigint (8 bytes) — bigserial inserted (modern servers) + # See IfxSqli.receiveInsertDone (line 2347). + reader.read_exact(10 + 8) + self._statement_already_done = True + self._rowcount = 1 # best-effort; literal INSERT = 1 row elif tag == MessageType.SQ_DONE: self._consume_done(reader) elif tag == 55: # SQ_COST @@ -314,9 +366,16 @@ class Cursor: return elif tag == MessageType.SQ_DONE: self._consume_done(reader) - elif tag == 55: + elif tag == 55: # SQ_COST reader.read_int() reader.read_int() + elif tag == 94: # SQ_INSERTDONE + # serial8 (10 bytes) + bigserial (8 bytes) + reader.read_exact(10 + 8) + # If the server sent INSERTDONE, the row was inserted. + # Track best-effort rowcount = 1 for literal-value INSERTs. + if self._rowcount < 0: + self._rowcount = 1 elif tag == MessageType.SQ_ERR: self._raise_sq_err(reader) else: diff --git a/tests/test_dml.py b/tests/test_dml.py new file mode 100644 index 0000000..e5bbc61 --- /dev/null +++ b/tests/test_dml.py @@ -0,0 +1,117 @@ +"""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()