Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions docs/cypher.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
- ✅
- ✅
Expand Down Expand Up @@ -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
- ✅
- ❌
Expand All @@ -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
- ✅
- ❌
Expand Down Expand Up @@ -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
--------------------------

Expand Down Expand Up @@ -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
----------------------------
Expand All @@ -178,7 +205,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
Expand Down Expand Up @@ -206,8 +257,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``.
- ``LIMIT``, ``ORDER BY``, aggregation, joins across separate patterns, or mutation clauses.
- ``ORDER BY``, aggregation, joins across separate patterns, or mutation clauses.
106 changes: 86 additions & 20 deletions src/pygraphdb/cypher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -15,47 +15,59 @@

_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<source_var>{_IDENTIFIER})\s*"
rf"\{{\s*id\s*:\s*(?P<quote>['\"])(?P<source_id>.*?)(?P=quote)\s*\}}\)"
)
_HOP_RE = re.compile(
_OUT_HOP_RE = re.compile(
rf"^\s*-\s*\[(?:(?P<rel_var>{_IDENTIFIER})\s*)?:(?P<edge_type>[^\]\s]+)\]\s*->\s*"
rf"\((?P<target_var>{_IDENTIFIER})\)"
)
_IN_HOP_RE = re.compile(
rf"^\s*<-\s*\[(?:(?P<rel_var>{_IDENTIFIER})\s*)?:(?P<edge_type>[^\]\s]+)\]\s*-\s*"
rf"\((?P<target_var>{_IDENTIFIER})\)"
)
_ANY_HOP_RE = re.compile(
rf"^\s*-\s*\[(?:(?P<rel_var>{_IDENTIFIER})\s*)?:(?P<edge_type>[^\]\s]+)\]\s*-\s*"
rf"\((?P<target_var>{_IDENTIFIER})\)"
)
_RETURN_RE = re.compile(
rf"^\s*RETURN\s+(?P<returns>{_IDENTIFIER}(?:\s*,\s*{_IDENTIFIER})*)\s*;?\s*$",
rf"^\s*RETURN\s+(?P<returns>{_RETURN_ITEMS})(?:\s+LIMIT\s+(?P<limit>\d+))?\s*;?\s*$",
re.IGNORECASE | re.DOTALL,
)
_NODE_SCAN_RE = re.compile(
rf"^\s*MATCH\s+\((?P<var>{_IDENTIFIER}):(?P<label>{_IDENTIFIER})(?:\s*\{{\s*(?P<property>{_IDENTIFIER})\s*:\s*(?P<value>.*?)\s*\}})?\)\s+"
rf"RETURN\s+(?P<returns>{_IDENTIFIER}(?:\s*,\s*{_IDENTIFIER})*)\s*;?\s*$",
rf"RETURN\s+(?P<returns>{_RETURN_ITEMS})(?:\s+LIMIT\s+(?P<limit>\d+))?\s*;?\s*$",
re.IGNORECASE | re.DOTALL,
)
_CALL_SAMPLE_RE = re.compile(
r"^\s*CALL\s+pg\.sample_typed_paths\s*\((?P<args>.*)\)\s+"
r"YIELD\s+path\s+RETURN\s+path\s*;?\s*$",
r"YIELD\s+path\s+RETURN\s+path(?:\s+LIMIT\s+(?P<limit>\d+))?\s*;?\s*$",
re.IGNORECASE | re.DOTALL,
)


@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
hops: tuple[TraversalHop, ...]
returns: tuple[str, ...]
limit: int | None = None


@dataclass(frozen=True)
Expand All @@ -65,6 +77,7 @@ class SampleTypedPathsCall:
seed_ids: list[str]
pattern: list[dict[str, object]]
returns: tuple[str, ...] = ("path",)
limit: int | None = None


@dataclass(frozen=True)
Expand All @@ -76,6 +89,7 @@ class NodeScanQuery:
property_name: str | None
property_value: object
returns: tuple[str, ...]
limit: int | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -146,6 +160,8 @@ def execute(graph, query: str) -> QueryResult:
parsed = parse(query)
if isinstance(parsed, SampleTypedPathsCall):
paths = graph.sample_typed_paths(parsed.seed_ids, parsed.pattern)
if parsed.limit is not None:
paths = paths[:parsed.limit]
return QueryResult(
columns=parsed.returns,
records=[{"path": path} for path in paths],
Expand All @@ -161,7 +177,7 @@ def _parse_node_scan(query: str) -> NodeScanQuery | None:
return None
returns = _parse_returns(match.group("returns"))
variable = match.group("var")
unknown = [name for name in returns if name != variable]
unknown = [name for name in _return_variables(returns) if name != variable]
if unknown:
raise ValueError(f"RETURN references unbound variable(s): {', '.join(unknown)}")
property_value = None
Expand All @@ -173,6 +189,7 @@ def _parse_node_scan(query: str) -> NodeScanQuery | None:
property_name=match.group("property"),
property_value=property_value,
returns=returns,
limit=_parse_limit(match.group("limit")),
)


Expand All @@ -190,14 +207,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():]
Expand All @@ -213,6 +231,7 @@ def _parse_match(query: str) -> MatchQuery:
source_id=source_id,
hops=tuple(hops),
returns=_parse_returns(return_match.group("returns")),
limit=_parse_limit(return_match.group("limit")),
)
_validate_match_returns(parsed)
return parsed
Expand All @@ -227,7 +246,7 @@ def _parse_sample_typed_paths(query: str) -> SampleTypedPathsCall:
raise ValueError("pg.sample_typed_paths seed IDs must be a list of strings")
if not isinstance(pattern, list) or not all(isinstance(hop, dict) for hop in pattern):
raise ValueError("pg.sample_typed_paths pattern must be a list of dictionaries")
return SampleTypedPathsCall(seed_ids=seed_ids, pattern=pattern)
return SampleTypedPathsCall(seed_ids=seed_ids, pattern=pattern, limit=_parse_limit(match.groupdict().get("limit")))


