From e9d3b412eae9aba6db0f35c3fc743c6c7f6e8703 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 25 Oct 2026 17:03:14 +0900 Subject: [PATCH 1/3] Native point reads: shorter call path (one-parameter text, flat exec call, first row, record wrap) The untimed decomposition of the native point read (bindings-profile-20261025 and native-point-call-ab-laptop-20261025/decomp.out) puts about 15 of the 34 us of db.query(...).first() and 21 of the 53 us of lookup_by_key in Python outside the engine. The ctypes crossing itself is not the cost (the call minus the image's own timers is 0.6 us), so this change leaves the signatures alone and takes the measurable pieces of the Python side: encode_params writes a lone int or str parameter directly (the text json.dumps gives), lib.exec_query is one function for arcadedb_n_exec (no getattr by name, no argument tuple, cached bytes for the language and the statement), NativeResultSet.first() takes a buffered first row without the iterator protocol, lookup_by_key remembers its statement text per (type, keys), and the record wrapper no longer imports a module or makes an object per row. New tests first: 6 of them fail on native-preview and all pass here. Co-Authored-By: Claude Sonnet 5.5 --- .../src/arcadedb_embedded/_native/codec.py | 12 + .../src/arcadedb_embedded/_native/database.py | 33 +- .../src/arcadedb_embedded/_native/lib.py | 26 ++ .../src/arcadedb_embedded/_native/records.py | 16 +- .../python/tests/test_native_point_call.py | 298 ++++++++++++++++++ 5 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 bindings/python/tests/test_native_point_call.py diff --git a/bindings/python/src/arcadedb_embedded/_native/codec.py b/bindings/python/src/arcadedb_embedded/_native/codec.py index c57504fad6f..eae80fb3232 100644 --- a/bindings/python/src/arcadedb_embedded/_native/codec.py +++ b/bindings/python/src/arcadedb_embedded/_native/codec.py @@ -66,6 +66,8 @@ def _default(value, _bin=None): _PLAIN = frozenset((str, int, float, bool, type(None))) _dumps = json.dumps +# the C function json.dumps writes a str with +_enc_str = json.encoder.encode_basestring_ascii _fast = os.environ.get("ARCADEDB_NATIVE_FASTENC", "1") != "0" @@ -95,6 +97,16 @@ def encode_params(args, binary=False): return ("", None, 0) if binary else "" values = args[0] if len(args) == 1 and isinstance(args[0], (list, tuple)) else args plain = _PLAIN + if _fast and len(values) == 1: + # one int or str parameter (the point read, WHERE id = ?): the text is written directly, the bytes json.dumps({"pos": [v]}) gives, without the set of types and the encoder call + v = values[0] + t = type(v) + if t is int: + text = '{"pos": [%d]}' % v + return (text, None, 0) if binary else text + if t is str: + text = '{"pos": [%s]}' % _enc_str(v) + return (text, None, 0) if binary else text if _fast and set(map(type, values)) <= plain: # all plain scalars (the per-row INSERT of a load): the cached default encoder, no default hook, no per-call encoder object text = _dumps({"pos": values if type(values) is list else list(values)}) diff --git a/bindings/python/src/arcadedb_embedded/_native/database.py b/bindings/python/src/arcadedb_embedded/_native/database.py index 68fe220b894..1f32b4be236 100644 --- a/bindings/python/src/arcadedb_embedded/_native/database.py +++ b/bindings/python/src/arcadedb_embedded/_native/database.py @@ -25,6 +25,8 @@ _EPOCH_DAY_ORDINAL = date(1970, 1, 1).toordinal() _NEXT_BATCH = 2048 _KIND = {"v": "v", "e": "e", "d": "d"} +_wrap_record = None +_LOOKUP_SQL = {} # (type name, key names) -> the statement text of lookup_by_key def _error(prefix: str, reply: dict) -> ArcadeDBError: @@ -86,12 +88,14 @@ def get_rid(self) -> Optional[str]: return self._meta[0] if self._meta else None def _record(self, kinds): - from .records import _wrap - + global _wrap_record + # records.py imports this module: looked up on first use, then kept (an import statement per call costs about 0.4 us) + if _wrap_record is None: + from .records import _wrap as _wrap_record self._check_open() if not self._meta or self._meta[2] not in kinds: return None - return _wrap(self._database, self) + return _wrap_record(self._database, self) def get_vertex(self): return self._record("v") @@ -340,6 +344,14 @@ def count(self) -> int: return n def first(self) -> Optional[NativeResult]: + buf = self._buf + database = self._database + if buf and not self._closed and (database is None or not database._closed): + # the usual point read: the first row is already here and the result is readable (what __next__ checks): take it and close + props, meta = buf.popleft() + row = NativeResult(props, meta, database) + self.close() + return row try: return next(iter(self)) except StopIteration: @@ -504,12 +516,11 @@ def _exec( count, ) else: - raw, floats = lib.call_bin( - "arcadedb_n_exec", + raw, floats = lib.exec_query( self._h, flags, - lib.b(language), - lib.b(command), + lib.bc(language), + lib.bc(command), lib.b(text), _FIRST_BATCH, ) @@ -649,9 +660,13 @@ def lookup_by_rid(self, rid: str): def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]): self._check_not_closed() - where = " AND ".join(f"`{k}` = ?" for k in keys) try: - lookup_sql = f"SELECT FROM `{type_name}` WHERE {where} LIMIT 1" # nosec B608 - backtick-quoted identifiers; values go through ? parameters + lookup_sql = _LOOKUP_SQL.get((type_name, tuple(keys))) + if lookup_sql is None: + where = " AND ".join(f"`{k}` = ?" for k in keys) + lookup_sql = f"SELECT FROM `{type_name}` WHERE {where} LIMIT 1" # nosec B608 - backtick-quoted identifiers; values go through ? parameters + if len(_LOOKUP_SQL) < 1024: + _LOOKUP_SQL[(type_name, tuple(keys))] = lookup_sql row = self.query("sql", lookup_sql, list(values)).first() return row.get_element() if row is not None else None except ArcadeDBError as e: diff --git a/bindings/python/src/arcadedb_embedded/_native/lib.py b/bindings/python/src/arcadedb_embedded/_native/lib.py index 8ac33145143..ff94fc29992 100644 --- a/bindings/python/src/arcadedb_embedded/_native/lib.py +++ b/bindings/python/src/arcadedb_embedded/_native/lib.py @@ -268,6 +268,32 @@ def call_bin(name: str, *args): return raw.decode("utf-8"), floats +def exec_query(handle, flags, language, command, params, count): + """``arcadedb_n_exec`` for a statement whose parameters need no float side buffer: the same ``(text, floats)`` as + :func:`call_bin` returns for it. The entry point is looked up once per library, not by name per call, the isolate thread is read + without a function call and no argument tuple is built; the reply is read as :func:`call_bin` reads it. + """ + lib = _lib or _load() + t = _local.__dict__.get("thread") + th = t.handle if t is not None else _thread_handle() + ptr = lib.arcadedb_n_exec(th, handle, flags, language, command, params, count) + try: + raw = ctypes.string_at(ptr) + floats = None + # a negative start counts from the end and stops at 0 for a short reply + i = raw.rfind(b',"bin":', -40) + if i >= 0 and raw.endswith(b"}"): + n = int(raw[i + 7 : -1]) + import numpy as np + + off = (len(raw) + 1 + 3) & ~3 + floats = np.frombuffer(ctypes.string_at(ptr + off, 4 * n), dtype=np.float32) + raw = raw[:i] + b"}" + finally: + lib.arcadedb_free(th, ptr) + return raw.decode("utf-8"), floats + + def call_void(name: str, *args) -> None: """Run an entry point that returns nothing (``arcadedb_n_set_upcall``).""" lib = _lib or _load() diff --git a/bindings/python/src/arcadedb_embedded/_native/records.py b/bindings/python/src/arcadedb_embedded/_native/records.py index 7f03d2f2307..701c9e91d21 100644 --- a/bindings/python/src/arcadedb_embedded/_native/records.py +++ b/bindings/python/src/arcadedb_embedded/_native/records.py @@ -14,11 +14,17 @@ from . import not_yet from .codec import RID +_JAVA_OBJECT = None + def _java_object(): - from .database import _JavaObject + """The one stand-in for the Java object of a record (it has no state: any use of it says so); made on first use, as database.py imports this module.""" + global _JAVA_OBJECT + if _JAVA_OBJECT is None: + from .database import _JavaObject - return _JavaObject() + _JAVA_OBJECT = _JavaObject() + return _JAVA_OBJECT _UNSAVED = "#-1:-1" @@ -186,14 +192,16 @@ def get_out(self) -> Vertex: return self._end("outV") +_CLASS_OF = {"v": Vertex, "e": Edge} + + def _wrap(database, row): """The wrapper for a result row that is a record (vertex, edge or document); None for a projection.""" meta = row._meta if not meta: return None rid, type_name, kind = meta - cls = {"v": Vertex, "e": Edge}.get(kind, Document) - return cls(database, type_name, rid, row._props) + return _CLASS_OF.get(kind, Document)(database, type_name, rid, row._props) def _wrap_dict(database, record: dict, kind: str = "d"): diff --git a/bindings/python/tests/test_native_point_call.py b/bindings/python/tests/test_native_point_call.py new file mode 100644 index 00000000000..40d6bc5c469 --- /dev/null +++ b/bindings/python/tests/test_native_point_call.py @@ -0,0 +1,298 @@ +"""The call path of a point read on the native backend (``db.query(sql, "... WHERE id = ?", i).first()`` and ``db.lookup_by_key``): the +parameter text, the ctypes call, the first row and the record wrapper. Results have to be the same as before the path was shortened, +for every kind of parameter and key; the first group runs on both backends, the second looks at the helpers of the native one. +""" + +import json +import os + +import pytest + +NATIVE = os.environ.get("ARCADEDB_BACKEND", "jpype").lower() == "native" +native_only = pytest.mark.skipif(not NATIVE, reason="the native call path") + +STRINGS = [ + "", + "plain", + "café 日本語 \U0001f600 end", # 2-, 3- and 4-byte UTF-8 sequences + 'quote " backslash \\ slash / newline \n tab \t control \u0001', + "`backtick` 'single' ?", + "x" * 300, +] +INTS = [0, 1, -1, 7, 10**9, -(10**9), 2**31, 2**53 + 1, 2**63 - 1, -(2**63)] + + +def _docs(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE P").close() + temp_db.command("sql", "CREATE PROPERTY P.id LONG").close() + temp_db.command("sql", "CREATE PROPERTY P.name STRING").close() + temp_db.command("sql", "CREATE INDEX ON P (id) UNIQUE").close() + temp_db.command("sql", "CREATE INDEX ON P (name) UNIQUE").close() + rows = [{"id": i, "name": f"n{i}"} for i in INTS] + rows += [{"id": 100 + k, "name": s or "empty"} for k, s in enumerate(STRINGS)] + temp_db.insert_many("P", rows) + return rows + + +def test_point_read_by_an_int_parameter_finds_every_row(temp_db): + rows = _docs(temp_db) + for want in rows: + got = temp_db.query("sql", "SELECT FROM P WHERE id = ?", want["id"]).first() + assert got is not None and got.get("id") == want["id"] + assert got.get("name") == want["name"] + assert temp_db.query("sql", "SELECT FROM P WHERE id = ?", 424242).first() is None + + +def test_point_read_by_a_str_parameter_finds_every_row(temp_db): + rows = _docs(temp_db) + for want in rows: + got = temp_db.query("sql", "SELECT FROM P WHERE name = ?", want["name"]).first() + assert got is not None and got.get("id") == want["id"] + assert ( + temp_db.query("sql", "SELECT FROM P WHERE name = ?", "no such").first() is None + ) + + +def test_lookup_by_key_returns_the_record_for_int_str_and_composite_keys(temp_db): + rows = _docs(temp_db) + for want in rows: + by_id = temp_db.lookup_by_key("P", ["id"], [want["id"]]) + by_name = temp_db.lookup_by_key("P", ["name"], [want["name"]]) + assert by_id is not None and by_name is not None + assert by_id.get("name") == want["name"] and by_name.get("id") == want["id"] + assert by_id.get_rid() == by_name.get_rid() + both = temp_db.lookup_by_key("P", ["id", "name"], [want["id"], want["name"]]) + assert both is not None and both.get_rid() == by_id.get_rid() + assert temp_db.lookup_by_key("P", ["id"], [424242]) is None + assert temp_db.lookup_by_key("P", ["id", "name"], [INTS[0], "n1"]) is None + + +def test_lookup_by_key_keeps_the_types_and_keys_of_each_call_apart(temp_db): + _docs(temp_db) + temp_db.command("sql", "CREATE VERTEX TYPE Q").close() + temp_db.command("sql", "CREATE PROPERTY Q.id LONG").close() + temp_db.command("sql", "CREATE PROPERTY Q.name STRING").close() + temp_db.command("sql", "CREATE INDEX ON Q (id) UNIQUE").close() + temp_db.command("sql", "CREATE INDEX ON Q (name) UNIQUE").close() + with temp_db.transaction(): + temp_db.command( + "sql", "CREATE VERTEX Q SET id = 1, name = 'vertex one'" + ).close() + for _ in range( + 3 + ): # the second and third round reads the statement texts remembered by the first + p, q = temp_db.lookup_by_key("P", ["id"], [1]), temp_db.lookup_by_key( + "Q", ["id"], [1] + ) + assert p.get("name") == "n1" and q.get("name") == "vertex one" + assert type(p).__name__ == "Document" and type(q).__name__ == "Vertex" + assert temp_db.lookup_by_key("P", ["name"], ["n1"]).get("id") == 1 + assert ( + temp_db.lookup_by_key("Q", ["name"], ["n1"]) is None + ) # Q has no such row; the statement is not P's + + +def test_first_returns_the_first_row_and_closes_the_result(temp_db): + _docs(temp_db) + rs = temp_db.query("sql", "SELECT FROM P ORDER BY id") + assert rs.first().get("id") == min(INTS) + assert temp_db.query("sql", "SELECT FROM P WHERE id = -999").first() is None + + +@native_only +def test_a_second_first_on_the_same_result_raises_as_before(temp_db): + from arcadedb_embedded.exceptions import ArcadeDBError + + _docs(temp_db) + rs = temp_db.query("sql", "SELECT FROM P ORDER BY id") + rs.first() + with pytest.raises( + ArcadeDBError + ): # first() closed it: the rows it did not return are gone + rs.first() + + +def test_first_of_a_big_result_gives_the_first_row_and_leaves_no_cursor(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE R").close() + temp_db.insert_many( + "R", [{"i": i} for i in range(3000)] + ) # more than the first reply batch + for _ in range(50): + assert temp_db.query("sql", "SELECT FROM R ORDER BY i").first().get("i") == 0 + assert temp_db.query("sql", "SELECT count(*) AS c FROM R").first().get("c") == 3000 + + +@native_only +def test_a_row_read_after_the_database_closed_still_raises(temp_db): + from arcadedb_embedded.exceptions import ArcadeDBError + + _docs(temp_db) + row = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first() + rs = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 7) + temp_db.close() + with pytest.raises(ArcadeDBError): + row.get("name") + with pytest.raises(ArcadeDBError): + rs.first() + + +# ---------------------------------------------------------------- the helpers of the native backend + + +def _native(): + from arcadedb_embedded._native import codec, database, lib, records + + return codec, database, lib, records + + +@native_only +@pytest.mark.parametrize( + "value", INTS + STRINGS + [True, False, None, 1.5, -0.0, 10**30] +) +def test_encode_params_writes_one_parameter_as_json_dumps_does(value): + codec = _native()[0] + want = json.dumps({"pos": [value]}) + for args in ((value,), ([value],), ((value,),)): + assert codec.encode_params(args) == want + assert codec.encode_params(args, binary=True) == (want, None, 0) + + +@native_only +def test_encode_params_leaves_subclasses_and_numpy_scalars_to_the_general_path(): + np = pytest.importorskip("numpy") + codec = _native()[0] + + class MyInt(int): + pass + + class MyStr(str): + pass + + for value in (MyInt(5), MyStr("x"), np.int64(5), np.float32(1.5), np.bool_(True)): + assert codec.encode_params((value,)) == codec.encode_params([value]) + assert codec.encode_params((MyInt(5),)) == '{"pos": [5]}' + assert codec.encode_params((np.int64(5),)) == '{"pos": [5]}' + assert ( + codec.encode_params((True,)) == '{"pos": [true]}' + ) # a bool is not sent as the int 1 + + +@native_only +def test_encode_params_one_int_or_str_does_not_go_through_the_json_encoder(monkeypatch): + codec = _native()[0] + + def boom(*a, **k): + raise AssertionError("json encoder used for a lone int or str parameter") + + monkeypatch.setattr(codec, "_dumps", boom) + assert codec.encode_params((12,)) == '{"pos": [12]}' + assert codec.encode_params((["a\nb"],)) == '{"pos": ["a\\nb"]}' + assert codec.encode_params(("é",)) == '{"pos": ["\\u00e9"]}' + + +@native_only +def test_encode_params_other_shapes_keep_their_text(): + codec = _native()[0] + assert codec.encode_params(()) == "" + assert codec.encode_params((), binary=True) == ("", None, 0) + assert codec.encode_params((1, "a", None)) == '{"pos": [1, "a", null]}' + assert codec.encode_params(([],)) == '{"pos": []}' + assert codec.encode_params(({"a": 1},)) == '{"named": {"a": 1}}' + + +@native_only +def test_exec_query_returns_what_call_bin_returns_for_the_same_statement(temp_db): + codec, database, lib, records = _native() + _docs(temp_db) + text, _, count = codec.encode_params((7,), binary=True) + args = ( + temp_db._h, + 4, + lib.bc("sql"), + lib.bc("SELECT FROM P WHERE id = ?"), + lib.b(text), + 256, + ) + assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) + assert lib.exec_query(*args)[0].startswith("{") + # a reply with a float tail: the vectors of the later rows come back as numbers in the text, the first batch as a tail + temp_db.command("sql", "CREATE DOCUMENT TYPE V").close() + temp_db.command("sql", "CREATE PROPERTY V.v ARRAY_OF_FLOATS").close() + temp_db.insert_many( + "V", [{"v": [float(k + j) / 4 for j in range(16)]} for k in range(4)] + ) + vargs = (temp_db._h, 4, lib.bc("sql"), lib.bc("SELECT v FROM V"), b"", 256) + got, want = lib.exec_query(*vargs), lib.call_bin("arcadedb_n_exec", *vargs) + assert got[0] == want[0] and got[1] is not None and list(got[1]) == list(want[1]) + + +@native_only +def test_exec_query_with_a_param_text_of_several_kinds(temp_db): + _, _, lib, _ = _native() + _docs(temp_db) + for param in ('{"pos": [7]}', '{"pos": ["n7"]}', '{"pos": [null]}'): + args = ( + temp_db._h, + 4, + lib.bc("sql"), + lib.bc("SELECT FROM P WHERE id = ? OR name = ?"), + lib.b(param), + 256, + ) + # the statement has two placeholders: a one-element list is an error reply, the same one from both entry points + assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) + + +@native_only +def test_lookup_by_key_builds_each_statement_text_once(temp_db): + _, database, _, _ = _native() + _docs(temp_db) + database._LOOKUP_SQL.clear() + temp_db.lookup_by_key("P", ["id"], [1]) + temp_db.lookup_by_key("P", ["id", "name"], [1, "n1"]) + assert sorted(database._LOOKUP_SQL.values()) == [ + "SELECT FROM `P` WHERE `id` = ? AND `name` = ? LIMIT 1", + "SELECT FROM `P` WHERE `id` = ? LIMIT 1", + ] + one = database._LOOKUP_SQL[("P", ("id",))] + temp_db.lookup_by_key( + "P", ("id",), (2,) + ) # a tuple of keys and values is the same lookup + assert database._LOOKUP_SQL[("P", ("id",))] is one + + +@native_only +def test_records_share_one_java_stand_in_and_an_import_free_wrap(temp_db): + _, database, _, records = _native() + _docs(temp_db) + assert records._java_object() is records._java_object() + a, b = temp_db.lookup_by_key("P", ["id"], [1]), temp_db.lookup_by_key( + "P", ["id"], [7] + ) + assert a._java_document is b._java_document + with pytest.raises(NotImplementedError): + a._java_document.anything + assert a.get_rid() != b.get_rid() and a.to_dict() == {"id": 1, "name": "n1"} + a2 = a.modify() # a mutable copy keeps working on its own properties + a2.set("name", "changed") + assert a.get("name") == "n1" and a2.get("name") == "changed" + + +@native_only +def test_first_takes_the_buffered_row_without_the_iterator_protocol( + temp_db, monkeypatch +): + _, database, _, _ = _native() + _docs(temp_db) + + def boom(self): + raise AssertionError( + "first() went through __next__ for a row that was already here" + ) + + monkeypatch.setattr(database.NativeResultSet, "__next__", boom) + assert ( + temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first().get("name") + == "n1" + ) + assert temp_db.lookup_by_key("P", ["id"], [1]).get("name") == "n1" From b2d64810f4a6dc9f64969893ff5d1d850fd45ebc Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 25 Oct 2026 17:59:17 +0900 Subject: [PATCH 2/3] Native lookup_by_key refuses keys no index covers, as the JVM wheel does The native lookup_by_key ran SELECT ... WHERE k = ? LIMIT 1, which works without an index and scans the whole type. The JVM engine refuses (IllegalArgumentException: No index has been created on type ... properties [...]), and the docs call the method an index lookup. Native now reads schema:indexes once per (type, keys) pair and database handle and raises ArcadeDBError with the engine's wording unless an LSM_TREE or HASH index covers exactly those properties in that order, on the type or a supertype (a FULL_TEXT index gets the engine's "No key index" text). The result is remembered per handle next to the statement text, so a repeated lookup is still one crossing; DROP/ALTER (and any sqlscript) through command() clears it. A missing type keeps the schema error it had. Tests: the point-call test creates the composite index its composite lookup needs; new parity tests (both wheels) cover keys no index covers, order and prefix of a composite, a missing type, another database with the same type name, DROP INDEX, a supertype index and a FULL_TEXT index; a native-only test pins the single schema read per pair. Co-Authored-By: Claude Sonnet 5.5 --- bindings/python/docs/api/database.md | 7 + .../src/arcadedb_embedded/_native/database.py | 79 ++++++++++- .../python/tests/test_native_point_call.py | 126 ++++++++++++++++++ 3 files changed, 206 insertions(+), 6 deletions(-) diff --git a/bindings/python/docs/api/database.md b/bindings/python/docs/api/database.md index 683a0036b94..90c854ee4ca 100644 --- a/bindings/python/docs/api/database.md +++ b/bindings/python/docs/api/database.md @@ -863,6 +863,13 @@ Lookup a record by an indexed key (index-based: O(1) for a hash index, O(log n) - Python record wrapper (`Document`, `Vertex`, or `Edge`, matching the underlying record type) or `None` if not found +**Raises:** `ArcadeDBError` when no `LSM_TREE` or `HASH` index covers exactly `keys`, in +this order, on the type or a supertype (`No index has been created on type 'User' +properties [email]`). The JVM wheel and the native wheel both refuse it: the call never +scans the type. A `FULL_TEXT` index on the keys is refused with its own text (`No key +index has been created ...`). An index dropped with `DROP INDEX` stops serving the call +on the next call. + **Example:** ```python diff --git a/bindings/python/src/arcadedb_embedded/_native/database.py b/bindings/python/src/arcadedb_embedded/_native/database.py index 1f32b4be236..3cb3641bd30 100644 --- a/bindings/python/src/arcadedb_embedded/_native/database.py +++ b/bindings/python/src/arcadedb_embedded/_native/database.py @@ -26,7 +26,21 @@ _NEXT_BATCH = 2048 _KIND = {"v": "v", "e": "e", "d": "d"} _wrap_record = None -_LOOKUP_SQL = {} # (type name, key names) -> the statement text of lookup_by_key +_LOOKUP_SQL = ( + {} +) # (type name, key names) -> the statement text of lookup_by_key (the text only: it holds for every database) +_EXACT_KEY_INDEX_TYPES = ( + "LSM_TREE", + "HASH", +) # Schema.INDEX_TYPE.isExactKeyLookup(): the kinds that answer "value equals key" + + +def _may_drop_index(language: str, command: str) -> bool: + """Whether a command can take an index away (DROP or ALTER in SQL, anything in a script), so lookup_by_key has to look again.""" + lang = language.lower() + return lang == "sqlscript" or ( + lang == "sql" and command.lstrip()[:5].upper() in ("DROP ", "ALTER") + ) def _error(prefix: str, reply: dict) -> ArcadeDBError: @@ -398,6 +412,9 @@ def __init__(self, handle: int, owned_by_server: bool = False): self._vector_meta: dict = {} self._vector_dimensions: dict = {} self._vector_id_properties: dict = {} + # (type name, key names) -> the statement of lookup_by_key, for the pairs this database has an index for. Per handle, not + # module-wide like _LOOKUP_SQL: whether an index exists is a fact about one database (and DROP INDEX clears it: command()). + self._lookup_sql: dict = {} # -- lifecycle @@ -545,6 +562,8 @@ def query(self, language: str, command: str, *args) -> NativeResultSet: def command(self, language: str, command: str, *args) -> NativeResultSet: """Run a statement that may write.""" + if self._lookup_sql and _may_drop_index(language, command): + self._lookup_sql.clear() # the next lookup_by_key reads the schema again return self._exec(1, "Command failed", language, command, args) # -- transactions @@ -661,17 +680,65 @@ def lookup_by_rid(self, rid: str): def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]): self._check_not_closed() try: - lookup_sql = _LOOKUP_SQL.get((type_name, tuple(keys))) + pair = (type_name, tuple(keys)) + lookup_sql = self._lookup_sql.get(pair) if lookup_sql is None: - where = " AND ".join(f"`{k}` = ?" for k in keys) - lookup_sql = f"SELECT FROM `{type_name}` WHERE {where} LIMIT 1" # nosec B608 - backtick-quoted identifiers; values go through ? parameters - if len(_LOOKUP_SQL) < 1024: - _LOOKUP_SQL[(type_name, tuple(keys))] = lookup_sql + # the first lookup of this pair on this handle: the index check the JVM engine makes on every call (see _has_key_index) + has_index = self._has_key_index(type_name, pair[1]) + lookup_sql = _LOOKUP_SQL.get(pair) + if lookup_sql is None: + where = " AND ".join(f"`{k}` = ?" for k in keys) + lookup_sql = f"SELECT FROM `{type_name}` WHERE {where} LIMIT 1" # nosec B608 - backtick-quoted identifiers; values go through ? parameters + if len(_LOOKUP_SQL) < 1024: + _LOOKUP_SQL[pair] = lookup_sql + if has_index and len(self._lookup_sql) < 1024: + self._lookup_sql[pair] = lookup_sql row = self.query("sql", lookup_sql, list(values)).first() return row.get_element() if row is not None else None except ArcadeDBError as e: raise ArcadeDBError(f"Failed to lookup by key in '{type_name}': {e}") from e + def _has_key_index(self, type_name: str, keys: tuple) -> bool: + """Whether an exact-key index (LSM_TREE or HASH) covers exactly ``keys``, in this order, on the type or one of its supertypes. + + ``Database.lookupByKey`` of the engine refuses a lookup no index covers (``IllegalArgumentException: No index has been created + on type ... properties [...]``); the statement lookup_by_key runs here would scan the whole type instead, silently (P-04). One read of + ``schema:indexes`` per (type, keys) and handle, remembered by the caller. A subtype lists its own bucket indexes for the indexes + it inherits, so the type's own rows are enough. Returns False for a type that does not exist: the statement then fails with the + schema error, as before. Raises ArcadeDBError with the engine's wording when the type has no such index. + """ + wanted = list(keys) + rows = self.query( + "sql", "SELECT FROM schema:indexes WHERE typeName = ?", type_name + ).to_list() + other = None # an index on these properties of a kind that cannot look up by exact key (FULL_TEXT, vector, geospatial) + for r in rows: + props = r.get("properties") or [] + if props and isinstance(props[0], list): + props = props[0] + if list(props) != wanted: + continue + if r.get("indexType") in _EXACT_KEY_INDEX_TYPES: + return True + if other is None or ("[" in r["name"] and "[" not in other["name"]): + other = r + if ( + not rows + and not self.query( + "sql", "SELECT FROM schema:types WHERE name = ?", type_name + ).to_list() + ): + return False + shown = "[" + ", ".join(wanted) + "]" # Arrays.toString + if other is not None: + raise ArcadeDBError( + f"java.lang.IllegalArgumentException: No key index has been created on type '{type_name}' properties {shown}: " + f"index '{other['name']}' is {other.get('indexType')} and cannot look up a record by exact key" + ) + raise ArcadeDBError( + f"java.lang.IllegalArgumentException: No index has been created on type '{type_name}' properties {shown}" + ) + def insert_many( self, type_name: str, rows, commit_every: int = 10_000, parallel: bool = False ) -> int: diff --git a/bindings/python/tests/test_native_point_call.py b/bindings/python/tests/test_native_point_call.py index 40d6bc5c469..8d3290bf27e 100644 --- a/bindings/python/tests/test_native_point_call.py +++ b/bindings/python/tests/test_native_point_call.py @@ -5,6 +5,7 @@ import json import os +import re import pytest @@ -28,6 +29,9 @@ def _docs(temp_db): temp_db.command("sql", "CREATE PROPERTY P.name STRING").close() temp_db.command("sql", "CREATE INDEX ON P (id) UNIQUE").close() temp_db.command("sql", "CREATE INDEX ON P (name) UNIQUE").close() + temp_db.command( + "sql", "CREATE INDEX ON P (id, name) UNIQUE" + ).close() # the composite lookups rows = [{"id": i, "name": f"n{i}"} for i in INTS] rows += [{"id": 100 + k, "name": s or "empty"} for k, s in enumerate(STRINGS)] temp_db.insert_many("P", rows) @@ -67,6 +71,128 @@ def test_lookup_by_key_returns_the_record_for_int_str_and_composite_keys(temp_db assert temp_db.lookup_by_key("P", ["id", "name"], [INTS[0], "n1"]) is None +def _no_index_error(type_name, keys): + """The text both wheels give for a lookup no index covers (Arrays.toString of the keys).""" + return ( + rf"Failed to lookup by key in '{type_name}': .*No index has been created on type " + rf"'{type_name}' properties {re.escape('[' + ', '.join(keys) + ']')}" + ) + + +def test_lookup_by_key_without_an_index_on_exactly_those_keys_raises(temp_db): + """``lookup_by_key`` is an index lookup (docs/api/database.md): keys no index covers raise ArcadeDBError on the JVM wheel + (the engine's ``No index has been created``), and on the native wheel too, where the statement it runs would scan the type (P-04). + The refusal repeats and does not make the next call pass.""" + from arcadedb_embedded.exceptions import ArcadeDBError + + _docs(temp_db) + temp_db.command("sql", "CREATE PROPERTY P.extra STRING").close() + for keys, values in ( + (["extra"], ["x"]), # a property with no index + ( + ["name", "id"], + ["n1", 1], + ), # the composite index is (id, name): the order counts + (["id", "extra"], [1, "x"]), # only a prefix of the keys is indexed + (["id", "name", "extra"], [1, "n1", "x"]), + ): + for _ in range(3): + with pytest.raises(ArcadeDBError, match=_no_index_error("P", keys)): + temp_db.lookup_by_key("P", keys, values) + # the keys an index covers still work after the refusals + assert temp_db.lookup_by_key("P", ["id"], [1]).get("name") == "n1" + assert temp_db.lookup_by_key("P", ["id", "name"], [1, "n1"]).get("id") == 1 + + +def test_lookup_by_key_on_a_type_that_does_not_exist_still_raises_the_schema_error( + temp_db, +): + from arcadedb_embedded.exceptions import ArcadeDBError + + with pytest.raises(ArcadeDBError, match=r"Failed to lookup by key in 'Nope'") as e: + temp_db.lookup_by_key("Nope", ["id"], [1]) + assert "No index has been created" not in str(e.value) + + +def test_lookup_by_key_follows_the_indexes_of_the_database_it_is_called_on( + temp_db, tmp_path +): + """What one database answers must not be remembered for another with a type of the same name, nor after DROP INDEX.""" + import arcadedb_embedded as arcadedb + from arcadedb_embedded.exceptions import ArcadeDBError + + temp_db.command("sql", "CREATE DOCUMENT TYPE K").close() + temp_db.command("sql", "CREATE PROPERTY K.k LONG").close() + temp_db.command("sql", "CREATE INDEX ON K (k) NOTUNIQUE").close() + temp_db.insert_many("K", [{"k": 1}, {"k": 2}]) + for _ in range(2): # the second call is the remembered one + assert temp_db.lookup_by_key("K", ["k"], [2]).get("k") == 2 + + other = arcadedb.create_database(str(tmp_path / "other_db")) + try: + other.command("sql", "CREATE DOCUMENT TYPE K").close() + other.command("sql", "CREATE PROPERTY K.k LONG").close() + other.insert_many("K", [{"k": 1}, {"k": 2}]) + with pytest.raises(ArcadeDBError, match=_no_index_error("K", ["k"])): + other.lookup_by_key("K", ["k"], [2]) + finally: + other.close() + assert temp_db.lookup_by_key("K", ["k"], [1]).get("k") == 1 + + temp_db.command("sql", "DROP INDEX `K[k]`").close() + with pytest.raises(ArcadeDBError, match=_no_index_error("K", ["k"])): + temp_db.lookup_by_key("K", ["k"], [2]) + temp_db.command("sql", "CREATE INDEX ON K (k) NOTUNIQUE").close() + assert temp_db.lookup_by_key("K", ["k"], [2]).get("k") == 2 + + +def test_lookup_by_key_uses_an_index_of_a_supertype_and_refuses_a_full_text_one( + temp_db, +): + from arcadedb_embedded.exceptions import ArcadeDBError + + for sql in ( + "CREATE DOCUMENT TYPE Base", + "CREATE PROPERTY Base.id LONG", + "CREATE PROPERTY Base.body STRING", + "CREATE INDEX ON Base (id) UNIQUE", + "CREATE INDEX ON Base (body) FULL_TEXT", + "CREATE DOCUMENT TYPE Sub EXTENDS Base", + ): + temp_db.command("sql", sql).close() + temp_db.insert_many("Sub", [{"id": 5, "body": "hello world"}]) + for _ in range(2): + found = temp_db.lookup_by_key("Sub", ["id"], [5]) + assert found is not None and found.get("body") == "hello world" + for type_name in ("Base", "Sub"): + with pytest.raises( + ArcadeDBError, + match=rf"No key index has been created on type '{type_name}' properties \[body\]", + ): + temp_db.lookup_by_key(type_name, ["body"], ["hello"]) + + +@native_only +def test_lookup_by_key_reads_the_schema_once_per_pair_not_once_per_call(temp_db): + """The index check costs one extra crossing the first time a (type, keys) pair is looked up on a handle and none after.""" + _docs(temp_db) + real, seen = temp_db.query, [] + + def spy(language, command, *args): + seen.append(command) + return real(language, command, *args) + + temp_db.query = spy + try: + for i in range(5): + assert temp_db.lookup_by_key("P", ["id"], [INTS[i]]) is not None + assert temp_db.lookup_by_key("P", ["id", "name"], [INTS[i], f"n{INTS[i]}"]) + finally: + del temp_db.query + checks = [c for c in seen if "schema:" in c] + assert len(checks) == 2 and len(seen) == 2 * 5 + 2, seen + + def test_lookup_by_key_keeps_the_types_and_keys_of_each_call_apart(temp_db): _docs(temp_db) temp_db.command("sql", "CREATE VERTEX TYPE Q").close() From 4947dc062666238c7165bcf5e57cdca04acc0a30 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Sun, 25 Oct 2026 18:27:17 +0900 Subject: [PATCH 3/3] Native point-call tests: the native-only ones in their own file, not collected on JPype test_native_point_call.py kept the tests of the native call helpers (crossing counts, the schema-read cache, the parameter codec) next to the point-read tests both wheels run, and marked them skipif. On the JPype legs that was 33 skips, and scripts/check_test_skips.py fails the job on any skip. The native-only tests move to test_native_point_call_native.py, which conftest.py does not collect on the JPype backend (as for test_native_startup_runtime.py). Co-Authored-By: Claude Sonnet 5.5 --- bindings/python/tests/conftest.py | 2 + .../python/tests/test_native_point_call.py | 222 +----------------- .../tests/test_native_point_call_native.py | 207 ++++++++++++++++ 3 files changed, 213 insertions(+), 218 deletions(-) create mode 100644 bindings/python/tests/test_native_point_call_native.py diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py index 77820a36136..ff969bb3c9b 100644 --- a/bindings/python/tests/conftest.py +++ b/bindings/python/tests/conftest.py @@ -29,6 +29,8 @@ collect_ignore.append("test_native_udf.py") # the native library's start-up output (#477) can only be checked with the native library loaded collect_ignore.append("test_native_startup_runtime.py") + # the helpers of the native point-read path (parameter codec, ctypes entry points, statement-text and schema-read caches) + collect_ignore.append("test_native_point_call_native.py") if not os.path.isdir(os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs")): # the upstream pull request branch has no docs/ directory collect_ignore.append("test_docs_examples.py") diff --git a/bindings/python/tests/test_native_point_call.py b/bindings/python/tests/test_native_point_call.py index 8d3290bf27e..d0889607555 100644 --- a/bindings/python/tests/test_native_point_call.py +++ b/bindings/python/tests/test_native_point_call.py @@ -1,17 +1,13 @@ -"""The call path of a point read on the native backend (``db.query(sql, "... WHERE id = ?", i).first()`` and ``db.lookup_by_key``): the -parameter text, the ctypes call, the first row and the record wrapper. Results have to be the same as before the path was shortened, -for every kind of parameter and key; the first group runs on both backends, the second looks at the helpers of the native one. +"""The results of a point read (``db.query(sql, "... WHERE id = ?", i).first()`` and ``db.lookup_by_key``) on both wheels: the parameter, +the first row and the record wrapper. The native backend shortened this path, and the results have to be the same as before, for every +kind of parameter and key. What looks at the helpers of the native call path (crossing counts, the schema-read cache, the parameter +codec) is in test_native_point_call_native.py, which conftest.py does not collect on the JPype backend. """ -import json -import os import re import pytest -NATIVE = os.environ.get("ARCADEDB_BACKEND", "jpype").lower() == "native" -native_only = pytest.mark.skipif(not NATIVE, reason="the native call path") - STRINGS = [ "", "plain", @@ -172,27 +168,6 @@ def test_lookup_by_key_uses_an_index_of_a_supertype_and_refuses_a_full_text_one( temp_db.lookup_by_key(type_name, ["body"], ["hello"]) -@native_only -def test_lookup_by_key_reads_the_schema_once_per_pair_not_once_per_call(temp_db): - """The index check costs one extra crossing the first time a (type, keys) pair is looked up on a handle and none after.""" - _docs(temp_db) - real, seen = temp_db.query, [] - - def spy(language, command, *args): - seen.append(command) - return real(language, command, *args) - - temp_db.query = spy - try: - for i in range(5): - assert temp_db.lookup_by_key("P", ["id"], [INTS[i]]) is not None - assert temp_db.lookup_by_key("P", ["id", "name"], [INTS[i], f"n{INTS[i]}"]) - finally: - del temp_db.query - checks = [c for c in seen if "schema:" in c] - assert len(checks) == 2 and len(seen) == 2 * 5 + 2, seen - - def test_lookup_by_key_keeps_the_types_and_keys_of_each_call_apart(temp_db): _docs(temp_db) temp_db.command("sql", "CREATE VERTEX TYPE Q").close() @@ -225,19 +200,6 @@ def test_first_returns_the_first_row_and_closes_the_result(temp_db): assert temp_db.query("sql", "SELECT FROM P WHERE id = -999").first() is None -@native_only -def test_a_second_first_on_the_same_result_raises_as_before(temp_db): - from arcadedb_embedded.exceptions import ArcadeDBError - - _docs(temp_db) - rs = temp_db.query("sql", "SELECT FROM P ORDER BY id") - rs.first() - with pytest.raises( - ArcadeDBError - ): # first() closed it: the rows it did not return are gone - rs.first() - - def test_first_of_a_big_result_gives_the_first_row_and_leaves_no_cursor(temp_db): temp_db.command("sql", "CREATE DOCUMENT TYPE R").close() temp_db.insert_many( @@ -246,179 +208,3 @@ def test_first_of_a_big_result_gives_the_first_row_and_leaves_no_cursor(temp_db) for _ in range(50): assert temp_db.query("sql", "SELECT FROM R ORDER BY i").first().get("i") == 0 assert temp_db.query("sql", "SELECT count(*) AS c FROM R").first().get("c") == 3000 - - -@native_only -def test_a_row_read_after_the_database_closed_still_raises(temp_db): - from arcadedb_embedded.exceptions import ArcadeDBError - - _docs(temp_db) - row = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first() - rs = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 7) - temp_db.close() - with pytest.raises(ArcadeDBError): - row.get("name") - with pytest.raises(ArcadeDBError): - rs.first() - - -# ---------------------------------------------------------------- the helpers of the native backend - - -def _native(): - from arcadedb_embedded._native import codec, database, lib, records - - return codec, database, lib, records - - -@native_only -@pytest.mark.parametrize( - "value", INTS + STRINGS + [True, False, None, 1.5, -0.0, 10**30] -) -def test_encode_params_writes_one_parameter_as_json_dumps_does(value): - codec = _native()[0] - want = json.dumps({"pos": [value]}) - for args in ((value,), ([value],), ((value,),)): - assert codec.encode_params(args) == want - assert codec.encode_params(args, binary=True) == (want, None, 0) - - -@native_only -def test_encode_params_leaves_subclasses_and_numpy_scalars_to_the_general_path(): - np = pytest.importorskip("numpy") - codec = _native()[0] - - class MyInt(int): - pass - - class MyStr(str): - pass - - for value in (MyInt(5), MyStr("x"), np.int64(5), np.float32(1.5), np.bool_(True)): - assert codec.encode_params((value,)) == codec.encode_params([value]) - assert codec.encode_params((MyInt(5),)) == '{"pos": [5]}' - assert codec.encode_params((np.int64(5),)) == '{"pos": [5]}' - assert ( - codec.encode_params((True,)) == '{"pos": [true]}' - ) # a bool is not sent as the int 1 - - -@native_only -def test_encode_params_one_int_or_str_does_not_go_through_the_json_encoder(monkeypatch): - codec = _native()[0] - - def boom(*a, **k): - raise AssertionError("json encoder used for a lone int or str parameter") - - monkeypatch.setattr(codec, "_dumps", boom) - assert codec.encode_params((12,)) == '{"pos": [12]}' - assert codec.encode_params((["a\nb"],)) == '{"pos": ["a\\nb"]}' - assert codec.encode_params(("é",)) == '{"pos": ["\\u00e9"]}' - - -@native_only -def test_encode_params_other_shapes_keep_their_text(): - codec = _native()[0] - assert codec.encode_params(()) == "" - assert codec.encode_params((), binary=True) == ("", None, 0) - assert codec.encode_params((1, "a", None)) == '{"pos": [1, "a", null]}' - assert codec.encode_params(([],)) == '{"pos": []}' - assert codec.encode_params(({"a": 1},)) == '{"named": {"a": 1}}' - - -@native_only -def test_exec_query_returns_what_call_bin_returns_for_the_same_statement(temp_db): - codec, database, lib, records = _native() - _docs(temp_db) - text, _, count = codec.encode_params((7,), binary=True) - args = ( - temp_db._h, - 4, - lib.bc("sql"), - lib.bc("SELECT FROM P WHERE id = ?"), - lib.b(text), - 256, - ) - assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) - assert lib.exec_query(*args)[0].startswith("{") - # a reply with a float tail: the vectors of the later rows come back as numbers in the text, the first batch as a tail - temp_db.command("sql", "CREATE DOCUMENT TYPE V").close() - temp_db.command("sql", "CREATE PROPERTY V.v ARRAY_OF_FLOATS").close() - temp_db.insert_many( - "V", [{"v": [float(k + j) / 4 for j in range(16)]} for k in range(4)] - ) - vargs = (temp_db._h, 4, lib.bc("sql"), lib.bc("SELECT v FROM V"), b"", 256) - got, want = lib.exec_query(*vargs), lib.call_bin("arcadedb_n_exec", *vargs) - assert got[0] == want[0] and got[1] is not None and list(got[1]) == list(want[1]) - - -@native_only -def test_exec_query_with_a_param_text_of_several_kinds(temp_db): - _, _, lib, _ = _native() - _docs(temp_db) - for param in ('{"pos": [7]}', '{"pos": ["n7"]}', '{"pos": [null]}'): - args = ( - temp_db._h, - 4, - lib.bc("sql"), - lib.bc("SELECT FROM P WHERE id = ? OR name = ?"), - lib.b(param), - 256, - ) - # the statement has two placeholders: a one-element list is an error reply, the same one from both entry points - assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) - - -@native_only -def test_lookup_by_key_builds_each_statement_text_once(temp_db): - _, database, _, _ = _native() - _docs(temp_db) - database._LOOKUP_SQL.clear() - temp_db.lookup_by_key("P", ["id"], [1]) - temp_db.lookup_by_key("P", ["id", "name"], [1, "n1"]) - assert sorted(database._LOOKUP_SQL.values()) == [ - "SELECT FROM `P` WHERE `id` = ? AND `name` = ? LIMIT 1", - "SELECT FROM `P` WHERE `id` = ? LIMIT 1", - ] - one = database._LOOKUP_SQL[("P", ("id",))] - temp_db.lookup_by_key( - "P", ("id",), (2,) - ) # a tuple of keys and values is the same lookup - assert database._LOOKUP_SQL[("P", ("id",))] is one - - -@native_only -def test_records_share_one_java_stand_in_and_an_import_free_wrap(temp_db): - _, database, _, records = _native() - _docs(temp_db) - assert records._java_object() is records._java_object() - a, b = temp_db.lookup_by_key("P", ["id"], [1]), temp_db.lookup_by_key( - "P", ["id"], [7] - ) - assert a._java_document is b._java_document - with pytest.raises(NotImplementedError): - a._java_document.anything - assert a.get_rid() != b.get_rid() and a.to_dict() == {"id": 1, "name": "n1"} - a2 = a.modify() # a mutable copy keeps working on its own properties - a2.set("name", "changed") - assert a.get("name") == "n1" and a2.get("name") == "changed" - - -@native_only -def test_first_takes_the_buffered_row_without_the_iterator_protocol( - temp_db, monkeypatch -): - _, database, _, _ = _native() - _docs(temp_db) - - def boom(self): - raise AssertionError( - "first() went through __next__ for a row that was already here" - ) - - monkeypatch.setattr(database.NativeResultSet, "__next__", boom) - assert ( - temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first().get("name") - == "n1" - ) - assert temp_db.lookup_by_key("P", ["id"], [1]).get("name") == "n1" diff --git a/bindings/python/tests/test_native_point_call_native.py b/bindings/python/tests/test_native_point_call_native.py new file mode 100644 index 00000000000..befc27edd17 --- /dev/null +++ b/bindings/python/tests/test_native_point_call_native.py @@ -0,0 +1,207 @@ +"""The helpers of the native call path of a point read (``db.query(sql, "... WHERE id = ?", i).first()`` and ``db.lookup_by_key``): the +parameter text, the ctypes call, the schema-read cache, the first row and the record wrapper. The results of the point reads themselves, for +both wheels, are checked in test_native_point_call.py. + +Needs the native library, so conftest.py does not collect this file on the JPype backend (a skip there would fail +scripts/check_test_skips.py); it runs in the native parity run. +""" + +import json + +import pytest +from tests.test_native_point_call import INTS, STRINGS, _docs + + +def _native(): + from arcadedb_embedded._native import codec, database, lib, records + + return codec, database, lib, records + + +def test_lookup_by_key_reads_the_schema_once_per_pair_not_once_per_call(temp_db): + """The index check costs one extra crossing the first time a (type, keys) pair is looked up on a handle and none after.""" + _docs(temp_db) + real, seen = temp_db.query, [] + + def spy(language, command, *args): + seen.append(command) + return real(language, command, *args) + + temp_db.query = spy + try: + for i in range(5): + assert temp_db.lookup_by_key("P", ["id"], [INTS[i]]) is not None + assert temp_db.lookup_by_key("P", ["id", "name"], [INTS[i], f"n{INTS[i]}"]) + finally: + del temp_db.query + checks = [c for c in seen if "schema:" in c] + assert len(checks) == 2 and len(seen) == 2 * 5 + 2, seen + + +def test_a_second_first_on_the_same_result_raises_as_before(temp_db): + from arcadedb_embedded.exceptions import ArcadeDBError + + _docs(temp_db) + rs = temp_db.query("sql", "SELECT FROM P ORDER BY id") + rs.first() + with pytest.raises( + ArcadeDBError + ): # first() closed it: the rows it did not return are gone + rs.first() + + +def test_a_row_read_after_the_database_closed_still_raises(temp_db): + from arcadedb_embedded.exceptions import ArcadeDBError + + _docs(temp_db) + row = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first() + rs = temp_db.query("sql", "SELECT FROM P WHERE id = ?", 7) + temp_db.close() + with pytest.raises(ArcadeDBError): + row.get("name") + with pytest.raises(ArcadeDBError): + rs.first() + + +@pytest.mark.parametrize( + "value", INTS + STRINGS + [True, False, None, 1.5, -0.0, 10**30] +) +def test_encode_params_writes_one_parameter_as_json_dumps_does(value): + codec = _native()[0] + want = json.dumps({"pos": [value]}) + for args in ((value,), ([value],), ((value,),)): + assert codec.encode_params(args) == want + assert codec.encode_params(args, binary=True) == (want, None, 0) + + +def test_encode_params_leaves_subclasses_and_numpy_scalars_to_the_general_path(): + np = pytest.importorskip("numpy") + codec = _native()[0] + + class MyInt(int): + pass + + class MyStr(str): + pass + + for value in (MyInt(5), MyStr("x"), np.int64(5), np.float32(1.5), np.bool_(True)): + assert codec.encode_params((value,)) == codec.encode_params([value]) + assert codec.encode_params((MyInt(5),)) == '{"pos": [5]}' + assert codec.encode_params((np.int64(5),)) == '{"pos": [5]}' + assert ( + codec.encode_params((True,)) == '{"pos": [true]}' + ) # a bool is not sent as the int 1 + + +def test_encode_params_one_int_or_str_does_not_go_through_the_json_encoder(monkeypatch): + codec = _native()[0] + + def boom(*a, **k): + raise AssertionError("json encoder used for a lone int or str parameter") + + monkeypatch.setattr(codec, "_dumps", boom) + assert codec.encode_params((12,)) == '{"pos": [12]}' + assert codec.encode_params((["a\nb"],)) == '{"pos": ["a\\nb"]}' + assert codec.encode_params(("é",)) == '{"pos": ["\\u00e9"]}' + + +def test_encode_params_other_shapes_keep_their_text(): + codec = _native()[0] + assert codec.encode_params(()) == "" + assert codec.encode_params((), binary=True) == ("", None, 0) + assert codec.encode_params((1, "a", None)) == '{"pos": [1, "a", null]}' + assert codec.encode_params(([],)) == '{"pos": []}' + assert codec.encode_params(({"a": 1},)) == '{"named": {"a": 1}}' + + +def test_exec_query_returns_what_call_bin_returns_for_the_same_statement(temp_db): + codec, database, lib, records = _native() + _docs(temp_db) + text, _, count = codec.encode_params((7,), binary=True) + args = ( + temp_db._h, + 4, + lib.bc("sql"), + lib.bc("SELECT FROM P WHERE id = ?"), + lib.b(text), + 256, + ) + assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) + assert lib.exec_query(*args)[0].startswith("{") + # a reply with a float tail: the vectors of the later rows come back as numbers in the text, the first batch as a tail + temp_db.command("sql", "CREATE DOCUMENT TYPE V").close() + temp_db.command("sql", "CREATE PROPERTY V.v ARRAY_OF_FLOATS").close() + temp_db.insert_many( + "V", [{"v": [float(k + j) / 4 for j in range(16)]} for k in range(4)] + ) + vargs = (temp_db._h, 4, lib.bc("sql"), lib.bc("SELECT v FROM V"), b"", 256) + got, want = lib.exec_query(*vargs), lib.call_bin("arcadedb_n_exec", *vargs) + assert got[0] == want[0] and got[1] is not None and list(got[1]) == list(want[1]) + + +def test_exec_query_with_a_param_text_of_several_kinds(temp_db): + _, _, lib, _ = _native() + _docs(temp_db) + for param in ('{"pos": [7]}', '{"pos": ["n7"]}', '{"pos": [null]}'): + args = ( + temp_db._h, + 4, + lib.bc("sql"), + lib.bc("SELECT FROM P WHERE id = ? OR name = ?"), + lib.b(param), + 256, + ) + # the statement has two placeholders: a one-element list is an error reply, the same one from both entry points + assert lib.exec_query(*args) == lib.call_bin("arcadedb_n_exec", *args) + + +def test_lookup_by_key_builds_each_statement_text_once(temp_db): + _, database, _, _ = _native() + _docs(temp_db) + database._LOOKUP_SQL.clear() + temp_db.lookup_by_key("P", ["id"], [1]) + temp_db.lookup_by_key("P", ["id", "name"], [1, "n1"]) + assert sorted(database._LOOKUP_SQL.values()) == [ + "SELECT FROM `P` WHERE `id` = ? AND `name` = ? LIMIT 1", + "SELECT FROM `P` WHERE `id` = ? LIMIT 1", + ] + one = database._LOOKUP_SQL[("P", ("id",))] + temp_db.lookup_by_key( + "P", ("id",), (2,) + ) # a tuple of keys and values is the same lookup + assert database._LOOKUP_SQL[("P", ("id",))] is one + + +def test_records_share_one_java_stand_in_and_an_import_free_wrap(temp_db): + _, database, _, records = _native() + _docs(temp_db) + assert records._java_object() is records._java_object() + a, b = temp_db.lookup_by_key("P", ["id"], [1]), temp_db.lookup_by_key( + "P", ["id"], [7] + ) + assert a._java_document is b._java_document + with pytest.raises(NotImplementedError): + a._java_document.anything + assert a.get_rid() != b.get_rid() and a.to_dict() == {"id": 1, "name": "n1"} + a2 = a.modify() # a mutable copy keeps working on its own properties + a2.set("name", "changed") + assert a.get("name") == "n1" and a2.get("name") == "changed" + + +def test_first_takes_the_buffered_row_without_the_iterator_protocol( + temp_db, monkeypatch +): + _, database, _, _ = _native() + _docs(temp_db) + + def boom(self): + raise AssertionError( + "first() went through __next__ for a row that was already here" + ) + + monkeypatch.setattr(database.NativeResultSet, "__next__", boom) + assert ( + temp_db.query("sql", "SELECT FROM P WHERE id = ?", 1).first().get("name") + == "n1" + ) + assert temp_db.lookup_by_key("P", ["id"], [1]).get("name") == "n1"