Skip to content

Commit 470fcf4

Browse files
committed
fix(cypher): harden cartesian OR/XOR pattern guard via AST (#1391)
1 parent 7b3101e commit 470fcf4

2 files changed

Lines changed: 42 additions & 15 deletions

File tree

graphistry/compute/gfql/cypher/lowering.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,7 @@
122122
rewrite_temporal_constructors_in_expr,
123123
)
124124
from graphistry.compute.gfql.same_path_types import WhereComparison, col, compare
125-
# #1295 / #1260 S2 — bounded reentry helpers extracted to focused subpackage.
126-
# These are re-exported here to keep existing imports
127-
# (``from graphistry.compute.gfql.cypher.lowering import _reentry_hidden_column_name``)
128-
# working unchanged across the codebase. The moved modules import this module
129-
# lazily inside function bodies for the few helpers (``_unsupported``,
130-
# ``_render_expr_node``, etc.) they still need from lowering.py.
125+
# Reentry helpers moved to focused subpackages and are re-exported here for compatibility.
131126
from graphistry.compute.gfql.cypher.reentry.naming import (
132127
_is_hidden_reentry_property,
133128
_reentry_hidden_column_name,
@@ -5637,12 +5632,7 @@ def _is_variable_length_relationship_pattern(relationship: RelationshipPattern)
56375632

56385633

56395634
def _reject_nonterminal_variable_length_relationship_patterns(query: CypherQuery) -> None: # noqa: ARG001
5640-
"""No-op: variable-length rels in connected patterns are now supported.
5641-
5642-
The lowering sets ``prune_to_endpoints=True`` on non-terminal
5643-
variable-length edges so the next hop starts from the correct
5644-
wavefront endpoints only. See #1001 for reentry-match follow-up.
5645-
"""
5635+
"""No-op: variable-length relationships in connected patterns are supported."""
56465636

56475637

56485638
def _variable_length_relationship_aliases(
@@ -6231,9 +6221,19 @@ def lower_match_query(
62316221
row_where: Optional[ExpressionText] = None
62326222
row_where_predicates: List[str] = list(dynamic_row_where_predicates)
62336223
if query.where is not None:
6234-
where_expr_upper = boolean_expr_to_text(query.where.expr_tree).upper() if query.where.expr_tree is not None else ""
6235-
if _cartesian_node_only_patterns(merged_match) is not None and query.where.expr_tree is not None and _where_expr_tree_pattern_predicates(query.where.expr_tree) and (" OR " in where_expr_upper or " XOR " in where_expr_upper):
6236-
raise _unsupported_at_span("Cypher WHERE pattern predicates mixed with OR/XOR are not yet supported for cartesian MATCH patterns", field="where", value=where_expr_upper, span=query.where.span)
6224+
if _cartesian_node_only_patterns(merged_match) is not None and query.where.expr_tree is not None:
6225+
where_expr_upper = boolean_expr_to_text(query.where.expr_tree).upper()
6226+
stack: List[BooleanExpr] = [query.where.expr_tree]
6227+
while stack:
6228+
cur = stack.pop()
6229+
left = cast(Optional[BooleanExpr], cur.left)
6230+
right = cast(Optional[BooleanExpr], cur.right)
6231+
if cur.op in {"or", "xor"} and ((left is not None and _where_expr_tree_pattern_predicates(left)) or (right is not None and _where_expr_tree_pattern_predicates(right))):
6232+
raise _unsupported_at_span("Cypher WHERE pattern predicates mixed with OR/XOR are not yet supported for cartesian MATCH patterns", field="where", value=where_expr_upper, span=query.where.span)
6233+
if left is not None:
6234+
stack.append(left)
6235+
if right is not None:
6236+
stack.append(right)
62376237
where_expr, where_pattern_row_filters = _rewrite_where_expr_patterns_to_markers(
62386238
where=query.where,
62396239
alias_targets=alias_targets,

graphistry/tests/compute/gfql/cypher/test_lowering.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,33 @@ def test_string_cypher_rejects_cartesian_where_pattern_predicates_mixed_with_or(
15741574
)
15751575

15761576

1577+
def test_string_cypher_rejects_cartesian_where_pattern_predicates_mixed_with_xor() -> None:
1578+
graph = _mk_graph(
1579+
pd.DataFrame({"id": [0, 1], "label__TheLabel": [False, True]}),
1580+
pd.DataFrame({"s": [0], "d": [1], "type": ["T"]}),
1581+
)
1582+
with pytest.raises(GFQLValidationError, match="OR/XOR"):
1583+
graph.gfql(
1584+
"MATCH (a), (b) "
1585+
"WHERE (a)-[:T]->(b:TheLabel) XOR a.id = 0 "
1586+
"RETURN DISTINCT b"
1587+
)
1588+
1589+
1590+
def test_string_cypher_supports_cartesian_scalar_or_without_pattern_predicates() -> None:
1591+
result = _mk_cartesian_node_graph().gfql(
1592+
"MATCH (a), (b) "
1593+
"WHERE a.num = 1 OR b.num = 1 "
1594+
"RETURN a.num AS a_num, b.num AS b_num "
1595+
"ORDER BY a_num, b_num"
1596+
)
1597+
assert result._nodes.to_dict(orient="records") == [
1598+
{"a_num": 1, "b_num": 1},
1599+
{"a_num": 1, "b_num": 2},
1600+
{"a_num": 2, "b_num": 1},
1601+
]
1602+
1603+
15771604
def test_string_cypher_supports_cartesian_dynamic_pattern_property_projection() -> None:
15781605
graph = _mk_cartesian_dynamic_pattern_graph()
15791606

0 commit comments

Comments
 (0)