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/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..3cb3641bd30 100644 --- a/bindings/python/src/arcadedb_embedded/_native/database.py +++ b/bindings/python/src/arcadedb_embedded/_native/database.py @@ -25,6 +25,22 @@ _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 (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: @@ -86,12 +102,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 +358,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: @@ -386,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 @@ -504,12 +533,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, ) @@ -534,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 @@ -649,14 +679,66 @@ 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 + pair = (type_name, tuple(keys)) + lookup_sql = self._lookup_sql.get(pair) + if lookup_sql is None: + # 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/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/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 new file mode 100644 index 00000000000..d0889607555 --- /dev/null +++ b/bindings/python/tests/test_native_point_call.py @@ -0,0 +1,210 @@ +"""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 re + +import pytest + +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() + 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) + 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 _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"]) + + +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 + + +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 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"