From 1c06d5b9e70d763ee37d3aab04cbb46711d9fc82 Mon Sep 17 00:00:00 2001 From: Mylonas Charilaos Date: Fri, 26 Jun 2026 00:16:02 +0200 Subject: [PATCH 1/2] Support reverse Cypher typed traversal --- docs/cypher.rst | 35 +++++++++++++++++++++++++++------ src/pygraphdb/cypher.py | 38 ++++++++++++++++++++++++++++-------- tests/test_cypher.py | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/docs/cypher.rst b/docs/cypher.rst index 35b6608..efcd190 100644 --- a/docs/cypher.rst +++ b/docs/cypher.rst @@ -60,12 +60,12 @@ Legend: ✅ supported, 🟡 partially supported, ❌ not supported. - Cypher supports repeated outgoing typed hops from an anchored start node. * - Reverse typed traversal - ✅ - - ❌ - - DB API supports ``direction="in"``. Cypher support for ``<-[:TYPE]-`` is not implemented yet. + - ✅ + - DB API supports ``direction="in"``. Cypher supports ``<-[:TYPE]-`` from an anchored node. * - Undirected typed traversal - ✅ - - ❌ - - DB API supports ``direction="any"``. Cypher support for ``-[:TYPE]-`` is not implemented yet. + - ✅ + - DB API supports ``direction="any"``. Cypher supports ``-[:TYPE]-`` from an anchored node. * - Untyped BFS traversal - ✅ - ❌ @@ -178,7 +178,31 @@ Relationship variables can be used across multiple hops as well. .. code-block:: python result = graph_db.query( - 'MATCH (d {id: "drug-1"})-[r1:drug-to-protein]->(p)-[r2:protein-to-disease]->(x) RETURN r1, r2, x' + 'MATCH (d {id: "drug-1"})-[r1:drug-to-protein]->(p)-[r2:protein-to-disease]->(x) RETURN r1, r2, x' + ) + +Reverse and Undirected Traversal +-------------------------------- + +Anchored typed traversals can follow outgoing, incoming, or either-direction +relationships. + +.. code-block:: python + + incoming = graph_db.query( + 'MATCH (p {id: "protein-1"})<-[:drug-to-protein]-(d) RETURN p, d' + ) + + undirected = graph_db.query( + 'MATCH (p {id: "protein-1"})-[:drug-to-protein]-(n) RETURN n' + ) + +Direction can vary by hop: + +.. code-block:: python + + result = graph_db.query( + 'MATCH (x {id: "disease-1"})<-[:protein-to-disease]-(p)<-[:drug-to-protein]-(d) RETURN x, p, d' ) Sampling Procedure @@ -206,7 +230,6 @@ Unsupported Cypher features raise ``ValueError`` with a message describing the supported subset. The current Cypher API does not yet support: - Multiple labels in one node pattern, such as ``(n:Drug:Approved)``. -- Reverse or undirected patterns such as ``<-[:TYPE]-`` or ``-[:TYPE]-``. - Unanchored all-node scans such as ``MATCH (n) RETURN n``. - ``WHERE`` predicates. - Property projections such as ``RETURN n.name``. diff --git a/src/pygraphdb/cypher.py b/src/pygraphdb/cypher.py index f690254..947a881 100644 --- a/src/pygraphdb/cypher.py +++ b/src/pygraphdb/cypher.py @@ -2,7 +2,7 @@ The supported subset maps directly to existing typed adjacency and sampling APIs: - MATCH (a {id: "node-id"})-[:TYPE1]->(b)-[:TYPE2]->(c) RETURN a, b, c + MATCH (a {id: "node-id"})-[:TYPE1]->(b)<-[:TYPE2]-(c) RETURN a, b, c CALL pg.sample_typed_paths(["node-id"], [{"edge_type": "TYPE", "sample_size": 2}]) YIELD path RETURN path """ @@ -19,10 +19,18 @@ rf"^\s*\((?P{_IDENTIFIER})\s*" rf"\{{\s*id\s*:\s*(?P['\"])(?P.*?)(?P=quote)\s*\}}\)" ) -_HOP_RE = re.compile( +_OUT_HOP_RE = re.compile( rf"^\s*-\s*\[(?:(?P{_IDENTIFIER})\s*)?:(?P[^\]\s]+)\]\s*->\s*" rf"\((?P{_IDENTIFIER})\)" ) +_IN_HOP_RE = re.compile( + rf"^\s*<-\s*\[(?:(?P{_IDENTIFIER})\s*)?:(?P[^\]\s]+)\]\s*-\s*" + rf"\((?P{_IDENTIFIER})\)" +) +_ANY_HOP_RE = re.compile( + rf"^\s*-\s*\[(?:(?P{_IDENTIFIER})\s*)?:(?P[^\]\s]+)\]\s*-\s*" + rf"\((?P{_IDENTIFIER})\)" +) _RETURN_RE = re.compile( rf"^\s*RETURN\s+(?P{_IDENTIFIER}(?:\s*,\s*{_IDENTIFIER})*)\s*;?\s*$", re.IGNORECASE | re.DOTALL, @@ -41,16 +49,17 @@ @dataclass(frozen=True) class TraversalHop: - """One outgoing typed relationship expansion in a parsed ``MATCH`` pattern.""" + """One typed relationship expansion in a parsed ``MATCH`` pattern.""" rel_var: str | None edge_type: str target_var: str + direction: str = "out" @dataclass(frozen=True) class MatchQuery: - """Parsed anchored, outgoing, typed path query.""" + """Parsed anchored typed path query.""" source_var: str source_id: str @@ -190,14 +199,15 @@ def _parse_match(query: str) -> MatchQuery: remainder = remainder[anchor_match.end():] hops = [] while True: - hop_match = _HOP_RE.match(remainder) - if hop_match is None: + hop_match, direction = _match_hop(remainder) + if hop_match is None or direction is None: break hops.append( TraversalHop( rel_var=hop_match.group("rel_var"), edge_type=hop_match.group("edge_type"), target_var=hop_match.group("target_var"), + direction=direction, ) ) remainder = remainder[hop_match.end():] @@ -248,7 +258,7 @@ def _execute_match(graph, parsed: MatchQuery) -> QueryResult: for adjacency in graph.iter_typed_adjacency( partial["current_node_id"], hop.edge_type, - direction="out", + direction=hop.direction, ): target_node = graph.get_node(adjacency["neighbor_id"]) if target_node is None: @@ -297,6 +307,18 @@ def _parse_returns(return_text: str) -> tuple[str, ...]: return tuple(part.strip() for part in return_text.split(",")) +def _match_hop(remainder: str): + for pattern, direction in ( + (_IN_HOP_RE, "in"), + (_OUT_HOP_RE, "out"), + (_ANY_HOP_RE, "any"), + ): + match = pattern.match(remainder) + if match is not None: + return match, direction + return None, None + + def _validate_match_returns(parsed: MatchQuery) -> None: bound_variables = {parsed.source_var} for hop in parsed.hops: @@ -349,7 +371,7 @@ def _split_top_level_args(args_text: str) -> list[str]: def _unsupported_query_error() -> ValueError: return ValueError( "Unsupported Cypher query. Supported subset: " - 'MATCH (a {id: "node-id"})-[:TYPE1]->(b)-[:TYPE2]->(c) RETURN a, b, c; ' + 'MATCH (a {id: "node-id"})-[:TYPE1]->(b)<-[:TYPE2]-(c) RETURN a, b, c; ' 'MATCH (n:Label) RETURN n; ' 'CALL pg.sample_typed_paths(["node-id"], [{"edge_type": "TYPE", "sample_size": 2}]) YIELD path RETURN path' ) diff --git a/tests/test_cypher.py b/tests/test_cypher.py index a11fda5..91c2230 100644 --- a/tests/test_cypher.py +++ b/tests/test_cypher.py @@ -111,6 +111,49 @@ def test_cypher_multi_hop_typed_match_can_bind_relationships(graph_db): } +def test_cypher_reverse_typed_match_returns_bound_nodes(graph_db): + populate_typed_graph(graph_db) + + result = graph_db.query('MATCH (p {id: "protein-1"})<-[:drug-to-protein]-(d) RETURN p, d') + + assert result.columns == ("p", "d") + assert len(result) == 1 + assert result.records[0]["p"].get_id == "protein-1" + assert result.records[0]["d"].get_id == "drug-1" + + +def test_cypher_reverse_typed_match_can_bind_relationship(graph_db): + populate_typed_graph(graph_db) + + result = graph_db.query('MATCH (p {id: "protein-1"})<-[r:drug-to-protein]-(d) RETURN r, d') + + assert result.columns == ("r", "d") + assert len(result) == 1 + assert result.records[0]["r"].get_id == "d1-p1" + assert result.records[0]["d"].get_id == "drug-1" + + +def test_cypher_undirected_typed_match_returns_both_directions(graph_db): + populate_typed_graph(graph_db) + + result = graph_db.query('MATCH (p {id: "protein-1"})-[:drug-to-protein]-(n) RETURN n') + + assert [record["n"].get_id for record in result] == ["drug-1"] + + +def test_cypher_mixed_direction_multi_hop_match(graph_db): + populate_typed_graph(graph_db) + + result = graph_db.query( + 'MATCH (x {id: "disease-1"})<-[:protein-to-disease]-(p)<-[:drug-to-protein]-(d) RETURN x, p, d' + ) + + assert result.columns == ("x", "p", "d") + assert [(record["x"].get_id, record["p"].get_id, record["d"].get_id) for record in result] == [ + ("disease-1", "protein-1", "drug-1") + ] + + def test_cypher_sample_typed_paths_call_returns_paths(graph_db): populate_typed_graph(graph_db) From 78a0240424a8b399948f0e5d9636cef08adb8c10 Mon Sep 17 00:00:00 2001 From: Mylonas Charilaos Date: Fri, 26 Jun 2026 00:20:26 +0200 Subject: [PATCH 2/2] Add Cypher projections and limits --- docs/cypher.rst | 36 ++++++++++++++++++--- src/pygraphdb/cypher.py | 70 +++++++++++++++++++++++++++++++++-------- tests/test_cypher.py | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 18 deletions(-) diff --git a/docs/cypher.rst b/docs/cypher.rst index efcd190..f604fa0 100644 --- a/docs/cypher.rst +++ b/docs/cypher.rst @@ -28,8 +28,8 @@ Legend: ✅ supported, 🟡 partially supported, ❌ not supported. - Notes * - Node and edge property storage - ✅ - - 🟡 - - Cypher can return bound ``Node`` and ``Edge`` objects, but property projections such as ``RETURN n.name`` are not implemented yet. + - ✅ + - Cypher can return bound ``Node`` and ``Edge`` objects and project properties such as ``RETURN n.name`` or ``RETURN r.score``. * - Native node labels - ✅ - ✅ @@ -86,6 +86,10 @@ Legend: ✅ supported, 🟡 partially supported, ❌ not supported. - 🟡 - ❌ - DB API has exact-match index lookup helpers, but Cypher ``WHERE`` parsing is future work. + * - Result limiting + - ✅ + - ✅ + - Cypher supports ``LIMIT`` on label scans, anchored typed traversals, and ``pg.sample_typed_paths`` calls. * - Mutating Cypher queries - ✅ - ❌ @@ -123,6 +127,21 @@ it for performance-sensitive lookup. If a property index is not registered, Cypher still restricts the search to the label index and then filters decoded nodes in Python. +Property Projections and Limits +------------------------------- + +Use dot notation in ``RETURN`` to project values from bound nodes and +relationships. Missing properties return ``None``. The special fields ``id`` and +``labels`` are available on nodes; ``id``, ``source``, and ``target`` are +available on relationships. + +.. code-block:: python + + result = graph_db.query('MATCH (d:Drug) RETURN d.id, d.name LIMIT 10') + + for record in result: + print(record["d.id"], record["d.name"]) + Anchored One-Hop Traversal -------------------------- @@ -157,7 +176,15 @@ Relationship variables can be bound and returned. ) for record in result: - print(record["r"].get_id, record["r"].properties) + print(record["r"].get_id, record["r"].properties) + +Relationship properties can be projected directly. + +.. code-block:: python + + result = graph_db.query( + 'MATCH (d {id: "drug-1"})-[r:drug-to-disease]->(x) RETURN r.id, r.type LIMIT 1' + ) Anchored Multi-Hop Traversal ---------------------------- @@ -232,5 +259,4 @@ supported subset. The current Cypher API does not yet support: - Multiple labels in one node pattern, such as ``(n:Drug:Approved)``. - Unanchored all-node scans such as ``MATCH (n) RETURN n``. - ``WHERE`` predicates. -- Property projections such as ``RETURN n.name``. -- ``LIMIT``, ``ORDER BY``, aggregation, joins across separate patterns, or mutation clauses. +- ``ORDER BY``, aggregation, joins across separate patterns, or mutation clauses. diff --git a/src/pygraphdb/cypher.py b/src/pygraphdb/cypher.py index 947a881..8142f68 100644 --- a/src/pygraphdb/cypher.py +++ b/src/pygraphdb/cypher.py @@ -2,7 +2,7 @@ The supported subset maps directly to existing typed adjacency and sampling APIs: - MATCH (a {id: "node-id"})-[:TYPE1]->(b)<-[:TYPE2]-(c) RETURN a, b, c + MATCH (a {id: "node-id"})-[:TYPE1]->(b)<-[:TYPE2]-(c) RETURN a.name, b LIMIT 10 CALL pg.sample_typed_paths(["node-id"], [{"edge_type": "TYPE", "sample_size": 2}]) YIELD path RETURN path """ @@ -15,6 +15,8 @@ _IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" _IDENTIFIER_RE = re.compile(rf"^{_IDENTIFIER}$") +_RETURN_ITEM = rf"{_IDENTIFIER}(?:\.{_IDENTIFIER})?" +_RETURN_ITEMS = rf"{_RETURN_ITEM}(?:\s*,\s*{_RETURN_ITEM})*" _ANCHOR_RE = re.compile( rf"^\s*\((?P{_IDENTIFIER})\s*" rf"\{{\s*id\s*:\s*(?P['\"])(?P.*?)(?P=quote)\s*\}}\)" @@ -32,17 +34,17 @@ rf"\((?P{_IDENTIFIER})\)" ) _RETURN_RE = re.compile( - rf"^\s*RETURN\s+(?P{_IDENTIFIER}(?:\s*,\s*{_IDENTIFIER})*)\s*;?\s*$", + rf"^\s*RETURN\s+(?P{_RETURN_ITEMS})(?:\s+LIMIT\s+(?P\d+))?\s*;?\s*$", re.IGNORECASE | re.DOTALL, ) _NODE_SCAN_RE = re.compile( rf"^\s*MATCH\s+\((?P{_IDENTIFIER}):(?P