From 3403dd276476faa9f13a24194ad94a130dd738b1 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Wed, 4 Nov 2026 05:48:24 +0900 Subject: [PATCH] JVM readers: a record or row to dict, the property names and result-set iteration in one crossing each (#227) Document.to_dict took 18 reads of Java objects for 3 properties, Result.to_dict 14, a property-names call 12, and each row of a result set two calls (hasNext, next). The new bridge class ReaderCalls writes a record or a row as the typed JSON of TypedRows (one call, one string read, the C json decoder), returns the names as one JSON text, and hands over the next row in one call. Values keep the types convert_java_to_python gives them; the side array is read only when the JSON refers to it; a result set is still read one row per call. Without the class the old paths run. Document.to_dict now also refreshes the cached names, so a property set after an earlier read is no longer missing from get_property_names(). Co-Authored-By: Claude Opus 5.5 --- src/arcadedb/graph.py | 36 +- src/arcadedb/results.py | 61 +- src/java/com/arcadedb/python/ReaderCalls.java | 107 ++++ src/java/com/arcadedb/python/TypedRows.java | 4 +- tests/conftest.py | 1 + tests/test_reader_crossings.py | 523 ++++++++++++++++++ 6 files changed, 719 insertions(+), 13 deletions(-) create mode 100644 src/java/com/arcadedb/python/ReaderCalls.java create mode 100644 tests/test_reader_crossings.py diff --git a/src/arcadedb/graph.py b/src/arcadedb/graph.py index 4641788..3f2de79 100644 --- a/src/arcadedb/graph.py +++ b/src/arcadedb/graph.py @@ -41,6 +41,17 @@ def _graph_calls(): return _GRAPH_CALLS or None +def _reader_calls(): + """``(ReaderCalls, results)``: the ``com.arcadedb.python.ReaderCalls`` bridge class + (None, with the bridge's usual one warning, when the jar is missing, which sends the + caller down the plain path) and the ``results`` module that holds the readers of its + text. The class is looked up on every call, as ``results._bridge_class`` caches it: + the JVM may not have started yet, and an answer given then must not stick.""" + from . import results + + return results._bridge_class("ReaderCalls"), results + + class Document: """Wrapper for ArcadeDB Document. @@ -66,9 +77,16 @@ def _check_open(self) -> None: def _property_names_tuple(self) -> Tuple[str, ...]: self._check_open() if self._property_names_cache is None: - self._property_names_cache = tuple( - str(name) for name in self._java_document.getPropertyNames() - ) + reader, results = _reader_calls() + if reader is not None: + # the names as one JSON text instead of one JPype read per name + self._property_names_cache = results._loads_names( + reader.documentNames(self._java_document) + ) + else: + self._property_names_cache = tuple( + str(name) for name in self._java_document.getPropertyNames() + ) return self._property_names_cache @staticmethod @@ -177,6 +195,18 @@ def get_identity(self): def to_dict(self, convert_types: bool = True) -> Dict[str, Any]: """Convert to dictionary.""" + if convert_types: + reader, results = _reader_calls() + if reader is not None: + # One crossing for the whole record, and no JPype read of a name + # or a value: Java writes it as the typed JSON of TypedRows, the + # values keep the types convert_java_to_python gives them. + self._check_open() + row = results._loads_typed_row(reader.documentJson(self._java_document)) + # the names of this read, so a property set since an earlier + # read is not missing from get_property_names() + self._property_names_cache = tuple(row) + return row property_names = self._property_names_tuple() # checks the database if not convert_types: return {name: self._java_document.get(name) for name in property_names} diff --git a/src/arcadedb/results.py b/src/arcadedb/results.py index 4c00cd9..f2544a4 100644 --- a/src/arcadedb/results.py +++ b/src/arcadedb/results.py @@ -92,6 +92,25 @@ def hook(obj): return hook +def _loads_typed_row(java_row): + """The dict of one row or record that ``ReaderCalls.rowJson`` / ``documentJson`` wrote. + + ``java_row`` is ``{String json, Object[] side}``. The side array is read only + when the text carries the tag character (the writer escapes it as + ``\\u0001``): a row of plain values costs one array read and one string read, + and the C ``json`` decoder, and no other JPype call. + """ + text = str(java_row[0]) + if "\\u0001" not in text: + return json.loads(text) + return json.loads(text, object_hook=_typed_hook(java_row[1])) + + +def _loads_names(java_text): + """The property names ``ReaderCalls.rowNames`` / ``documentNames`` wrote, as a tuple.""" + return tuple(json.loads(str(java_text))) + + def _bridge_class(name): """Cached bridge-class lookup; None (with one warning) if unavailable. @@ -374,7 +393,15 @@ def _drained(self) -> None: def __next__(self) -> "Result": if self._readable(): try: - if self._java_result_set.hasNext(): + reader = _bridge_class("ReaderCalls") + if reader is not None: + # hasNext() and next() in ONE crossing: the row is still read + # when it is asked for, and the end is the same null as + # hasNext() false + java_row = reader.nextOrNull(self._java_result_set) + if java_row is not None: + return Result(java_row, self._database) + elif self._java_result_set.hasNext(): return Result(self._java_result_set.next(), self._database) except JException as exc: raise _read_error(exc) from exc @@ -1246,9 +1273,16 @@ def _needs_database(self) -> bool: def _property_names_tuple(self) -> Tuple[str, ...]: self._check_open() if self._property_names_cache is None: - self._property_names_cache = tuple( - str(name) for name in self._java_result.getPropertyNames() - ) + reader = _bridge_class("ReaderCalls") + if reader is not None: + # the names as one JSON text instead of one JPype read per name + self._property_names_cache = _loads_names( + reader.rowNames(self._java_result) + ) + else: + self._property_names_cache = tuple( + str(name) for name in self._java_result.getPropertyNames() + ) return self._property_names_cache def has_property(self, name: str) -> bool: @@ -1414,10 +1448,21 @@ def to_dict(self, convert_types: bool = True) -> Dict[str, Any]: """ self._check_open() if convert_types: - # One crossing for the whole row (names and values) instead of one - # per property: a JPype call costs microseconds of dispatch, so a - # small row was dominated by the number of calls. The values are - # the engine's own objects, converted exactly as below. + # One crossing for the whole row, and no JPype read of a name or a + # value: Java writes the row as the typed JSON of TypedRows (the + # transport of to_list()), so a row of plain values costs the call, + # one array read and one string read. The values have the types + # convert_java_to_python gives them. + reader = _bridge_class("ReaderCalls") + if reader is not None: + row = _loads_typed_row(reader.rowJson(self._java_result)) + if self._property_names_cache is None: + self._property_names_cache = tuple(row) + return row + # Without ReaderCalls: one crossing for the whole row (names and + # values) instead of one per property, then a JPype read per name and + # value. The values are the engine's own objects, converted exactly + # as below. row_access = _bridge_class("RowAccess") if row_access is not None: pair = row_access.namesAndValues(self._java_result) diff --git a/src/java/com/arcadedb/python/ReaderCalls.java b/src/java/com/arcadedb/python/ReaderCalls.java new file mode 100644 index 0000000..dd93799 --- /dev/null +++ b/src/java/com/arcadedb/python/ReaderCalls.java @@ -0,0 +1,107 @@ +/* + * Python-bindings bridge: the readers of records and rows in one crossing. + * + * A JPype call costs a few microseconds of dispatch, and so does every read of a Java object that comes back + * (a String, an array element, a set element). A record of three properties took 18 of them to become a dict + * (getPropertyNames, one hasNext/next pair per name, one get per property, one conversion per name and value), a row + * from a result set 14, a call for the property names 12, and each row of a result set two (hasNext, next). + * + * This writes a record or a row as the typed JSON of TypedRows (see there for the tags: the values JSON cannot carry + * exactly are tagged, the engine's own objects such as RIDs, embedded documents and float[] go to a side array that + * Python reads only when the JSON refers to it), so Python makes one call, reads one String, and parses it with the C + * json module. Values keep the types convert_java_to_python gives them: the typed JSON is the transport + * ResultSet.to_list() already uses, and a test compares every value type with the per-property path. + * + * Compiled into arcadedb-python-bridge.jar during the wheel build (scripts/Dockerfile.build and + * scripts/build-native.sh) and consumed by Result.to_dict(), Document.to_dict(), the property-name readers of both, + * and ResultSet.__next__() in the Python bindings. + */ +package com.arcadedb.python; + +import com.arcadedb.database.Document; +import com.arcadedb.query.sql.executor.Result; +import com.arcadedb.query.sql.executor.ResultSet; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public final class ReaderCalls { + + private ReaderCalls() { + } + + /** + * The next row of a result set, or null when it has no more: the {@code hasNext()} and {@code next()} of one Python + * {@code __next__} in one crossing. A row is still read when it is asked for (no batch is taken ahead), and an error + * in the statement surfaces from here as it did from either call. + */ + public static Result nextOrNull(final ResultSet rs) { + return rs.hasNext() ? rs.next() : null; + } + + /** + * One row as {@code {String json, Object[] side}}: {@code json} is a row object in the typed JSON of + * {@link TypedRows}, {@code side} holds the engine objects the JSON refers to by index. The properties are in the + * order {@code getPropertyNames()} gives them. + */ + public static Object[] rowJson(final Result row) { + final StringBuilder sb = new StringBuilder(128); + final List side = new ArrayList<>(); + TypedRows.appendRow(sb, row, side); + return new Object[] { sb.toString(), side.toArray() }; + } + + /** + * One record as {@code {String json, Object[] side}}, like {@link #rowJson(Result)}: the properties are the ones + * {@code getPropertyNames()} lists, each value is {@code get(name)}. + */ + public static Object[] documentJson(final Document doc) { + final String[] names = doc.getPropertyNames().toArray(new String[0]); + final StringBuilder sb = new StringBuilder(128); + final List side = new ArrayList<>(); + for (final String name : names) + if (TypedRows.TAG.equals(name)) { + // a property named like the tag: hand the names and values over as they are, Python pairs them + final Object[] values = new Object[names.length]; + for (int i = 0; i < names.length; i++) + values[i] = doc.get(names[i]); + sb.append("{\"\\u0001\":[\"r\",0]}"); + side.add(new Object[] { names, values }); + return new Object[] { sb.toString(), side.toArray() }; + } + sb.append('{'); + for (int i = 0; i < names.length; i++) { + if (i > 0) + sb.append(','); + TypedRows.quote(sb, names[i]); + sb.append(':'); + TypedRows.append(sb, doc.get(names[i]), side); + } + sb.append('}'); + return new Object[] { sb.toString(), side.toArray() }; + } + + /** The property names of a row as one JSON array of strings, in the order {@code getPropertyNames()} gives them. */ + public static String rowNames(final Result row) { + return namesJson(row.getPropertyNames()); + } + + /** The property names of a record as one JSON array of strings, in the order {@code getPropertyNames()} gives them. */ + public static String documentNames(final Document doc) { + return namesJson(doc.getPropertyNames()); + } + + private static String namesJson(final Set names) { + final StringBuilder sb = new StringBuilder(16 * names.size() + 2); + sb.append('['); + boolean first = true; + for (final String name : names) { + if (!first) + sb.append(','); + first = false; + TypedRows.quote(sb, name); + } + return sb.append(']').toString(); + } +} diff --git a/src/java/com/arcadedb/python/TypedRows.java b/src/java/com/arcadedb/python/TypedRows.java index 9c2e0f8..db2b937 100644 --- a/src/java/com/arcadedb/python/TypedRows.java +++ b/src/java/com/arcadedb/python/TypedRows.java @@ -98,7 +98,7 @@ public static Object[] nextRows(final ResultSet rs, final int max) { return new Object[] { sb.toString(), side.toArray(), drained }; } - private static void appendRow(final StringBuilder sb, final Result row, final List side) { + static void appendRow(final StringBuilder sb, final Result row, final List side) { final String[] names = row.getPropertyNames().toArray(new String[0]); for (final String name : names) if (TAG.equals(name)) { @@ -287,7 +287,7 @@ private static StringBuilder pad(final StringBuilder sb, final int value, final * A JSON string. Surrogates are always written as \\u escapes (a lone one is not valid UTF-16 text to hand across the * bridge), as are control characters. */ - private static void quote(final StringBuilder sb, final String s) { + static void quote(final StringBuilder sb, final String s) { sb.append('"'); final int length = s.length(); for (int i = 0; i < length; i++) { diff --git a/tests/conftest.py b/tests/conftest.py index 01ab332..1208394 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,6 +97,7 @@ "test_build_no_uv_refresh.py": "build scripts of the JVM wheel", "test_build_wheel_check.py": "build scripts of the JVM wheel", "test_query_vector_params_jvm.py": "the java.util objects the JVM build's parameter conversion builds", + "test_reader_crossings.py": "crossing counts of the JVM build's record and row readers (JPype proxies of the Java objects and the bridge classes)", } if BACKEND == "native" and not os.environ.get("NATIVE_PARITY_ALL"): collect_ignore.extend(NATIVE_NOT_APPLICABLE) diff --git a/tests/test_reader_crossings.py b/tests/test_reader_crossings.py new file mode 100644 index 0000000..ecd104e --- /dev/null +++ b/tests/test_reader_crossings.py @@ -0,0 +1,523 @@ +"""Crossings of the JVM build's record and row readers (one JPype call is a few microseconds of dispatch). + +A record of three properties took 18 reads of Java objects to become a dict, a row 14, a call for the +property names 12, and each row of a result set read with ``for row in rs`` two calls (``hasNext()``, +``next()``). The bridge's ``ReaderCalls`` writes a record or a row as the typed JSON of ``TypedRows`` and +hands over the next row in one call. Every test here pins two things: the number of calls into the JVM +and of reads of the arrays and sets they return (counted on a proxy of the Java object or bridge class, +the technique of ``test_percall_calls.py``), and that the answer is the one the per-property path gives +(same keys in the same order, same Python types and values, same errors, same result-set state), so a +count cannot be bought with a change of behaviour. +""" + +import jpype +import pytest + +from arcadedb import graph, results +from arcadedb.exceptions import ArcadeDBError +from arcadedb.type_conversion import convert_java_to_python + +# fixtures and helpers of the typed-row tests: the wide table with every property type (fixture ``db``) +from tests.test_typed_rows import _in_memory, db, exact # noqa: F401 + + +class _CountedArray: + """A Java array that logs every element read; elements that are arrays are counted too.""" + + def __init__(self, target, log, label): + self._target = target + self._log = log + self._label = label + + def __len__(self): + return len(self._target) + + def __getitem__(self, index): + label = f"{self._label}[{index}]" + self._log.append(label) + return _counted(self._target[index], self._log, label) + + def __iter__(self): + for index in range(len(self._target)): + yield self[index] + + +class _CountedSet: + """A Java collection that logs every element read.""" + + def __init__(self, target, log, label): + self._target = target + self._log = log + self._label = label + + def __iter__(self): + for item in self._target: + self._log.append(f"{self._label}.next") + yield item + + +def _counted(value, log, label): + if isinstance(value, jpype.JArray): + return _CountedArray(value, log, label) + if label.endswith(".getPropertyNames"): + return _CountedSet(value, log, label) + return value + + +class Counting: + """Counts the method calls made on a Java object or a Java class, and the reads of what they return. + + Arguments that are themselves a ``Counting`` are unwrapped before the call, so the proxy of a result set + can be handed to a bridge class as it is. Arrays a call returns, and the set ``getPropertyNames()`` + returns, log each element read. + """ + + def __init__(self, target, log, label): + self._target = target + self._log = log + self._label = label + + def __getattr__(self, name): + attr = getattr(self._target, name) + if not callable(attr): + return attr + label = f"{self._label}.{name}" + + def call(*args): + self._log.append(label) + args = [a._target if isinstance(a, Counting) else a for a in args] + return _counted(attr(*args), self._log, label) + + return call + + +@pytest.fixture +def small_db(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE T") + with temp_db.transaction(): + for key in range(5): + temp_db.command( + "sql", "INSERT INTO T SET k = ?, v = ?, s = ?", key, key * 10, f"s{key}" + ) + return temp_db + + +@pytest.fixture +def log(small_db, monkeypatch): + """The list every counted call is appended to; the bridge classes are counted.""" + calls = [] + for name in ("ReaderCalls", "RowAccess"): + real = results._bridge_class(name) + assert real is not None, f"the bridge jar must carry {name}" + monkeypatch.setitem(results._BRIDGE_CLASSES, name, Counting(real, calls, name)) + return calls + + +def _row(db, query="SELECT k, v, s FROM T WHERE k = 3"): + return db.query("sql", query).first() + + +def _per_property(java_row): + """A row read the way the bridge-less path reads it: one read per name and per value.""" + return { + str(n): convert_java_to_python(java_row.getProperty(str(n))) + for n in java_row.getPropertyNames() + } + + +def _per_property_document(java_document): + return { + str(n): convert_java_to_python(java_document.get(str(n))) + for n in java_document.getPropertyNames() + } + + +# ---------------------------------------------------------------- Result.to_dict + + +def test_result_to_dict_is_one_call_and_one_read(small_db, log): + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + assert row.to_dict() == {"k": 3, "v": 30, "s": "s3"} + assert log == ["ReaderCalls.rowJson", "ReaderCalls.rowJson[0]"] + + +def test_result_to_dict_costs_less_than_the_row_access_path(small_db, log, monkeypatch): + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + new = row.to_dict() + new_log = list(log) + + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + old = row.to_dict() + old_log = list(log) + + assert new == old + assert list(new) == list(old) + assert old_log[0] == "RowAccess.namesAndValues" + assert len(new_log) == 2 + assert ( + len(old_log) >= 9 + ) # the call, then the names array and each name, the values array and each value + assert len(new_log) < len(old_log) + + +def test_result_to_dict_without_any_bridge_class_is_the_same(small_db, monkeypatch): + expected = _row(small_db).to_dict() + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + monkeypatch.setitem(results._BRIDGE_CLASSES, "RowAccess", None) + row = _row(small_db) + assert row.to_dict() == expected + assert list(row.to_dict()) == list(expected) + + +def test_result_to_dict_without_conversion_is_unchanged(small_db): + row = _row(small_db) + raw = row.to_dict(convert_types=False) + assert {str(k): int(v) if k != "s" else str(v) for k, v in raw.items()} == { + "k": 3, + "v": 30, + "s": "s3", + } + + +def test_every_property_type_reads_as_the_per_property_path_reads_it(db): + seen = 0 + for row in db.query("sql", "SELECT FROM Wide"): + want = _per_property(row._java_result) + assert row.get_property_names() == list( + want + ) # before to_dict() has cached them + got = row.to_dict() + assert exact(got) == exact(want) + assert list(got) == list(want) + seen += 1 + assert seen == 7 + + +@pytest.mark.parametrize( + "query", + [ + "SELECT FROM Wide LIMIT 1", + "SELECT FROM VT", + "SELECT count(*) AS c, list(s) AS ls, set(s) AS ss FROM Wide", + "SELECT [1, 2] AS a, {'x': {'y': 1}} AS m, 1 / 3.0 AS q, date('2020-01-01') AS d, 2.5 AS dec", + "SELECT s AS `\u0001`, i FROM Wide LIMIT 3", + ], +) +def test_other_row_shapes_read_the_same(db, query): + rows = 0 + for row in db.query("sql", query): + want = _per_property(row._java_result) + assert row.get_property_names() == list(want) + assert exact(row.to_dict()) == exact(want) + assert list(row.to_dict()) == list(want) + rows += 1 + assert rows >= 1 + + +def test_values_sql_cannot_produce_read_the_same(db): + """Java values built in the test: instants, offsets, sets, a tag-named map key, a tag-named property.""" + time = jpype.JClass("java.time.Instant") + zoned = jpype.JClass("java.time.ZonedDateTime") + zone = jpype.JClass("java.time.ZoneId") + big_integer = jpype.JClass("java.math.BigInteger") + hash_map = jpype.JClass("java.util.HashMap") + linked_set = jpype.JClass("java.util.LinkedHashSet") + java_date = jpype.JClass("java.util.Date") + instant = time.parse("2021-02-03T04:05:06.789123456Z") + tag_key = hash_map() + tag_key.put("\u0001", "tag") + members = linked_set() + members.add("a") + members.add("b") + rows = [ + { + "instant": instant, + "zoned": zoned.ofInstant(instant, zone.of("Asia/Seoul")), + "big": big_integer("123456789012345678901234567890"), + "util_date": java_date(1612325106789), + "set": members, + "tag_key": tag_key, + "ints": jpype.JArray(jpype.JInt)([1, 2]), + "floats": jpype.JArray(jpype.JFloat)([0.5, float("nan")]), + "emoji": "x \U0001f600 
", + }, + {"\u0001": 1, "other": 2}, + ] + seen = 0 + for row in results.ResultSet(_in_memory(rows), db): + want = _per_property(row._java_result) + assert row.get_property_names() == list(want) + got = row.to_dict() + assert exact(got) == exact(want) + assert list(got) == list(want) + seen += 1 + assert seen == 2 + + +def test_a_value_python_cannot_hold_raises_as_before(db): + far_future = jpype.JClass("java.time.Instant").parse("+10000-01-01T00:00:00Z") + with pytest.raises(OverflowError): + [r.to_dict() for r in results.ResultSet(_in_memory([{"t": far_future}]), db)] + + +def test_an_object_array_raises_as_before(db): + strings = jpype.JArray(jpype.JString)(["a", "b"]) + with pytest.raises(BufferError): + [r.to_dict() for r in results.ResultSet(_in_memory([{"a": strings}]), db)] + + +def test_result_to_dict_remembers_the_names(small_db, log): + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + row.to_dict() + log.clear() + assert row.get_property_names() == ["k", "v", "s"] + assert row.property_names == ["k", "v", "s"] + assert log == [] + + +# ---------------------------------------------------------------- Document.to_dict + + +def test_document_to_dict_is_one_call_and_one_read(small_db, log): + record = _row(small_db, "SELECT FROM T WHERE k = 3").get_element() + record._java_document = Counting(record._java_document, log, "doc") + log.clear() + assert record.to_dict() == {"k": 3, "v": 30, "s": "s3"} + assert log == ["ReaderCalls.documentJson", "ReaderCalls.documentJson[0]"] + + +def test_document_to_dict_costs_less_than_the_per_property_path( + small_db, log, monkeypatch +): + record = _row(small_db, "SELECT FROM T WHERE k = 3").get_element() + record._java_document = Counting(record._java_document, log, "doc") + log.clear() + new = record.to_dict() + new_log = list(log) + + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + record = _row(small_db, "SELECT FROM T WHERE k = 3").get_element() + record._java_document = Counting(record._java_document, log, "doc") + log.clear() + old = record.to_dict() + old_log = list(log) + + assert new == old + assert list(new) == list(old) + assert old_log.count("doc.get") == 3 + assert "doc.getPropertyNames" in old_log + assert len(old_log) >= 7 # getPropertyNames, each name, one get per property + assert len(new_log) == 2 + + +def test_document_to_dict_without_the_bridge_class_is_the_same(small_db, monkeypatch): + expected = _row(small_db, "SELECT FROM T WHERE k = 3").get_element().to_dict() + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + record = _row(small_db, "SELECT FROM T WHERE k = 3").get_element() + assert record.to_dict() == expected + assert sorted(record.get_property_names()) == ["k", "s", "v"] + + +def test_every_property_type_of_a_record_reads_as_the_per_property_path_reads_it(db): + seen = 0 + for row in db.query("sql", "SELECT FROM Wide"): + record = row.get_element() + want = _per_property_document(record._java_document) + assert record.get_property_names() == list(want) + got = record.to_dict() + assert exact(got) == exact(want) + assert list(got) == list(want) + seen += 1 + assert seen == 7 + vertices = [ + r.get_element() for r in db.query("sql", "SELECT FROM VT") + ] # a vertex wrapper reads the same way + assert len(vertices) == 1 + assert isinstance(vertices[0], graph.Vertex) + assert exact(vertices[0].to_dict()) == exact( + _per_property_document(vertices[0]._java_document) + ) + + +def test_a_property_named_like_the_tag_reads_as_before(small_db): + with small_db.transaction(): + doc = small_db.new_document("T") + doc.set("\u0001", 1).set("other", 2) + doc.save() + rid = str(doc.get_identity()) + record = small_db.lookup_by_rid(rid) + want = _per_property_document(record._java_document) + assert want == {"\u0001": 1, "other": 2} + assert record.get_property_names() == list(want) + assert exact(record.to_dict()) == exact(want) + + +def test_a_record_read_again_after_a_set_has_the_new_property(small_db): + with small_db.transaction(): + doc = small_db.new_document("T") + doc.set("a", 1) + assert doc.to_dict() == {"a": 1} + doc.set("b", 2) + assert doc.to_dict() == {"a": 1, "b": 2} + assert sorted(doc.get_property_names()) == ["a", "b"] + + +def test_a_record_after_its_database_closed_still_raises(small_db): + record = _row(small_db, "SELECT FROM T WHERE k = 1").get_element() + row = _row(small_db, "SELECT FROM T WHERE k = 2") + small_db.close() + with pytest.raises(ArcadeDBError, match="Database is closed"): + record.to_dict() + with pytest.raises(ArcadeDBError, match="Database is closed"): + record.get_property_names() + with pytest.raises(ArcadeDBError, match="Database is closed"): + row.to_dict() + with pytest.raises(ArcadeDBError, match="Database is closed"): + row.get_property_names() + + +# ---------------------------------------------------------------- property names + + +def test_row_property_names_are_one_call(small_db, log): + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + assert row.get_property_names() == ["k", "v", "s"] + assert log == ["ReaderCalls.rowNames"] + assert row.property_names == ["k", "v", "s"] + assert log == ["ReaderCalls.rowNames"] # cached + + +def test_document_property_names_are_one_call(small_db, log): + record = _row(small_db, "SELECT FROM T WHERE k = 3").get_element() + record._java_document = Counting(record._java_document, log, "doc") + log.clear() + assert sorted(record.get_property_names()) == ["k", "s", "v"] + assert log == ["ReaderCalls.documentNames"] + assert sorted(record.get_property_names()) == ["k", "s", "v"] + assert log == ["ReaderCalls.documentNames"] # cached + + +def test_property_names_cost_less_than_the_walk_over_the_java_set( + small_db, log, monkeypatch +): + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + row = _row(small_db) + row._java_result = Counting(row._java_result, log, "row") + log.clear() + assert row.get_property_names() == ["k", "v", "s"] + # the call, then one read per name of the Java set + assert log == [ + "row.getPropertyNames", + "row.getPropertyNames.next", + "row.getPropertyNames.next", + "row.getPropertyNames.next", + ] + + +UNUSUAL_NAMES = ['a"b', "c\\d", "sp ace", "é日本", "\U0001f600", "tab\tname"] + + +def test_unusual_property_names_come_back_as_the_engine_has_them(small_db): + with small_db.transaction(): + doc = small_db.new_document("T") + for index, name in enumerate(UNUSUAL_NAMES): + doc.set(name, index) + doc.save() + rid = str(doc.get_identity()) + record = small_db.lookup_by_rid(rid) + expected = [str(n) for n in record._java_document.getPropertyNames()] + assert sorted(expected) == sorted(UNUSUAL_NAMES) + assert record.get_property_names() == expected + record = small_db.lookup_by_rid(rid) + assert list(record.to_dict()) == expected + row = small_db.query("sql", "SELECT FROM T WHERE k IS NULL").first() + assert row.get_property_names() == [ + str(n) for n in row._java_result.getPropertyNames() + ] + assert list(row.to_dict()) == row.get_property_names() + + +# ---------------------------------------------------------------- ResultSet iteration + + +def test_iteration_is_one_call_per_row(small_db, log): + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + rs._java_result_set = Counting(rs._java_result_set, log, "rs") + log.clear() + rows = list(rs) + calls = list(log) + assert [r.get("k") for r in rows] == [0, 1, 2, 3, 4] + assert calls[:6] == ["ReaderCalls.nextOrNull"] * 6 # five rows and the end + assert set(calls[6:]) <= {"rs.close"} + + +def test_iteration_costs_less_than_hasnext_and_next(small_db, log, monkeypatch): + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + rs._java_result_set = Counting(rs._java_result_set, log, "rs") + log.clear() + new_rows = list(rs) + new_calls = len([c for c in log if c != "rs.close"]) + + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + rs._java_result_set = Counting(rs._java_result_set, log, "rs") + log.clear() + old_rows = list(rs) + old_calls = len([c for c in log if c != "rs.close"]) + + expected = [{"k": k} for k in range(5)] + assert [r.to_dict() for r in new_rows] == expected + assert [r.to_dict() for r in old_rows] == expected + assert old_calls == 11 # hasNext and next per row, and the hasNext that ends it + assert new_calls == 6 + assert new_calls < old_calls + + +def test_a_row_is_read_when_it_is_asked_for(small_db, log): + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + rs._java_result_set = Counting(rs._java_result_set, log, "rs") + log.clear() + first = next(rs) + calls = list(log) + assert first.get("k") == 0 + assert calls == ["ReaderCalls.nextOrNull"] # no row taken ahead + assert not rs._closed and not rs._exhausted + assert [r.get("k") for r in rs] == [1, 2, 3, 4] + assert rs._closed and rs._exhausted + assert list(rs) == [] + + +def test_iteration_without_the_bridge_class_is_the_same(small_db, monkeypatch): + monkeypatch.setitem(results._BRIDGE_CLASSES, "ReaderCalls", None) + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + assert [r.get("k") for r in rs] == [0, 1, 2, 3, 4] + assert rs._closed and rs._exhausted + assert list(rs) == [] + + +def test_a_set_closed_before_its_end_still_raises(small_db): + rs = small_db.query("sql", "SELECT k FROM T ORDER BY k") + assert next(rs).get("k") == 0 + rs.close() + with pytest.raises(ArcadeDBError, match="closed before all its rows"): + next(rs) + + +def test_an_empty_set_ends_at_once(small_db, log): + rs = small_db.query("sql", "SELECT k FROM T WHERE k = 99") + rs._java_result_set = Counting(rs._java_result_set, log, "rs") + log.clear() + assert list(rs) == [] + assert log[0] == "ReaderCalls.nextOrNull" + assert rs._exhausted and rs._closed