Phase 3: DDL + DML + commit/rollback wire machinery

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
This commit is contained in:
Ryan Malloy 2026-05-04 08:02:48 -06:00
parent 34ad04a872
commit 92c4fdbcbf
6 changed files with 341 additions and 10 deletions

View File

@ -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

View File

@ -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

View File

@ -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)
```

View File

@ -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."""

View File

@ -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:

117
tests/test_dml.py Normal file
View File

@ -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()