From e86ddf4a87d8f0eea720a890ee5c1e6424dfc331 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Wed, 4 Nov 2026 05:46:36 +0900 Subject: [PATCH 1/2] Test the Python part of the native point operations (lookup_by_key, edge ends, steps) (refs #229) Spies on the codec, the cursor object, the row object and the statement text objects, plus parity checks against the general encoder and the long result path. The spy tests fail on main (the cuts come in the next commit); the parity tests pass on both. Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 2 + tests/test_native_point_op_glue.py | 387 +++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 tests/test_native_point_op_glue.py diff --git a/tests/conftest.py b/tests/conftest.py index 494ffe2e..3ab9d9cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,8 @@ collect_ignore.append("test_native_statement_texts.py") # iter_json_batches reads the cursor one batch at a time (the result set's buffer and its _fill are observed) collect_ignore.append("test_native_streaming_batches.py") + # the Python part of the native point operations (lookup_by_key, edge ends, steps): spies on the codec, the cursor and the row object + collect_ignore.append("test_native_point_op_glue.py") if not os.path.isfile( os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs", "index.md") ): diff --git a/tests/test_native_point_op_glue.py b/tests/test_native_point_op_glue.py new file mode 100644 index 00000000..340fe0e8 --- /dev/null +++ b/tests/test_native_point_op_glue.py @@ -0,0 +1,387 @@ +"""The Python part of the point operations of the native build: ``lookup_by_key``, ``lookup_by_rid``, the end vertices of an edge and the edge +lists of a vertex (conftest.py collects this file on the native backend only). + +The native library does the work of each of these in one crossing; what remained around it in Python was repeated work: a parameter map with a +record id went through the general encoder (``_prep``, a hook closure, an encoder object) for every call, ``lookup_by_key`` built a cursor +object to hand out one row and imported a module and built the same JSON through the general encoder on each call, a step or an edge end built +the same statement text again for every record, and a step made a result object for every row only to wrap it as a record. These tests pin that +each of those is gone (spies on the codec, the cursor, the row object and the text handed to the engine) and that every result, text and error is +the one the long path gives (the oracle is the general encoder, ``to_list()`` and the JSON text of the engine reply). +""" + +import datetime as dt +import decimal +import json + +import numpy as np +import pytest + +from arcadedb._native import codec, extras, lib, records +from arcadedb._native.database import NativeDatabase, NativeResult, NativeResultSet +from arcadedb.exceptions import ArcadeDBError +from arcadedb.graph import Edge, Vertex + + +class _Name(str): + pass + + +NAMED = [ + {"rid": codec.RID("#12:3")}, + {"rid": codec.RID("#1:0"), "n": 5}, + {"a": 1, "b": "text", "c": None, "d": True, "e": 1.5}, + {"id": -(2**70)}, + {"ünï": "café \U0001f600", "q": 'say "hi" \n \u0001'}, + {}, + {1: "int key"}, + {"x": float("nan")}, +] +# every named map the short encoder must leave to the general one +NAMED_GENERAL = [ + {"d": dt.datetime(2026, 11, 4, 12, 30)}, + {"rid": codec.RID("#1:2"), "when": dt.date(2026, 11, 4)}, + {"n": decimal.Decimal("1.5")}, + {"v": np.arange(4, dtype=np.float32)}, + {"l": [1, 2, 3]}, + {"m": {"k": 1}}, + {"s": _Name("subclass")}, + {"i": np.int64(7)}, +] + + +@pytest.fixture +def oracle(monkeypatch): + """encode_params as it was before the short encoder: the general path for every parameter.""" + + def general(args, binary=False): + monkeypatch.setattr(codec, "_fast", False) + try: + return codec.encode_params(args, binary) + finally: + monkeypatch.setattr(codec, "_fast", True) + + return general + + +@pytest.mark.parametrize("binary", [False, True]) +@pytest.mark.parametrize("named", NAMED + NAMED_GENERAL, ids=repr) +def test_a_named_map_encodes_as_the_general_path_does(named, binary, oracle): + for args in ((named,), ([named],)): + assert codec.encode_params(args, binary) == oracle(args, binary) + + +@pytest.mark.parametrize("binary", [False, True]) +@pytest.mark.parametrize("named", NAMED, ids=repr) +def test_a_named_map_of_plain_values_and_rids_uses_no_hook_encoder( + named, binary, monkeypatch +): + def forbidden(*args, **kwargs): + raise AssertionError("the general encoder ran") + + monkeypatch.setattr(codec, "_prep", forbidden) + monkeypatch.setattr(codec, "_dump", forbidden) + text = codec.encode_params((named,), binary) + text = text[0] if binary else text + assert json.loads(text)["named"].keys() == {str(k) for k in named} + + +def test_a_named_map_with_another_value_still_takes_the_general_path(monkeypatch): + seen = [] + real = codec._dump + monkeypatch.setattr(codec, "_dump", lambda *a: seen.append(a[0]) or real(*a)) + codec.encode_params(({"when": dt.datetime(2026, 11, 4)},), True) + codec.encode_params(({"rid": codec.RID("#1:2"), "l": [1]},), True) + assert len(seen) == 2 + + +def test_a_rid_subclass_takes_the_general_path(oracle): + class Sub(codec.RID): + pass + + args = ({"rid": Sub("#3:4")},) + assert codec.encode_params(args, True) == oracle(args, True) + + +LOOKUPS = [ + (["id"], [9]), + (["name"], ["café \U0001f600"]), + (["a", "b"], [1, "x"]), + ([_Name("id")], [9]), + (["id"], [None]), + (["id"], [True]), + (["id"], [1.5]), + (["id"], [dt.date(2026, 11, 4)]), + (["id"], [dt.datetime(2026, 11, 4, 1, 2, 3)]), + (["id"], [decimal.Decimal("2.50")]), + (["id"], [codec.RID("#4:5")]), + (["id"], [np.int64(3)]), + (["id"], [_Name("sub")]), + (["id"], [[1, 2, 3]]), + (["id"], [{1: "x"}]), + (("id",), (7,)), + (["k%d" % i for i in range(12)], list(range(12))), + (["k%d" % i for i in range(12)], [float(i) for i in range(12)]), + (["id"], [b"\x00\x01"]), + ([], []), +] + + +@pytest.mark.parametrize("keys,values", LOOKUPS, ids=repr) +def test_the_lookup_text_is_the_one_the_general_encoder_gives(keys, values): + expected = extras._enc({"keys": [str(k) for k in keys], "values": list(values)}) + assert codec.encode_lookup(keys, values) == expected + + +def _documents(db, n=30): + db.command("sql", "CREATE DOCUMENT TYPE P") + db.command("sql", "CREATE PROPERTY P.id INTEGER") + db.command("sql", "CREATE PROPERTY P.name STRING") + db.command("sql", "CREATE INDEX ON P (id) UNIQUE") + db.command("sql", "CREATE INDEX ON P (name) UNIQUE") + with db.transaction(): + for i in range(n): + db.command("sql", "INSERT INTO P SET id = ?, name = ?", i, f"name {i} é") + + +def _graph(db, count=8): + db.command("sql", "CREATE VERTEX TYPE Person") + db.command("sql", "CREATE PROPERTY Person.id INTEGER") + db.command("sql", "CREATE INDEX ON Person (id) UNIQUE") + db.command("sql", "CREATE EDGE TYPE Knows") + db.command("sql", "CREATE EDGE TYPE Likes") + with db.transaction(): + people = [db.new_vertex("Person").set("id", i).save() for i in range(count)] + for i in range(count - 1): + people[i].new_edge("Knows", people[i + 1], since=2000 + i) + people[i].new_edge("Likes", people[(i + 2) % count]) + return people + + +def test_lookup_by_key_makes_no_cursor_and_no_general_encoding(temp_db, monkeypatch): + db = temp_db + _documents(db) + built = [] + real_init = NativeResultSet.__init__ + monkeypatch.setattr( + NativeResultSet, + "__init__", + lambda self, *a, **k: built.append(1) or real_init(self, *a, **k), + ) + + def forbidden(*args, **kwargs): + raise AssertionError("the general encoder ran") + + monkeypatch.setattr(extras, "_enc", forbidden) + monkeypatch.setattr(codec, "_prep", forbidden) + record = db.lookup_by_key("P", ["id"], [9]) + assert record.get("id") == 9 and record.get("name") == "name 9 é" + assert record.get_type_name() == "P" + assert not built # a reply that holds the whole result needs no cursor object + + +def test_lookup_by_key_results_are_unchanged(temp_db): + db = temp_db + _documents(db) + for key in (0, 13, 29): + record = db.lookup_by_key("P", ["id"], [key]) + assert record.to_dict() == {"id": key, "name": f"name {key} é"} + assert record.get_rid().startswith("#") + assert db.lookup_by_rid(record.get_rid()).to_dict() == record.to_dict() + assert db.lookup_by_key("P", ["name"], ["name 4 é"]).get("id") == 4 + assert db.lookup_by_key("P", ["id"], [999]) is None + assert db.lookup_by_key("P", ("id",), (5,)).get("id") == 5 + with pytest.raises(ArcadeDBError, match="Failed to lookup by key in 'P'"): + db.lookup_by_key("P", ["nosuchproperty"], [1]) + with pytest.raises(ArcadeDBError, match="Failed to lookup by key in 'Nope'"): + db.lookup_by_key("Nope", ["id"], [1]) + + +def test_lookup_by_key_makes_one_crossing(temp_db, monkeypatch): + db = temp_db + _documents(db) + calls = [] + real = lib.call + monkeypatch.setattr( + lib, "call", lambda name, *a: calls.append(name) or real(name, *a) + ) + db.lookup_by_key("P", ["id"], [3]) + db.lookup_by_key("P", ["id"], [-3]) + assert calls == ["arcadedb_n_lookup"] * 2 + + +def test_lookup_by_key_records_hold_their_own_properties(temp_db): + db = temp_db + _documents(db) + first = db.lookup_by_key("P", ["id"], [2]) + second = db.lookup_by_key("P", ["id"], [2]) + assert first._props is not second._props + first.modify().set("name", "changed") + assert second.get("name") == "name 2 é" + changed = first.modify() + changed.set("name", "other") + assert first.get("name") == "name 2 é" + + +def test_lookup_by_key_of_a_vertex_and_a_closed_database(temp_db_path): + import arcadedb + + with arcadedb.create_database(temp_db_path) as db: + people = _graph(db) + vertex = db.lookup_by_key("Person", ["id"], [3]) + assert isinstance(vertex, Vertex) and vertex.get_rid() == people[3].get_rid() + with pytest.raises(ArcadeDBError, match="Database is closed"): + db.lookup_by_key("Person", ["id"], [3]) + with pytest.raises(ArcadeDBError, match="Database is closed"): + vertex.get_out_edges() + + +def test_lookup_by_rid_results_and_errors_are_unchanged(temp_db): + db = temp_db + people = _graph(db) + rid = people[2].get_rid() + for key in (rid, codec.RID(rid)): + record = db.lookup_by_rid(key) + assert isinstance(record, Vertex) and record.get("id") == 2 + edge = people[0].get_out_edges("Knows")[0] + assert isinstance(db.lookup_by_rid(edge.get_rid()), Edge) + with pytest.raises( + ArcadeDBError, match="Failed to lookup RID 'nope': not a record id" + ): + db.lookup_by_rid("nope") + with pytest.raises( + ArcadeDBError + ): # a position the bucket does not hold: the engine's answer, as before + db.lookup_by_rid(rid[: rid.index(":") + 1] + "99999") + + +def test_an_edge_end_and_a_step_send_one_text_object_for_every_record( + temp_db, monkeypatch +): + db = temp_db + people = _graph(db) + seen = [] + original = NativeDatabase._exec + + def recording(self, flags, prefix, language, command, args): + seen.append(command) + return original(self, flags, prefix, language, command, args) + + monkeypatch.setattr(NativeDatabase, "_exec", recording) + edges = [p.get_out_edges("Knows")[0] for p in people[:-1]] + steps = list(seen) + del seen[:] + for edge in edges: + edge.get_out() + edge.get_in() + ends = list(seen) + del seen[:] + for person in people: + person.get_in_edges() + person.get_both_edges("Knows", "Likes") + more = list(seen) + # the same statement is the same object: nothing is built again per call (the engine keys its statement cache by text) + assert len({id(t) for t in steps}) == 1 and len(set(steps)) == 1 + assert ( + len({id(t) for t in ends[0::2]}) == 1 and len({id(t) for t in ends[1::2]}) == 1 + ) + assert ends[0] != ends[1] + assert ( + len({id(t) for t in more[0::2]}) == 1 and len({id(t) for t in more[1::2]}) == 1 + ) + assert steps[0] == "SELECT expand(outE('Knows')) FROM :rid" + assert ends[0] == "SELECT expand(outV()) FROM :rid" + assert ends[1] == "SELECT expand(inV()) FROM :rid" + assert more[0] == "SELECT expand(inE()) FROM :rid" + assert more[1] == "SELECT expand(bothE('Knows', 'Likes')) FROM :rid" + + +def test_a_label_with_a_quote_is_escaped_and_a_bad_label_fails_as_before(temp_db): + db = temp_db + people = _graph(db) + assert ( + records._edges_sql("outE", ("it's",)) + == "SELECT expand(outE('it\\'s')) FROM :rid" + ) + for bad in ([1], [["a"]], [None]): + with pytest.raises(AttributeError, match="replace"): + people[0].get_out_edges(*bad) + + +def test_the_statement_texts_kept_are_bounded(monkeypatch): + monkeypatch.setattr(records, "_EDGES_SQL", {}) + monkeypatch.setattr(records, "_EDGES_SQL_MAX", 3) + texts = [records._edges_sql("outE", (f"L{i}",)) for i in range(10)] + assert len(records._EDGES_SQL) == 3 + assert texts[9] == "SELECT expand(outE('L9')) FROM :rid" + assert records._edges_sql("outE", ("L0",)) is records._edges_sql("outE", ("L0",)) + + +def test_a_step_builds_no_row_objects(temp_db, monkeypatch): + db = temp_db + people = _graph(db) + oracle = [ + (row.get_rid(), row._meta[1], row.to_dict()) + for row in db.query( + "sql", + "SELECT expand(outE()) FROM :rid", + {"rid": codec.RID(people[0].get_rid())}, + ) + ] + assert len(oracle) == 2 + built = [] + real_init = NativeResult.__init__ + monkeypatch.setattr( + NativeResult, + "__init__", + lambda self, *a, **k: built.append(1) or real_init(self, *a, **k), + ) + edges = people[0].get_out_edges() + assert all(isinstance(e, Edge) for e in edges) + assert ( + not built + ) # one record per row, no result object between the row and the record + assert [(e.get_rid(), e.get_type_name(), e.to_dict()) for e in edges] == oracle + assert people[0].get_out_edges("Likes") and people[7].get_out_edges("Knows") == [] + assert not built + + +def test_the_end_vertices_and_steps_are_the_records_the_long_path_gives(temp_db): + db = temp_db + people = _graph(db) + by_rid = {p.get_rid(): p for p in people} + for i, person in enumerate(people[:-1]): + edge = person.get_out_edges("Knows")[0] + assert edge.get("since") == 2000 + i + assert edge.get_out().get_rid() == person.get_rid() + assert edge.get_in().get_rid() == people[i + 1].get_rid() + assert edge.get_in().to_dict() == by_rid[edge.get_in().get_rid()].to_dict() + assert isinstance(edge.get_in(), Vertex) + assert {e.get_rid() for e in edge.get_in().get_in_edges("Knows")} == { + edge.get_rid() + } + both = people[3].get_both_edges() + assert len(both) == 4 + one = people[3].get_out_edges("Knows")[0] + other = people[3].get_out_edges("Knows")[0] + assert one._props is not other._props # each record has its own dict + assert one.modify().set("since", 1).get("since") == 1 and other.get("since") == 2003 + assert people[3].get_out_edges("Knows")[0].get("since") == 2003 + + +def test_a_step_and_an_end_make_one_crossing(temp_db, monkeypatch): + db = temp_db + people = _graph(db) + edge = people[0].get_out_edges("Knows")[0] + calls = [] + real_exec, real_call_bin, real_call = lib.exec_query, lib.call_bin, lib.call + monkeypatch.setattr( + lib, "exec_query", lambda *a: calls.append("exec_query") or real_exec(*a) + ) + monkeypatch.setattr( + lib, "call_bin", lambda n, *a: calls.append(n) or real_call_bin(n, *a) + ) + monkeypatch.setattr(lib, "call", lambda n, *a: calls.append(n) or real_call(n, *a)) + people[0].get_out_edges("Knows") + edge.get_in() + edge.get_out() + assert calls == ["exec_query"] * 3 From 60ac1a858f407c201131218cd9a48b6e3cfa2062 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Wed, 4 Nov 2026 05:46:50 +0900 Subject: [PATCH 2/2] Native point operations: cut the repeated Python work around the one crossing (closes #229) - encode_params: a lone named map of plain values and RIDs is written by the cached encoder (same text; no _prep, hook closure or encoder object). Every edge end and step sends {rid: RID}. - lookup_by_key: JSON by encode_lookup (plain values take the cached encoder, no per-call import of extras), and the first row of a reply that holds the whole result is read without building a NativeResultSet. - Edge.get_in/get_out and the steps build their statement text once (per function, per label list, bounded), a step reads its rows as (props, meta) pairs instead of a NativeResult per row, and a record built from a row nobody else holds takes the property dict as it is. - lookup_by_rid keeps its text (test_native_statement_texts pins it); it only skips the property copy. Results, statement texts and errors are unchanged; the crossings per call are unchanged (spied in the new tests). Co-Authored-By: Claude Opus 5.5 --- src/arcadedb/_native/codec.py | 25 ++++++++++++ src/arcadedb/_native/database.py | 54 +++++++++++++++++++++---- src/arcadedb/_native/records.py | 67 ++++++++++++++++++++++++++------ 3 files changed, 128 insertions(+), 18 deletions(-) diff --git a/src/arcadedb/_native/codec.py b/src/arcadedb/_native/codec.py index eace5938..9f8816b9 100644 --- a/src/arcadedb/_native/codec.py +++ b/src/arcadedb/_native/codec.py @@ -201,6 +201,21 @@ def encode_params(args, binary=False): return (text, None, 0) if binary else text if len(values) == 1 and isinstance(values[0], Mapping): named = values[0] + if _fast: + # every value a plain scalar or an RID (a named point read, the record id of a step or an edge end): no float side buffer + # can arise, so the cached encoder writes the text, with an RID as the tagged object the hook would have returned. The + # text is the same bytes; what is skipped is _prep, the hook closure and the per-call encoder object. + out = {} + for k, v in named.items(): + t = type(v) + if t is RID: + v = {TAG: ["R", v.text]} + elif t not in plain: + break + out[k] = v + else: + text = _dumps({"named": out}) + return (text, None, 0) if binary else text if not all(type(v) in plain for v in named.values()): named = {k: _prep(v, binary) for k, v in named.items()} return _dump({"named": dict(named)}, binary) @@ -209,6 +224,16 @@ def encode_params(args, binary=False): return _dump({"pos": list(values)}, binary) +def encode_lookup(keys, values): + """The JSON text ``arcadedb_n_lookup`` takes, ``{"keys": [..], "values": [..]}`` in the tagged encoding of query parameters + (the text ``json.dumps(_prep(obj), default=_default)`` gives). Plain values are written by the cached encoder: for them + ``_prep`` returns its argument and the hook is never called.""" + obj = {"keys": [str(k) for k in keys], "values": list(values)} + if _fast and set(map(type, obj["values"])) <= _PLAIN: + return _dumps(obj) + return json.dumps(_prep(obj), default=_default) + + def _dump(obj, binary): """JSON text; binary mode returns ``(text, float32 side buffer bytes or None, float count)``.""" if not binary: diff --git a/src/arcadedb/_native/database.py b/src/arcadedb/_native/database.py index 42143386..f30dc526 100644 --- a/src/arcadedb/_native/database.py +++ b/src/arcadedb/_native/database.py @@ -16,7 +16,14 @@ from . import interrupts, lib, needs_jvm from .bulk import insert_rows_columnar from .codec import _prep as _prep_row -from .codec import encode_params, json_native, loads, loads_tagged, row_default +from .codec import ( + encode_lookup, + encode_params, + json_native, + loads, + loads_tagged, + row_default, +) _LAZY_COMMAND_ROWS = os.environ.get("ARCADEDB_NATIVE_LAZY_CMD", "1") != "0" _BINARY_REPLY = os.environ.get("ARCADEDB_NATIVE_BIN_REPLY", "1") != "0" @@ -73,6 +80,29 @@ def _call(prefix: str, name: str, *args) -> dict: _TX_IDLE = '{"ok":true,"active":false,' +def _first_row(database, reply: dict): + """``NativeResultSet(database, reply).first()`` for a reply that holds the whole result: the first row as a ``NativeResult``, with no + cursor object built to hand out one row and be closed. A reply with more rows to fetch, or with a pending read error, takes the cursor. + """ + rows = reply["rows"] + if rows and reply["drained"] and not reply.get("rerr") and not database._closed: + return NativeResult(rows[0], reply["meta"][0], database) + return NativeResultSet(database, reply).first() + + +def _element_of(database, row): + """``row.get_element()`` for a row that only the caller holds: the record takes the row's properties as they are (no copy).""" + global _wrap_record + if row is None: + return None + if _wrap_record is None: + from .records import _wrap as _wrap_record + row._check_open() + if not row._meta or row._meta[2] not in "vde": + return None + return _wrap_record(database, row, True) + + class NativeResult: """One row. Values are already Python types (datetime, Decimal, lists, dicts, RID text).""" @@ -287,6 +317,19 @@ def __next__(self) -> NativeResult: self._finish() raise StopIteration + def _drain_pairs(self) -> list: + """The ``(props, meta)`` of every row, read as :meth:`_drain_dicts` reads them, for a caller that builds its own objects.""" + out: list = [] + if not self._readable(): + return out + while True: + out.extend(self._buf) + self._buf.clear() + if not self._fill(): + break + self._finish() + return out + def _drain_dicts(self) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] if not self._readable(): @@ -799,7 +842,7 @@ def lookup_by_rid(self, rid: str): row = self.query("sql", rid_sql).first() if row is None: raise ArcadeDBError(f"Failed to lookup RID '{rid}': record not found") - return row.get_element() + return _element_of(self, row) def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]): """The first record of ``type_name`` whose ``keys`` properties equal ``values``, wrapped, or None. @@ -809,20 +852,17 @@ def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]): (a scan is ``query()`` with a ``WHERE``). No statement has that rule, hence this op and not a ``SELECT``. """ self._check_not_closed() - from .extras import _enc - reply = loads( lib.call( "arcadedb_n_lookup", self._h, lib.b(type_name), - lib.b(_enc({"keys": [str(k) for k in keys], "values": list(values)})), + lib.b(encode_lookup(keys, values)), ) ) if not reply.get("ok"): raise _error(f"Failed to lookup by key in '{type_name}'", reply) - row = NativeResultSet(self, reply).first() - return row.get_element() if row is not None else None + return _element_of(self, _first_row(self, reply)) def insert_many( self, diff --git a/src/arcadedb/_native/records.py b/src/arcadedb/_native/records.py index 65a442ca..e925303e 100644 --- a/src/arcadedb/_native/records.py +++ b/src/arcadedb/_native/records.py @@ -226,14 +226,24 @@ class _Mixin: _DELETE = "DELETE FROM :rid" def __init__( - self, database, type_name, rid=None, props=None, mutable=False, base=None + self, + database, + type_name, + rid=None, + props=None, + mutable=False, + own=False, + base=None, ): self._java_document = _java_object() self._database = database self._db = database self._type = type_name self._rid = rid or None - self._props: Dict[str, Any] = dict(props or {}) + # own: the caller built props for this record and keeps no other reference (the row of a lookup), so the dict is taken as it is + self._props: Dict[str, Any] = ( + props if own and props is not None else dict(props or {}) + ) self._mutable = mutable or not rid # a copy of the values this record held as read (None: not taken yet). A read does not take it: modify(), the first set() of a saved # record or save() does, from the values then held, which are the read-time values. save() refuses to overwrite a changed record. @@ -431,10 +441,12 @@ def new_edge(self, label: str, target: "Vertex", **kwargs) -> "Edge": def _edges(self, fn: str, labels) -> List["Edge"]: self._check_open() - args = ", ".join("'" + label.replace("'", "\\'") + "'" for label in labels) - edges_sql = f"SELECT expand({fn}({args})) FROM :rid" # nosec B608 - fn is a fixed function name, labels are escaped literals, the record id is a parameter + edges_sql = _edges_sql(fn, labels) rows = self._db.query("sql", edges_sql, {"rid": RID(self._rid)}) - return [_wrap(self._db, row) for row in rows] + database = self._db + return [ + _wrap_meta(database, props, meta) for props, meta in rows._drain_pairs() + ] def get_out_edges(self, *labels: str) -> List["Edge"]: return self._edges("outE", labels) @@ -450,9 +462,8 @@ class Edge(_Mixin, _BaseEdge): def _end(self, fn: str) -> "Vertex": self._check_open() - end_sql = f"SELECT expand({fn}()) FROM :rid" # nosec B608 - fn is a fixed function name, the record id is a parameter - row = self._db.query("sql", end_sql, {"rid": RID(self._rid)}).first() - return _wrap(self._db, row) + row = self._db.query("sql", _END_SQL[fn], {"rid": RID(self._rid)}).first() + return _wrap(self._db, row, True) def get_in(self) -> Vertex: return self._end("inV") @@ -463,14 +474,48 @@ def get_out(self) -> Vertex: _CLASS_OF = {"v": Vertex, "e": Edge} +# The statement of a step or an edge end is built once per function and label list, not once per call: the same text object goes to the +# engine every time (it keys its statement cache by text). The label list is bounded so a program that builds labels cannot grow it. +_END_SQL: Dict[str, str] = { + fn: f"SELECT expand({fn}()) FROM :rid" # nosec B608 - fn is a fixed function name, the record id is a parameter + for fn in ("inV", "outV") +} +_EDGES_SQL: Dict[Any, str] = {} +_EDGES_SQL_MAX = 512 + + +def _edges_sql(fn: str, labels) -> str: + key = (fn, labels) + try: + sql = _EDGES_SQL.get(key) + except TypeError: + sql = None # a label that cannot be hashed: building the text below fails as it always did + if sql is None: + args = ", ".join("'" + label.replace("'", "\\'") + "'" for label in labels) + sql = f"SELECT expand({fn}({args})) FROM :rid" # nosec B608 - fn is a fixed function name, labels are escaped literals, the record id is a parameter + if len(_EDGES_SQL) < _EDGES_SQL_MAX: + _EDGES_SQL[key] = sql + return sql + + +def _wrap(database, row, own=False): + """The wrapper for a result row that is a record (vertex, edge or document); None for a projection. -def _wrap(database, row): - """The wrapper for a result row that is a record (vertex, edge or document); None for a projection.""" + ``own``: nobody else holds the row, so the record takes its property dict as it is instead of a copy. + """ meta = row._meta if not meta: return None rid, type_name, kind = meta - return _CLASS_OF.get(kind, Document)(database, type_name, rid, row._props) + return _CLASS_OF.get(kind, Document)(database, type_name, rid, row._props, own=own) + + +def _wrap_meta(database, props, meta): + """:func:`_wrap` for the ``(props, meta)`` pair of a row the caller drained itself (nothing else holds the dict).""" + if not meta: + return None + rid, type_name, kind = meta + return _CLASS_OF.get(kind, Document)(database, type_name, rid, props, own=True) def _wrap_dict(database, record: dict, kind: str = "d"):