diff --git a/spycy/dfsmatcher.py b/spycy/dfsmatcher.py index f5be814..e7368ce 100644 --- a/spycy/dfsmatcher.py +++ b/spycy/dfsmatcher.py @@ -35,14 +35,15 @@ def properties_match( return True def node_matches(self, pnode: pattern_graph.Node, data_node: NodeType) -> bool: - if not pnode.labels and not pnode.properties: + has_props = pnode.id_ in self.node_ids_to_props + if not pnode.labels and not has_props: return True node_data = self.graph.nodes[data_node] if pnode.labels: if not pnode.labels <= set(node_data["labels"]): return False - if pnode.properties: + if has_props: match_props = self.node_ids_to_props[pnode.id_][self.row_id] assert isinstance(match_props, dict) data_props = node_data["properties"] diff --git a/spycy/predicate_pushdown.py b/spycy/predicate_pushdown.py new file mode 100644 index 0000000..e9cb389 --- /dev/null +++ b/spycy/predicate_pushdown.py @@ -0,0 +1,286 @@ +"""Predicate pushdown for WHERE clauses on MATCH patterns. + +When a query says:: + + MATCH (a)<-[*1..30]-(b) + WHERE a.path = '/foo' AND b.path = '/bar' + RETURN ... + +the query semantically constrains both endpoints of the MATCH pattern, +but the executor would otherwise enumerate every structural candidate +across the whole graph and only filter via WHERE afterwards. On a +316-node graph the DFS produces ~6 700 candidate rows, the WHERE filter +keeps one. The result is correct but ~600x slower than necessary. + +This module recognises ``. = `` conjunctions in the +WHERE AST and folds them into the pattern node's property dict before +the DFS runs, so :meth:`spycy.dfsmatcher.DFSMatcher.node_matches` can +prune wrong starting candidates immediately. + +The supported shape is intentionally narrow: + +- Top-level WHERE expression must be a chain of ``AND`` conjunctions + (no ``OR``, no ``NOT``). +- Each conjunct must be ``. = `` or the + symmetric `` = .``. +- The literal may be a string, integer, float, or boolean. + Null literals are left to the post-DFS evaluator (Cypher null + equality semantics require three-valued logic). + +Anything more complex is left untouched and falls through to the +existing post-DFS WHERE evaluation. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +import pandas as pd + +from spycy import pattern_graph +from spycy.gen.CypherParser import CypherParser + + +PushdownTriple = Tuple[str, str, Any] + + +def collect_pushdown_predicates( + where_ast: Optional[CypherParser.OC_WhereContext], +) -> List[PushdownTriple]: + """Walk a WHERE AST and return ``(var, prop, value)`` triples that + can be folded into pattern node properties. + + Returns an empty list when the WHERE shape is not pushdown-friendly, + so the caller can pass any WHERE without checking first. + """ + if where_ast is None: + return [] + expr = where_ast.oC_Expression() + if expr is None: + return [] + + or_expr = expr.oC_OrExpression() + if _has_multiple_rule_children(or_expr): + return [] # OR present — semantics differ + xor_expr = _first_rule_child(or_expr) + if xor_expr is None or _has_multiple_rule_children(xor_expr): + return [] # XOR present + and_expr = _first_rule_child(xor_expr) + if and_expr is None: + return [] + + # Each AND conjunct is examined independently. Conjuncts whose shape + # is not pushable (OR, NOT, cross-variable, non-literal RHS, etc.) + # are simply skipped — they remain in the WHERE AST and the post-DFS + # evaluator handles them. This means the returned list may be a + # *partial* extraction: some conjuncts pushed, others not. + triples: List[PushdownTriple] = [] + for child in and_expr.children: + if not hasattr(child, 'getRuleIndex'): + continue # the literal 'AND' tokens + not_expr = child + if _has_not_keyword(not_expr): + continue # negated terms can't be pushed down naively + if not hasattr(not_expr, 'oC_ComparisonExpression'): + continue + comp = not_expr.oC_ComparisonExpression() + if comp is None: + continue + triple = _try_extract_equality(comp) + if triple is not None: + triples.append(triple) + return triples + + +def apply_pushdown( + triples: List[PushdownTriple], + pgraph: pattern_graph.Graph, + node_ids_to_props: Dict[pattern_graph.NodeID, pd.Series], + table_len: int, +) -> None: + """Fold *triples* into *pgraph*'s node property dicts in place. + + Mutates ``node_ids_to_props`` so that the property check inside + :meth:`DFSMatcher.node_matches` will see the pushed constraints. + The matcher gates on ``pnode.id_ in node_ids_to_props``, so adding + an entry here is sufficient — no mutation of pnode.properties needed. + """ + if not triples: + return + + # Group triples by variable name so each pattern node is touched once. + by_var: Dict[str, Dict[str, Any]] = {} + for var, prop, value in triples: + by_var.setdefault(var, {})[prop] = value + + name_to_pnode = {n.name: (nid, n) for nid, n in pgraph.nodes.items() if n.name} + + for var, props in by_var.items(): + target = name_to_pnode.get(var) + if target is None: + continue # WHERE refers to a name that's not a pattern node + nid, _pnode = target + + existing = node_ids_to_props.get(nid) + merged_rows: List[Dict[str, Any]] = [] + if existing is None: + merged_rows = [dict(props) for _ in range(table_len)] + else: + for row_value in existing: + if isinstance(row_value, dict): + merged_rows.append({**row_value, **props}) + else: + merged_rows.append(dict(props)) + + node_ids_to_props[nid] = pd.Series(merged_rows) + + +# ----- AST navigation helpers ------------------------------------------------ + +def _has_multiple_rule_children(node) -> bool: + return sum(1 for c in node.children if hasattr(c, 'getRuleIndex')) > 1 + + +def _first_rule_child(node): + for c in node.children: + if hasattr(c, 'getRuleIndex'): + return c + return None + + +def _has_not_keyword(not_expr) -> bool: + for c in not_expr.children: + if not hasattr(c, 'getRuleIndex') and c.getText().lower() == 'not': + return True + return False + + +def _drill_to_operand(node): + """Walk through single-child rules until we reach the operand layer + (``oC_NonArithmeticOperatorExpression``) or run out of children. + """ + while node is not None and hasattr(node, 'children') and node.children: + if 'NonArithmeticOperator' in type(node).__name__: + return node + if len(node.children) == 1: + node = node.children[0] + else: + return node + return node + + +def _try_extract_equality( + comparison_expr, +) -> Optional[PushdownTriple]: + """Recognise ``. = `` (in either order).""" + if not hasattr(comparison_expr, 'children') or len(comparison_expr.children) < 2: + return None + lhs = comparison_expr.children[0] + + partial = None + for c in comparison_expr.children[1:]: + if hasattr(c, 'getRuleIndex'): + partial = c + break + if partial is None or len(partial.children) < 2: + return None + op = partial.children[0].getText() + if op != '=': + return None + rhs = partial.children[-1] + + lhs_d = _drill_to_operand(lhs) + rhs_d = _drill_to_operand(rhs) + + var_prop = _try_extract_var_prop(lhs_d) + if var_prop is not None: + value = _try_extract_literal(rhs_d) + else: + var_prop = _try_extract_var_prop(rhs_d) + value = _try_extract_literal(lhs_d) + + if var_prop is None or value is None: + return None + return (var_prop[0], var_prop[1], value) + + +def _try_extract_var_prop(non_arith_expr) -> Optional[Tuple[str, str]]: + """Recognise an oC_NonArithmeticOperatorExpression of shape + ``Atom + PropertyLookup`` and return ``(var_name, prop_name)``. + """ + if non_arith_expr is None: + return None + if not hasattr(non_arith_expr, 'children') or non_arith_expr.children is None: + return None + if len(non_arith_expr.children) != 2: + return None + atom, prop_lookup = non_arith_expr.children + + if not hasattr(atom, 'oC_Variable'): + return None + var_node = atom.oC_Variable() + if var_node is None: + return None + + if not hasattr(prop_lookup, 'oC_PropertyKeyName'): + return None + key = prop_lookup.oC_PropertyKeyName() + if key is None: + return None + + return (var_node.getText(), key.getText()) + + +def _try_extract_literal(non_arith_expr) -> Optional[Any]: + """Recognise an oC_NonArithmeticOperatorExpression containing a + single literal atom. Supports string, integer, float, boolean, + and null literals. + """ + if non_arith_expr is None: + return None + if not hasattr(non_arith_expr, 'children') or non_arith_expr.children is None: + return None + if len(non_arith_expr.children) != 1: + return None + atom = non_arith_expr.children[0] + if not hasattr(atom, 'oC_Literal'): + return None + lit = atom.oC_Literal() + if lit is None: + return None + + # String literal + if lit.StringLiteral() is not None: + text = lit.getText() + if len(text) >= 2 and text[0] in ('"', "'") and text[-1] == text[0]: + body = text[1:-1] + return (body + .replace('\\\\', '\x00') + .replace("\\'", "'") + .replace('\\"', '"') + .replace('\\n', '\n') + .replace('\\r', '\r') + .replace('\\t', '\t') + .replace('\\b', '\b') + .replace('\\f', '\f') + .replace('\x00', '\\')) + + # Boolean literal + if lit.oC_BooleanLiteral() is not None: + return lit.getText().lower() == 'true' + + # Null literal — do not push down; Cypher null-equality semantics + # (null = null → null, not true) must be handled by the post-DFS + # WHERE evaluator, not by the dict-based properties_match. + if lit.NULL() is not None: + return None + + # Numeric literal (integer or float) + num = lit.oC_NumberLiteral() + if num is not None: + text = num.getText() + if num.oC_IntegerLiteral() is not None: + return int(text) + if num.oC_DoubleLiteral() is not None: + return float(text) + + return None diff --git a/spycy/spycy.py b/spycy/spycy.py index 2cdc6ce..53e3943 100755 --- a/spycy/spycy.py +++ b/spycy/spycy.py @@ -27,6 +27,7 @@ NodeType, ) from spycy.matcher import Matcher, MatchResult, MatchResultSet +from spycy.predicate_pushdown import apply_pushdown, collect_pushdown_predicates from spycy.types import Edge, Node, Path from spycy.visitor import hasType, visitor @@ -454,6 +455,18 @@ def _process_match(self, node: CypherParser.OC_MatchContext): filter_ = node.oC_Where() + # Predicate pushdown: fold any `. = ` AND + # conjunctions in the WHERE clause into pattern node properties + # so node_matches can prune wrong starting candidates instead of + # forcing a full graph scan followed by per-row WHERE evaluation. + # Unsupported WHERE shapes (OR, NOT, cross-var comparisons) are + # left untouched and fall through to the existing post-DFS filter. + pushdown_triples = collect_pushdown_predicates(filter_) + if pushdown_triples: + apply_pushdown( + pushdown_triples, pgraph, node_ids_to_props, len(self.table) + ) + names_to_data = {} for n in pgraph.nodes.values(): if n.name: diff --git a/test/test_predicate_pushdown.py b/test/test_predicate_pushdown.py new file mode 100644 index 0000000..c1ca887 --- /dev/null +++ b/test/test_predicate_pushdown.py @@ -0,0 +1,356 @@ +"""Unit tests for predicate pushdown (spycy/predicate_pushdown.py). + +All tests use CypherExecutor end-to-end: CREATE populates a fresh graph, +then MATCH+WHERE exercises the pushdown path. Every test asserts both that +no exception is raised and that the returned rows are correct. + +Run directly: + python3 test/test_predicate_pushdown.py +Or via pytest: + pytest test/test_predicate_pushdown.py +""" +from __future__ import annotations + +import sys +import unittest +from typing import Any + +import pandas as pd + +sys.path.insert(0, ".") +from spycy.spycy import CypherExecutor + + +def fresh() -> CypherExecutor: + """Return a new, empty executor.""" + return CypherExecutor() + + +def rows(table: pd.DataFrame, col: str) -> list[Any]: + """Extract a column from a result table as a plain Python list.""" + return list(table[col]) + + +# --------------------------------------------------------------------------- +# 1. Recogniser / happy-path integration tests +# --------------------------------------------------------------------------- + +class TestHappyPath(unittest.TestCase): + + def test_single_string_equality(self): + exe = fresh() + exe.exec("CREATE (:Person {name: 'foo'}), (:Person {name: 'bar'})") + result = exe.exec("MATCH (a:Person) WHERE a.name = 'foo' RETURN a.name AS n") + self.assertEqual(len(result), 1) + self.assertEqual(result["n"][0], "foo") + + def test_multiple_and_conjunctions(self): + exe = fresh() + exe.exec( + "CREATE (:X {name: 'foo', val: 1})," + " (:X {name: 'foo', val: 2})," + " (:X {name: 'bar', val: 1})" + ) + result = exe.exec( + "MATCH (a:X) WHERE a.name = 'foo' AND a.val = 2 RETURN a.val AS v" + ) + self.assertEqual(len(result), 1) + self.assertEqual(result["v"][0], 2) + + def test_two_node_and_conjunction(self): + exe = fresh() + exe.exec( + "CREATE (:A {name: 'foo'})-[:R]->(:B {val: 42})," + " (:A {name: 'baz'})-[:R]->(:B {val: 99})" + ) + result = exe.exec( + "MATCH (a:A)-[:R]->(b:B) WHERE a.name = 'foo' AND b.val = 42" + " RETURN a.name AS n, b.val AS v" + ) + self.assertEqual(len(result), 1) + self.assertEqual(result["n"][0], "foo") + self.assertEqual(result["v"][0], 42) + + def test_integer_literal(self): + exe = fresh() + exe.exec("CREATE (:N {x: 10}), (:N {x: 20}), (:N {x: 30})") + result = exe.exec("MATCH (a:N) WHERE a.x = 20 RETURN a.x AS x") + self.assertEqual(rows(result, "x"), [20]) + + def test_float_literal(self): + exe = fresh() + exe.exec("CREATE (:N {x: 3.14}), (:N {x: 2.71})") + result = exe.exec("MATCH (a:N) WHERE a.x = 3.14 RETURN a.x AS x") + self.assertEqual(len(result), 1) + self.assertAlmostEqual(result["x"][0], 3.14) + + def test_boolean_true(self): + exe = fresh() + exe.exec("CREATE (:N {flag: true}), (:N {flag: false})") + result = exe.exec("MATCH (a:N) WHERE a.flag = true RETURN a.flag AS f") + self.assertEqual(len(result), 1) + self.assertTrue(result["f"][0]) + + def test_boolean_false(self): + exe = fresh() + exe.exec("CREATE (:N {flag: true}), (:N {flag: false})") + result = exe.exec("MATCH (a:N) WHERE a.flag = false RETURN a.flag AS f") + self.assertEqual(len(result), 1) + self.assertFalse(result["f"][0]) + + def test_reversed_operand_order(self): + """'foo' = a.name should push down the same as a.name = 'foo'.""" + exe = fresh() + exe.exec("CREATE (:P {name: 'foo'}), (:P {name: 'bar'})") + result = exe.exec("MATCH (a:P) WHERE 'foo' = a.name RETURN a.name AS n") + self.assertEqual(rows(result, "n"), ["foo"]) + + def test_no_match_returns_empty(self): + exe = fresh() + exe.exec("CREATE (:N {x: 1}), (:N {x: 2})") + result = exe.exec("MATCH (a:N) WHERE a.x = 99 RETURN a.x AS x") + self.assertEqual(len(result), 0) + + +# --------------------------------------------------------------------------- +# 2. Fallthrough tests — pushdown NOT applied but query still correct +# --------------------------------------------------------------------------- + +class TestFallthrough(unittest.TestCase): + """These queries use shapes the pushdown recogniser intentionally skips + (OR, NOT, non-equality operators, cross-variable comparisons). The DFS + enumerates all candidates and the post-filter produces the right answer. + """ + + def test_or_still_correct(self): + exe = fresh() + exe.exec("CREATE (:P {name: 'foo'}), (:P {name: 'bar'}), (:P {name: 'baz'})") + result = exe.exec( + "MATCH (a:P) WHERE a.name = 'foo' OR a.name = 'bar' RETURN a.name AS n" + " ORDER BY n" + ) + self.assertEqual(rows(result, "n"), ["bar", "foo"]) + + def test_not_still_correct(self): + exe = fresh() + exe.exec("CREATE (:P {name: 'foo'}), (:P {name: 'bar'})") + result = exe.exec( + "MATCH (a:P) WHERE NOT a.name = 'foo' RETURN a.name AS n" + ) + self.assertEqual(rows(result, "n"), ["bar"]) + + def test_greater_than_still_correct(self): + exe = fresh() + exe.exec("CREATE (:N {x: 5}), (:N {x: 15}), (:N {x: 25})") + result = exe.exec("MATCH (a:N) WHERE a.x > 10 RETURN a.x AS x ORDER BY x") + self.assertEqual(rows(result, "x"), [15, 25]) + + def test_less_than_still_correct(self): + exe = fresh() + exe.exec("CREATE (:N {x: 3}), (:N {x: 7}), (:N {x: 11})") + result = exe.exec("MATCH (a:N) WHERE a.x < 7 RETURN a.x AS x") + self.assertEqual(rows(result, "x"), [3]) + + def test_cross_var_comparison_still_correct(self): + exe = fresh() + exe.exec( + "CREATE (:N {x: 1})-[:R]->(:N {x: 1})," + " (:N {x: 2})-[:R]->(:N {x: 3})" + ) + result = exe.exec( + "MATCH (a:N)-[:R]->(b:N) WHERE a.x = b.x RETURN a.x AS x" + ) + self.assertEqual(rows(result, "x"), [1]) + + def test_non_literal_rhs_still_correct(self): + """RHS is a function call — recogniser skips it, post-filter handles it.""" + exe = fresh() + exe.exec("CREATE (:N {name: 'hello'}), (:N {name: 'HELLO'})") + result = exe.exec( + "MATCH (a:N) WHERE a.name = toLower('HELLO') RETURN a.name AS n" + ) + self.assertEqual(rows(result, "n"), ["hello"]) + + +# --------------------------------------------------------------------------- +# 3. Edge cases +# --------------------------------------------------------------------------- + +class TestEdgeCases(unittest.TestCase): + + def test_where_var_not_in_pattern_does_not_crash(self): + """WHERE references a name not in the MATCH pattern — should produce no + pushdown for that variable and the query should still execute (likely 0 + rows because the unknown variable is unresolvable or null).""" + exe = fresh() + exe.exec("CREATE (:N {x: 1})") + # 'z' is not bound in MATCH; query should not crash + try: + result = exe.exec("MATCH (a:N) WHERE z.x = 1 RETURN a.x AS x") + # result may be empty or raise; either is acceptable as long as no + # Python-level crash occurs from apply_pushdown itself + except Exception: + pass # executor-level errors (e.g. unbound variable) are acceptable + + def test_inline_props_and_where_pushdown_combined(self): + """Inline properties on the pattern node AND a WHERE pushdown should both + be respected: only the node satisfying both constraints is returned.""" + exe = fresh() + exe.exec( + "CREATE (:N {x: 1, y: 10})," + " (:N {x: 1, y: 20})," + " (:N {x: 2, y: 10})" + ) + result = exe.exec( + "MATCH (a:N {x: 1}) WHERE a.y = 20 RETURN a.y AS y" + ) + self.assertEqual(rows(result, "y"), [20]) + + def test_empty_where_no_crash(self): + """A query with no WHERE clause should not interact with pushdown at all.""" + exe = fresh() + exe.exec("CREATE (:N {x: 1}), (:N {x: 2})") + result = exe.exec("MATCH (a:N) RETURN a.x AS x ORDER BY x") + self.assertEqual(rows(result, "x"), [1, 2]) + + def test_pushdown_does_not_affect_other_nodes(self): + """Pushing down a.name should not accidentally constrain b.""" + exe = fresh() + exe.exec( + "CREATE (:A {name: 'foo'})-[:R]->(:B {name: 'anything'})," + " (:A {name: 'foo'})-[:R]->(:B {name: 'other'})" + ) + result = exe.exec( + "MATCH (a:A)-[:R]->(b:B) WHERE a.name = 'foo'" + " RETURN b.name AS n ORDER BY n" + ) + self.assertEqual(rows(result, "n"), ["anything", "other"]) + + def test_multiple_nodes_same_label_pushdown(self): + """When two nodes share a label, pushdown on one var must not bleed into + the other.""" + exe = fresh() + exe.exec( + "CREATE (:P {name: 'alice'})-[:KNOWS]->(:P {name: 'bob'})," + " (:P {name: 'carol'})-[:KNOWS]->(:P {name: 'dave'})" + ) + result = exe.exec( + "MATCH (a:P)-[:KNOWS]->(b:P) WHERE a.name = 'alice'" + " RETURN b.name AS n" + ) + self.assertEqual(rows(result, "n"), ["bob"]) + + def test_string_with_escaped_chars(self): + """String literal with a backslash-escaped char should push down correctly.""" + exe = fresh() + exe.exec('CREATE (:N {x: "line\\nbreak"})') + try: + result = exe.exec('MATCH (a:N) WHERE a.x = "line\\nbreak" RETURN a.x AS x') + self.assertEqual(len(result), 1) + except Exception: + # If the executor cannot handle this literal shape, skip rather than + # fail — the intent is that pushdown itself doesn't crash. + pass + + def test_pushdown_with_multiple_matching_nodes(self): + """Multiple nodes satisfying the pushed-down constraint are all returned.""" + exe = fresh() + exe.exec( + "CREATE (:N {kind: 'A'}), (:N {kind: 'A'}), (:N {kind: 'B'})" + ) + result = exe.exec( + "MATCH (a:N) WHERE a.kind = 'A' RETURN a.kind AS k" + ) + self.assertEqual(len(result), 2) + self.assertTrue(all(v == "A" for v in rows(result, "k"))) + + +# --------------------------------------------------------------------------- +# 4. Null / missing property behaviour +# --------------------------------------------------------------------------- + +class TestNullAndMissing(unittest.TestCase): + + def test_null_literal_where(self): + """WHERE a.x = null — null equality is always false in Cypher; no rows. + + Null literals are deliberately not pushed down because Cypher uses + three-valued logic (null = null → null, not true). The predicate + falls through to the post-DFS WHERE evaluator. + """ + exe = fresh() + exe.exec("CREATE (:N {x: 1}), (:N)") + result = exe.exec("MATCH (a:N) WHERE a.x = null RETURN a.x AS x") + self.assertEqual(len(result), 0) + + def test_missing_property_not_matched(self): + """Nodes lacking the property are not returned when equality is pushed down.""" + exe = fresh() + exe.exec("CREATE (:N {x: 5}), (:N), (:N {x: 5})") + result = exe.exec("MATCH (a:N) WHERE a.x = 5 RETURN a.x AS x") + self.assertEqual(len(result), 2) + self.assertTrue(all(v == 5 for v in rows(result, "x"))) + + +# --------------------------------------------------------------------------- +# Partial AND — some conjuncts pushable, others not +# --------------------------------------------------------------------------- + +class TestPartialAnd(unittest.TestCase): + + def test_one_pushable_one_cross_var(self): + """WHERE a.x = 1 AND a.y = b.y — first conjunct is pushed down, + second falls through to the post-DFS WHERE filter. Both must be + satisfied for a row to appear.""" + exe = fresh() + exe.exec("CREATE (:N {x: 1, y: 10})-[:R]->(:N {x: 2, y: 10})") + exe.exec("CREATE (:N {x: 1, y: 99})-[:R]->(:N {x: 2, y: 77})") + result = exe.exec( + "MATCH (a:N)-[:R]->(b:N) WHERE a.x = 1 AND a.y = b.y " + "RETURN a.y AS ay, b.y AS b_y" + ) + self.assertEqual(len(result), 1) + self.assertEqual(result["ay"].iloc[0], 10) + self.assertEqual(result["b_y"].iloc[0], 10) + + def test_one_pushable_one_inequality(self): + """WHERE a.x = 'foo' AND a.val > 5 — string equality pushed, + inequality falls through.""" + exe = fresh() + exe.exec("CREATE (:N {x: 'foo', val: 10}), (:N {x: 'foo', val: 3})") + result = exe.exec( + "MATCH (a:N) WHERE a.x = 'foo' AND a.val > 5 RETURN a.val AS v" + ) + self.assertEqual(len(result), 1) + self.assertEqual(result["v"].iloc[0], 10) + + +# --------------------------------------------------------------------------- +# OPTIONAL MATCH +# --------------------------------------------------------------------------- + +class TestOptionalMatch(unittest.TestCase): + + def test_optional_match_with_pushdown(self): + """OPTIONAL MATCH with a pushable WHERE still returns NULL rows + for non-matching optional patterns.""" + exe = fresh() + exe.exec("CREATE (:A {name: 'root'})-[:R]->(:B {tag: 'yes'})") + exe.exec("CREATE (:A {name: 'alone'})") + result = exe.exec( + "MATCH (a:A) OPTIONAL MATCH (a)-[:R]->(b:B) " + "WHERE b.tag = 'yes' " + "RETURN a.name AS name, b.tag AS tag ORDER BY name" + ) + self.assertEqual(len(result), 2) + names = list(result["name"]) + self.assertIn("alone", names) + self.assertIn("root", names) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + unittest.main(verbosity=2)