diff --git a/drivine/cypher/sql/oracle_cypher_compiler.py b/drivine/cypher/sql/oracle_cypher_compiler.py index 80198f7..8bd8c2b 100644 --- a/drivine/cypher/sql/oracle_cypher_compiler.py +++ b/drivine/cypher/sql/oracle_cypher_compiler.py @@ -959,13 +959,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 1ae1d2b..51e359e 100644 --- a/tests/integration/test_cypher_compiled.py +++ b/tests/integration/test_cypher_compiled.py @@ -2944,6 +2944,89 @@ 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], + ] + # 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( cypher_pm: PersistenceManager, ): diff --git a/tests/sync_integration/test_cypher_compiled.py b/tests/sync_integration/test_cypher_compiled.py index 9e00b7b..5b9d5a8 100644 --- a/tests/sync_integration/test_cypher_compiled.py +++ b/tests/sync_integration/test_cypher_compiled.py @@ -2919,6 +2919,93 @@ 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], + ] + # 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( 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():