diff --git a/flowquery-py/src/graph/database.py b/flowquery-py/src/graph/database.py index 527275c..6eda9d1 100644 --- a/flowquery-py/src/graph/database.py +++ b/flowquery-py/src/graph/database.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Union +from typing import Any, AsyncIterator, Dict, List, Optional, Union from ..parsing.ast_node import ASTNode from .node import Node @@ -48,35 +48,57 @@ def add_relationship(self, relationship: 'Relationship', statement: ASTNode) -> physical = PhysicalRelationship() physical.type = relationship.type physical.statement = statement + if relationship.source is not None: + physical.source = relationship.source + if relationship.target is not None: + physical.target = relationship.target Database._relationships[relationship.type] = physical def get_relationship(self, relationship: 'Relationship') -> Optional['PhysicalRelationship']: """Gets a relationship from the database.""" return Database._relationships.get(relationship.type) if relationship.type else None - async def schema(self) -> list[dict[str, Any]]: + def get_relationships(self, relationship: 'Relationship') -> list['PhysicalRelationship']: + """Gets multiple physical relationships for ORed types.""" + result = [] + for rel_type in relationship.types: + physical = Database._relationships.get(rel_type) + if physical: + result.append(physical) + return result + + async def schema(self) -> List[Dict[str, Any]]: """Returns the graph schema with node/relationship labels and sample data.""" - result: list[dict[str, Any]] = [] + return [item async for item in self._schema()] + async def _schema(self) -> AsyncIterator[Dict[str, Any]]: + """Async generator for graph schema with node/relationship labels and sample data.""" for label, physical_node in Database._nodes.items(): records = await physical_node.data() - entry: dict[str, Any] = {"kind": "node", "label": label} + entry: Dict[str, Any] = {"kind": "Node", "label": label} if records: sample = {k: v for k, v in records[0].items() if k != "id"} - if sample: + properties = list(sample.keys()) + if properties: + entry["properties"] = properties entry["sample"] = sample - result.append(entry) + yield entry for rel_type, physical_rel in Database._relationships.items(): records = await physical_rel.data() - entry_rel: dict[str, Any] = {"kind": "relationship", "type": rel_type} + entry_rel: Dict[str, Any] = { + "kind": "Relationship", + "type": rel_type, + "from_label": physical_rel.source.label if physical_rel.source else None, + "to_label": physical_rel.target.label if physical_rel.target else None, + } if records: sample = {k: v for k, v in records[0].items() if k not in ("left_id", "right_id")} - if sample: + properties = list(sample.keys()) + if properties: + entry_rel["properties"] = properties entry_rel["sample"] = sample - result.append(entry_rel) - - return result + yield entry_rel async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeData', 'RelationshipData']: """Gets data for a node or relationship.""" @@ -87,6 +109,17 @@ async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeD data = await node.data() return NodeData(data) elif isinstance(element, Relationship): + if len(element.types) > 1: + physicals = self.get_relationships(element) + if not physicals: + raise ValueError(f"No physical relationships found for types {', '.join(element.types)}") + all_records = [] + for i, physical in enumerate(physicals): + records = await physical.data() + type_name = element.types[i] + for record in records: + all_records.append({**record, "_type": type_name}) + return RelationshipData(all_records) relationship = self.get_relationship(element) if relationship is None: raise ValueError(f"Physical relationship not found for type {element.type}") diff --git a/flowquery-py/src/graph/relationship.py b/flowquery-py/src/graph/relationship.py index e944301..869af24 100644 --- a/flowquery-py/src/graph/relationship.py +++ b/flowquery-py/src/graph/relationship.py @@ -19,7 +19,7 @@ class Relationship(ASTNode): def __init__(self) -> None: super().__init__() self._identifier: Optional[str] = None - self._type: Optional[str] = None + self._types: List[str] = [] self._hops: Hops = Hops() self._source: Optional['Node'] = None self._target: Optional['Node'] = None @@ -39,11 +39,19 @@ def identifier(self, value: str) -> None: @property def type(self) -> Optional[str]: - return self._type + return self._types[0] if self._types else None @type.setter def type(self, value: str) -> None: - self._type = value + self._types = [value] + + @property + def types(self) -> List[str]: + return self._types + + @types.setter + def types(self, value: List[str]) -> None: + self._types = value @property def hops(self) -> Hops: diff --git a/flowquery-py/src/graph/relationship_data.py b/flowquery-py/src/graph/relationship_data.py index c717aa1..92a6fb8 100644 --- a/flowquery-py/src/graph/relationship_data.py +++ b/flowquery-py/src/graph/relationship_data.py @@ -25,11 +25,12 @@ def find(self, id: str, hop: int = 0, direction: str = "right") -> bool: return self._find(id, hop, key) def properties(self) -> Optional[Dict[str, Any]]: - """Get properties of current relationship, excluding left_id and right_id.""" + """Get properties of current relationship, excluding left_id, right_id, and _type.""" current = self.current() if current: props = dict(current) props.pop("left_id", None) props.pop("right_id", None) + props.pop("_type", None) return props return None diff --git a/flowquery-py/src/graph/relationship_match_collector.py b/flowquery-py/src/graph/relationship_match_collector.py index 2a8b06e..f7d65fc 100644 --- a/flowquery-py/src/graph/relationship_match_collector.py +++ b/flowquery-py/src/graph/relationship_match_collector.py @@ -28,9 +28,15 @@ def push(self, relationship: 'Relationship', traversal_id: str = "") -> Relation """Push a new match onto the collector.""" start_node_value = relationship.source.value() if relationship.source else None rel_data = relationship.get_data() + current_record = rel_data.current() if rel_data else None + default_type = relationship.type or "" + if current_record and isinstance(current_record, dict): + actual_type = current_record.get('_type', default_type) + else: + actual_type = default_type rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {} match: RelationshipMatchRecord = { - "type": relationship.type or "", + "type": actual_type, "startNode": start_node_value or {}, "endNode": None, "properties": rel_props, diff --git a/flowquery-py/src/graph/relationship_reference.py b/flowquery-py/src/graph/relationship_reference.py index 6a84ed3..f308d7d 100644 --- a/flowquery-py/src/graph/relationship_reference.py +++ b/flowquery-py/src/graph/relationship_reference.py @@ -10,8 +10,8 @@ class RelationshipReference(Relationship): def __init__(self, relationship: Relationship, referred: ASTNode) -> None: super().__init__() self._referred = referred - if relationship.type: - self.type = relationship.type + if relationship.types: + self.types = relationship.types @property def referred(self) -> ASTNode: diff --git a/flowquery-py/src/parsing/functions/__init__.py b/flowquery-py/src/parsing/functions/__init__.py index 1e7bcd1..99ad1c3 100644 --- a/flowquery-py/src/parsing/functions/__init__.py +++ b/flowquery-py/src/parsing/functions/__init__.py @@ -39,6 +39,7 @@ from .to_json import ToJson from .to_lower import ToLower from .to_string import ToString +from .trim import Trim from .type_ import Type from .value_holder import ValueHolder @@ -78,6 +79,7 @@ "ToJson", "ToLower", "ToString", + "Trim", "Type", "Functions", "Schema", diff --git a/flowquery-py/src/parsing/functions/predicate_sum.py b/flowquery-py/src/parsing/functions/predicate_sum.py index a180244..90814d5 100644 --- a/flowquery-py/src/parsing/functions/predicate_sum.py +++ b/flowquery-py/src/parsing/functions/predicate_sum.py @@ -1,6 +1,6 @@ """PredicateSum function.""" -from typing import Any, Optional +from typing import Any from .function_metadata import FunctionDef from .predicate_function import PredicateFunction @@ -41,12 +41,9 @@ def value(self) -> Any: if array is None or not isinstance(array, list): raise ValueError("Invalid array for sum function") - _sum: Optional[Any] = None + _sum: int = 0 for item in array: self._value_holder.holder = item if self.where is None or self.where.value(): - if _sum is None: - _sum = self._return.value() - else: - _sum += self._return.value() + _sum += self._return.value() return _sum diff --git a/flowquery-py/src/parsing/functions/schema.py b/flowquery-py/src/parsing/functions/schema.py index 6bde093..2e5f53c 100644 --- a/flowquery-py/src/parsing/functions/schema.py +++ b/flowquery-py/src/parsing/functions/schema.py @@ -9,23 +9,27 @@ @FunctionDef({ "description": ( "Returns the graph schema listing all nodes and relationships " - "with a sample of their data." + "with their properties and a sample of their data." ), "category": "async", "parameters": [], "output": { - "description": "Schema entry with kind, label/type, and optional sample data", + "description": "Schema entry with label/type, properties, and optional sample data", "type": "object", }, "examples": [ - "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample", + "CALL schema() YIELD label, type, from_label, to_label, properties, sample " + "RETURN label, type, from_label, to_label, properties, sample", ], }) class Schema(AsyncFunction): """Returns the graph schema of the database. - Lists all nodes and relationships with their labels/types and a sample - of their data (excluding id from nodes, left_id and right_id from relationships). + Lists all nodes and relationships with their labels/types, properties, + and a sample of their data (excluding id from nodes, left_id and right_id from relationships). + + Nodes: {label, properties, sample} + Relationships: {type, from_label, to_label, properties, sample} """ async def generate(self) -> AsyncGenerator[Any, None]: diff --git a/flowquery-py/src/parsing/functions/trim.py b/flowquery-py/src/parsing/functions/trim.py new file mode 100644 index 0000000..7a73b48 --- /dev/null +++ b/flowquery-py/src/parsing/functions/trim.py @@ -0,0 +1,35 @@ +"""Trim function.""" + +from typing import Any + +from .function import Function +from .function_metadata import FunctionDef + + +@FunctionDef({ + "description": "Removes leading and trailing whitespace from a string", + "category": "scalar", + "parameters": [ + {"name": "text", "description": "String to trim", "type": "string"} + ], + "output": {"description": "Trimmed string", "type": "string", "example": "hello"}, + "examples": [ + "WITH ' hello ' AS s RETURN trim(s)", + "WITH '\\tfoo\\n' AS s RETURN trim(s)" + ] +}) +class Trim(Function): + """Trim function. + + Removes leading and trailing whitespace from a string. + """ + + def __init__(self) -> None: + super().__init__("trim") + self._expected_parameter_count = 1 + + def value(self) -> Any: + val = self.get_children()[0].value() + if not isinstance(val, str): + raise ValueError("Invalid argument for trim function: expected a string") + return val.strip() diff --git a/flowquery-py/src/parsing/operations/group_by.py b/flowquery-py/src/parsing/operations/group_by.py index 96e2501..81baad1 100644 --- a/flowquery-py/src/parsing/operations/group_by.py +++ b/flowquery-py/src/parsing/operations/group_by.py @@ -122,6 +122,8 @@ def generate_results( self.mappers[mapper_index].overridden = child.value yield from self.generate_results(mapper_index + 1, child) else: + if node.elements is None: + node.elements = [reducer.element() for reducer in self.reducers] if node.elements: for i, element in enumerate(node.elements): self.reducers[i].overridden = element.value diff --git a/flowquery-py/src/parsing/parser.py b/flowquery-py/src/parsing/parser.py index 56a8663..56bf797 100644 --- a/flowquery-py/src/parsing/parser.py +++ b/flowquery-py/src/parsing/parser.py @@ -398,6 +398,8 @@ def _parse_create(self) -> Optional[Operation]: raise ValueError("Expected target node definition") relationship = Relationship() relationship.type = rel_type + relationship.source = node + relationship.target = target self._expect_and_skip_whitespace_and_comments() if not self.token.is_as(): @@ -576,8 +578,16 @@ def _parse_relationship(self) -> Optional[Relationship]: self.set_next_token() if not self.token.is_identifier_or_keyword(): raise ValueError("Expected relationship type identifier") - rel_type: str = self.token.value or "" + rel_types: List[str] = [self.token.value or ""] self.set_next_token() + while self.token.is_pipe(): + self.set_next_token() + if self.token.is_colon(): + self.set_next_token() + if not self.token.is_identifier_or_keyword(): + raise ValueError("Expected relationship type identifier after '|'") + rel_types.append(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(): @@ -607,7 +617,7 @@ def _parse_relationship(self) -> Optional[Relationship]: self._state.variables[variable] = relationship if hops is not None: relationship.hops = hops - relationship.type = rel_type + relationship.types = rel_types return relationship def _parse_properties(self) -> Iterator[Tuple[str, Expression]]: diff --git a/flowquery-py/tests/compute/test_runner.py b/flowquery-py/tests/compute/test_runner.py index 52a2801..57bb774 100644 --- a/flowquery-py/tests/compute/test_runner.py +++ b/flowquery-py/tests/compute/test_runner.py @@ -681,6 +681,42 @@ async def test_to_lower_function_with_all_uppercase(self): assert len(results) == 1 assert results[0] == {"result": "foo bar"} + @pytest.mark.asyncio + async def test_trim_function(self): + """Test trim function.""" + runner = Runner('RETURN trim(" hello ") as result') + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "hello"} + + @pytest.mark.asyncio + async def test_trim_function_with_tabs_and_newlines(self): + """Test trim function with tabs and newlines.""" + runner = Runner('WITH "\tfoo\n" AS s RETURN trim(s) as result') + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "foo"} + + @pytest.mark.asyncio + async def test_trim_function_with_no_whitespace(self): + """Test trim function with no whitespace.""" + runner = Runner('RETURN trim("hello") as result') + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": "hello"} + + @pytest.mark.asyncio + async def test_trim_function_with_empty_string(self): + """Test trim function with empty string.""" + runner = Runner('RETURN trim("") as result') + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"result": ""} + @pytest.mark.asyncio async def test_associative_array_with_key_which_is_keyword(self): """Test associative array with key which is keyword.""" @@ -2152,20 +2188,24 @@ async def test_schema_returns_nodes_and_relationships_with_sample_data(self): ).run() runner = Runner( - "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample" + "CALL schema() YIELD kind, label, type, from_label, to_label, properties, sample RETURN kind, label, type, from_label, to_label, properties, sample" ) await runner.run() results = runner.results - animal = next((r for r in results if r.get("kind") == "node" and r.get("label") == "Animal"), None) + animal = next((r for r in results if r.get("kind") == "Node" and r.get("label") == "Animal"), None) assert animal is not None + assert animal["properties"] == ["species", "legs"] assert animal["sample"] is not None assert "id" not in animal["sample"] assert "species" in animal["sample"] assert "legs" in animal["sample"] - chases = next((r for r in results if r.get("kind") == "relationship" and r.get("type") == "CHASES"), None) + chases = next((r for r in results if r.get("kind") == "Relationship" and r.get("type") == "CHASES"), None) assert chases is not None + assert chases["from_label"] == "Animal" + assert chases["to_label"] == "Animal" + assert chases["properties"] == ["speed"] assert chases["sample"] is not None assert "left_id" not in chases["sample"] assert "right_id" not in chases["sample"] @@ -2549,6 +2589,64 @@ async def test_collected_nodes_and_re_matching(self): # Add operator tests # ============================================================ + @pytest.mark.asyncio + async def test_collected_patterns_and_unwind(self): + """Test collecting graph patterns and unwinding them.""" + await 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 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() + runner = Runner(""" + MATCH p=(a:Person)-[:KNOWS*0..3]->(b:Person) + WITH collect(p) AS patterns + UNWIND patterns AS pattern + RETURN pattern + """) + await runner.run() + results = runner.results + assert len(results) == 10 + # Index 0: Person 1 zero-hop - pattern = [node1] (single node) + assert len(results[0]["pattern"]) == 1 + assert results[0]["pattern"][0]["id"] == 1 + # Index 1: Person 1 -> Person 2 (1-hop) + assert len(results[1]["pattern"]) == 3 + # Index 2: Person 1 -> Person 2 -> Person 3 (2-hop) + assert len(results[2]["pattern"]) == 5 + # Index 3: Person 1 -> Person 2 -> Person 3 -> Person 4 (3-hop) + assert len(results[3]["pattern"]) == 7 + # Index 4: Person 2 zero-hop + assert len(results[4]["pattern"]) == 1 + assert results[4]["pattern"][0]["id"] == 2 + # Index 5: Person 2 -> Person 3 (1-hop) + assert len(results[5]["pattern"]) == 3 + # Index 6: Person 2 -> Person 3 -> Person 4 (2-hop) + assert len(results[6]["pattern"]) == 5 + # Index 7: Person 3 zero-hop + assert len(results[7]["pattern"]) == 1 + assert results[7]["pattern"][0]["id"] == 3 + # Index 8: Person 3 -> Person 4 (1-hop) + assert len(results[8]["pattern"]) == 3 + # Index 9: Person 4 zero-hop + assert len(results[9]["pattern"]) == 1 + assert results[9]["pattern"][0]["id"] == 4 + @pytest.mark.asyncio async def test_add_two_integers(self): """Test add two integers.""" @@ -2854,4 +2952,151 @@ async def test_union_with_empty_right_side(self): await runner.run() results = runner.results assert len(results) == 1 - assert results == [{"x": 1}] \ No newline at end of file + assert results == [{"x": 1}] + + @pytest.mark.asyncio + async def test_language_name_hits_query_with_virtual_graph(self): + """Test full language-name-hits query with virtual graph. + + Reproduces the original bug: collect(distinct ...) on MATCH results, + then sum(lang IN langs | ...) in a WITH clause, was throwing + "Invalid array for sum function" because collect() returned null + instead of [] when no rows entered aggregation. + """ + # Create Language nodes + await Runner( + """ + CREATE VIRTUAL (:Language) AS { + UNWIND [ + {id: 1, name: 'Python'}, + {id: 2, name: 'JavaScript'}, + {id: 3, name: 'TypeScript'} + ] AS record + RETURN record.id AS id, record.name AS name + } + """ + ).run() + + # Create Chat nodes with messages + await Runner( + """ + CREATE VIRTUAL (:Chat) AS { + UNWIND [ + {id: 1, name: 'Dev Discussion', messages: [ + {From: 'Alice', SentDateTime: '2025-01-01T10:00:00', Content: 'I love Python and JavaScript'}, + {From: 'Bob', SentDateTime: '2025-01-01T10:05:00', Content: 'What languages do you prefer?'} + ]}, + {id: 2, name: 'General', messages: [ + {From: 'Charlie', SentDateTime: '2025-01-02T09:00:00', Content: 'The weather is nice today'}, + {From: 'Alice', SentDateTime: '2025-01-02T09:05:00', Content: 'TypeScript is great for language tooling'} + ]} + ] AS record + RETURN record.id AS id, record.name AS name, record.messages AS messages + } + """ + ).run() + + # Create User nodes + await Runner( + """ + CREATE VIRTUAL (:User) AS { + UNWIND [ + {id: 1, displayName: 'Alice'}, + {id: 2, displayName: 'Bob'}, + {id: 3, displayName: 'Charlie'} + ] AS record + RETURN record.id AS id, record.displayName AS displayName + } + """ + ).run() + + # Create PARTICIPATES_IN relationships + await Runner( + """ + CREATE VIRTUAL (:User)-[:PARTICIPATES_IN]-(:Chat) AS { + UNWIND [ + {left_id: 1, right_id: 1}, + {left_id: 2, right_id: 1}, + {left_id: 3, right_id: 2}, + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + """ + ).run() + + # Run the original query (using 'sender' alias since 'from' is a reserved keyword) + runner = Runner( + """ + MATCH (l:Language) + WITH collect(distinct l.name) AS langs + MATCH (c:Chat) + UNWIND c.messages AS msg + WITH c, msg, langs, + sum(lang IN langs | 1 where toLower(msg.Content) CONTAINS toLower(lang)) AS langNameHits + WHERE toLower(msg.Content) CONTAINS "language" + OR toLower(msg.Content) CONTAINS "languages" + OR langNameHits > 0 + OPTIONAL MATCH (u:User)-[:PARTICIPATES_IN]->(c) + RETURN + c.name AS chat, + collect(distinct u.displayName) AS participants, + msg.From AS sender, + msg.SentDateTime AS sentDateTime, + msg.Content AS message + """ + ) + await runner.run() + results = runner.results + + # Messages that mention a language name or the word "language(s)": + # 1. "I love Python and JavaScript" - langNameHits=2 + # 2. "What languages do you prefer?" - contains "languages" + # 3. "TypeScript is great for language tooling" - langNameHits=1, also "language" + assert len(results) == 3 + assert results[0]["chat"] == "Dev Discussion" + assert results[0]["message"] == "I love Python and JavaScript" + assert results[0]["sender"] == "Alice" + assert results[1]["chat"] == "Dev Discussion" + assert results[1]["message"] == "What languages do you prefer?" + assert results[1]["sender"] == "Bob" + assert results[2]["chat"] == "General" + assert results[2]["message"] == "TypeScript is great for language tooling" + assert results[2]["sender"] == "Alice" + + @pytest.mark.asyncio + async def test_sum_with_empty_collected_array(self): + """Reproduces the original bug: collect on empty input should yield [] + and sum over that empty array should return 0, not throw.""" + runner = Runner( + """ + UNWIND [] AS lang + WITH collect(distinct lang) AS langs + UNWIND ['hello', 'world'] AS msg + WITH msg, langs, sum(l IN langs | 1 where toLower(msg) CONTAINS toLower(l)) AS hits + RETURN msg, hits + """ + ) + await runner.run() + results = runner.results + assert len(results) == 2 + assert results[0] == {"msg": "hello", "hits": 0} + assert results[1] == {"msg": "world", "hits": 0} + + @pytest.mark.asyncio + async def test_sum_where_all_elements_filtered_returns_0(self): + """Test sum returns 0 when where clause filters everything.""" + runner = Runner("RETURN sum(n in [1, 2, 3] | n where n > 100) as sum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"sum": 0} + + @pytest.mark.asyncio + async def test_sum_over_empty_array_returns_0(self): + """Test sum over empty array returns 0.""" + runner = Runner("WITH [] AS arr RETURN sum(n in arr | n) as sum") + await runner.run() + results = runner.results + assert len(results) == 1 + assert results[0] == {"sum": 0} \ No newline at end of file diff --git a/src/graph/database.ts b/src/graph/database.ts index a8eb41a..34fedf9 100644 --- a/src/graph/database.ts +++ b/src/graph/database.ts @@ -34,20 +34,34 @@ class Database { } const physical = new PhysicalRelationship(null, relationship.type); physical.statement = statement; + physical.source = relationship.source; + physical.target = relationship.target; Database.relationships.set(relationship.type, physical); } public getRelationship(relationship: Relationship): PhysicalRelationship | null { return Database.relationships.get(relationship.type!) || null; } + public getRelationships(relationship: Relationship): PhysicalRelationship[] { + const result: PhysicalRelationship[] = []; + for (const type of relationship.types) { + const physical = Database.relationships.get(type); + if (physical) { + result.push(physical); + } + } + return result; + } public async schema(): Promise[]> { const result: Record[] = []; for (const [label, physical] of Database.nodes) { const records = await physical.data(); - const entry: Record = { kind: "node", label }; + const entry: Record = { kind: "Node", label }; if (records.length > 0) { const { id, ...sample } = records[0]; - if (Object.keys(sample).length > 0) { + const properties = Object.keys(sample); + if (properties.length > 0) { + entry.properties = properties; entry.sample = sample; } } @@ -56,10 +70,17 @@ class Database { for (const [type, physical] of Database.relationships) { const records = await physical.data(); - const entry: Record = { kind: "relationship", type }; + const entry: Record = { + kind: "Relationship", + type, + from_label: physical.source?.label || null, + to_label: physical.target?.label || null, + }; if (records.length > 0) { const { left_id, right_id, ...sample } = records[0]; - if (Object.keys(sample).length > 0) { + const properties = Object.keys(sample); + if (properties.length > 0) { + entry.properties = properties; entry.sample = sample; } } @@ -78,6 +99,23 @@ class Database { const data = await node.data(); return new NodeData(data as NodeRecord[]); } else if (element instanceof Relationship) { + if (element.types.length > 1) { + const physicals = this.getRelationships(element); + if (physicals.length === 0) { + throw new Error( + `No physical relationships found for types ${element.types.join(", ")}` + ); + } + const allRecords: RelationshipRecord[] = []; + for (let i = 0; i < physicals.length; i++) { + const records = (await physicals[i].data()) as RelationshipRecord[]; + const typeName = element.types[i]; + for (const record of records) { + allRecords.push({ ...record, _type: typeName }); + } + } + return new RelationshipData(allRecords); + } const relationship = this.getRelationship(element); if (relationship === null) { throw new Error(`Physical relationship not found for type ${element.type}`); diff --git a/src/graph/relationship.ts b/src/graph/relationship.ts index 26594e0..053c332 100644 --- a/src/graph/relationship.ts +++ b/src/graph/relationship.ts @@ -9,7 +9,7 @@ import RelationshipMatchCollector, { class Relationship extends ASTNode { protected _identifier: string | null = null; - protected _type: string | null = null; + protected _types: string[] = []; protected _properties: Map = new Map(); protected _hops: Hops = new Hops(); @@ -25,7 +25,9 @@ class Relationship extends ASTNode { constructor(identifier: string | null = null, type: string | null = null) { super(); this._identifier = identifier; - this._type = type; + if (type !== null) { + this._types = [type]; + } } public set identifier(identifier: string) { this._identifier = identifier; @@ -34,10 +36,16 @@ class Relationship extends ASTNode { return this._identifier; } public set type(type: string) { - this._type = type; + this._types = [type]; } public get type(): string | null { - return this._type; + return this._types.length > 0 ? this._types[0] : null; + } + public set types(types: string[]) { + this._types = types; + } + public get types(): string[] { + return this._types; } public get properties(): Map { return this._properties; diff --git a/src/graph/relationship_data.ts b/src/graph/relationship_data.ts index a011b94..072042b 100644 --- a/src/graph/relationship_data.ts +++ b/src/graph/relationship_data.ts @@ -18,7 +18,7 @@ class RelationshipData extends Data { public properties(): Record | null { const current = this.current(); if (current) { - const { left_id, right_id, ...props } = current; + const { left_id, right_id, _type, ...props } = current; return props; } return null; diff --git a/src/graph/relationship_match_collector.ts b/src/graph/relationship_match_collector.ts index 708cf53..2272d8c 100644 --- a/src/graph/relationship_match_collector.ts +++ b/src/graph/relationship_match_collector.ts @@ -12,11 +12,15 @@ class RelationshipMatchCollector { private _nodeIds: Array = []; public push(relationship: Relationship, traversalId: string): RelationshipMatchRecord { + const data = relationship.getData(); + const currentRecord = data?.current(); + const actualType = + currentRecord && "_type" in currentRecord ? currentRecord["_type"] : relationship.type!; const match: RelationshipMatchRecord = { - type: relationship.type!, + type: actualType, startNode: relationship.source?.value() || {}, endNode: null, - properties: relationship.getData()?.properties() as Record, + properties: data?.properties() as Record, }; this._matches.push(match); this._nodeIds.push(traversalId); diff --git a/src/graph/relationship_reference.ts b/src/graph/relationship_reference.ts index f0cbb61..3ba2f77 100644 --- a/src/graph/relationship_reference.ts +++ b/src/graph/relationship_reference.ts @@ -6,7 +6,7 @@ class RelationshipReference extends Relationship { constructor(base: Relationship, reference: Relationship) { super(); this._identifier = base.identifier; - this._type = base.type; + this._types = base.types; this._hops = base.hops!; this._source = base.source; this._target = base.target; diff --git a/src/parsing/functions/function_factory.ts b/src/parsing/functions/function_factory.ts index 172244c..cf9618c 100644 --- a/src/parsing/functions/function_factory.ts +++ b/src/parsing/functions/function_factory.ts @@ -29,6 +29,7 @@ import "./sum"; import "./to_json"; import "./to_lower"; import "./to_string"; +import "./trim"; import "./type"; // Re-export AsyncDataProvider for backwards compatibility diff --git a/src/parsing/functions/predicate_sum.ts b/src/parsing/functions/predicate_sum.ts index 29e4492..0c1662e 100644 --- a/src/parsing/functions/predicate_sum.ts +++ b/src/parsing/functions/predicate_sum.ts @@ -1,17 +1,26 @@ -import PredicateFunction from "./predicate_function"; import { FunctionDef } from "./function_metadata"; +import PredicateFunction from "./predicate_function"; @FunctionDef({ - description: "Calculates the sum of values in an array with optional filtering. Uses list comprehension syntax: sum(variable IN array [WHERE condition] | expression)", + description: + "Calculates the sum of values in an array with optional filtering. Uses list comprehension syntax: sum(variable IN array [WHERE condition] | expression)", category: "predicate", parameters: [ { name: "variable", description: "Variable name to bind each element", type: "string" }, { name: "array", description: "Array to iterate over", type: "array" }, { name: "expression", description: "Expression to sum for each element", type: "any" }, - { name: "where", description: "Optional filter condition", type: "boolean", required: false } + { + name: "where", + description: "Optional filter condition", + type: "boolean", + required: false, + }, ], output: { description: "Sum of the evaluated expressions", type: "number", example: 6 }, - examples: ["WITH [1, 2, 3] AS nums RETURN sum(n IN nums | n)", "WITH [1, 2, 3, 4] AS nums RETURN sum(n IN nums WHERE n > 1 | n * 2)"] + examples: [ + "WITH [1, 2, 3] AS nums RETURN sum(n IN nums | n)", + "WITH [1, 2, 3, 4] AS nums RETURN sum(n IN nums WHERE n > 1 | n * 2)", + ], }) class PredicateSum extends PredicateFunction { constructor() { @@ -24,19 +33,15 @@ class PredicateSum extends PredicateFunction { if (array === null || !Array.isArray(array)) { throw new Error("Invalid array for sum function"); } - let _sum: any | null = null; - for(let i = 0; i < array.length; i++) { + let _sum: number = 0; + for (let i = 0; i < array.length; i++) { this._valueHolder.holder = array[i]; if (this.where === null || this.where.value()) { - if (_sum === null) { - _sum = this._return.value(); - } else { - _sum += this._return.value(); - } + _sum += this._return.value(); } } return _sum; } } -export default PredicateSum; \ No newline at end of file +export default PredicateSum; diff --git a/src/parsing/functions/schema.ts b/src/parsing/functions/schema.ts index 5762247..1375e76 100644 --- a/src/parsing/functions/schema.ts +++ b/src/parsing/functions/schema.ts @@ -5,8 +5,11 @@ import { FunctionDef } from "./function_metadata"; /** * Built-in function that returns the graph schema of the database. * - * Lists all nodes and relationships with their labels/types and a sample - * of their data (excluding id from nodes, left_id and right_id from relationships). + * Lists all nodes and relationships with their labels/types, properties, + * and a sample of their data (excluding id from nodes, left_id and right_id from relationships). + * + * Nodes: {label, properties, sample} + * Relationships: {type, from_label, to_label, properties, sample} * * @example * ``` @@ -15,11 +18,11 @@ import { FunctionDef } from "./function_metadata"; */ @FunctionDef({ description: - "Returns the graph schema listing all nodes and relationships with a sample of their data.", + "Returns the graph schema listing all nodes and relationships with their properties and a sample of their data.", category: "async", parameters: [], output: { - description: "Schema entry with kind, label/type, and optional sample data", + description: "Schema entry with label/type, properties, and optional sample data", type: "object", }, examples: ["LOAD FROM schema() AS s RETURN s"], diff --git a/src/parsing/functions/trim.ts b/src/parsing/functions/trim.ts new file mode 100644 index 0000000..d039cd1 --- /dev/null +++ b/src/parsing/functions/trim.ts @@ -0,0 +1,25 @@ +import Function from "./function"; +import { FunctionDef } from "./function_metadata"; + +@FunctionDef({ + description: "Removes leading and trailing whitespace from a string", + category: "scalar", + parameters: [{ name: "text", description: "String to trim", type: "string" }], + output: { description: "Trimmed string", type: "string", example: "hello" }, + examples: ["WITH ' hello ' AS s RETURN trim(s)", "WITH '\\tfoo\\n' AS s RETURN trim(s)"], +}) +class Trim extends Function { + constructor() { + super("trim"); + this._expectedParameterCount = 1; + } + public value(): any { + const val = this.getChildren()[0].value(); + if (typeof val !== "string") { + throw new Error("Invalid argument for trim function: expected a string"); + } + return val.trim(); + } +} + +export default Trim; diff --git a/src/parsing/operations/group_by.ts b/src/parsing/operations/group_by.ts index 012917c..ff96be9 100644 --- a/src/parsing/operations/group_by.ts +++ b/src/parsing/operations/group_by.ts @@ -106,7 +106,10 @@ class GroupBy extends Projection { yield* this.generate_results(mapperIndex + 1, child); } } else { - node.elements?.forEach((element, reducerIndex) => { + if (node.elements === null) { + node.elements = this.reducers.map((reducer) => reducer.element()); + } + node.elements.forEach((element, reducerIndex) => { this.reducers[reducerIndex].overridden = element.value; }); const record: Record = {}; diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 5f254d4..594aced 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -420,6 +420,8 @@ class Parser extends BaseParser { } relationship = new Relationship(); relationship.type = type; + relationship.source = node; + relationship.target = target; } this.expectAndSkipWhitespaceAndComments(); if (!this.token.isAs()) { @@ -673,8 +675,19 @@ class Parser extends BaseParser { if (!this.token.isIdentifierOrKeyword()) { throw new Error("Expected relationship type identifier"); } - const type: string = this.token.value || ""; + const types: string[] = [this.token.value || ""]; this.setNextToken(); + while (this.token.isPipe()) { + this.setNextToken(); + if (this.token.isColon()) { + this.setNextToken(); + } + if (!this.token.isIdentifierOrKeyword()) { + throw new Error("Expected relationship type identifier after '|'"); + } + types.push(this.token.value || ""); + this.setNextToken(); + } const hops: Hops | null = this.parseRelationshipHops(); const properties: Map = new Map(this.parseProperties()); if (!this.token.isClosingBracket()) { @@ -711,7 +724,7 @@ class Parser extends BaseParser { if (hops !== null) { relationship.hops = hops; } - relationship.type = type; + relationship.types = types; return relationship; } diff --git a/tests/compute/runner.test.ts b/tests/compute/runner.test.ts index 1c84bb5..ff7d1fa 100644 --- a/tests/compute/runner.test.ts +++ b/tests/compute/runner.test.ts @@ -613,6 +613,38 @@ test("Test toLower function with all uppercase", async () => { expect(results[0]).toEqual({ result: "foo bar" }); }); +test("Test trim function", async () => { + const runner = new Runner('RETURN trim(" hello ") as result'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "hello" }); +}); + +test("Test trim function with tabs and newlines", async () => { + const runner = new Runner('WITH "\tfoo\n" AS s RETURN trim(s) as result'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "foo" }); +}); + +test("Test trim function with no whitespace", async () => { + const runner = new Runner('RETURN trim("hello") as result'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "hello" }); +}); + +test("Test trim function with empty string", async () => { + const runner = new Runner('RETURN trim("") as result'); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ result: "" }); +}); + test("Test associative array with key which is keyword", async () => { const runner = new Runner("RETURN {return: 1} as aa"); await runner.run(); @@ -1964,20 +1996,24 @@ test("Test schema() returns nodes and relationships with sample data", async () `).run(); const runner = new Runner( - "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample" + "CALL schema() YIELD kind, label, type, from_label, to_label, properties, sample RETURN kind, label, type, from_label, to_label, properties, sample" ); await runner.run(); const results = runner.results; - const animal = results.find((r: any) => r.kind === "node" && r.label === "Animal"); + const animal = results.find((r: any) => r.kind === "Node" && r.label === "Animal"); expect(animal).toBeDefined(); + expect(animal.properties).toEqual(["species", "legs"]); expect(animal.sample).toBeDefined(); expect(animal.sample).not.toHaveProperty("id"); expect(animal.sample).toHaveProperty("species"); expect(animal.sample).toHaveProperty("legs"); - const chases = results.find((r: any) => r.kind === "relationship" && r.type === "CHASES"); + const chases = results.find((r: any) => r.kind === "Relationship" && r.type === "CHASES"); expect(chases).toBeDefined(); + expect(chases.from_label).toBe("Animal"); + expect(chases.to_label).toBe("Animal"); + expect(chases.properties).toEqual(["speed"]); expect(chases.sample).toBeDefined(); expect(chases.sample).not.toHaveProperty("left_id"); expect(chases.sample).not.toHaveProperty("right_id"); @@ -2690,3 +2726,243 @@ test("Test UNION with empty right side", async () => { expect(results.length).toBe(1); expect(results).toEqual([{ x: 1 }]); }); + +test("Test language name hits query with virtual graph", async () => { + // Create Language nodes + await new Runner(` + CREATE VIRTUAL (:Language) AS { + UNWIND [ + {id: 1, name: 'Python'}, + {id: 2, name: 'JavaScript'}, + {id: 3, name: 'TypeScript'} + ] AS record + RETURN record.id AS id, record.name AS name + } + `).run(); + + // Create Chat nodes with messages + await new Runner(` + CREATE VIRTUAL (:Chat) AS { + UNWIND [ + {id: 1, name: 'Dev Discussion', messages: [ + {From: 'Alice', SentDateTime: '2025-01-01T10:00:00', Content: 'I love Python and JavaScript'}, + {From: 'Bob', SentDateTime: '2025-01-01T10:05:00', Content: 'What languages do you prefer?'} + ]}, + {id: 2, name: 'General', messages: [ + {From: 'Charlie', SentDateTime: '2025-01-02T09:00:00', Content: 'The weather is nice today'}, + {From: 'Alice', SentDateTime: '2025-01-02T09:05:00', Content: 'TypeScript is great for language tooling'} + ]} + ] AS record + RETURN record.id AS id, record.name AS name, record.messages AS messages + } + `).run(); + + // Create User nodes + await new Runner(` + CREATE VIRTUAL (:User) AS { + UNWIND [ + {id: 1, displayName: 'Alice'}, + {id: 2, displayName: 'Bob'}, + {id: 3, displayName: 'Charlie'} + ] AS record + RETURN record.id AS id, record.displayName AS displayName + } + `).run(); + + // Create PARTICIPATES_IN relationships + await new Runner(` + CREATE VIRTUAL (:User)-[:PARTICIPATES_IN]-(:Chat) AS { + UNWIND [ + {left_id: 1, right_id: 1}, + {left_id: 2, right_id: 1}, + {left_id: 3, right_id: 2}, + {left_id: 1, right_id: 2} + ] AS record + RETURN record.left_id AS left_id, record.right_id AS right_id + } + `).run(); + + // Run the original query (using 'sender' alias since 'from' is a reserved keyword) + const runner = new Runner(` + MATCH (l:Language) + WITH collect(distinct l.name) AS langs + MATCH (c:Chat) + UNWIND c.messages AS msg + WITH c, msg, langs, + sum(lang IN langs | 1 where toLower(msg.Content) CONTAINS toLower(lang)) AS langNameHits + WHERE toLower(msg.Content) CONTAINS "language" + OR toLower(msg.Content) CONTAINS "languages" + OR langNameHits > 0 + OPTIONAL MATCH (u:User)-[:PARTICIPATES_IN]->(c) + RETURN + c.name AS chat, + collect(distinct u.displayName) AS participants, + msg.From AS sender, + msg.SentDateTime AS sentDateTime, + msg.Content AS message + `); + await runner.run(); + const results = runner.results; + + // Messages that mention a language name or the word "language(s)": + // 1. "I love Python and JavaScript" - langNameHits=2 (matches Python and JavaScript) + // 2. "What languages do you prefer?" - contains "languages" + // 3. "TypeScript is great for language tooling" - langNameHits=1, also contains "language" + expect(results.length).toBe(3); + expect(results[0].chat).toBe("Dev Discussion"); + expect(results[0].message).toBe("I love Python and JavaScript"); + expect(results[0].sender).toBe("Alice"); + expect(results[1].chat).toBe("Dev Discussion"); + expect(results[1].message).toBe("What languages do you prefer?"); + expect(results[1].sender).toBe("Bob"); + expect(results[2].chat).toBe("General"); + expect(results[2].message).toBe("TypeScript is great for language tooling"); + expect(results[2].sender).toBe("Alice"); +}); + +test("Test sum with empty collected array", async () => { + // Reproduces the original bug: collect on empty input should yield [] + // and sum over that empty array should return 0, not throw + const runner = new Runner(` + UNWIND [] AS lang + WITH collect(distinct lang) AS langs + UNWIND ['hello', 'world'] AS msg + WITH msg, langs, sum(l IN langs | 1 where toLower(msg) CONTAINS toLower(l)) AS hits + RETURN msg, hits + `); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ msg: "hello", hits: 0 }); + expect(results[1]).toEqual({ msg: "world", hits: 0 }); +}); + +test("Test sum where all elements filtered returns 0", async () => { + const runner = new Runner("RETURN sum(n in [1, 2, 3] | n where n > 100) as sum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ sum: 0 }); +}); + +test("Test sum over empty array returns 0", async () => { + const runner = new Runner("WITH [] AS arr RETURN sum(n in arr | n) as sum"); + await runner.run(); + const results = runner.results; + expect(results.length).toBe(1); + expect(results[0]).toEqual({ sum: 0 }); +}); + +test("Test match with ORed relationship types", async () => { + await new Runner(` + CREATE VIRTUAL (:Person) AS { + unwind [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + {id: 3, name: 'Charlie'} + ] 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} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Person)-[:FOLLOWS]-(:Person) AS { + unwind [ + {left_id: 2, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + const match = new Runner(` + MATCH (a:Person)-[:KNOWS|FOLLOWS]->(b:Person) + RETURN a.name AS name1, b.name AS name2 + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ name1: "Alice", name2: "Bob" }); + expect(results[1]).toEqual({ name1: "Bob", name2: "Charlie" }); +}); + +test("Test match with ORed relationship types with optional colon syntax", async () => { + await new Runner(` + CREATE VIRTUAL (:Animal) AS { + unwind [ + {id: 1, name: 'Cat'}, + {id: 2, name: 'Dog'}, + {id: 3, name: 'Fish'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Animal)-[:CHASES]-(:Animal) AS { + unwind [ + {left_id: 1, right_id: 2} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:Animal)-[:EATS]-(:Animal) AS { + unwind [ + {left_id: 1, right_id: 3} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id + } + `).run(); + const match = new Runner(` + MATCH (a:Animal)-[:CHASES|:EATS]->(b:Animal) + RETURN a.name AS name1, b.name AS name2 + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ name1: "Cat", name2: "Dog" }); + expect(results[1]).toEqual({ name1: "Cat", name2: "Fish" }); +}); + +test("Test match with ORed relationship types returns correct type in relationship variable", async () => { + await new Runner(` + CREATE VIRTUAL (:City) AS { + unwind [ + {id: 1, name: 'NYC'}, + {id: 2, name: 'LA'}, + {id: 3, name: 'Chicago'} + ] as record + RETURN record.id as id, record.name as name + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:FLIGHT]-(:City) AS { + unwind [ + {left_id: 1, right_id: 2, airline: 'Delta'} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id, record.airline as airline + } + `).run(); + await new Runner(` + CREATE VIRTUAL (:City)-[:TRAIN]-(:City) AS { + unwind [ + {left_id: 1, right_id: 3, line: 'Amtrak'} + ] as record + RETURN record.left_id as left_id, record.right_id as right_id, record.line as line + } + `).run(); + const match = new Runner(` + MATCH (a:City)-[r:FLIGHT|TRAIN]->(b:City) + RETURN a.name AS from, b.name AS to, r.type AS type + `); + await match.run(); + const results = match.results; + expect(results.length).toBe(2); + expect(results[0]).toEqual({ from: "NYC", to: "LA", type: "FLIGHT" }); + expect(results[1]).toEqual({ from: "NYC", to: "Chicago", type: "TRAIN" }); +}); diff --git a/tests/parsing/parser.test.ts b/tests/parsing/parser.test.ts index 3b880a0..6bc0735 100644 --- a/tests/parsing/parser.test.ts +++ b/tests/parsing/parser.test.ts @@ -750,6 +750,43 @@ test("Match with graph pattern including relationships", () => { expect(target.label).toBe("Person"); }); +test("Match with ORed relationship types", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (a:Person)-[:KNOWS|FOLLOWS]->(b:Person) RETURN a, b"); + const match = ast.firstChild() as Match; + expect(match.patterns[0].chain.length).toBe(3); + const relationship = match.patterns[0].chain[1] as Relationship; + expect(relationship.types).toEqual(["KNOWS", "FOLLOWS"]); + expect(relationship.type).toBe("KNOWS"); +}); + +test("Match with ORed relationship types with optional colons", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (a:Person)-[:KNOWS|:FOLLOWS|:LIKES]->(b:Person) RETURN a, b"); + const match = ast.firstChild() as Match; + const relationship = match.patterns[0].chain[1] as Relationship; + expect(relationship.types).toEqual(["KNOWS", "FOLLOWS", "LIKES"]); +}); + +test("Match with ORed relationship types and variable", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (a:Person)-[r:KNOWS|FOLLOWS]->(b:Person) RETURN a, r, b"); + const match = ast.firstChild() as Match; + const relationship = match.patterns[0].chain[1] as Relationship; + expect(relationship.identifier).toBe("r"); + expect(relationship.types).toEqual(["KNOWS", "FOLLOWS"]); +}); + +test("Match with ORed relationship types and hops", () => { + const parser = new Parser(); + const ast = parser.parse("MATCH (a:Person)-[:KNOWS|FOLLOWS*1..3]->(b:Person) RETURN a, b"); + const match = ast.firstChild() as Match; + const relationship = match.patterns[0].chain[1] as Relationship; + expect(relationship.types).toEqual(["KNOWS", "FOLLOWS"]); + expect(relationship.hops!.min).toBe(1); + expect(relationship.hops!.max).toBe(3); +}); + test("Test not equal operator", () => { const parser = new Parser(); const ast = parser.parse("RETURN 1 <> 2");