def _execute_match(graph, parsed: MatchQuery) -> QueryResult:
Expand All @@ -248,7 +267,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:
Expand All @@ -267,10 +286,9 @@ def _execute_match(graph, parsed: MatchQuery) -> QueryResult:
if not frontier:
break

records = [
{column: partial["bindings"][column] for column in parsed.returns}
for partial in frontier
]
records = [_project_record(partial["bindings"], parsed.returns) for partial in frontier]
if parsed.limit is not None:
records = records[:parsed.limit]
return QueryResult(columns=parsed.returns, records=records)


Expand All @@ -283,31 +301,79 @@ def _execute_node_scan(graph, parsed: NodeScanQuery) -> QueryResult:
node_ids = label_ids

records = []
for node_id in node_ids:
for node_id in sorted(node_ids):
node = graph.get_node(node_id)
if node is None:
continue
if parsed.property_name is not None and node.properties.get(parsed.property_name) != parsed.property_value:
continue
records.append({parsed.variable: node})
records.append(_project_record({parsed.variable: node}, parsed.returns))
if parsed.limit is not None and len(records) >= parsed.limit:
break
return QueryResult(columns=parsed.returns, records=records)


def _parse_returns(return_text: str) -> tuple[str, ...]:
return tuple(part.strip() for part in return_text.split(","))


def _parse_limit(limit_text: str | None) -> int | None:
if limit_text is None:
return None
limit = int(limit_text)
if limit < 0:
raise ValueError("LIMIT must be non-negative")
return limit


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:
bound_variables.add(hop.target_var)
if hop.rel_var is not None:
bound_variables.add(hop.rel_var)
unknown = [name for name in parsed.returns if name not in bound_variables]
unknown = [name for name in _return_variables(parsed.returns) if name not in bound_variables]
if unknown:
raise ValueError(f"RETURN references unbound variable(s): {', '.join(unknown)}")


def _return_variables(returns: tuple[str, ...]) -> tuple[str, ...]:
return tuple(item.split(".", 1)[0] for item in returns)


def _project_record(bindings: dict[str, object], returns: tuple[str, ...]) -> dict[str, object]:
return {column: _project_value(bindings, column) for column in returns}


def _project_value(bindings: dict[str, object], return_item: str):
variable, _, property_name = return_item.partition(".")
value = bindings[variable]
if not property_name:
return value
properties = getattr(value, "properties", {})
if property_name in properties:
return properties[property_name]
if property_name == "id" and hasattr(value, "get_id"):
return value.get_id
if property_name == "labels" and hasattr(value, "labels"):
return value.labels
if property_name in {"source", "target"} and hasattr(value, property_name):
return getattr(value, property_name)
return None


def _parse_call_arguments(args_text: str):
parts = _split_top_level_args(args_text)
if len(parts) != 2:
Expand Down Expand Up @@ -349,7 +415,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'
)
Loading
Loading