From f31cd8e1cf1f1844ac9f9c849009de00b5a8c276 Mon Sep 17 00:00:00 2001 From: Peter Lemenkov Date: Fri, 3 Jul 2026 23:08:38 +0200 Subject: [PATCH] Oracle 9i (fv2): fix describe desync on NOT-NULL columns, report null_ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-column tail of the fv2 describe (0x62 RPA) is ` null_ok(1B) namelen_bytes(1B) ub4(namelen_chars) DALC(name) 00 00`. `decode_fv2_describe` read the first two bytes as a ub4 name-length, which only works when the leading byte is 0x01. That byte is actually `null_ok` (0x00 NOT NULL, 0x01 nullable): a nullable column sends 0x01, read as a width-1 ub4 whose value coincidentally equals the name length, so the bug stayed hidden. A NOT-NULL column sends 0x00, which decode_ub4 reads as width-0/value-0 (one byte) — the column stream slips, the name garbles (USERNAME -> b'\x08USERNAM'), and a multi-column NOT-NULL fetch then dies with ORA-03115. Every offline fixture was `SELECT AS name FROM dual`; a literal is always nullable, so null_ok was always 0x01 and the slip never triggered. Read null_ok + the 1-byte byte-length explicitly, then the genuine ub4 char-length. null_ok now feeds Cursor.description[6], so 9i column nullability is reported instead of being hardcoded nullable. Fix lives in the shared tns.py decoder, so sync and async both get it. Added an offline regression fixture from a live 9.2.0.4 capture of two identical-length names differing only in NOT NULL vs nullable, and updated PROTOCOL.md §19.1. Verified live on 9.2.0.4 (all_users, all_tables, a NOT-NULL/nullable table) and confirmed the 10g+ DCB path is unaffected. Closes #217 refs #97 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Peter Lemenkov --- docs/PROTOCOL.md | 34 +++++++++++++++++++++++----------- oracle/tns.py | 18 +++++++++++++++--- tests/test_tns_decode.py | 24 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ee0f71c..8b4663e 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1838,7 +1838,7 @@ metadata **inside the `TTI_RPA` (08)** answering the `0x62` call: ``` 08 01 * -column = 00 00 +column = 00 00 ``` The per-column **`OAC-fv2`** is exactly the §5.3 OAC field order **minus the @@ -1852,16 +1852,28 @@ MaxArrLen(ub4) Flags2(ub4) ToId(DALC) Version(ub4) Charset(ub4) FormOfUse(1B) The leading **`DataType` byte is the standard Oracle internal type code** — the same numbering as pyoracle's `TNS_TYPE_*` constants (1=VARCHAR2, 2=NUMBER, 12=DATE, 23=RAW, 96=CHAR, 181=TIMESTAMP) — so the fv2 path **reuses the existing -type→value decoders**, no new numbering. The column name follows as -`ub4 namelen, ub4 namelen, DALC` (byte-length and char-length, then the -DALC-encoded UPPERCASE name), then a two-byte `00 00` inter-column separator. - -> Two RE traps worth recording: a single-character column name (e.g. `N`) -> mis-anchors if you scan for the name by ASCII — parse the OAC deterministically -> instead; and the **last** column's OAC runs straight into the describe trailer, -> so only interior columns segment cleanly by eye. Validated against eight live -> captures (NUMBER/NUMBER(p,s)/VARCHAR2/CHAR/DATE/RAW/FLOAT/TIMESTAMP/NVARCHAR2, -> 1–5 columns, 1–3 rows). +type→value decoders**, no new numbering. The OAC is followed by a **`null_ok` +byte** (`0x00` = NOT NULL, `0x01` = nullable), a **1-byte `namelen_bytes`**, a +**`ub4 namelen_chars`**, then the **DALC-encoded UPPERCASE name** and a two-byte +`00 00` inter-column separator. + +> **Do not read the post-OAC fields as two `ub4` name-lengths.** The first byte +> is `null_ok`, not a length. It only *looks* like a ub4 width byte for nullable +> columns, where `null_ok = 0x01` reads as "width 1" and its value coincidentally +> equals the name length. A **NOT NULL** column sends `null_ok = 0x00`, which a +> ub4 decoder misreads as width-0/value-0 (one byte), slipping the whole column +> stream: the name garbles (`USERNAME` → `\x08USERNAM`) and a multi-column +> NOT-NULL fetch then dies with **ORA-03115**. Read `null_ok` + the 1-byte +> byte-length explicitly, then the genuine `ub4` char-length. `null_ok` feeds +> `Cursor.description[6]`. The earlier "two ub4 name-lengths" model survived only +> because the initial captures were all `SELECT AS name FROM dual` — +> literals are always nullable, so `null_ok` was always `0x01` and the slip never +> triggered. +> +> Two more RE traps: a single-character column name (e.g. `N`) mis-anchors if you +> scan for the name by ASCII — parse the OAC deterministically instead; and the +> **last** column's OAC runs straight into the describe trailer, so only interior +> columns segment cleanly by eye. ### 19.2 fv2 row data diff --git a/oracle/tns.py b/oracle/tns.py index 79705ca..8e703a1 100644 --- a/oracle/tns.py +++ b/oracle/tns.py @@ -2631,14 +2631,26 @@ def _decode_oac_fv2(Rest: bytes) -> tuple[dict, bytes]: def decode_fv2_describe(Data: bytes) -> list[dict]: # Parse the TTI_RPA (0x08) answering the 0x62 describe-columns call into a # list of column dicts (docs/PROTOCOL.md §19.1). Layout: - # 08 01 then per column: ub4(NL) ub4(NL) DALC(name) 00 00 + # 08 01 then per column: + # null_ok(1B) namelen_bytes(1B) ub4(namelen_chars) DALC(name) 00 00 + # + # The first byte after the OAC is null_ok (0x00 = NOT NULL, 0x01 = nullable), + # NOT part of the name length. The historic "two ub4 name-lengths" reading + # only survived because every offline fixture was `SELECT AS name + # FROM dual` — a literal is always nullable, so null_ok=0x01 read as a width-1 + # ub4 whose value happened to equal the name length. A real NOT-NULL column + # sends null_ok=0x00, which decode_ub4 misreads as width-0/value-0 (one byte), + # slipping the whole column stream and garbling the name (b'\x08USERNAM') — and + # a multi-column NOT-NULL select then fails the fetch with ORA-03115. Read + # null_ok + the 1-byte byte-length explicitly, then the genuine ub4 char-length. NumCols = Data[2] Rest = Data[3:] Columns = [] for _ in range(NumCols): (Col, Rest) = _decode_oac_fv2(Rest) - (_NlBytes, Rest) = decode_ub4(Rest) # name length in bytes - (_NlChars, Rest) = decode_ub4(Rest) # name length in chars + Col['null_ok'] = 0 if Rest[0] == 0 else 1 # 0x00 NOT NULL, 0x01 nullable + Rest = Rest[2:] # null_ok(1B) + namelen_bytes(1B) + (_NlChars, Rest) = decode_ub4(Rest) # name length in chars (ub4) (Name, Rest) = decode_dalc(Rest) Col['column_name'] = Name if isinstance(Name, bytes) else b"" Columns.append(Col) diff --git a/tests/test_tns_decode.py b/tests/test_tns_decode.py index 80364ea..11ae2ec 100644 --- a/tests/test_tns_decode.py +++ b/tests/test_tns_decode.py @@ -736,6 +736,19 @@ class TestFv2Describe(unittest.TestCase): "44415445000002000000011600000000000001050105055a51494e5400000400" "0000000101000300000000000000000000000000000101") + # Real describe RPA for `SELECT usernameX, usernameY FROM t9d` where t9d is + # `(usernameX VARCHAR2(8) NOT NULL, usernameY VARCHAR2(8))`. The two columns + # have identical-length names and differ ONLY in nullability, isolating the + # per-column null_ok byte: NOT NULL -> 00, nullable -> 01. This is the case + # every DUAL-alias fixture above misses (a literal is always nullable, so its + # null_ok is 0x01 and the old two-ub4 read slipped by only when it hit 0x00). + # A NOT-NULL column previously garbled the name to b'\x08USERNAM' and desynced + # the stream (a multi-column NOT-NULL fetch then died with ORA-03115). #97. + DESCRIBE_NOTNULL = bytes.fromhex( + "08010201800000010800000000011f010009010909555345524e414d455800" + "0001800000010800000000011f010109010909555345524e414d4559000004" + "00000000010100030000000000000000000000000000010100000000") + def test_decode_columns(self): from oracle.tns import decode_fv2_describe cols = decode_fv2_describe(self.DESCRIBE) @@ -744,6 +757,17 @@ def test_decode_columns(self): self.assertEqual(got, [ (b'ZQNUM', 2), (b'ZQVC', 1), (b'ZQCHR', 96), (b'ZQDATE', 12), (b'ZQINT', 2)]) + # literals over DUAL are always nullable + self.assertEqual([c['null_ok'] for c in cols], [1, 1, 1, 1, 1]) + + def test_decode_notnull_columns(self): + # Regression: a NOT-NULL column (null_ok byte 0x00) must not slip the + # stream, and null_ok must be reported per column. + from oracle.tns import decode_fv2_describe + cols = decode_fv2_describe(self.DESCRIBE_NOTNULL) + self.assertEqual([(c['column_name'], c['data_type'], c['null_ok']) + for c in cols], + [(b'USERNAMEX', 1, 0), (b'USERNAMEY', 1, 1)]) class TestFv2ExecResponse(unittest.TestCase):