From f89bd4da4c23832a3356bd062546875054376060 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Wed, 4 Nov 2026 05:48:24 +0900 Subject: [PATCH 1/3] 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 56f82a7c..7a23dfeb 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 @@ -179,6 +197,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 c2e11377..e05479ee 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 @@ -1260,9 +1287,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: @@ -1428,10 +1462,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 00000000..dd937993 --- /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 9c2e0f82..db2b9374 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 51677a9f..b3cbf6e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,7 @@ "test_insert_many_gc_pause.py": "the cyclic collector pause is the JVM build's (the native insert_many allocates nothing the collector tracks)", "test_graph_batch_tagged_shapes.py": "the JVM wheel's graph batch bulk path (bridge VertexBatcher and EdgeBatcher, the property-matrix fallback)", "test_async_proxy_cache.py": "the JPype proxy classes of the JVM build's async executor", + "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 00000000..ecd104e4 --- /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 From 0c688a5fa04fa3fa5e00fdc7abe11b1190d91704 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Thu, 5 Nov 2026 06:45:45 +0900 Subject: [PATCH 2/3] Docs: the timed figures of the JVM reader crossings (to_dict and result iteration) The study that adopts this PR timed record to dict at 3 and 12 properties and iteration into dicts at 100 and 10,000 rows on the arcadedb-jvm package. The Results section gets those four gating figures with their 95% intervals, the unit (time per record or row), the control that the change does not touch, and the scope (JVM build, these workloads, one laptop). The numbers are generated from the study's ab-score.md by script. Co-Authored-By: Claude Opus 5.5 --- .../pages/how-to/databases-and-queries.adoc | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc index 9a8e10be..bde487dd 100644 --- a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc +++ b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc @@ -242,6 +242,27 @@ A query vector for `vectorNeighbors` can be a numpy array or a list. This sectio + One laptop study on the native build, 1,000,000 rows read with `batch_size=10_000`, 10 clean pairs of this change against the previous behaviour; each figure is the ratio of the new value to the old one, so below 1 is lower. Peak RSS: 0.286 (95% interval 0.284 to 0.289). Time to the first batch: 0.010 (95% interval 0.009 to 0.010). Wall time of the whole read: 0.958 (95% interval 0.952 to 0.966). This is one machine and one result shape, not a speed promise. +On the JVM build, `Document.to_dict()`, `Result.to_dict()` and `get_property_names()` cross into the JVM once per record or row, not once per property, and stepping through a result set takes one crossing per row, not two. The values and their Python types are the same as before. This was timed on the `{jvm-pkg-name}` package, built with the change and without it, one fresh process per run. The time is per record or row, in microseconds, and the table gives the ratio of the time with the change to the time without it (median over 12 clean pairs, 95% interval). Below 1 means less time. + +[cols="3,2"] +|=== +| What was timed | Ratio with the change / without it + +| `to_dict()` of a `Document`, 1,000 records of 3 properties (`INTEGER`, `STRING`, `DOUBLE`) +| 0.307 [0.303, 0.311] + +| `to_dict()` of a `Document`, 1,000 records of 12 properties +| 0.133 [0.132, 0.135] + +| `[row.to_dict() for row in db.query(...)]`, 100 rows of 3 properties, the query start included +| 0.657 [0.649, 0.664] + +| `[row.to_dict() for row in db.query(...)]`, 10,000 rows of 3 properties, the query start included +| 0.474 [0.454, 0.480] +|=== + +The control times `json.dumps` of rows already fetched, which uses no query and no JPype call. Its ratios on the four workloads are 0.996, 0.987, 0.993 and 1.000, and the same build measured twice gave ratios whose intervals contain 1. The figures come from one Linux x86_64 laptop with two cores for the process, Python 3.12, the median of 21 timed passes per run, and builds alternated in both orders. They cover the JVM build and these four workloads only, not wider rows, other value types, `to_list()`, or the native build. + === Record ids in rows A record id inside a row value (`SELECT @rid AS r`, a `LINK` property, a list of links such as `out('Knows').@rid`) comes back as an RID object, not as text. `str(rid)` is the text (`"#10:5"`), so is an f-string, and `get_rid()` always returns text or `None`. Two RIDs of the same record are equal and hash alike, so they work in sets and as dict keys, and RIDs sort by bucket, then position. `getBucketId()`, `getPosition()`, `toString()` and `isValid()` work on the object. It goes back in as a query parameter (`WHERE @rid = ?`, or a list passed by name) and to `db.lookup_by_rid()`, as itself or as its text. `to_json_list()` is the exception: a record id in its rows is the text (`"#10:5"`), on both builds, and so is a map key that is not a string, because a JSON object has no other kind of key. From cad8e655eb0195dcf3f1889252dd0b6fd0e7003f Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Thu, 5 Nov 2026 11:56:34 +0900 Subject: [PATCH 3/3] tests: list the read- and reader-crossing tests together in the native skip list #214 and #233 each add one NATIVE_NOT_APPLICABLE entry at the same place, so merging one made the other conflict and cost a full CI run per rebase. Both branches now carry the same two entries in the same order; an entry for a test file that is not on this branch is only a collect_ignore name and changes nothing here. Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index b3cbf6e3..f14bafb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,7 @@ "test_insert_many_gc_pause.py": "the cyclic collector pause is the JVM build's (the native insert_many allocates nothing the collector tracks)", "test_graph_batch_tagged_shapes.py": "the JVM wheel's graph batch bulk path (bridge VertexBatcher and EdgeBatcher, the property-matrix fallback)", "test_async_proxy_cache.py": "the JPype proxy classes of the JVM build's async executor", + "test_read_crossings.py": "crossing counts of the JVM build's read path (JPype proxies of the Java objects and the bridge classes)", "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"):