flowquery 1.0.34 → 1.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/flowquery.min.js +1 -1
- package/dist/graph/database.d.ts +1 -0
- package/dist/graph/database.d.ts.map +1 -1
- package/dist/graph/database.js +43 -6
- package/dist/graph/database.js.map +1 -1
- package/dist/graph/relationship.d.ts +3 -1
- package/dist/graph/relationship.d.ts.map +1 -1
- package/dist/graph/relationship.js +12 -4
- package/dist/graph/relationship.js.map +1 -1
- package/dist/graph/relationship_data.js +1 -1
- package/dist/graph/relationship_data.js.map +1 -1
- package/dist/graph/relationship_match_collector.d.ts.map +1 -1
- package/dist/graph/relationship_match_collector.js +6 -3
- package/dist/graph/relationship_match_collector.js.map +1 -1
- package/dist/graph/relationship_reference.js +1 -1
- package/dist/graph/relationship_reference.js.map +1 -1
- package/dist/parsing/functions/function_factory.d.ts +1 -0
- package/dist/parsing/functions/function_factory.d.ts.map +1 -1
- package/dist/parsing/functions/function_factory.js +1 -0
- package/dist/parsing/functions/function_factory.js.map +1 -1
- package/dist/parsing/functions/predicate_sum.d.ts.map +1 -1
- package/dist/parsing/functions/predicate_sum.js +13 -10
- package/dist/parsing/functions/predicate_sum.js.map +1 -1
- package/dist/parsing/functions/schema.d.ts +5 -2
- package/dist/parsing/functions/schema.d.ts.map +1 -1
- package/dist/parsing/functions/schema.js +7 -4
- package/dist/parsing/functions/schema.js.map +1 -1
- package/dist/parsing/functions/trim.d.ts +7 -0
- package/dist/parsing/functions/trim.d.ts.map +1 -0
- package/dist/parsing/functions/trim.js +37 -0
- package/dist/parsing/functions/trim.js.map +1 -0
- package/dist/parsing/operations/group_by.d.ts.map +1 -1
- package/dist/parsing/operations/group_by.js +4 -2
- package/dist/parsing/operations/group_by.js.map +1 -1
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +15 -2
- package/dist/parsing/parser.js.map +1 -1
- package/docs/flowquery.min.js +1 -1
- package/flowquery-py/pyproject.toml +1 -1
- package/flowquery-py/src/graph/database.py +44 -11
- package/flowquery-py/src/graph/relationship.py +11 -3
- package/flowquery-py/src/graph/relationship_data.py +2 -1
- package/flowquery-py/src/graph/relationship_match_collector.py +7 -1
- package/flowquery-py/src/graph/relationship_reference.py +2 -2
- package/flowquery-py/src/parsing/functions/__init__.py +2 -0
- package/flowquery-py/src/parsing/functions/predicate_sum.py +3 -6
- package/flowquery-py/src/parsing/functions/schema.py +9 -5
- package/flowquery-py/src/parsing/functions/trim.py +35 -0
- package/flowquery-py/src/parsing/operations/group_by.py +2 -0
- package/flowquery-py/src/parsing/parser.py +12 -2
- package/flowquery-py/tests/compute/test_runner.py +249 -4
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/graph/database.ts +42 -4
- package/src/graph/relationship.ts +12 -4
- package/src/graph/relationship_data.ts +1 -1
- package/src/graph/relationship_match_collector.ts +6 -2
- package/src/graph/relationship_reference.ts +1 -1
- package/src/parsing/functions/function_factory.ts +1 -0
- package/src/parsing/functions/predicate_sum.ts +17 -12
- package/src/parsing/functions/schema.ts +7 -4
- package/src/parsing/functions/trim.ts +25 -0
- package/src/parsing/operations/group_by.ts +4 -1
- package/src/parsing/parser.ts +15 -2
- package/tests/compute/runner.test.ts +279 -3
- package/tests/parsing/parser.test.ts +37 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any, Dict, Optional, Union
|
|
5
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, Union
|
|
6
6
|
|
|
7
7
|
from ..parsing.ast_node import ASTNode
|
|
8
8
|
from .node import Node
|
|
@@ -48,35 +48,57 @@ class Database:
|
|
|
48
48
|
physical = PhysicalRelationship()
|
|
49
49
|
physical.type = relationship.type
|
|
50
50
|
physical.statement = statement
|
|
51
|
+
if relationship.source is not None:
|
|
52
|
+
physical.source = relationship.source
|
|
53
|
+
if relationship.target is not None:
|
|
54
|
+
physical.target = relationship.target
|
|
51
55
|
Database._relationships[relationship.type] = physical
|
|
52
56
|
|
|
53
57
|
def get_relationship(self, relationship: 'Relationship') -> Optional['PhysicalRelationship']:
|
|
54
58
|
"""Gets a relationship from the database."""
|
|
55
59
|
return Database._relationships.get(relationship.type) if relationship.type else None
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
def get_relationships(self, relationship: 'Relationship') -> list['PhysicalRelationship']:
|
|
62
|
+
"""Gets multiple physical relationships for ORed types."""
|
|
63
|
+
result = []
|
|
64
|
+
for rel_type in relationship.types:
|
|
65
|
+
physical = Database._relationships.get(rel_type)
|
|
66
|
+
if physical:
|
|
67
|
+
result.append(physical)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
async def schema(self) -> List[Dict[str, Any]]:
|
|
58
71
|
"""Returns the graph schema with node/relationship labels and sample data."""
|
|
59
|
-
|
|
72
|
+
return [item async for item in self._schema()]
|
|
60
73
|
|
|
74
|
+
async def _schema(self) -> AsyncIterator[Dict[str, Any]]:
|
|
75
|
+
"""Async generator for graph schema with node/relationship labels and sample data."""
|
|
61
76
|
for label, physical_node in Database._nodes.items():
|
|
62
77
|
records = await physical_node.data()
|
|
63
|
-
entry:
|
|
78
|
+
entry: Dict[str, Any] = {"kind": "Node", "label": label}
|
|
64
79
|
if records:
|
|
65
80
|
sample = {k: v for k, v in records[0].items() if k != "id"}
|
|
66
|
-
|
|
81
|
+
properties = list(sample.keys())
|
|
82
|
+
if properties:
|
|
83
|
+
entry["properties"] = properties
|
|
67
84
|
entry["sample"] = sample
|
|
68
|
-
|
|
85
|
+
yield entry
|
|
69
86
|
|
|
70
87
|
for rel_type, physical_rel in Database._relationships.items():
|
|
71
88
|
records = await physical_rel.data()
|
|
72
|
-
entry_rel:
|
|
89
|
+
entry_rel: Dict[str, Any] = {
|
|
90
|
+
"kind": "Relationship",
|
|
91
|
+
"type": rel_type,
|
|
92
|
+
"from_label": physical_rel.source.label if physical_rel.source else None,
|
|
93
|
+
"to_label": physical_rel.target.label if physical_rel.target else None,
|
|
94
|
+
}
|
|
73
95
|
if records:
|
|
74
96
|
sample = {k: v for k, v in records[0].items() if k not in ("left_id", "right_id")}
|
|
75
|
-
|
|
97
|
+
properties = list(sample.keys())
|
|
98
|
+
if properties:
|
|
99
|
+
entry_rel["properties"] = properties
|
|
76
100
|
entry_rel["sample"] = sample
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return result
|
|
101
|
+
yield entry_rel
|
|
80
102
|
|
|
81
103
|
async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeData', 'RelationshipData']:
|
|
82
104
|
"""Gets data for a node or relationship."""
|
|
@@ -87,6 +109,17 @@ class Database:
|
|
|
87
109
|
data = await node.data()
|
|
88
110
|
return NodeData(data)
|
|
89
111
|
elif isinstance(element, Relationship):
|
|
112
|
+
if len(element.types) > 1:
|
|
113
|
+
physicals = self.get_relationships(element)
|
|
114
|
+
if not physicals:
|
|
115
|
+
raise ValueError(f"No physical relationships found for types {', '.join(element.types)}")
|
|
116
|
+
all_records = []
|
|
117
|
+
for i, physical in enumerate(physicals):
|
|
118
|
+
records = await physical.data()
|
|
119
|
+
type_name = element.types[i]
|
|
120
|
+
for record in records:
|
|
121
|
+
all_records.append({**record, "_type": type_name})
|
|
122
|
+
return RelationshipData(all_records)
|
|
90
123
|
relationship = self.get_relationship(element)
|
|
91
124
|
if relationship is None:
|
|
92
125
|
raise ValueError(f"Physical relationship not found for type {element.type}")
|
|
@@ -19,7 +19,7 @@ class Relationship(ASTNode):
|
|
|
19
19
|
def __init__(self) -> None:
|
|
20
20
|
super().__init__()
|
|
21
21
|
self._identifier: Optional[str] = None
|
|
22
|
-
self.
|
|
22
|
+
self._types: List[str] = []
|
|
23
23
|
self._hops: Hops = Hops()
|
|
24
24
|
self._source: Optional['Node'] = None
|
|
25
25
|
self._target: Optional['Node'] = None
|
|
@@ -39,11 +39,19 @@ class Relationship(ASTNode):
|
|
|
39
39
|
|
|
40
40
|
@property
|
|
41
41
|
def type(self) -> Optional[str]:
|
|
42
|
-
return self.
|
|
42
|
+
return self._types[0] if self._types else None
|
|
43
43
|
|
|
44
44
|
@type.setter
|
|
45
45
|
def type(self, value: str) -> None:
|
|
46
|
-
self.
|
|
46
|
+
self._types = [value]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def types(self) -> List[str]:
|
|
50
|
+
return self._types
|
|
51
|
+
|
|
52
|
+
@types.setter
|
|
53
|
+
def types(self, value: List[str]) -> None:
|
|
54
|
+
self._types = value
|
|
47
55
|
|
|
48
56
|
@property
|
|
49
57
|
def hops(self) -> Hops:
|
|
@@ -25,11 +25,12 @@ class RelationshipData(Data):
|
|
|
25
25
|
return self._find(id, hop, key)
|
|
26
26
|
|
|
27
27
|
def properties(self) -> Optional[Dict[str, Any]]:
|
|
28
|
-
"""Get properties of current relationship, excluding left_id and
|
|
28
|
+
"""Get properties of current relationship, excluding left_id, right_id, and _type."""
|
|
29
29
|
current = self.current()
|
|
30
30
|
if current:
|
|
31
31
|
props = dict(current)
|
|
32
32
|
props.pop("left_id", None)
|
|
33
33
|
props.pop("right_id", None)
|
|
34
|
+
props.pop("_type", None)
|
|
34
35
|
return props
|
|
35
36
|
return None
|
|
@@ -28,9 +28,15 @@ class RelationshipMatchCollector:
|
|
|
28
28
|
"""Push a new match onto the collector."""
|
|
29
29
|
start_node_value = relationship.source.value() if relationship.source else None
|
|
30
30
|
rel_data = relationship.get_data()
|
|
31
|
+
current_record = rel_data.current() if rel_data else None
|
|
32
|
+
default_type = relationship.type or ""
|
|
33
|
+
if current_record and isinstance(current_record, dict):
|
|
34
|
+
actual_type = current_record.get('_type', default_type)
|
|
35
|
+
else:
|
|
36
|
+
actual_type = default_type
|
|
31
37
|
rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {}
|
|
32
38
|
match: RelationshipMatchRecord = {
|
|
33
|
-
"type":
|
|
39
|
+
"type": actual_type,
|
|
34
40
|
"startNode": start_node_value or {},
|
|
35
41
|
"endNode": None,
|
|
36
42
|
"properties": rel_props,
|
|
@@ -10,8 +10,8 @@ class RelationshipReference(Relationship):
|
|
|
10
10
|
def __init__(self, relationship: Relationship, referred: ASTNode) -> None:
|
|
11
11
|
super().__init__()
|
|
12
12
|
self._referred = referred
|
|
13
|
-
if relationship.
|
|
14
|
-
self.
|
|
13
|
+
if relationship.types:
|
|
14
|
+
self.types = relationship.types
|
|
15
15
|
|
|
16
16
|
@property
|
|
17
17
|
def referred(self) -> ASTNode:
|
|
@@ -39,6 +39,7 @@ from .sum import Sum
|
|
|
39
39
|
from .to_json import ToJson
|
|
40
40
|
from .to_lower import ToLower
|
|
41
41
|
from .to_string import ToString
|
|
42
|
+
from .trim import Trim
|
|
42
43
|
from .type_ import Type
|
|
43
44
|
from .value_holder import ValueHolder
|
|
44
45
|
|
|
@@ -78,6 +79,7 @@ __all__ = [
|
|
|
78
79
|
"ToJson",
|
|
79
80
|
"ToLower",
|
|
80
81
|
"ToString",
|
|
82
|
+
"Trim",
|
|
81
83
|
"Type",
|
|
82
84
|
"Functions",
|
|
83
85
|
"Schema",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""PredicateSum function."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
from .function_metadata import FunctionDef
|
|
6
6
|
from .predicate_function import PredicateFunction
|
|
@@ -41,12 +41,9 @@ class PredicateSum(PredicateFunction):
|
|
|
41
41
|
if array is None or not isinstance(array, list):
|
|
42
42
|
raise ValueError("Invalid array for sum function")
|
|
43
43
|
|
|
44
|
-
_sum:
|
|
44
|
+
_sum: int = 0
|
|
45
45
|
for item in array:
|
|
46
46
|
self._value_holder.holder = item
|
|
47
47
|
if self.where is None or self.where.value():
|
|
48
|
-
|
|
49
|
-
_sum = self._return.value()
|
|
50
|
-
else:
|
|
51
|
-
_sum += self._return.value()
|
|
48
|
+
_sum += self._return.value()
|
|
52
49
|
return _sum
|
|
@@ -9,23 +9,27 @@ from .function_metadata import FunctionDef
|
|
|
9
9
|
@FunctionDef({
|
|
10
10
|
"description": (
|
|
11
11
|
"Returns the graph schema listing all nodes and relationships "
|
|
12
|
-
"with a sample of their data."
|
|
12
|
+
"with their properties and a sample of their data."
|
|
13
13
|
),
|
|
14
14
|
"category": "async",
|
|
15
15
|
"parameters": [],
|
|
16
16
|
"output": {
|
|
17
|
-
"description": "Schema entry with
|
|
17
|
+
"description": "Schema entry with label/type, properties, and optional sample data",
|
|
18
18
|
"type": "object",
|
|
19
19
|
},
|
|
20
20
|
"examples": [
|
|
21
|
-
"CALL schema() YIELD
|
|
21
|
+
"CALL schema() YIELD label, type, from_label, to_label, properties, sample "
|
|
22
|
+
"RETURN label, type, from_label, to_label, properties, sample",
|
|
22
23
|
],
|
|
23
24
|
})
|
|
24
25
|
class Schema(AsyncFunction):
|
|
25
26
|
"""Returns the graph schema of the database.
|
|
26
27
|
|
|
27
|
-
Lists all nodes and relationships with their labels/types
|
|
28
|
-
of their data (excluding id from nodes, left_id and right_id from relationships).
|
|
28
|
+
Lists all nodes and relationships with their labels/types, properties,
|
|
29
|
+
and a sample of their data (excluding id from nodes, left_id and right_id from relationships).
|
|
30
|
+
|
|
31
|
+
Nodes: {label, properties, sample}
|
|
32
|
+
Relationships: {type, from_label, to_label, properties, sample}
|
|
29
33
|
"""
|
|
30
34
|
|
|
31
35
|
async def generate(self) -> AsyncGenerator[Any, None]:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Trim function."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .function import Function
|
|
6
|
+
from .function_metadata import FunctionDef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@FunctionDef({
|
|
10
|
+
"description": "Removes leading and trailing whitespace from a string",
|
|
11
|
+
"category": "scalar",
|
|
12
|
+
"parameters": [
|
|
13
|
+
{"name": "text", "description": "String to trim", "type": "string"}
|
|
14
|
+
],
|
|
15
|
+
"output": {"description": "Trimmed string", "type": "string", "example": "hello"},
|
|
16
|
+
"examples": [
|
|
17
|
+
"WITH ' hello ' AS s RETURN trim(s)",
|
|
18
|
+
"WITH '\\tfoo\\n' AS s RETURN trim(s)"
|
|
19
|
+
]
|
|
20
|
+
})
|
|
21
|
+
class Trim(Function):
|
|
22
|
+
"""Trim function.
|
|
23
|
+
|
|
24
|
+
Removes leading and trailing whitespace from a string.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
super().__init__("trim")
|
|
29
|
+
self._expected_parameter_count = 1
|
|
30
|
+
|
|
31
|
+
def value(self) -> Any:
|
|
32
|
+
val = self.get_children()[0].value()
|
|
33
|
+
if not isinstance(val, str):
|
|
34
|
+
raise ValueError("Invalid argument for trim function: expected a string")
|
|
35
|
+
return val.strip()
|
|
@@ -122,6 +122,8 @@ class GroupBy(Projection):
|
|
|
122
122
|
self.mappers[mapper_index].overridden = child.value
|
|
123
123
|
yield from self.generate_results(mapper_index + 1, child)
|
|
124
124
|
else:
|
|
125
|
+
if node.elements is None:
|
|
126
|
+
node.elements = [reducer.element() for reducer in self.reducers]
|
|
125
127
|
if node.elements:
|
|
126
128
|
for i, element in enumerate(node.elements):
|
|
127
129
|
self.reducers[i].overridden = element.value
|
|
@@ -398,6 +398,8 @@ class Parser(BaseParser):
|
|
|
398
398
|
raise ValueError("Expected target node definition")
|
|
399
399
|
relationship = Relationship()
|
|
400
400
|
relationship.type = rel_type
|
|
401
|
+
relationship.source = node
|
|
402
|
+
relationship.target = target
|
|
401
403
|
|
|
402
404
|
self._expect_and_skip_whitespace_and_comments()
|
|
403
405
|
if not self.token.is_as():
|
|
@@ -576,8 +578,16 @@ class Parser(BaseParser):
|
|
|
576
578
|
self.set_next_token()
|
|
577
579
|
if not self.token.is_identifier_or_keyword():
|
|
578
580
|
raise ValueError("Expected relationship type identifier")
|
|
579
|
-
|
|
581
|
+
rel_types: List[str] = [self.token.value or ""]
|
|
580
582
|
self.set_next_token()
|
|
583
|
+
while self.token.is_pipe():
|
|
584
|
+
self.set_next_token()
|
|
585
|
+
if self.token.is_colon():
|
|
586
|
+
self.set_next_token()
|
|
587
|
+
if not self.token.is_identifier_or_keyword():
|
|
588
|
+
raise ValueError("Expected relationship type identifier after '|'")
|
|
589
|
+
rel_types.append(self.token.value or "")
|
|
590
|
+
self.set_next_token()
|
|
581
591
|
hops = self._parse_relationship_hops()
|
|
582
592
|
properties: Dict[str, Expression] = dict(self._parse_properties())
|
|
583
593
|
if not self.token.is_closing_bracket():
|
|
@@ -607,7 +617,7 @@ class Parser(BaseParser):
|
|
|
607
617
|
self._state.variables[variable] = relationship
|
|
608
618
|
if hops is not None:
|
|
609
619
|
relationship.hops = hops
|
|
610
|
-
relationship.
|
|
620
|
+
relationship.types = rel_types
|
|
611
621
|
return relationship
|
|
612
622
|
|
|
613
623
|
def _parse_properties(self) -> Iterator[Tuple[str, Expression]]:
|
|
@@ -681,6 +681,42 @@ class TestRunner:
|
|
|
681
681
|
assert len(results) == 1
|
|
682
682
|
assert results[0] == {"result": "foo bar"}
|
|
683
683
|
|
|
684
|
+
@pytest.mark.asyncio
|
|
685
|
+
async def test_trim_function(self):
|
|
686
|
+
"""Test trim function."""
|
|
687
|
+
runner = Runner('RETURN trim(" hello ") as result')
|
|
688
|
+
await runner.run()
|
|
689
|
+
results = runner.results
|
|
690
|
+
assert len(results) == 1
|
|
691
|
+
assert results[0] == {"result": "hello"}
|
|
692
|
+
|
|
693
|
+
@pytest.mark.asyncio
|
|
694
|
+
async def test_trim_function_with_tabs_and_newlines(self):
|
|
695
|
+
"""Test trim function with tabs and newlines."""
|
|
696
|
+
runner = Runner('WITH "\tfoo\n" AS s RETURN trim(s) as result')
|
|
697
|
+
await runner.run()
|
|
698
|
+
results = runner.results
|
|
699
|
+
assert len(results) == 1
|
|
700
|
+
assert results[0] == {"result": "foo"}
|
|
701
|
+
|
|
702
|
+
@pytest.mark.asyncio
|
|
703
|
+
async def test_trim_function_with_no_whitespace(self):
|
|
704
|
+
"""Test trim function with no whitespace."""
|
|
705
|
+
runner = Runner('RETURN trim("hello") as result')
|
|
706
|
+
await runner.run()
|
|
707
|
+
results = runner.results
|
|
708
|
+
assert len(results) == 1
|
|
709
|
+
assert results[0] == {"result": "hello"}
|
|
710
|
+
|
|
711
|
+
@pytest.mark.asyncio
|
|
712
|
+
async def test_trim_function_with_empty_string(self):
|
|
713
|
+
"""Test trim function with empty string."""
|
|
714
|
+
runner = Runner('RETURN trim("") as result')
|
|
715
|
+
await runner.run()
|
|
716
|
+
results = runner.results
|
|
717
|
+
assert len(results) == 1
|
|
718
|
+
assert results[0] == {"result": ""}
|
|
719
|
+
|
|
684
720
|
@pytest.mark.asyncio
|
|
685
721
|
async def test_associative_array_with_key_which_is_keyword(self):
|
|
686
722
|
"""Test associative array with key which is keyword."""
|
|
@@ -2152,20 +2188,24 @@ class TestRunner:
|
|
|
2152
2188
|
).run()
|
|
2153
2189
|
|
|
2154
2190
|
runner = Runner(
|
|
2155
|
-
"CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample"
|
|
2191
|
+
"CALL schema() YIELD kind, label, type, from_label, to_label, properties, sample RETURN kind, label, type, from_label, to_label, properties, sample"
|
|
2156
2192
|
)
|
|
2157
2193
|
await runner.run()
|
|
2158
2194
|
results = runner.results
|
|
2159
2195
|
|
|
2160
|
-
animal = next((r for r in results if r.get("kind") == "
|
|
2196
|
+
animal = next((r for r in results if r.get("kind") == "Node" and r.get("label") == "Animal"), None)
|
|
2161
2197
|
assert animal is not None
|
|
2198
|
+
assert animal["properties"] == ["species", "legs"]
|
|
2162
2199
|
assert animal["sample"] is not None
|
|
2163
2200
|
assert "id" not in animal["sample"]
|
|
2164
2201
|
assert "species" in animal["sample"]
|
|
2165
2202
|
assert "legs" in animal["sample"]
|
|
2166
2203
|
|
|
2167
|
-
chases = next((r for r in results if r.get("kind") == "
|
|
2204
|
+
chases = next((r for r in results if r.get("kind") == "Relationship" and r.get("type") == "CHASES"), None)
|
|
2168
2205
|
assert chases is not None
|
|
2206
|
+
assert chases["from_label"] == "Animal"
|
|
2207
|
+
assert chases["to_label"] == "Animal"
|
|
2208
|
+
assert chases["properties"] == ["speed"]
|
|
2169
2209
|
assert chases["sample"] is not None
|
|
2170
2210
|
assert "left_id" not in chases["sample"]
|
|
2171
2211
|
assert "right_id" not in chases["sample"]
|
|
@@ -2549,6 +2589,64 @@ class TestRunner:
|
|
|
2549
2589
|
# Add operator tests
|
|
2550
2590
|
# ============================================================
|
|
2551
2591
|
|
|
2592
|
+
@pytest.mark.asyncio
|
|
2593
|
+
async def test_collected_patterns_and_unwind(self):
|
|
2594
|
+
"""Test collecting graph patterns and unwinding them."""
|
|
2595
|
+
await Runner("""
|
|
2596
|
+
CREATE VIRTUAL (:Person) AS {
|
|
2597
|
+
unwind [
|
|
2598
|
+
{id: 1, name: 'Person 1'},
|
|
2599
|
+
{id: 2, name: 'Person 2'},
|
|
2600
|
+
{id: 3, name: 'Person 3'},
|
|
2601
|
+
{id: 4, name: 'Person 4'}
|
|
2602
|
+
] as record
|
|
2603
|
+
RETURN record.id as id, record.name as name
|
|
2604
|
+
}
|
|
2605
|
+
""").run()
|
|
2606
|
+
await Runner("""
|
|
2607
|
+
CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
|
|
2608
|
+
unwind [
|
|
2609
|
+
{left_id: 1, right_id: 2},
|
|
2610
|
+
{left_id: 2, right_id: 3},
|
|
2611
|
+
{left_id: 3, right_id: 4}
|
|
2612
|
+
] as record
|
|
2613
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
2614
|
+
}
|
|
2615
|
+
""").run()
|
|
2616
|
+
runner = Runner("""
|
|
2617
|
+
MATCH p=(a:Person)-[:KNOWS*0..3]->(b:Person)
|
|
2618
|
+
WITH collect(p) AS patterns
|
|
2619
|
+
UNWIND patterns AS pattern
|
|
2620
|
+
RETURN pattern
|
|
2621
|
+
""")
|
|
2622
|
+
await runner.run()
|
|
2623
|
+
results = runner.results
|
|
2624
|
+
assert len(results) == 10
|
|
2625
|
+
# Index 0: Person 1 zero-hop - pattern = [node1] (single node)
|
|
2626
|
+
assert len(results[0]["pattern"]) == 1
|
|
2627
|
+
assert results[0]["pattern"][0]["id"] == 1
|
|
2628
|
+
# Index 1: Person 1 -> Person 2 (1-hop)
|
|
2629
|
+
assert len(results[1]["pattern"]) == 3
|
|
2630
|
+
# Index 2: Person 1 -> Person 2 -> Person 3 (2-hop)
|
|
2631
|
+
assert len(results[2]["pattern"]) == 5
|
|
2632
|
+
# Index 3: Person 1 -> Person 2 -> Person 3 -> Person 4 (3-hop)
|
|
2633
|
+
assert len(results[3]["pattern"]) == 7
|
|
2634
|
+
# Index 4: Person 2 zero-hop
|
|
2635
|
+
assert len(results[4]["pattern"]) == 1
|
|
2636
|
+
assert results[4]["pattern"][0]["id"] == 2
|
|
2637
|
+
# Index 5: Person 2 -> Person 3 (1-hop)
|
|
2638
|
+
assert len(results[5]["pattern"]) == 3
|
|
2639
|
+
# Index 6: Person 2 -> Person 3 -> Person 4 (2-hop)
|
|
2640
|
+
assert len(results[6]["pattern"]) == 5
|
|
2641
|
+
# Index 7: Person 3 zero-hop
|
|
2642
|
+
assert len(results[7]["pattern"]) == 1
|
|
2643
|
+
assert results[7]["pattern"][0]["id"] == 3
|
|
2644
|
+
# Index 8: Person 3 -> Person 4 (1-hop)
|
|
2645
|
+
assert len(results[8]["pattern"]) == 3
|
|
2646
|
+
# Index 9: Person 4 zero-hop
|
|
2647
|
+
assert len(results[9]["pattern"]) == 1
|
|
2648
|
+
assert results[9]["pattern"][0]["id"] == 4
|
|
2649
|
+
|
|
2552
2650
|
@pytest.mark.asyncio
|
|
2553
2651
|
async def test_add_two_integers(self):
|
|
2554
2652
|
"""Test add two integers."""
|
|
@@ -2854,4 +2952,151 @@ class TestRunner:
|
|
|
2854
2952
|
await runner.run()
|
|
2855
2953
|
results = runner.results
|
|
2856
2954
|
assert len(results) == 1
|
|
2857
|
-
assert results == [{"x": 1}]
|
|
2955
|
+
assert results == [{"x": 1}]
|
|
2956
|
+
|
|
2957
|
+
@pytest.mark.asyncio
|
|
2958
|
+
async def test_language_name_hits_query_with_virtual_graph(self):
|
|
2959
|
+
"""Test full language-name-hits query with virtual graph.
|
|
2960
|
+
|
|
2961
|
+
Reproduces the original bug: collect(distinct ...) on MATCH results,
|
|
2962
|
+
then sum(lang IN langs | ...) in a WITH clause, was throwing
|
|
2963
|
+
"Invalid array for sum function" because collect() returned null
|
|
2964
|
+
instead of [] when no rows entered aggregation.
|
|
2965
|
+
"""
|
|
2966
|
+
# Create Language nodes
|
|
2967
|
+
await Runner(
|
|
2968
|
+
"""
|
|
2969
|
+
CREATE VIRTUAL (:Language) AS {
|
|
2970
|
+
UNWIND [
|
|
2971
|
+
{id: 1, name: 'Python'},
|
|
2972
|
+
{id: 2, name: 'JavaScript'},
|
|
2973
|
+
{id: 3, name: 'TypeScript'}
|
|
2974
|
+
] AS record
|
|
2975
|
+
RETURN record.id AS id, record.name AS name
|
|
2976
|
+
}
|
|
2977
|
+
"""
|
|
2978
|
+
).run()
|
|
2979
|
+
|
|
2980
|
+
# Create Chat nodes with messages
|
|
2981
|
+
await Runner(
|
|
2982
|
+
"""
|
|
2983
|
+
CREATE VIRTUAL (:Chat) AS {
|
|
2984
|
+
UNWIND [
|
|
2985
|
+
{id: 1, name: 'Dev Discussion', messages: [
|
|
2986
|
+
{From: 'Alice', SentDateTime: '2025-01-01T10:00:00', Content: 'I love Python and JavaScript'},
|
|
2987
|
+
{From: 'Bob', SentDateTime: '2025-01-01T10:05:00', Content: 'What languages do you prefer?'}
|
|
2988
|
+
]},
|
|
2989
|
+
{id: 2, name: 'General', messages: [
|
|
2990
|
+
{From: 'Charlie', SentDateTime: '2025-01-02T09:00:00', Content: 'The weather is nice today'},
|
|
2991
|
+
{From: 'Alice', SentDateTime: '2025-01-02T09:05:00', Content: 'TypeScript is great for language tooling'}
|
|
2992
|
+
]}
|
|
2993
|
+
] AS record
|
|
2994
|
+
RETURN record.id AS id, record.name AS name, record.messages AS messages
|
|
2995
|
+
}
|
|
2996
|
+
"""
|
|
2997
|
+
).run()
|
|
2998
|
+
|
|
2999
|
+
# Create User nodes
|
|
3000
|
+
await Runner(
|
|
3001
|
+
"""
|
|
3002
|
+
CREATE VIRTUAL (:User) AS {
|
|
3003
|
+
UNWIND [
|
|
3004
|
+
{id: 1, displayName: 'Alice'},
|
|
3005
|
+
{id: 2, displayName: 'Bob'},
|
|
3006
|
+
{id: 3, displayName: 'Charlie'}
|
|
3007
|
+
] AS record
|
|
3008
|
+
RETURN record.id AS id, record.displayName AS displayName
|
|
3009
|
+
}
|
|
3010
|
+
"""
|
|
3011
|
+
).run()
|
|
3012
|
+
|
|
3013
|
+
# Create PARTICIPATES_IN relationships
|
|
3014
|
+
await Runner(
|
|
3015
|
+
"""
|
|
3016
|
+
CREATE VIRTUAL (:User)-[:PARTICIPATES_IN]-(:Chat) AS {
|
|
3017
|
+
UNWIND [
|
|
3018
|
+
{left_id: 1, right_id: 1},
|
|
3019
|
+
{left_id: 2, right_id: 1},
|
|
3020
|
+
{left_id: 3, right_id: 2},
|
|
3021
|
+
{left_id: 1, right_id: 2}
|
|
3022
|
+
] AS record
|
|
3023
|
+
RETURN record.left_id AS left_id, record.right_id AS right_id
|
|
3024
|
+
}
|
|
3025
|
+
"""
|
|
3026
|
+
).run()
|
|
3027
|
+
|
|
3028
|
+
# Run the original query (using 'sender' alias since 'from' is a reserved keyword)
|
|
3029
|
+
runner = Runner(
|
|
3030
|
+
"""
|
|
3031
|
+
MATCH (l:Language)
|
|
3032
|
+
WITH collect(distinct l.name) AS langs
|
|
3033
|
+
MATCH (c:Chat)
|
|
3034
|
+
UNWIND c.messages AS msg
|
|
3035
|
+
WITH c, msg, langs,
|
|
3036
|
+
sum(lang IN langs | 1 where toLower(msg.Content) CONTAINS toLower(lang)) AS langNameHits
|
|
3037
|
+
WHERE toLower(msg.Content) CONTAINS "language"
|
|
3038
|
+
OR toLower(msg.Content) CONTAINS "languages"
|
|
3039
|
+
OR langNameHits > 0
|
|
3040
|
+
OPTIONAL MATCH (u:User)-[:PARTICIPATES_IN]->(c)
|
|
3041
|
+
RETURN
|
|
3042
|
+
c.name AS chat,
|
|
3043
|
+
collect(distinct u.displayName) AS participants,
|
|
3044
|
+
msg.From AS sender,
|
|
3045
|
+
msg.SentDateTime AS sentDateTime,
|
|
3046
|
+
msg.Content AS message
|
|
3047
|
+
"""
|
|
3048
|
+
)
|
|
3049
|
+
await runner.run()
|
|
3050
|
+
results = runner.results
|
|
3051
|
+
|
|
3052
|
+
# Messages that mention a language name or the word "language(s)":
|
|
3053
|
+
# 1. "I love Python and JavaScript" - langNameHits=2
|
|
3054
|
+
# 2. "What languages do you prefer?" - contains "languages"
|
|
3055
|
+
# 3. "TypeScript is great for language tooling" - langNameHits=1, also "language"
|
|
3056
|
+
assert len(results) == 3
|
|
3057
|
+
assert results[0]["chat"] == "Dev Discussion"
|
|
3058
|
+
assert results[0]["message"] == "I love Python and JavaScript"
|
|
3059
|
+
assert results[0]["sender"] == "Alice"
|
|
3060
|
+
assert results[1]["chat"] == "Dev Discussion"
|
|
3061
|
+
assert results[1]["message"] == "What languages do you prefer?"
|
|
3062
|
+
assert results[1]["sender"] == "Bob"
|
|
3063
|
+
assert results[2]["chat"] == "General"
|
|
3064
|
+
assert results[2]["message"] == "TypeScript is great for language tooling"
|
|
3065
|
+
assert results[2]["sender"] == "Alice"
|
|
3066
|
+
|
|
3067
|
+
@pytest.mark.asyncio
|
|
3068
|
+
async def test_sum_with_empty_collected_array(self):
|
|
3069
|
+
"""Reproduces the original bug: collect on empty input should yield []
|
|
3070
|
+
and sum over that empty array should return 0, not throw."""
|
|
3071
|
+
runner = Runner(
|
|
3072
|
+
"""
|
|
3073
|
+
UNWIND [] AS lang
|
|
3074
|
+
WITH collect(distinct lang) AS langs
|
|
3075
|
+
UNWIND ['hello', 'world'] AS msg
|
|
3076
|
+
WITH msg, langs, sum(l IN langs | 1 where toLower(msg) CONTAINS toLower(l)) AS hits
|
|
3077
|
+
RETURN msg, hits
|
|
3078
|
+
"""
|
|
3079
|
+
)
|
|
3080
|
+
await runner.run()
|
|
3081
|
+
results = runner.results
|
|
3082
|
+
assert len(results) == 2
|
|
3083
|
+
assert results[0] == {"msg": "hello", "hits": 0}
|
|
3084
|
+
assert results[1] == {"msg": "world", "hits": 0}
|
|
3085
|
+
|
|
3086
|
+
@pytest.mark.asyncio
|
|
3087
|
+
async def test_sum_where_all_elements_filtered_returns_0(self):
|
|
3088
|
+
"""Test sum returns 0 when where clause filters everything."""
|
|
3089
|
+
runner = Runner("RETURN sum(n in [1, 2, 3] | n where n > 100) as sum")
|
|
3090
|
+
await runner.run()
|
|
3091
|
+
results = runner.results
|
|
3092
|
+
assert len(results) == 1
|
|
3093
|
+
assert results[0] == {"sum": 0}
|
|
3094
|
+
|
|
3095
|
+
@pytest.mark.asyncio
|
|
3096
|
+
async def test_sum_over_empty_array_returns_0(self):
|
|
3097
|
+
"""Test sum over empty array returns 0."""
|
|
3098
|
+
runner = Runner("WITH [] AS arr RETURN sum(n in arr | n) as sum")
|
|
3099
|
+
await runner.run()
|
|
3100
|
+
results = runner.results
|
|
3101
|
+
assert len(results) == 1
|
|
3102
|
+
assert results[0] == {"sum": 0}
|