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
6 changes: 6 additions & 0 deletions src/arcadedb/_native/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
_np = None

TAG = "\u0001"
_TAG_ESCAPED = "\\u0001" # TAG as it appears in reply text: NativeCodec.quote writes it as a \u escape
_EPOCH_NAIVE = datetime(1970, 1, 1)

# A list of this many Python floats or more may cross in the float32 side buffer (see _exact_float_list). A shorter one is cheaper
Expand Down Expand Up @@ -322,6 +323,11 @@ def _hook(obj):

def loads(text: str, floats=None):
"""Decode a reply, restoring tagged values. ``floats``: the float32 tail of a binary reply (``["B", offset, count]`` values)."""
# A tagged value always puts the escaped tag in the text (NativeCodec.quote escapes every character below 0x20). Without it the reply
# holds none and the per-object hook, a Python call for every decoded object, is skipped (#151). A string or a key that merely
# contains U+0001 takes the hook path as before.
if _TAG_ESCAPED not in text:
return json.loads(text)
if floats is None:
return json.loads(text, object_hook=_hook)

Expand Down
73 changes: 73 additions & 0 deletions tests/test_native_codec_loads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""codec.loads skips the per-object hook when a reply holds no tagged value (#151).

The native library writes every character below 0x20 as a \\u escape (NativeCodec.quote), so a tagged value always puts the escaped tag
in the reply text. These tests state that the fast path gives the same objects as the hook path, that it is taken only for tag-free text,
and that a string or a key merely containing U+0001 still decodes as before.
"""

import json
from datetime import date

import pytest

from arcadedb._native import codec


def _reply(rows):
meta = [[f"#3:{i}", "T", "d"] for i in range(len(rows))]
return json.dumps(
{"ok": True, "rs": 1, "rows": rows, "meta": meta, "drained": True},
separators=(",", ":"),
)


class TestLoadsWithoutTag:
@pytest.mark.parametrize(
"rows",
[
[],
[{"a": 1}],
[
{
"name": "x",
"n": 2,
"f": 0.5,
"flag": True,
"none": None,
"list": [1, 2],
"map": {"k": "v"},
}
],
[{"name": f"n{i}", "n": i} for i in range(1000)],
],
)
def test_a_tag_free_reply_decodes_as_with_the_hook(self, rows):
text = _reply(rows)
assert codec._TAG_ESCAPED not in text
assert codec.loads(text) == json.loads(text, object_hook=codec._hook)

def test_the_hook_is_not_called_for_a_tag_free_reply(self, monkeypatch):
calls = []
monkeypatch.setattr(codec, "_hook", lambda obj: calls.append(obj) or obj)
codec.loads(_reply([{"a": 1}, {"b": 2}]))
assert calls == []

def test_the_float_tail_argument_does_not_change_a_tag_free_reply(self):
text = _reply([{"a": 1.5}])
assert codec.loads(text, floats=[0.25, 0.5]) == json.loads(text)


class TestLoadsWithTag:
def test_a_tagged_value_is_still_restored(self):
text = _reply([{"d": {codec.TAG: ["D", "2026-10-31"]}}])
assert codec._TAG_ESCAPED in text
assert codec.loads(text)["rows"][0]["d"] == date(2026, 10, 31)

def test_a_string_holding_the_control_character_comes_back_unchanged(self):
text = _reply([{"s": "a\u0001b"}])
assert codec._TAG_ESCAPED in text
assert codec.loads(text)["rows"][0]["s"] == "a\u0001b"

def test_a_column_named_like_the_tag_is_kept(self):
text = _reply([{codec.TAG: "plain", "x": 1}])
assert codec.loads(text)["rows"][0] == {codec.TAG: "plain", "x": 1}
Loading