diff --git a/src/arcadedb/_native/codec.py b/src/arcadedb/_native/codec.py index c4b9e4a..c42d259 100644 --- a/src/arcadedb/_native/codec.py +++ b/src/arcadedb/_native/codec.py @@ -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 @@ -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) diff --git a/tests/test_native_codec_loads.py b/tests/test_native_codec_loads.py new file mode 100644 index 0000000..3846854 --- /dev/null +++ b/tests/test_native_codec_loads.py @@ -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}