From acd3ee9a260194d4311c9a387381be72b26687a3 Mon Sep 17 00:00:00 2001 From: Niclas Kjall-Ohlsson Date: Sun, 1 Feb 2026 14:52:02 +0100 Subject: [PATCH 1/2] Node and relationship properties --- src/graph/node.ts | 23 ++++++++++++ src/graph/relationship.ts | 24 +++++++++++-- src/graph/relationship_data.ts | 3 ++ src/graph/relationship_match_collector.ts | 2 +- src/parsing/parser.ts | 44 +++++++++++++++++++++++ tests/compute/runner.test.ts | 22 ++++++++++++ tests/parsing/parser.test.ts | 32 +++++++++++++++++ 7 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/graph/node.ts b/src/graph/node.ts index 6211bea..8d60d86 100644 --- a/src/graph/node.ts +++ b/src/graph/node.ts @@ -41,6 +41,23 @@ class Node extends ASTNode { public get properties(): Map { return this._properties; } + public set properties(properties: Map) { + this._properties = properties; + } + private _matchesProperties(hop: number = 0): boolean { + const data: NodeData = this._data!; + for (const [key, expression] of this._properties) { + const record: NodeRecord = data.current(hop)!; + if (record === null) { + throw new Error("No current node data available"); + } + if (!(key in record)) { + throw new Error("Node does not have property"); + } + return record[key] === expression.value(); + } + return true; + } public setProperty(key: string, value: Expression): void { this._properties.set(key, value); } @@ -72,6 +89,9 @@ class Node extends ASTNode { this._data?.reset(); while (this._data?.next()) { this.setValue(this._data?.current()!); + if (!this._matchesProperties()) { + continue; + } await this._outgoing?.find(this._value!.id); await this.runTodoNext(); } @@ -80,6 +100,9 @@ class Node extends ASTNode { this._data?.reset(); while (this._data?.find(id, hop)) { this.setValue(this._data?.current(hop) as NodeRecord); + if (!this._matchesProperties(hop)) { + continue; + } this._incoming?.setEndNode(this); await this._outgoing?.find(this._value!.id, hop); await this.runTodoNext(); diff --git a/src/graph/relationship.ts b/src/graph/relationship.ts index fe2b9dc..270b956 100644 --- a/src/graph/relationship.ts +++ b/src/graph/relationship.ts @@ -38,8 +38,25 @@ class Relationship extends ASTNode { public get type(): string | null { return this._type; } - public get properties(): Record { - return this._data?.properties() || {}; + public get properties(): Map { + return this._properties; + } + public set properties(properties: Map) { + this._properties = properties; + } + private _matchesProperties(hop: number = 0): boolean { + const data: RelationshipData = this._data!; + for (const [key, expression] of this._properties) { + const record: RelationshipRecord = data.current(hop)!; + if (record === null) { + throw new Error("No current relationship data available"); + } + if (!(key in record)) { + throw new Error("Relationship does not have property"); + } + return record[key] === expression.value(); + } + return true; } public setProperty(key: string, value: Expression): void { this._properties.set(key, value); @@ -106,6 +123,9 @@ class Relationship extends ASTNode { const data: RelationshipRecord = this._data?.current(hop) as RelationshipRecord; if (hop >= this.hops!.min) { this.setValue(this); + if (!this._matchesProperties(hop)) { + continue; + } await this._target?.find(data.right_id, hop); if (this._matches.isCircular()) { throw new Error("Circular relationship detected"); diff --git a/src/graph/relationship_data.ts b/src/graph/relationship_data.ts index 216e258..995f3a9 100644 --- a/src/graph/relationship_data.ts +++ b/src/graph/relationship_data.ts @@ -22,6 +22,9 @@ class RelationshipData extends Data { } return null; } + public current(hop: number = 0): RelationshipRecord | null { + return super.current(hop) as RelationshipRecord | null; + } } export default RelationshipData; diff --git a/src/graph/relationship_match_collector.ts b/src/graph/relationship_match_collector.ts index da98935..24970be 100644 --- a/src/graph/relationship_match_collector.ts +++ b/src/graph/relationship_match_collector.ts @@ -16,7 +16,7 @@ class RelationshipMatchCollector { type: relationship.type!, startNode: relationship.source?.value() || {}, endNode: null, - properties: relationship.properties, + properties: relationship.getData()?.properties() as Record, }; this._matches.push(match); this._nodeIds.push(match.startNode.id); diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 6b0510c..3176e69 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -431,6 +431,7 @@ class Parser extends BaseParser { } this.skipWhitespaceAndComments(); let node = new Node(); + node.properties = new Map(this.parseProperties()); node.label = label!; if (label !== null && identifier !== null) { node.identifier = identifier; @@ -449,6 +450,47 @@ class Parser extends BaseParser { return node; } + private *parseProperties(): Iterable<[string, Expression]> { + let parts: number = 0; + while (true) { + this.skipWhitespaceAndComments(); + if (!this.token.isOpeningBrace() && parts == 0) { + return; + } else if (!this.token.isOpeningBrace() && parts > 0) { + throw new Error("Expected opening brace"); + } + this.setNextToken(); + this.skipWhitespaceAndComments(); + if (!this.token.isIdentifier()) { + throw new Error("Expected identifier"); + } + const key: string = this.token.value!; + this.setNextToken(); + this.skipWhitespaceAndComments(); + if (!this.token.isColon()) { + throw new Error("Expected colon"); + } + this.setNextToken(); + this.skipWhitespaceAndComments(); + const expression: Expression | null = this.parseExpression(); + if (expression === null) { + throw new Error("Expected expression"); + } + this.skipWhitespaceAndComments(); + if (!this.token.isClosingBrace()) { + throw new Error("Expected closing brace"); + } + this.setNextToken(); + yield [key, expression]; + this.skipWhitespaceAndComments(); + if (!this.token.isComma()) { + break; + } + this.setNextToken(); + parts++; + } + } + private *parsePatterns(): IterableIterator { while (true) { let identifier: string | null = null; @@ -565,6 +607,7 @@ class Parser extends BaseParser { const type: string = this.token.value || ""; this.setNextToken(); const hops: Hops | null = this.parseRelationshipHops(); + const properties: Map = new Map(this.parseProperties()); if (!this.token.isClosingBracket()) { throw new Error("Expected closing bracket for relationship definition"); } @@ -577,6 +620,7 @@ class Parser extends BaseParser { this.setNextToken(); } let relationship = new Relationship(); + relationship.properties = properties; if (type !== null && variable !== null) { relationship.identifier = variable; this.variables.set(variable, relationship); diff --git a/tests/compute/runner.test.ts b/tests/compute/runner.test.ts index 829d0da..4ad9f67 100644 --- a/tests/compute/runner.test.ts +++ b/tests/compute/runner.test.ts @@ -1282,3 +1282,25 @@ test("Test equality comparison", async () => { } } }); + +test("Test match with constraints", async () => { + await new Runner(` + CREATE VIRTUAL (:Employee) AS { + unwind [ + {id: 1, name: 'Employee 1'}, + {id: 2, name: 'Employee 2'}, + {id: 3, name: 'Employee 3'}, + {id: 4, name: 'Employee 4'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + const match = new Runner(` + match (e:Employee{name:'Employee 1'}) + return e.name as name + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(1); + expect(results[0].name).toBe("Employee 1"); +}); diff --git a/tests/parsing/parser.test.ts b/tests/parsing/parser.test.ts index 7af20fe..2e996c2 100644 --- a/tests/parsing/parser.test.ts +++ b/tests/parsing/parser.test.ts @@ -760,3 +760,35 @@ test("Test check pattern expression without NodeReference", () => { parser.parse("MATCH (a:Person) WHERE (:Person)-[:KNOWS]->(:Person) RETURN a"); }).toThrow("PatternExpression must contain at least one NodeReference"); }); + +test("Test node with properties", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (a:Person{value: 'hello'}) return a"); + // prettier-ignore + expect(ast.print()).toBe( + "ASTNode\n" + + "- Match\n" + + "- Return\n" + + "-- Expression (a)\n" + + "--- Reference (a)" + ); + const match: Match = ast.firstChild() as Match; + const node: Node = match.patterns[0].chain[0] as Node; + expect(node.properties.get("value")?.value()).toBe("hello"); +}); + +test("Test relationship with properties", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (:Person)-[r:LIKES{since: 2022}]->(:Food) return a"); + // prettier-ignore + expect(ast.print()).toBe( + "ASTNode\n" + + "- Match\n" + + "- Return\n" + + "-- Expression (a)\n" + + "--- Reference (a)" + ); + const match: Match = ast.firstChild() as Match; + const relationship: Relationship = match.patterns[0].chain[1] as Relationship; + expect(relationship.properties.get("since")?.value()).toBe(2022); +}); From 46339f65feccf7b065e38778cfbd98d9a20735da Mon Sep 17 00:00:00 2001 From: Niclas Kjall-Ohlsson Date: Fri, 6 Feb 2026 22:11:11 +0100 Subject: [PATCH 2/2] Node and relationship properties + relationship direction support --- flowquery-py/src/graph/data.py | 55 +++-- flowquery-py/src/graph/node.py | 23 ++ flowquery-py/src/graph/node_data.py | 2 +- flowquery-py/src/graph/relationship.py | 57 ++++- flowquery-py/src/graph/relationship_data.py | 9 +- .../src/graph/relationship_match_collector.py | 4 +- flowquery-py/src/parsing/parser.py | 41 +++- flowquery-py/tests/compute/test_runner.py | 206 +++++++++++++++++- flowquery-py/tests/parsing/test_parser.py | 41 ++++ src/graph/data.ts | 54 +++-- src/graph/node_data.ts | 2 +- src/graph/relationship.ts | 18 +- src/graph/relationship_data.ts | 6 +- src/graph/relationship_reference.ts | 3 +- src/parsing/parser.ts | 3 + tests/compute/runner.test.ts | 156 +++++++++++++ 16 files changed, 621 insertions(+), 59 deletions(-) diff --git a/flowquery-py/src/graph/data.py b/flowquery-py/src/graph/data.py index f7690fe..02073ac 100644 --- a/flowquery-py/src/graph/data.py +++ b/flowquery-py/src/graph/data.py @@ -38,14 +38,20 @@ def clone(self) -> "IndexEntry": class Layer: """Layer for managing index state at a specific level.""" - def __init__(self, index: Dict[str, IndexEntry]): - self._index: Dict[str, IndexEntry] = index + def __init__(self, indexes: Dict[str, Dict[str, IndexEntry]]): + self._indexes: Dict[str, Dict[str, IndexEntry]] = indexes self._current: int = -1 + def index(self, name: str) -> Dict[str, IndexEntry]: + """Get or create an index by name.""" + if name not in self._indexes: + self._indexes[name] = {} + return self._indexes[name] + @property - def index(self) -> Dict[str, IndexEntry]: - """Get the index dictionary.""" - return self._index + def indexes(self) -> Dict[str, Dict[str, IndexEntry]]: + """Get all indexes.""" + return self._indexes @property def current(self) -> int: @@ -67,30 +73,40 @@ def __init__(self, records: Optional[List[Dict[str, Any]]] = None): def _build_index(self, key: str, level: int = 0) -> None: """Build an index for the given key at the specified level.""" - self.layer(level).index.clear() - for idx, record in enumerate(self._records): + idx = self.layer(level).index(key) + idx.clear() + for i, record in enumerate(self._records): if key in record: - if record[key] not in self.layer(level).index: - self.layer(level).index[record[key]] = IndexEntry() - self.layer(level).index[record[key]].add(idx) + if record[key] not in idx: + idx[record[key]] = IndexEntry() + idx[record[key]].add(i) def layer(self, level: int = 0) -> Layer: """Get or create a layer at the specified level.""" if level not in self._layers: first = self._layers[0] - cloned = {} - for key, entry in first.index.items(): - cloned[key] = entry.clone() - self._layers[level] = Layer(cloned) + cloned_indexes = {} + for name, index_map in first.indexes.items(): + cloned_map = {} + for key, entry in index_map.items(): + cloned_map[key] = entry.clone() + cloned_indexes[name] = cloned_map + self._layers[level] = Layer(cloned_indexes) return self._layers[level] - def _find(self, key: str, level: int = 0) -> bool: + def _find(self, key: str, level: int = 0, index_name: Optional[str] = None) -> bool: """Find the next record with the given key value.""" - if key not in self.layer(level).index: + idx: Optional[Dict[str, IndexEntry]] = None + if index_name: + idx = self.layer(level).index(index_name) + else: + indexes = self.layer(level).indexes + idx = next(iter(indexes.values())) if indexes else None + if not idx or key not in idx: self.layer(level).current = len(self._records) # Move to end return False else: - entry = self.layer(level).index[key] + entry = idx[key] more = entry.next() if not more: self.layer(level).current = len(self._records) # Move to end @@ -102,8 +118,9 @@ def reset(self) -> None: """Reset iteration to the beginning.""" for layer in self._layers.values(): layer.current = -1 - for entry in layer.index.values(): - entry.reset() + for index_map in layer.indexes.values(): + for entry in index_map.values(): + entry.reset() def next(self, level: int = 0) -> bool: """Move to the next record. Returns True if successful.""" diff --git a/flowquery-py/src/graph/node.py b/flowquery-py/src/graph/node.py index 33e652c..4e57f4d 100644 --- a/flowquery-py/src/graph/node.py +++ b/flowquery-py/src/graph/node.py @@ -50,12 +50,31 @@ def label(self, value: Optional[str]) -> None: def properties(self) -> Dict[str, Expression]: return self._properties + @properties.setter + def properties(self, value: Dict[str, Expression]) -> None: + self._properties = value + def set_property(self, key: str, value: Expression) -> None: self._properties[key] = value def get_property(self, key: str) -> Optional[Expression]: return self._properties.get(key) + def _matches_properties(self, hop: int = 0) -> bool: + """Check if current record matches all constraint properties.""" + if not self._properties: + return True + if self._data is None: + return True + for key, expression in self._properties.items(): + record = self._data.current(hop) + if record is None: + raise ValueError("No current node data available") + if key not in record: + raise ValueError("Node does not have property") + return bool(record[key] == expression.value()) + return True + def set_value(self, value: Dict[str, Any]) -> None: self._value = value # type: ignore[assignment] @@ -88,6 +107,8 @@ async def next(self) -> None: current = self._data.current() if current is not None: self.set_value(current) + if not self._matches_properties(): + continue if self._outgoing and self._value: await self._outgoing.find(self._value['id']) await self.run_todo_next() @@ -99,6 +120,8 @@ async def find(self, id_: str, hop: int = 0) -> None: current = self._data.current(hop) if current is not None: self.set_value(current) + if not self._matches_properties(hop): + continue if self._incoming: self._incoming.set_end_node(self) if self._outgoing and self._value: diff --git a/flowquery-py/src/graph/node_data.py b/flowquery-py/src/graph/node_data.py index 8575ee2..5b65a93 100644 --- a/flowquery-py/src/graph/node_data.py +++ b/flowquery-py/src/graph/node_data.py @@ -19,7 +19,7 @@ def __init__(self, records: Optional[List[Dict[str, Any]]] = None): def find(self, id_: str, hop: int = 0) -> bool: """Find a record by ID.""" - return self._find(id_, hop) + return self._find(id_, hop, "id") def current(self, hop: int = 0) -> Optional[Dict[str, Any]]: """Get the current record.""" diff --git a/flowquery-py/src/graph/relationship.py b/flowquery-py/src/graph/relationship.py index b87b023..bfff4df 100644 --- a/flowquery-py/src/graph/relationship.py +++ b/flowquery-py/src/graph/relationship.py @@ -23,6 +23,7 @@ def __init__(self) -> None: self._hops: Hops = Hops() self._source: Optional['Node'] = None self._target: Optional['Node'] = None + self._direction: str = "right" self._data: Optional['RelationshipData'] = None self._value: Optional[Union[RelationshipMatchRecord, List[RelationshipMatchRecord]]] = None self._matches: RelationshipMatchCollector = RelationshipMatchCollector() @@ -54,10 +55,26 @@ def hops(self, value: Hops) -> None: @property def properties(self) -> Dict[str, Any]: - """Get properties from relationship data.""" - if self._data: - return self._data.properties() or {} - return {} + return self._properties + + @properties.setter + def properties(self, value: Dict[str, Any]) -> None: + self._properties = value + + def _matches_properties(self, hop: int = 0) -> bool: + """Check if current record matches all constraint properties.""" + if not self._properties: + return True + if self._data is None: + return True + for key, expression in self._properties.items(): + record = self._data.current(hop) + if record is None: + raise ValueError("No current relationship data available") + if key not in record: + raise ValueError("Relationship does not have property") + return bool(record[key] == expression.value()) + return True @property def source(self) -> Optional['Node']: @@ -75,6 +92,14 @@ def target(self) -> Optional['Node']: def target(self, value: 'Node') -> None: self._target = value + @property + def direction(self) -> str: + return self._direction + + @direction.setter + def direction(self, value: str) -> None: + self._direction = value + # Keep start/end aliases for backward compatibility @property def start(self) -> Optional['Node']: @@ -95,6 +120,9 @@ def end(self, value: 'Node') -> None: def set_data(self, data: Optional['RelationshipData']) -> None: self._data = data + def get_data(self) -> Optional['RelationshipData']: + return self._data + def set_value(self, relationship: 'Relationship') -> None: """Set value by pushing match to collector.""" self._matches.push(relationship) @@ -115,11 +143,13 @@ async def find(self, left_id: str, hop: int = 0) -> None: """Find relationships starting from the given node ID.""" # Save original source node original = self._source + is_left = self._direction == "left" if hop > 0: # For hops greater than 0, the source becomes the target of the previous hop self._source = self._target if hop == 0: - self._data.reset() if self._data else None + if self._data: + self._data.reset() # Handle zero-hop case: when min is 0 on a variable-length relationship, # match source node as target (no traversal) @@ -128,16 +158,25 @@ async def find(self, left_id: str, hop: int = 0) -> None: # No relationship match is pushed since no edge is traversed await self._target.find(left_id, hop) - while self._data and self._data.find(left_id, hop): + def find_match(id_: str, h: int) -> bool: + if self._data is None: + return False + if is_left: + return self._data.find_reverse(id_, h) + return self._data.find(id_, h) + follow_id = 'left_id' if is_left else 'right_id' + while self._data and find_match(left_id, hop): data = self._data.current(hop) if data and self._hops and hop >= self._hops.min: self.set_value(self) - if self._target and 'right_id' in data: - await self._target.find(data['right_id'], hop) + if not self._matches_properties(hop): + continue + if self._target and follow_id in data: + await self._target.find(data[follow_id], hop) if self._matches.is_circular(): raise ValueError("Circular relationship detected") if self._hops and hop + 1 < self._hops.max: - await self.find(data['right_id'], hop + 1) + await self.find(data[follow_id], hop + 1) self._matches.pop() # Restore original source node diff --git a/flowquery-py/src/graph/relationship_data.py b/flowquery-py/src/graph/relationship_data.py index cb6d193..0f8be1a 100644 --- a/flowquery-py/src/graph/relationship_data.py +++ b/flowquery-py/src/graph/relationship_data.py @@ -12,15 +12,20 @@ class RelationshipRecord(TypedDict, total=False): class RelationshipData(Data): - """Relationship data class extending Data with left_id-based indexing.""" + """Relationship data class extending Data with left_id and right_id indexing.""" def __init__(self, records: Optional[List[Dict[str, Any]]] = None): super().__init__(records) self._build_index("left_id") + self._build_index("right_id") def find(self, left_id: str, hop: int = 0) -> bool: """Find a relationship by start node ID.""" - return self._find(left_id, hop) + return self._find(left_id, hop, "left_id") + + def find_reverse(self, right_id: str, hop: int = 0) -> bool: + """Find a relationship by end node ID (reverse direction).""" + return self._find(right_id, hop, "right_id") def properties(self) -> Optional[Dict[str, Any]]: """Get properties of current relationship, excluding left_id and right_id.""" diff --git a/flowquery-py/src/graph/relationship_match_collector.py b/flowquery-py/src/graph/relationship_match_collector.py index 0c63912..7dc803d 100644 --- a/flowquery-py/src/graph/relationship_match_collector.py +++ b/flowquery-py/src/graph/relationship_match_collector.py @@ -27,11 +27,13 @@ def __init__(self) -> None: def push(self, relationship: 'Relationship') -> RelationshipMatchRecord: """Push a new match onto the collector.""" start_node_value = relationship.source.value() if relationship.source else None + rel_data = relationship.get_data() + rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {} match: RelationshipMatchRecord = { "type": relationship.type or "", "startNode": start_node_value or {}, "endNode": None, - "properties": relationship.properties, + "properties": rel_props, } self._matches.append(match) if isinstance(start_node_value, dict): diff --git a/flowquery-py/src/parsing/parser.py b/flowquery-py/src/parsing/parser.py index 2a7037f..6df4690 100644 --- a/flowquery-py/src/parsing/parser.py +++ b/flowquery-py/src/parsing/parser.py @@ -1,7 +1,7 @@ """Main parser for FlowQuery statements.""" import sys -from typing import Dict, Iterator, List, Optional, cast +from typing import Dict, Iterator, List, Optional, Tuple, cast from ..graph.hops import Hops from ..graph.node import Node @@ -467,6 +467,7 @@ def _parse_node(self) -> Optional[Node]: self._skip_whitespace_and_comments() node = Node() node.label = label + node.properties = dict(self._parse_properties()) if label is not None and identifier is not None: node.identifier = identifier self._variables[identifier] = node @@ -481,7 +482,9 @@ def _parse_node(self) -> Optional[Node]: return node def _parse_relationship(self) -> Optional[Relationship]: + direction = "right" if self.token.is_less_than() and self.peek() is not None and self.peek().is_subtract(): + direction = "left" self.set_next_token() self.set_next_token() elif self.token.is_subtract(): @@ -503,6 +506,7 @@ def _parse_relationship(self) -> Optional[Relationship]: rel_type: str = self.token.value or "" self.set_next_token() hops = self._parse_relationship_hops() + properties: Dict[str, Expression] = dict(self._parse_properties()) if not self.token.is_closing_bracket(): raise ValueError("Expected closing bracket for relationship definition") self.set_next_token() @@ -512,6 +516,8 @@ def _parse_relationship(self) -> Optional[Relationship]: if self.token.is_greater_than(): self.set_next_token() relationship = Relationship() + relationship.direction = direction + relationship.properties = properties if rel_type is not None and variable is not None: relationship.identifier = variable self._variables[variable] = relationship @@ -525,6 +531,39 @@ def _parse_relationship(self) -> Optional[Relationship]: relationship.type = rel_type return relationship + def _parse_properties(self) -> Iterator[Tuple[str, Expression]]: + parts: int = 0 + while True: + self._skip_whitespace_and_comments() + if not self.token.is_opening_brace() and parts == 0: + return + elif not self.token.is_opening_brace() and parts > 0: + raise ValueError("Expected opening brace") + self.set_next_token() + self._skip_whitespace_and_comments() + if not self.token.is_identifier(): + raise ValueError("Expected identifier") + key: str = self.token.value or "" + self.set_next_token() + self._skip_whitespace_and_comments() + if not self.token.is_colon(): + raise ValueError("Expected colon") + self.set_next_token() + self._skip_whitespace_and_comments() + expression = self._parse_expression() + if expression is None: + raise ValueError("Expected expression") + self._skip_whitespace_and_comments() + if not self.token.is_closing_brace(): + raise ValueError("Expected closing brace") + self.set_next_token() + yield (key, expression) + self._skip_whitespace_and_comments() + if not self.token.is_comma(): + break + self.set_next_token() + parts += 1 + def _parse_relationship_hops(self) -> Optional[Hops]: if not self.token.is_multiply(): return None diff --git a/flowquery-py/tests/compute/test_runner.py b/flowquery-py/tests/compute/test_runner.py index c041ccd..1ef1ba5 100644 --- a/flowquery-py/tests/compute/test_runner.py +++ b/flowquery-py/tests/compute/test_runner.py @@ -1335,4 +1335,208 @@ async def test_manager_chain(self): results = match.results # With * meaning 0+ hops, Employee 1 (CEO) also matches itself (zero-hop) # Employee 1→1 (zero-hop), 2→1, 3→2→1, 4→2→1 = 4 results - assert len(results) == 4 \ No newline at end of file + assert len(results) == 4 + + @pytest.mark.asyncio + async def test_match_with_leftward_relationship_direction(self): + """Test match with leftward relationship direction.""" + await Runner( + """ + CREATE VIRTUAL (:DirPerson) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:DirPerson)-[:REPORTS_TO]-(:DirPerson) AS { + unwind [ + {left_id: 2, right_id: 1}, + {left_id: 3, right_id: 1} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + """ + ).run() + # Rightward: left_id -> right_id (2->1, 3->1) + right_match = Runner( + """ + MATCH (a:DirPerson)-[:REPORTS_TO]->(b:DirPerson) + RETURN a.name AS employee, b.name AS manager + """ + ) + await right_match.run() + right_results = right_match.results + assert len(right_results) == 2 + assert right_results[0] == {"employee": "Person 2", "manager": "Person 1"} + assert right_results[1] == {"employee": "Person 3", "manager": "Person 1"} + + # Leftward: right_id -> left_id (1->2, 1->3) - reverse traversal + left_match = Runner( + """ + MATCH (m:DirPerson)<-[:REPORTS_TO]-(e:DirPerson) + RETURN m.name AS manager, e.name AS employee + """ + ) + await left_match.run() + left_results = left_match.results + assert len(left_results) == 2 + assert left_results[0] == {"manager": "Person 1", "employee": "Person 2"} + assert left_results[1] == {"manager": "Person 1", "employee": "Person 3"} + + @pytest.mark.asyncio + async def test_match_with_leftward_direction_swapped_data(self): + """Test match with leftward direction produces same results as rightward with swapped data.""" + await Runner( + """ + CREATE VIRTUAL (:DirCity) AS { + unwind [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'}, + {id: 3, name: 'Chicago'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:DirCity)-[:ROUTE]-(:DirCity) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 1, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + """ + ).run() + # Leftward from destination: find where right_id matches, follow left_id + match = Runner( + """ + MATCH (dest:DirCity)<-[:ROUTE]-(origin:DirCity) + RETURN dest.name AS destination, origin.name AS origin + """ + ) + await match.run() + results = match.results + assert len(results) == 2 + assert results[0] == {"destination": "Boston", "origin": "New York"} + assert results[1] == {"destination": "Chicago", "origin": "New York"} + + @pytest.mark.asyncio + async def test_match_with_leftward_variable_length(self): + """Test match with leftward variable-length relationships.""" + await Runner( + """ + CREATE VIRTUAL (:DirVarPerson) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:DirVarPerson)-[:MANAGES]-(:DirVarPerson) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 2, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + """ + ).run() + # Leftward variable-length: traverse from right_id to left_id + match = Runner( + """ + MATCH (a:DirVarPerson)<-[:MANAGES*]-(b:DirVarPerson) + RETURN a.name AS name1, b.name AS name2 + """ + ) + await match.run() + results = match.results + # Leftward indexes on right_id. find(id) looks up right_id=id, follows left_id. + # Person 1: zero-hop only (no right_id=1) + # Person 2: zero-hop, then left_id=1 (1 hop) + # Person 3: zero-hop, then left_id=2 (1 hop), then left_id=1 (2 hops) + assert len(results) == 6 + assert results[0] == {"name1": "Person 1", "name2": "Person 1"} + assert results[1] == {"name1": "Person 2", "name2": "Person 2"} + assert results[2] == {"name1": "Person 2", "name2": "Person 1"} + assert results[3] == {"name1": "Person 3", "name2": "Person 3"} + assert results[4] == {"name1": "Person 3", "name2": "Person 2"} + assert results[5] == {"name1": "Person 3", "name2": "Person 1"} + + @pytest.mark.asyncio + async def test_match_with_leftward_double_graph_pattern(self): + """Test match with leftward double graph pattern.""" + await Runner( + """ + CREATE VIRTUAL (:DirDoublePerson) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'}, + {id: 4, name: 'Person 4'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + await Runner( + """ + CREATE VIRTUAL (:DirDoublePerson)-[:KNOWS]-(:DirDoublePerson) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 2, right_id: 3}, + {left_id: 3, right_id: 4} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + """ + ).run() + # Leftward chain: (c)<-[:KNOWS]-(b)<-[:KNOWS]-(a) + match = Runner( + """ + MATCH (c:DirDoublePerson)<-[:KNOWS]-(b:DirDoublePerson)<-[:KNOWS]-(a:DirDoublePerson) + RETURN a.name AS name1, b.name AS name2, c.name AS name3 + """ + ) + await match.run() + results = match.results + assert len(results) == 2 + assert results[0] == {"name1": "Person 1", "name2": "Person 2", "name3": "Person 3"} + assert results[1] == {"name1": "Person 2", "name2": "Person 3", "name3": "Person 4"} + + async def test_match_with_constraints(self): + await Runner( + """ + CREATE VIRTUAL (:ConstraintEmployee) AS { + unwind [ + {id: 1, name: 'Employee 1'}, + {id: 2, name: 'Employee 2'}, + {id: 3, name: 'Employee 3'}, + {id: 4, name: 'Employee 4'} + ] as record + RETURN record.id as id, record.name as name + } + """ + ).run() + match = Runner( + """ + match (e:ConstraintEmployee{name:'Employee 1'}) + return e.name as name + """ + ) + await match.run() + results = match.results + assert len(results) == 1 + assert results[0]["name"] == "Employee 1" \ No newline at end of file diff --git a/flowquery-py/tests/parsing/test_parser.py b/flowquery-py/tests/parsing/test_parser.py index b1153a4..ce153b4 100644 --- a/flowquery-py/tests/parsing/test_parser.py +++ b/flowquery-py/tests/parsing/test_parser.py @@ -5,6 +5,9 @@ from flowquery.parsing.parser import Parser from flowquery.parsing.functions.async_function import AsyncFunction from flowquery.parsing.functions.function_metadata import FunctionDef +from flowquery.parsing.operations.match import Match +from flowquery.graph.node import Node +from flowquery.graph.relationship import Relationship # Test async function for CALL operation parsing test @@ -678,3 +681,41 @@ def test_check_pattern_expression_without_noderef(self): parser = Parser() with pytest.raises(Exception, match="PatternExpression must contain at least one NodeReference"): parser.parse("MATCH (a:Person) WHERE (:Person)-[:KNOWS]->(:Person) RETURN a") + + def test_node_with_properties(self): + """Test node with properties.""" + parser = Parser() + ast = parser.parse("MATCH (a:Person{value: 'hello'}) return a") + expected = ( + "ASTNode\n" + "- Match\n" + "- Return\n" + "-- Expression (a)\n" + "--- Reference (a)" + ) + assert ast.print() == expected + match_op = ast.first_child() + assert isinstance(match_op, Match) + node = match_op.patterns[0].chain[0] + assert isinstance(node, Node) + assert node.properties.get("value") is not None + assert node.properties["value"].value() == "hello" + + def test_relationship_with_properties(self): + """Test relationship with properties.""" + parser = Parser() + ast = parser.parse("MATCH (:Person)-[r:LIKES{since: 2022}]->(:Food) return a") + expected = ( + "ASTNode\n" + "- Match\n" + "- Return\n" + "-- Expression (a)\n" + "--- Reference (a)" + ) + assert ast.print() == expected + match_op = ast.first_child() + assert isinstance(match_op, Match) + relationship = match_op.patterns[0].chain[1] + assert isinstance(relationship, Relationship) + assert relationship.properties.get("since") is not None + assert relationship.properties["since"].value() == 2022 diff --git a/src/graph/data.ts b/src/graph/data.ts index 53e70fd..4d2b8dc 100644 --- a/src/graph/data.ts +++ b/src/graph/data.ts @@ -27,13 +27,19 @@ class IndexEntry { } class Layer { - private _index: Map = new Map(); + private _indexes: Map> = new Map(); private _current: number = -1; - constructor(index: Map) { - this._index = index; + constructor(indexes: Map>) { + this._indexes = indexes; } - public get index(): Map { - return this._index; + public index(name: string): Map { + if (!this._indexes.has(name)) { + this._indexes.set(name, new Map()); + } + return this._indexes.get(name)!; + } + public get indexes(): Map> { + return this._indexes; } public get current(): number { return this._current; @@ -52,33 +58,41 @@ class Data { this._layers.set(0, new Layer(new Map())); } protected _buildIndex(key: string, level: number = 0): void { - this.layer(level).index.clear(); - this._records.forEach((record, idx) => { + const idx = this.layer(level).index(key); + idx.clear(); + this._records.forEach((record, i) => { if (record.hasOwnProperty(key)) { - if (!this.layer(level).index.has(record[key])) { - this.layer(level).index.set(record[key], new IndexEntry()); + if (!idx.has(record[key])) { + idx.set(record[key], new IndexEntry()); } - this.layer(level).index.get(record[key])!.add(idx); + idx.get(record[key])!.add(i); } }); } public layer(level: number = 0): Layer { if (!this._layers.has(level)) { const first = this._layers.get(0)!; - const cloned = new Map(); - for (const [key, entry] of first.index) { - cloned.set(key, entry.clone()); + const clonedIndexes = new Map>(); + for (const [name, indexMap] of first.indexes) { + const clonedMap = new Map(); + for (const [key, entry] of indexMap) { + clonedMap.set(key, entry.clone()); + } + clonedIndexes.set(name, clonedMap); } - this._layers.set(level, new Layer(cloned)); + this._layers.set(level, new Layer(clonedIndexes)); } return this._layers.get(level)!; } - protected _find(key: string, level: number = 0): boolean { - if (!this.layer(level).index.has(key)) { + protected _find(key: string, level: number = 0, indexName?: string): boolean { + const idx = indexName + ? this.layer(level).index(indexName) + : this.layer(level).indexes.values().next().value; + if (!idx || !idx.has(key)) { this.layer(level).current = this._records.length; // Move to end return false; } else { - const entry = this.layer(level).index.get(key)!; + const entry = idx.get(key)!; const more = entry.next(); if (!more) { this.layer(level).current = this._records.length; // Move to end @@ -91,8 +105,10 @@ class Data { public reset(): void { for (const layer of this._layers.values()) { layer.current = -1; - for (const entry of layer.index.values()) { - entry.reset(); + for (const indexMap of layer.indexes.values()) { + for (const entry of indexMap.values()) { + entry.reset(); + } } } } diff --git a/src/graph/node_data.ts b/src/graph/node_data.ts index 6b3ec9f..350019f 100644 --- a/src/graph/node_data.ts +++ b/src/graph/node_data.ts @@ -8,7 +8,7 @@ class NodeData extends Data { super._buildIndex("id"); } public find(id: string, hop: number = 0): boolean { - return super._find(id, hop); + return super._find(id, hop, "id"); } public current(hop: number = 0): NodeRecord | null { return super.current(hop) as NodeRecord | null; diff --git a/src/graph/relationship.ts b/src/graph/relationship.ts index 270b956..5b88c8d 100644 --- a/src/graph/relationship.ts +++ b/src/graph/relationship.ts @@ -18,6 +18,7 @@ class Relationship extends ASTNode { protected _source: Node | null = null; protected _target: Node | null = null; + protected _direction: "left" | "right" = "right"; private _data: RelationshipData | null = null; @@ -86,6 +87,12 @@ class Relationship extends ASTNode { public get target(): Node | null { return this._target; } + public set direction(direction: "left" | "right") { + this._direction = direction; + } + public get direction(): "left" | "right" { + return this._direction; + } public value(): RelationshipMatchRecord | RelationshipMatchRecord[] | null { return this._value; } @@ -104,6 +111,7 @@ class Relationship extends ASTNode { public async find(left_id: string, hop: number = 0): Promise { // Save original source node const original = this._source; + const isLeft = this._direction === "left"; if (hop > 0) { // For hops greater than 0, the source becomes the target of the previous hop this._source = this._target; @@ -119,19 +127,23 @@ class Relationship extends ASTNode { await this._target.find(left_id, hop); } } - while (this._data?.find(left_id, hop)) { + const findMatch = isLeft + ? (id: string, h: number) => this._data!.findReverse(id, h) + : (id: string, h: number) => this._data!.find(id, h); + const followId = isLeft ? "left_id" : "right_id"; + while (findMatch(left_id, hop)) { const data: RelationshipRecord = this._data?.current(hop) as RelationshipRecord; if (hop >= this.hops!.min) { this.setValue(this); if (!this._matchesProperties(hop)) { continue; } - await this._target?.find(data.right_id, hop); + await this._target?.find(data[followId], hop); if (this._matches.isCircular()) { throw new Error("Circular relationship detected"); } if (hop + 1 < this.hops!.max) { - await this.find(data.right_id, hop + 1); + await this.find(data[followId], hop + 1); } this._matches.pop(); } diff --git a/src/graph/relationship_data.ts b/src/graph/relationship_data.ts index 995f3a9..5523670 100644 --- a/src/graph/relationship_data.ts +++ b/src/graph/relationship_data.ts @@ -6,9 +6,13 @@ class RelationshipData extends Data { constructor(records: RelationshipRecord[] = []) { super(records); super._buildIndex("left_id"); + super._buildIndex("right_id"); } public find(left_id: string, hop: number = 0): boolean { - return super._find(left_id, hop); + return super._find(left_id, hop, "left_id"); + } + public findReverse(right_id: string, hop: number = 0): boolean { + return super._find(right_id, hop, "right_id"); } /* ** Get the properties of the current relationship record diff --git a/src/graph/relationship_reference.ts b/src/graph/relationship_reference.ts index c0c39e8..f0cbb61 100644 --- a/src/graph/relationship_reference.ts +++ b/src/graph/relationship_reference.ts @@ -17,7 +17,8 @@ class RelationshipReference extends Relationship { const data: RelationshipRecord = this._reference!.getData()?.current( hop ) as RelationshipRecord; - await this._target?.find(data.right_id, hop); + const followId = this._direction === "left" ? "left_id" : "right_id"; + await this._target?.find(data[followId], hop); } } diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 3176e69..09995d7 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -580,7 +580,9 @@ class Parser extends BaseParser { } private parseRelationship(): Relationship | null { + let direction: "left" | "right" = "right"; if (this.token.isLessThan() && this.peek()?.isSubtract()) { + direction = "left"; this.setNextToken(); this.setNextToken(); } else if (this.token.isSubtract()) { @@ -620,6 +622,7 @@ class Parser extends BaseParser { this.setNextToken(); } let relationship = new Relationship(); + relationship.direction = direction; relationship.properties = properties; if (type !== null && variable !== null) { relationship.identifier = variable; diff --git a/tests/compute/runner.test.ts b/tests/compute/runner.test.ts index 4ad9f67..bfc0abf 100644 --- a/tests/compute/runner.test.ts +++ b/tests/compute/runner.test.ts @@ -1304,3 +1304,159 @@ test("Test match with constraints", async () => { expect(results.length).toBe(1); expect(results[0].name).toBe("Employee 1"); }); + +test("Test match with leftward relationship direction", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Person)-[:REPORTS_TO]-(:Person) AS { + unwind [ + {left_id: 2, right_id: 1}, + {left_id: 3, right_id: 1} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + // Rightward: left_id -> right_id (2->1, 3->1) + const rightMatch = new Runner(` + MATCH (a:Person)-[:REPORTS_TO]->(b:Person) + RETURN a.name AS employee, b.name AS manager + `); + await rightMatch.run(); + const rightResults = rightMatch.results; + expect(rightResults.length).toBe(2); + expect(rightResults[0]).toEqual({ employee: "Person 2", manager: "Person 1" }); + expect(rightResults[1]).toEqual({ employee: "Person 3", manager: "Person 1" }); + + // Leftward: right_id -> left_id (1->2, 1->3) — reverse traversal + const leftMatch = new Runner(` + MATCH (m:Person)<-[:REPORTS_TO]-(e:Person) + RETURN m.name AS manager, e.name AS employee + `); + await leftMatch.run(); + const leftResults = leftMatch.results; + expect(leftResults.length).toBe(2); + expect(leftResults[0]).toEqual({ manager: "Person 1", employee: "Person 2" }); + expect(leftResults[1]).toEqual({ manager: "Person 1", employee: "Person 3" }); +}); + +test("Test match with leftward direction produces same results as rightward with swapped data", async () => { + await new Runner(` + CREATE VIRTUAL (:City) AS { + unwind [ + {id: 1, name: 'New York'}, + {id: 2, name: 'Boston'}, + {id: 3, name: 'Chicago'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:ROUTE]-(:City) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 1, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + // Leftward from destination: find where right_id matches, follow left_id + const match = new Runner(` + MATCH (dest:City)<-[:ROUTE]-(origin:City) + RETURN dest.name AS destination, origin.name AS origin + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ destination: "Boston", origin: "New York" }); + expect(results[1]).toEqual({ destination: "Chicago", origin: "New York" }); +}); + +test("Test match with leftward variable-length relationships", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Person)-[:MANAGES]-(:Person) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 2, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + // Leftward variable-length: traverse from right_id to left_id + // Person 3 can reach Person 2 (1 hop) and Person 1 (2 hops) + const match = new Runner(` + MATCH (a:Person)<-[:MANAGES*]-(b:Person) + RETURN a.name AS name1, b.name AS name2 + `); + await match.run(); + const results = match.results; + // Zero-hop results for all 3 persons + multi-hop results + // Leftward indexes on right_id. find(id) looks up right_id=id, follows left_id. + // right_id=1: no records → Person 1 zero-hop only + // right_id=2: record {left_id:1, right_id:2} → Person 2 → Person 1, then recurse find(1) → no more + // right_id=3: record {left_id:2, right_id:3} → Person 3 → Person 2, then recurse find(2) → Person 1 + expect(results.length).toBe(6); + // Person 1: zero-hop + expect(results[0]).toEqual({ name1: "Person 1", name2: "Person 1" }); + // Person 2: zero-hop, then reaches Person 1 + expect(results[1]).toEqual({ name1: "Person 2", name2: "Person 2" }); + expect(results[2]).toEqual({ name1: "Person 2", name2: "Person 1" }); + // Person 3: zero-hop, then reaches Person 2, then Person 1 + expect(results[3]).toEqual({ name1: "Person 3", name2: "Person 3" }); + expect(results[4]).toEqual({ name1: "Person 3", name2: "Person 2" }); + expect(results[5]).toEqual({ name1: "Person 3", name2: "Person 1" }); +}); + +test("Test match with leftward double graph pattern", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + unwind [ + {id: 1, name: 'Person 1'}, + {id: 2, name: 'Person 2'}, + {id: 3, name: 'Person 3'}, + {id: 4, name: 'Person 4'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS { + unwind [ + {left_id: 1, right_id: 2}, + {left_id: 2, right_id: 3}, + {left_id: 3, right_id: 4} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + // Leftward chain: (c)<-[:KNOWS]-(b)<-[:KNOWS]-(a) + // First rel: find right_id=c, follow left_id to b + // Second rel: find right_id=b, follow left_id to a + const match = new Runner(` + MATCH (c:Person)<-[:KNOWS]-(b:Person)<-[:KNOWS]-(a:Person) + RETURN a.name AS name1, b.name AS name2, c.name AS name3 + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ name1: "Person 1", name2: "Person 2", name3: "Person 3" }); + expect(results[1]).toEqual({ name1: "Person 2", name2: "Person 3", name3: "Person 4" }); +});