From 1fd9c94673fd440dd98c50d09d5cd098f3ce254c Mon Sep 17 00:00:00 2001 From: jasper blues Date: Fri, 30 Oct 2026 08:07:08 +1000 Subject: [PATCH 1/2] Work out keys()' data guide per row on Oracle, so each row has its own keys On Oracle, keys() gave every row the first row's keys. The map's data guide was worked out by a scalar subquery in the select list of temporal_elements' first lateral view, and Oracle 23 works that subquery out once per statement. JSON_DATAGUIDE is now that view's own aggregate over DUAL, so it is worked out for each row, as drivine4j does since #430. Tests ported from drivine4j#430: keys of nodes, relationships, maps and parameters on each row, in a comprehension, a quantifier, a DISTINCT and an order. Fixes #257 --- drivine/cypher/sql/oracle_cypher_compiler.py | 10 ++- tests/integration/test_cypher_compiled.py | 79 ++++++++++++++++++ .../sync_integration/test_cypher_compiled.py | 82 +++++++++++++++++++ tests/unit/test_cypher_zoned_list_sort.py | 4 + 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/drivine/cypher/sql/oracle_cypher_compiler.py b/drivine/cypher/sql/oracle_cypher_compiler.py index 97d03fb..299a00e 100644 --- a/drivine/cypher/sql/oracle_cypher_compiler.py +++ b/drivine/cypher/sql/oracle_cypher_compiler.py @@ -935,13 +935,15 @@ def _map_keys(self, json: str) -> str: bound first: the data guide's query reads a column of a row outside it, and not an expression of one. The guide's paths are read by their places, as `temporal_elements` reads them: a `JSON_TABLE` over the guide of a map that holds a list of temporal values worked out item by item stalls the parse as one over the list does - (#331, #347). They are so read for every map, not only one the statement makes hold such a list: over the guide, - which a subquery gives, they are read many times faster than `JSON_TABLE` reads them (`keys(n)` of 100,000 - nodes: 0.4 s, not 4.8 s). Null has no keys, and gives null. + (#331, #347). They are so read for every map, not only one the statement makes hold such a list. The guide is + worked out by the lateral view itself, as its one aggregate over `DUAL`: a subquery that works it out, in that + view's select list, is worked out once, and every row then had the first row's keys + (liberation-data/drivine4j#414, here #257). drivine4j measured the keys of 100,000 maps so at 7 s, where a + `JSON_TABLE` over each guide takes 4.4 s. Null has no keys, and gives null. """ def keys(map_: str) -> str: - paths = self.temporal_elements(f"(SELECT JSON_DATAGUIDE({map_}, 2) FROM DUAL)", "dg") + paths = self.temporal_elements(f"JSON_DATAGUIDE({map_}, 2)", "dg") return f"CASE WHEN {_kind(map_)} = 'object' THEN {_keys_of(paths)} END" return self.bound([json], lambda it: keys(it[0])) diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index 6ca50e3..bf09292 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -2604,6 +2604,85 @@ async def test_a_walk_read_through_a_with_then_sorted_by_to_string_in_a_second_w ] +async def test_keys_gives_each_rows_own_keys_of_a_node_a_relationship_a_map_or_a_parameter_wherever_it_is_read( + cypher_pm: PersistenceManager, +): + # On Oracle every row had the first row's keys (liberation-data/drivine4j#414, here #257): the map's data guide was + # worked out by a subquery in a lateral view's select list, which Oracle works out once. The spec's cases read one + # row's keys only. In a comprehension, a quantifier, a DISTINCT and an order as well. + async def rows(cypher: str, parameters: dict[str, Any] | None = None) -> list[Any]: + return await cypher_pm.query(spec(cypher, parameters)) + + await clear(cypher_pm) + await rows( + "CREATE (a:CyKeyed {id: 1, name: 'Andres', surname: 'Lopez'}), " + "(b:CyKeyed {id: 2, otherName: 'Andres', otherSurname: 'Lopez'}), (c:CyKeyed {id: 3, a: 1}), " + "(d:CyKeyed {id: 4, b: 2}), (a)-[:CY_KEYED {w: 1, p: 1}]->(b), (c)-[:CY_KEYED {w: 2, q: 2, z: 3}]->(d)" + ) + assert await rows("MATCH (n:CyKeyed) UNWIND keys(n) AS k RETURN n.id, k ORDER BY n.id, k") == [ + [1, "id"], + [1, "name"], + [1, "surname"], + [2, "id"], + [2, "otherName"], + [2, "otherSurname"], + [3, "a"], + [3, "id"], + [4, "b"], + [4, "id"], + ] + # The TCK's Graph8 #2. + assert await rows("MATCH (n:CyKeyed) UNWIND keys(n) AS x RETURN DISTINCT x ORDER BY x") == [ + "a", + "b", + "id", + "name", + "otherName", + "otherSurname", + "surname", + ] + assert await rows("MATCH (n:CyKeyed) WITH n ORDER BY n.id RETURN collect(size(keys(n)))") == [[3, 3, 2, 2]] + assert await rows("MATCH (n:CyKeyed) RETURN 'b' IN keys(n) ORDER BY n.id") == [False, False, False, True] + assert await rows("MATCH (n:CyKeyed) RETURN [k IN keys(n) WHERE k IN ['a', 'b'] | k + '!'] ORDER BY n.id") == [ + [], + [], + ["a!"], + ["b!"], + ] + assert await rows("MATCH (:CyKeyed)-[r:CY_KEYED]->() UNWIND keys(r) AS k RETURN r.w, k ORDER BY r.w, k") == [ + [1, "p"], + [1, "w"], + [2, "q"], + [2, "w"], + [2, "z"], + ] + assert await rows("UNWIND [{a: 1}, {b: 2}] AS m RETURN keys(m)") == [["a"], ["b"]] + assert await rows("UNWIND $ms AS m RETURN keys(m)", {"ms": [{"a": 1}, {"b": 2}]}) == [["a"], ["b"]] + assert await rows("WITH [{a: 1}, {b: 2}] AS ms RETURN [m IN ms | keys(m)]") == [[["a"], ["b"]]] + assert await rows("UNWIND [{a: 1}, {b: 2}] AS m RETURN [x IN [m, {c: 3}] | keys(x)]") == [ + [["a"], ["c"]], + [["b"], ["c"]], + ] + assert await rows("UNWIND [{b: 1}, {a: 2}, {b: 3}] AS m RETURN DISTINCT keys(m) ORDER BY keys(m)") == [ + ["a"], + ["b"], + ] + assert await rows("UNWIND [{b: 1}, {a: 2}] AS m RETURN coalesce(m.a, m.b) ORDER BY keys(m)") == [2, 1] + # A quantifier over each row's keys in a WHERE, the keys of a relationship an OPTIONAL MATCH didn't find, and of + # maps in a list each row holds. + assert await rows( + "UNWIND $rows AS row MATCH (n:CyKeyed {id: row.id}) WHERE any(k IN keys(row.props) WHERE k = 'b') RETURN n.id", + {"rows": [{"id": 1, "props": {"a": 1}}, {"id": 4, "props": {"b": 2}}]}, + ) == [4] + assert await rows( + "MATCH (n:CyKeyed) OPTIONAL MATCH (n)-[r:CY_KEYED]->() RETURN n.id, size(keys(r)) ORDER BY n.id" + ) == [[1, 2], [2, None], [3, 3], [4, None]] + assert await rows("UNWIND [[{a: 1}, {b: 2, c: 3}], [{d: 4, e: 5, f: 6}]] AS l RETURN [x IN l | size(keys(x))]") == [ + [1, 2], + [3], + ] + + async def test_lists_and_maps_that_hold_a_null_compare_item_by_item_null_where_no_pair_differs( cypher_pm: PersistenceManager, ): diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 16356b3..f53b864 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -2582,6 +2582,88 @@ def test_a_walk_read_through_a_with_then_sorted_by_to_string_in_a_second_with_ke ] +def test_keys_gives_each_rows_own_keys_of_a_node_a_relationship_a_map_or_a_parameter_wherever_it_is_read( + cypher_pm: PersistenceManager, +): + # On Oracle every row had the first row's keys (liberation-data/drivine4j#414, here #257): the map's data guide was + # worked out by a subquery in a lateral view's select list, which Oracle works out once. The spec's cases read one + # row's keys only. In a comprehension, a quantifier, a DISTINCT and an order as well. + def rows(cypher: str, parameters: dict[str, Any] | None = None) -> list[Any]: + return cypher_pm.query(spec(cypher, parameters)) + + clear(cypher_pm) + rows( + "CREATE (a:CyKeyed {id: 1, name: 'Andres', surname: 'Lopez'}), " + "(b:CyKeyed {id: 2, otherName: 'Andres', otherSurname: 'Lopez'}), (c:CyKeyed {id: 3, a: 1}), " + "(d:CyKeyed {id: 4, b: 2}), (a)-[:CY_KEYED {w: 1, p: 1}]->(b), (c)-[:CY_KEYED {w: 2, q: 2, z: 3}]->(d)" + ) + assert rows("MATCH (n:CyKeyed) UNWIND keys(n) AS k RETURN n.id, k ORDER BY n.id, k") == [ + [1, "id"], + [1, "name"], + [1, "surname"], + [2, "id"], + [2, "otherName"], + [2, "otherSurname"], + [3, "a"], + [3, "id"], + [4, "b"], + [4, "id"], + ] + # The TCK's Graph8 #2. + assert rows("MATCH (n:CyKeyed) UNWIND keys(n) AS x RETURN DISTINCT x ORDER BY x") == [ + "a", + "b", + "id", + "name", + "otherName", + "otherSurname", + "surname", + ] + assert rows("MATCH (n:CyKeyed) WITH n ORDER BY n.id RETURN collect(size(keys(n)))") == [[3, 3, 2, 2]] + assert rows("MATCH (n:CyKeyed) RETURN 'b' IN keys(n) ORDER BY n.id") == [False, False, False, True] + assert rows("MATCH (n:CyKeyed) RETURN [k IN keys(n) WHERE k IN ['a', 'b'] | k + '!'] ORDER BY n.id") == [ + [], + [], + ["a!"], + ["b!"], + ] + assert rows("MATCH (:CyKeyed)-[r:CY_KEYED]->() UNWIND keys(r) AS k RETURN r.w, k ORDER BY r.w, k") == [ + [1, "p"], + [1, "w"], + [2, "q"], + [2, "w"], + [2, "z"], + ] + assert rows("UNWIND [{a: 1}, {b: 2}] AS m RETURN keys(m)") == [["a"], ["b"]] + assert rows("UNWIND $ms AS m RETURN keys(m)", {"ms": [{"a": 1}, {"b": 2}]}) == [["a"], ["b"]] + assert rows("WITH [{a: 1}, {b: 2}] AS ms RETURN [m IN ms | keys(m)]") == [[["a"], ["b"]]] + assert rows("UNWIND [{a: 1}, {b: 2}] AS m RETURN [x IN [m, {c: 3}] | keys(x)]") == [ + [["a"], ["c"]], + [["b"], ["c"]], + ] + assert rows("UNWIND [{b: 1}, {a: 2}, {b: 3}] AS m RETURN DISTINCT keys(m) ORDER BY keys(m)") == [ + ["a"], + ["b"], + ] + assert rows("UNWIND [{b: 1}, {a: 2}] AS m RETURN coalesce(m.a, m.b) ORDER BY keys(m)") == [2, 1] + # A quantifier over each row's keys in a WHERE, the keys of a relationship an OPTIONAL MATCH didn't find, and of + # maps in a list each row holds. + assert rows( + "UNWIND $rows AS row MATCH (n:CyKeyed {id: row.id}) WHERE any(k IN keys(row.props) WHERE k = 'b') RETURN n.id", + {"rows": [{"id": 1, "props": {"a": 1}}, {"id": 4, "props": {"b": 2}}]}, + ) == [4] + assert rows("MATCH (n:CyKeyed) OPTIONAL MATCH (n)-[r:CY_KEYED]->() RETURN n.id, size(keys(r)) ORDER BY n.id") == [ + [1, 2], + [2, None], + [3, 3], + [4, None], + ] + assert rows("UNWIND [[{a: 1}, {b: 2, c: 3}], [{d: 4, e: 5, f: 6}]] AS l RETURN [x IN l | size(keys(x))]") == [ + [1, 2], + [3], + ] + + def test_lists_and_maps_that_hold_a_null_compare_item_by_item_null_where_no_pair_differs( cypher_pm: PersistenceManager, ): diff --git a/tests/unit/test_cypher_zoned_list_sort.py b/tests/unit/test_cypher_zoned_list_sort.py index 5a88fde..409cbe8 100644 --- a/tests/unit/test_cypher_zoned_list_sort.py +++ b/tests/unit/test_cypher_zoned_list_sort.py @@ -129,6 +129,10 @@ def test_on_oracle_a_value_that_may_hold_temporal_values_and_a_map_of_them_and_i keys = OracleCypherCompiler("g").compile(KEYED).sql assert "PASSING dg_r.n - 1" in keys, keys assert "JSON_TABLE((SELECT JSON_DATAGUIDE" not in keys, keys + # Worked out by the lateral view itself: a subquery in its select list is worked out once, for every row + # (liberation-data/drivine4j#414, here #257). + assert "LATERAL (SELECT /*+ NO_MERGE */ JSON_DATAGUIDE(" in keys, keys + assert "(SELECT JSON_DATAGUIDE" not in keys, keys def test_on_oracle_a_list_of_temporal_values_a_with_takes_out_of_an_unwound_map_is_read_item_by_item_too(): From 4113395e5c45040e38b29347e1b79f660169dbf1 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Fri, 30 Oct 2026 12:59:45 +1000 Subject: [PATCH 2/2] Test the keys of nested maps on two rows whose keys differ --- tests/integration/test_cypher_compiled.py | 4 ++++ tests/sync_integration/test_cypher_compiled.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/tests/integration/test_cypher_compiled.py b/tests/integration/test_cypher_compiled.py index bf09292..2f1ef05 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -2681,6 +2681,10 @@ async def rows(cypher: str, parameters: dict[str, Any] | None = None) -> list[An [1, 2], [3], ] + # A nested map's paths are in its parent's data guide too, and only those one step from the top are keys. + assert await rows( + "UNWIND [{a: {x: 1}}, {b: {y: 2}, c: 3}] AS m RETURN size(keys(m)), keys(coalesce(m.a, m.b))" + ) == [[1, ["x"]], [2, ["y"]]] async def test_lists_and_maps_that_hold_a_null_compare_item_by_item_null_where_no_pair_differs( diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index f53b864..8fbaa50 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -2662,6 +2662,11 @@ def rows(cypher: str, parameters: dict[str, Any] | None = None) -> list[Any]: [1, 2], [3], ] + # A nested map's paths are in its parent's data guide too, and only those one step from the top are keys. + assert rows("UNWIND [{a: {x: 1}}, {b: {y: 2}, c: 3}] AS m RETURN size(keys(m)), keys(coalesce(m.a, m.b))") == [ + [1, ["x"]], + [2, ["y"]], + ] def test_lists_and_maps_that_hold_a_null_compare_item_by_item_null_where_no_pair_differs(