Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1838,7 +1838,7 @@ metadata **inside the `TTI_RPA` (08)** answering the `0x62` call:

```
08 01 <numcols:1B> <column>* <trailer>
column = <OAC-fv2> <ub4 namelen> <ub4 namelen> <DALC name> 00 00
column = <OAC-fv2> <null_ok:1B> <namelen_bytes:1B> <ub4 namelen_chars> <DALC name> 00 00
```

The per-column **`OAC-fv2`** is exactly the §5.3 OAC field order **minus the
Expand All @@ -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 <literal> 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

Expand Down
18 changes: 15 additions & 3 deletions oracle/tns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <numcols> then per column: <OAC-fv2> ub4(NL) ub4(NL) DALC(name) 00 00
# 08 01 <numcols> then per column:
# <OAC-fv2> 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 <literal> 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)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_tns_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down