1313import datetime
1414import mmap
1515import struct
16+ from collections .abc import Callable
1617from typing import Final , cast
1718
1819from dqlitewire .constants import WORD_SIZE , ValueType
@@ -1004,6 +1005,159 @@ def encode_value(value: WireInput, value_type: ValueType | None = None) -> tuple
10041005 raise EncodeError (f"Unknown value type: { value_type } " )
10051006
10061007
1008+ def _decode_value_integer (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1009+ return decode_int64 (data , label = "INTEGER cell" ), 8
1010+
1011+
1012+ def _decode_value_float (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1013+ return decode_double (data , label = "FLOAT cell" ), 8
1014+
1015+
1016+ def _decode_value_text (data : bytes | memoryview , text_errors : str ) -> tuple [WireValue , int ]:
1017+ return decode_text (data , label = "TEXT" , errors = text_errors )
1018+
1019+
1020+ def _decode_value_iso8601 (data : bytes | memoryview , text_errors : str ) -> tuple [WireValue , int ]:
1021+ # ISO8601 is treated as text at the wire level — the C reference
1022+ # uses text__encode / text__decode for DQLITE_ISO8601 (see dqlite
1023+ # src/tuple.c) and Go returns the raw string from the codec.
1024+ # Parsing to datetime belongs in the driver/DBAPI layer.
1025+ # The ``"ISO8601"`` label is forwarded so truncation / not-null-
1026+ # terminated diagnostics name the actual cell type rather than
1027+ # the generic ``"Text ..."`` default — symmetric with
1028+ # ``encode_value``.
1029+ return decode_text (data , label = "ISO8601" , errors = text_errors )
1030+
1031+
1032+ def _decode_value_blob (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1033+ return decode_blob (data )
1034+
1035+
1036+ def _decode_value_null (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1037+ # Permissive decode matching Go's
1038+ # ``r.message.getUint64()`` discard
1039+ # (``internal/protocol/message.go:539``) and C's
1040+ # ``uint64__decode(... &value->null)`` (``tuple.c:147``) where
1041+ # the value is read into a union member that is never inspected.
1042+ # Delegating to ``decode_uint64`` (instead of rolling an inline
1043+ # length check) gives the same wording discipline as the
1044+ # sibling INTEGER / UNIXTIME / FLOAT / BOOLEAN arms — operators
1045+ # grepping logs for "wire decode short-read" see a uniform
1046+ # ``"Need 8 bytes for <type> cell"`` phrasing across every
1047+ # short-read case. Our encoder still writes 8 zero bytes
1048+ # (``encode_null``), but a peer / future server / mock that
1049+ # emits non-zero NULL bytes is wire-conformant per upstream's
1050+ # tuple_decoder semantics. Strict-rejection provided no
1051+ # defensive value (the field is documented as unused) and was
1052+ # an interop hazard.
1053+ #
1054+ # Round-trip identity caveat: ``encode_value(None)`` writes 8
1055+ # zero bytes regardless of what the decoder consumed, so a
1056+ # mock-server / proxy / fuzzer that captured a non-zero NULL
1057+ # payload cannot recover byte-identity through the public
1058+ # encode path. The public encoder is uniformly zero-emitting;
1059+ # callers needing byte-identity must emit the captured 8 bytes
1060+ # plus the NULL type tag directly. The asymmetry mirrors the
1061+ # sibling permissive-read patterns at
1062+ # :meth:`DbResponse.decode_body` and
1063+ # :meth:`EmptyResponse.decode_body`, which document the same
1064+ # round-trip lossiness for their reserved fields.
1065+ decode_uint64 (data , label = "NULL cell" )
1066+ return None , NULL_CELL_WIDTH
1067+
1068+
1069+ def _decode_value_unixtime (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1070+ # Returns raw int64 (NOT datetime). Contrast with the BOOLEAN
1071+ # arm, which DOES collapse to ``bool`` because ``bool`` is a
1072+ # wire primitive per the module charter. UNIXTIME's primitive is
1073+ # the epoch-seconds int; the ``datetime`` semantic is driver-
1074+ # layer (see ``dqlitedbapi``).
1075+ #
1076+ # Higher-level clients (``dqlitedbapi``) turn this into a
1077+ # UTC-aware ``datetime``; Go's ``Rows.Next()`` does the
1078+ # conversion at the ``database/sql`` driver layer with
1079+ # ``time.Unix(...)``. Bare wire-layer consumers must convert
1080+ # themselves; the wire layer's parity bar is "raw bytes →
1081+ # primitive", not "raw bytes → semantic".
1082+ #
1083+ # NOTE: This arm is intentionally context-blind. UNIXTIME is
1084+ # legitimate in row cells (the C row-encode side at
1085+ # ``dqlite-upstream/src/query.c`` does emit it) but malformed
1086+ # in params (``dqlite-upstream/src/tuple.c::tuple_decoder__next``
1087+ # has no DQLITE_UNIXTIME case in the params switch; the default
1088+ # returns DQLITE_PARSE). The params-vs-row rejection lives at
1089+ # the caller's type-code switch in
1090+ # ``tuples.py::decode_params_tuple``, NOT here — a refactor that
1091+ # moved the rejection into this arm would also break the row
1092+ # decoder, which legitimately needs to accept UNIXTIME.
1093+ return decode_int64 (data , label = "UNIXTIME cell" ), 8
1094+
1095+
1096+ def _decode_value_boolean (data : bytes | memoryview , _text_errors : str ) -> tuple [WireValue , int ]:
1097+ # Returns ``bool`` (NOT raw int). Justified by the module
1098+ # charter at the top of this file, which enumerates ``bool`` as
1099+ # a wire primitive — not by raw preservation. Contrast with the
1100+ # UNIXTIME arm, which DOES preserve the raw int64 because
1101+ # epoch-seconds is the primitive and ``datetime`` is the
1102+ # driver-layer semantic. The principled split: a type is
1103+ # "raw"-preserved at the wire layer iff the wire primitive is
1104+ # itself the canonical representation; ``bool`` is canonical for
1105+ # BOOLEAN, ``int`` is canonical for UNIXTIME, and the datetime
1106+ # conversion is deferred to the driver (``dqlitedbapi``). A
1107+ # peer-emitted BOOLEAN cell carrying a raw uint64 outside
1108+ # ``{0, 1}`` loses the original integer through this collapse —
1109+ # see the encode-side asymmetry note on ``encode_value``'s
1110+ # BOOLEAN arm.
1111+ #
1112+ # Permissive truthiness decode: read the wire cell as an
1113+ # unsigned uint64 (matching C's ``uint64__decode(d->cursor,
1114+ # &value->boolean)`` in ``dqlite-upstream/src/tuple.c``,
1115+ # ``DQLITE_BOOLEAN`` case in ``tuple_decoder__next``) and coerce
1116+ # to ``bool``. Go's reference decoder uses signed
1117+ # ``getInt64() != 0`` instead
1118+ # (``internal/protocol/message.go:566``), which agrees on
1119+ # truthiness for every bit pattern but diverges on the raw int
1120+ # for the upper half of the range (``[2**63, 2**64 - 1]``);
1121+ # Python tracks the C side so a hypothetical future
1122+ # "keep_raw=True" debug knob would yield C-shaped values, not
1123+ # Go-shaped.
1124+ #
1125+ # The dqlite C server emits a ``DQLITE_BOOLEAN`` cell whenever
1126+ # the column's decltype is ``BOOLEAN`` and the value comes from
1127+ # ``sqlite3_column_int64`` (``encodeColumn`` in
1128+ # ``dqlite-upstream/src/query.c``, ``DQLITE_BOOLEAN`` case) —
1129+ # i.e. any int the column actually contains. SQLite typing is
1130+ # dynamic, so a row inserted as
1131+ # ``INSERT INTO t(b BOOLEAN) VALUES (5)`` arrives with raw=5 on
1132+ # the wire. Strict-rejection would deterministically poison the
1133+ # decoder for legitimate cluster data while Go and C clients
1134+ # work fine.
1135+ raw = decode_uint64 (data , label = "BOOLEAN cell" )
1136+ return bool (raw ), 8
1137+
1138+
1139+ # Dispatch table indexed by the integer value-type code. Replaces
1140+ # the prior 6-arm ``if value_type == ValueType.X`` chain (~500 ns
1141+ # per cell on CPython) with one C-level dict lookup plus one
1142+ # function call (~100 ns per cell). Mirrors the precomputed-table
1143+ # precedent at ``tuples.py``'s ``_NIBBLE_TO_VALUETYPE`` — same
1144+ # pattern, one layer up the call stack. For a 100k-row × 16-col
1145+ # fetch (1.6M cells) the saving is several hundred ms of
1146+ # loop-thread CPU.
1147+ _VALUE_TYPE_DECODERS : Final [
1148+ dict [int , Callable [[bytes | memoryview , str ], tuple [WireValue , int ]]]
1149+ ] = {
1150+ int (ValueType .INTEGER ): _decode_value_integer ,
1151+ int (ValueType .FLOAT ): _decode_value_float ,
1152+ int (ValueType .TEXT ): _decode_value_text ,
1153+ int (ValueType .BLOB ): _decode_value_blob ,
1154+ int (ValueType .NULL ): _decode_value_null ,
1155+ int (ValueType .UNIXTIME ): _decode_value_unixtime ,
1156+ int (ValueType .ISO8601 ): _decode_value_iso8601 ,
1157+ int (ValueType .BOOLEAN ): _decode_value_boolean ,
1158+ }
1159+
1160+
10071161def decode_value (
10081162 data : bytes | memoryview ,
10091163 value_type : ValueType ,
@@ -1028,120 +1182,13 @@ def decode_value(
10281182 See :func:`decode_text`'s WARNING about ``"replace"`` /
10291183 ``"surrogateescape"`` bypassing :func:`sanitize_for_log` and
10301184 failing re-encode before adopting a non-strict mode.
1185+
1186+ Per-value-type dispatch goes through ``_VALUE_TYPE_DECODERS``
1187+ (defined above) — see each ``_decode_value_*`` helper for
1188+ arm-specific context (BOOLEAN truthiness rationale, UNIXTIME
1189+ raw-vs-datetime split, NULL permissive-decode parity, etc.).
10311190 """
1032- if value_type == ValueType .BOOLEAN :
1033- # Returns ``bool`` (NOT raw int). Justified by the module
1034- # charter at the top of this file, which enumerates ``bool``
1035- # as a wire primitive — not by raw preservation. Contrast with
1036- # the UNIXTIME arm below, which DOES preserve the raw int64
1037- # because epoch-seconds is the primitive and ``datetime`` is
1038- # the driver-layer semantic. The principled split: a type is
1039- # "raw"-preserved at the wire layer iff the wire primitive is
1040- # itself the canonical representation; ``bool`` is canonical
1041- # for BOOLEAN, ``int`` is canonical for UNIXTIME, and the
1042- # datetime conversion is deferred to the driver
1043- # (``dqlitedbapi``). A peer-emitted BOOLEAN cell carrying a
1044- # raw uint64 outside ``{0, 1}`` loses the original integer
1045- # through this collapse — see the encode-side asymmetry note
1046- # on ``encode_value``'s BOOLEAN arm.
1047- #
1048- # Permissive truthiness decode: read the wire cell as an
1049- # unsigned uint64 (matching C's ``uint64__decode(d->cursor,
1050- # &value->boolean)`` in ``dqlite-upstream/src/tuple.c``,
1051- # ``DQLITE_BOOLEAN`` case in ``tuple_decoder__next``) and
1052- # coerce to ``bool``. Go's reference decoder uses signed
1053- # ``getInt64() != 0`` instead
1054- # (``internal/protocol/message.go:566``), which agrees on
1055- # truthiness for every bit pattern but diverges on the raw
1056- # int for the upper half of the range
1057- # (``[2**63, 2**64 - 1]``); Python tracks the C side so a
1058- # hypothetical future "keep_raw=True" debug knob would yield
1059- # C-shaped values, not Go-shaped.
1060- #
1061- # The dqlite C server emits a ``DQLITE_BOOLEAN`` cell whenever
1062- # the column's decltype is ``BOOLEAN`` and the value comes
1063- # from ``sqlite3_column_int64`` (``encodeColumn`` in
1064- # ``dqlite-upstream/src/query.c``, ``DQLITE_BOOLEAN`` case) —
1065- # i.e. any int the column actually contains. SQLite typing is
1066- # dynamic, so a row inserted as
1067- # ``INSERT INTO t(b BOOLEAN) VALUES (5)`` arrives with raw=5
1068- # on the wire. Strict-rejection would deterministically poison
1069- # the decoder for legitimate cluster data while Go and C
1070- # clients work fine.
1071- raw = decode_uint64 (data , label = "BOOLEAN cell" )
1072- return bool (raw ), 8
1073- elif value_type == ValueType .INTEGER :
1074- return decode_int64 (data , label = "INTEGER cell" ), 8
1075- elif value_type == ValueType .UNIXTIME :
1076- # Returns raw int64 (NOT datetime). Contrast with the BOOLEAN
1077- # arm above, which DOES collapse to ``bool`` because ``bool``
1078- # is a wire primitive per the module charter. UNIXTIME's
1079- # primitive is the epoch-seconds int; the ``datetime``
1080- # semantic is driver-layer (see ``dqlitedbapi``).
1081- #
1082- # Higher-level clients (``dqlitedbapi``) turn this into a
1083- # UTC-aware ``datetime``; Go's ``Rows.Next()`` does the
1084- # conversion at the ``database/sql`` driver layer with
1085- # ``time.Unix(...)``. Bare wire-layer consumers must convert
1086- # themselves; the wire layer's parity bar is "raw bytes →
1087- # primitive", not "raw bytes → semantic".
1088- #
1089- # NOTE: This arm is intentionally context-blind. UNIXTIME is
1090- # legitimate in row cells (the C row-encode side at
1091- # ``dqlite-upstream/src/query.c`` does emit it) but malformed
1092- # in params (``dqlite-upstream/src/tuple.c::tuple_decoder__next``
1093- # has no DQLITE_UNIXTIME case in the params switch; the
1094- # default returns DQLITE_PARSE). The params-vs-row rejection
1095- # lives at the caller's type-code switch in
1096- # ``tuples.py::decode_params_tuple``, NOT here — a refactor
1097- # that moved the rejection into this arm would also break the
1098- # row decoder, which legitimately needs to accept UNIXTIME.
1099- return decode_int64 (data , label = "UNIXTIME cell" ), 8
1100- elif value_type == ValueType .FLOAT :
1101- return decode_double (data , label = "FLOAT cell" ), 8
1102- elif value_type in (ValueType .TEXT , ValueType .ISO8601 ):
1103- # ISO8601 is treated as text at the wire level — the C reference
1104- # uses text__encode / text__decode for DQLITE_ISO8601 (see dqlite
1105- # src/tuple.c) and Go returns the raw string from the codec.
1106- # Parsing to datetime belongs in the driver/DBAPI layer.
1107- # Forward ``value_type.name`` as the label so truncation / not-
1108- # null-terminated diagnostics name the actual cell type
1109- # (``"ISO8601 not null-terminated"``) rather than the generic
1110- # ``"Text ..."`` default — symmetric with ``encode_value``.
1111- return decode_text (data , label = value_type .name , errors = text_errors )
1112- elif value_type == ValueType .BLOB :
1113- return decode_blob (data )
1114- elif value_type == ValueType .NULL :
1115- # Permissive decode matching Go's
1116- # ``r.message.getUint64()`` discard
1117- # (``internal/protocol/message.go:539``) and C's
1118- # ``uint64__decode(... &value->null)`` (``tuple.c:147``) where
1119- # the value is read into a union member that is never
1120- # inspected. Delegating to ``decode_uint64`` (instead of
1121- # rolling an inline length check) gives the same wording
1122- # discipline as the sibling INTEGER / UNIXTIME / FLOAT /
1123- # BOOLEAN arms — operators grepping logs for "wire decode
1124- # short-read" see a uniform ``"Need 8 bytes for <type> cell"``
1125- # phrasing across every short-read case. Our encoder still
1126- # writes 8 zero bytes
1127- # (``encode_null``), but a peer / future server / mock that
1128- # emits non-zero NULL bytes is wire-conformant per upstream's
1129- # tuple_decoder semantics. Strict-rejection provided no
1130- # defensive value (the field is documented as unused) and
1131- # was an interop hazard.
1132- #
1133- # Round-trip identity caveat: ``encode_value(None)`` writes 8
1134- # zero bytes regardless of what the decoder consumed, so a
1135- # mock-server / proxy / fuzzer that captured a non-zero NULL
1136- # payload cannot recover byte-identity through the public
1137- # encode path. The public encoder is uniformly zero-emitting;
1138- # callers needing byte-identity must emit the captured 8 bytes
1139- # plus the NULL type tag directly. The asymmetry mirrors the
1140- # sibling permissive-read patterns at
1141- # :meth:`DbResponse.decode_body` and
1142- # :meth:`EmptyResponse.decode_body`, which document the same
1143- # round-trip lossiness for their reserved fields.
1144- decode_uint64 (data , label = "NULL cell" )
1145- return None , NULL_CELL_WIDTH
1146- else :
1191+ decoder = _VALUE_TYPE_DECODERS .get (int (value_type ))
1192+ if decoder is None :
11471193 raise DecodeError (f"Unknown value type: { value_type } " )
1194+ return decoder (data , text_errors )
0 commit comments