flowquery 1.0.18 → 1.0.21

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.
Files changed (158) hide show
  1. package/.gitattributes +3 -0
  2. package/.github/workflows/python-publish.yml +56 -4
  3. package/.github/workflows/release.yml +26 -19
  4. package/.husky/pre-commit +26 -0
  5. package/README.md +37 -32
  6. package/dist/flowquery.min.js +1 -1
  7. package/dist/graph/data.d.ts +5 -4
  8. package/dist/graph/data.d.ts.map +1 -1
  9. package/dist/graph/data.js +38 -20
  10. package/dist/graph/data.js.map +1 -1
  11. package/dist/graph/node.d.ts +2 -0
  12. package/dist/graph/node.d.ts.map +1 -1
  13. package/dist/graph/node.js +23 -0
  14. package/dist/graph/node.js.map +1 -1
  15. package/dist/graph/node_data.js +1 -1
  16. package/dist/graph/node_data.js.map +1 -1
  17. package/dist/graph/pattern.d.ts.map +1 -1
  18. package/dist/graph/pattern.js +11 -4
  19. package/dist/graph/pattern.js.map +1 -1
  20. package/dist/graph/relationship.d.ts +6 -1
  21. package/dist/graph/relationship.d.ts.map +1 -1
  22. package/dist/graph/relationship.js +43 -5
  23. package/dist/graph/relationship.js.map +1 -1
  24. package/dist/graph/relationship_data.d.ts +2 -0
  25. package/dist/graph/relationship_data.d.ts.map +1 -1
  26. package/dist/graph/relationship_data.js +8 -1
  27. package/dist/graph/relationship_data.js.map +1 -1
  28. package/dist/graph/relationship_match_collector.js +2 -2
  29. package/dist/graph/relationship_match_collector.js.map +1 -1
  30. package/dist/graph/relationship_reference.d.ts.map +1 -1
  31. package/dist/graph/relationship_reference.js +2 -1
  32. package/dist/graph/relationship_reference.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/parsing/parser.d.ts +6 -0
  36. package/dist/parsing/parser.d.ts.map +1 -1
  37. package/dist/parsing/parser.js +139 -72
  38. package/dist/parsing/parser.js.map +1 -1
  39. package/docs/flowquery.min.js +1 -1
  40. package/flowquery-py/misc/data/test.json +10 -0
  41. package/flowquery-py/misc/data/users.json +242 -0
  42. package/flowquery-py/notebooks/TestFlowQuery.ipynb +440 -0
  43. package/flowquery-py/pyproject.toml +48 -2
  44. package/flowquery-py/src/__init__.py +7 -5
  45. package/flowquery-py/src/compute/runner.py +14 -10
  46. package/flowquery-py/src/extensibility.py +8 -8
  47. package/flowquery-py/src/graph/__init__.py +7 -7
  48. package/flowquery-py/src/graph/data.py +38 -20
  49. package/flowquery-py/src/graph/database.py +10 -20
  50. package/flowquery-py/src/graph/node.py +50 -19
  51. package/flowquery-py/src/graph/node_data.py +1 -1
  52. package/flowquery-py/src/graph/node_reference.py +10 -11
  53. package/flowquery-py/src/graph/pattern.py +27 -37
  54. package/flowquery-py/src/graph/pattern_expression.py +13 -11
  55. package/flowquery-py/src/graph/patterns.py +2 -2
  56. package/flowquery-py/src/graph/physical_node.py +4 -3
  57. package/flowquery-py/src/graph/physical_relationship.py +5 -5
  58. package/flowquery-py/src/graph/relationship.py +62 -14
  59. package/flowquery-py/src/graph/relationship_data.py +7 -2
  60. package/flowquery-py/src/graph/relationship_match_collector.py +15 -10
  61. package/flowquery-py/src/graph/relationship_reference.py +4 -4
  62. package/flowquery-py/src/io/command_line.py +13 -14
  63. package/flowquery-py/src/parsing/__init__.py +2 -2
  64. package/flowquery-py/src/parsing/alias_option.py +1 -1
  65. package/flowquery-py/src/parsing/ast_node.py +21 -20
  66. package/flowquery-py/src/parsing/base_parser.py +7 -7
  67. package/flowquery-py/src/parsing/components/__init__.py +3 -3
  68. package/flowquery-py/src/parsing/components/from_.py +3 -1
  69. package/flowquery-py/src/parsing/components/headers.py +2 -2
  70. package/flowquery-py/src/parsing/components/null.py +2 -2
  71. package/flowquery-py/src/parsing/context.py +7 -7
  72. package/flowquery-py/src/parsing/data_structures/associative_array.py +7 -7
  73. package/flowquery-py/src/parsing/data_structures/json_array.py +3 -3
  74. package/flowquery-py/src/parsing/data_structures/key_value_pair.py +4 -4
  75. package/flowquery-py/src/parsing/data_structures/lookup.py +2 -2
  76. package/flowquery-py/src/parsing/data_structures/range_lookup.py +2 -2
  77. package/flowquery-py/src/parsing/expressions/__init__.py +16 -16
  78. package/flowquery-py/src/parsing/expressions/expression.py +16 -13
  79. package/flowquery-py/src/parsing/expressions/expression_map.py +9 -9
  80. package/flowquery-py/src/parsing/expressions/f_string.py +3 -3
  81. package/flowquery-py/src/parsing/expressions/identifier.py +4 -3
  82. package/flowquery-py/src/parsing/expressions/number.py +3 -3
  83. package/flowquery-py/src/parsing/expressions/operator.py +16 -16
  84. package/flowquery-py/src/parsing/expressions/reference.py +3 -3
  85. package/flowquery-py/src/parsing/expressions/string.py +2 -2
  86. package/flowquery-py/src/parsing/functions/__init__.py +17 -17
  87. package/flowquery-py/src/parsing/functions/aggregate_function.py +8 -8
  88. package/flowquery-py/src/parsing/functions/async_function.py +12 -9
  89. package/flowquery-py/src/parsing/functions/avg.py +4 -4
  90. package/flowquery-py/src/parsing/functions/collect.py +6 -6
  91. package/flowquery-py/src/parsing/functions/function.py +6 -6
  92. package/flowquery-py/src/parsing/functions/function_factory.py +31 -34
  93. package/flowquery-py/src/parsing/functions/function_metadata.py +10 -11
  94. package/flowquery-py/src/parsing/functions/functions.py +14 -6
  95. package/flowquery-py/src/parsing/functions/join.py +3 -3
  96. package/flowquery-py/src/parsing/functions/keys.py +3 -3
  97. package/flowquery-py/src/parsing/functions/predicate_function.py +8 -7
  98. package/flowquery-py/src/parsing/functions/predicate_sum.py +12 -7
  99. package/flowquery-py/src/parsing/functions/rand.py +2 -2
  100. package/flowquery-py/src/parsing/functions/range_.py +9 -4
  101. package/flowquery-py/src/parsing/functions/replace.py +2 -2
  102. package/flowquery-py/src/parsing/functions/round_.py +2 -2
  103. package/flowquery-py/src/parsing/functions/size.py +2 -2
  104. package/flowquery-py/src/parsing/functions/split.py +9 -4
  105. package/flowquery-py/src/parsing/functions/stringify.py +3 -3
  106. package/flowquery-py/src/parsing/functions/sum.py +4 -4
  107. package/flowquery-py/src/parsing/functions/to_json.py +2 -2
  108. package/flowquery-py/src/parsing/functions/type_.py +3 -3
  109. package/flowquery-py/src/parsing/functions/value_holder.py +1 -1
  110. package/flowquery-py/src/parsing/logic/__init__.py +2 -2
  111. package/flowquery-py/src/parsing/logic/case.py +0 -1
  112. package/flowquery-py/src/parsing/logic/when.py +3 -1
  113. package/flowquery-py/src/parsing/operations/__init__.py +10 -10
  114. package/flowquery-py/src/parsing/operations/aggregated_return.py +3 -5
  115. package/flowquery-py/src/parsing/operations/aggregated_with.py +4 -4
  116. package/flowquery-py/src/parsing/operations/call.py +6 -7
  117. package/flowquery-py/src/parsing/operations/create_node.py +5 -4
  118. package/flowquery-py/src/parsing/operations/create_relationship.py +5 -4
  119. package/flowquery-py/src/parsing/operations/group_by.py +18 -16
  120. package/flowquery-py/src/parsing/operations/load.py +21 -19
  121. package/flowquery-py/src/parsing/operations/match.py +8 -7
  122. package/flowquery-py/src/parsing/operations/operation.py +3 -3
  123. package/flowquery-py/src/parsing/operations/projection.py +6 -6
  124. package/flowquery-py/src/parsing/operations/return_op.py +9 -5
  125. package/flowquery-py/src/parsing/operations/unwind.py +3 -2
  126. package/flowquery-py/src/parsing/operations/where.py +9 -7
  127. package/flowquery-py/src/parsing/operations/with_op.py +2 -2
  128. package/flowquery-py/src/parsing/parser.py +178 -114
  129. package/flowquery-py/src/parsing/token_to_node.py +2 -2
  130. package/flowquery-py/src/tokenization/__init__.py +4 -4
  131. package/flowquery-py/src/tokenization/keyword.py +1 -1
  132. package/flowquery-py/src/tokenization/operator.py +1 -1
  133. package/flowquery-py/src/tokenization/string_walker.py +4 -4
  134. package/flowquery-py/src/tokenization/symbol.py +1 -1
  135. package/flowquery-py/src/tokenization/token.py +11 -11
  136. package/flowquery-py/src/tokenization/token_mapper.py +10 -9
  137. package/flowquery-py/src/tokenization/token_type.py +1 -1
  138. package/flowquery-py/src/tokenization/tokenizer.py +19 -19
  139. package/flowquery-py/src/tokenization/trie.py +18 -17
  140. package/flowquery-py/src/utils/__init__.py +1 -1
  141. package/flowquery-py/src/utils/object_utils.py +3 -3
  142. package/flowquery-py/src/utils/string_utils.py +12 -12
  143. package/flowquery-py/tests/compute/test_runner.py +214 -7
  144. package/flowquery-py/tests/parsing/test_parser.py +41 -0
  145. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  146. package/package.json +1 -1
  147. package/src/graph/data.ts +38 -20
  148. package/src/graph/node.ts +23 -0
  149. package/src/graph/node_data.ts +1 -1
  150. package/src/graph/pattern.ts +13 -4
  151. package/src/graph/relationship.ts +45 -5
  152. package/src/graph/relationship_data.ts +8 -1
  153. package/src/graph/relationship_match_collector.ts +1 -1
  154. package/src/graph/relationship_reference.ts +2 -1
  155. package/src/index.ts +5 -5
  156. package/src/parsing/parser.ts +139 -71
  157. package/tests/compute/runner.test.ts +249 -79
  158. package/tests/parsing/parser.test.ts +32 -0
@@ -1,18 +1,21 @@
1
1
  """Graph pattern representation for FlowQuery."""
2
2
 
3
- from typing import Any, Generator, List, Optional, TYPE_CHECKING, Union
3
+ from __future__ import annotations
4
4
 
5
- from ..parsing.ast_node import ASTNode
5
+ from typing import Any, Generator, List, Optional, Sequence, Union
6
6
 
7
- if TYPE_CHECKING:
8
- from .node import Node
9
- from .relationship import Relationship
7
+ from ..parsing.ast_node import ASTNode
8
+ from .database import Database
9
+ from .node import Node
10
+ from .node_data import NodeData
11
+ from .relationship import Relationship
12
+ from .relationship_data import RelationshipData
10
13
 
11
14
 
12
15
  class Pattern(ASTNode):
13
16
  """Represents a graph pattern for matching."""
14
17
 
15
- def __init__(self):
18
+ def __init__(self) -> None:
16
19
  super().__init__()
17
20
  self._identifier: Optional[str] = None
18
21
  self._chain: List[Union['Node', 'Relationship']] = []
@@ -30,17 +33,14 @@ class Pattern(ASTNode):
30
33
  return self._chain
31
34
 
32
35
  @property
33
- def elements(self) -> List[ASTNode]:
36
+ def elements(self) -> Sequence[ASTNode]:
34
37
  return self._chain
35
38
 
36
39
  def add_element(self, element: Union['Node', 'Relationship']) -> None:
37
- from .node import Node
38
- from .relationship import Relationship
39
-
40
- if (len(self._chain) > 0 and
41
- type(self._chain[-1]) == type(element)):
40
+ if (len(self._chain) > 0 and
41
+ type(self._chain[-1]) is type(element)):
42
42
  raise ValueError("Cannot add two consecutive elements of the same type to the graph pattern")
43
-
43
+
44
44
  if len(self._chain) > 0:
45
45
  last = self._chain[-1]
46
46
  if isinstance(last, Node) and isinstance(element, Relationship):
@@ -49,13 +49,12 @@ class Pattern(ASTNode):
49
49
  if isinstance(last, Relationship) and isinstance(element, Node):
50
50
  last.target = element
51
51
  element.incoming = last
52
-
52
+
53
53
  self._chain.append(element)
54
54
  self.add_child(element)
55
55
 
56
56
  @property
57
57
  def start_node(self) -> 'Node':
58
- from .node import Node
59
58
  if len(self._chain) == 0:
60
59
  raise ValueError("Pattern is empty")
61
60
  first = self._chain[0]
@@ -65,7 +64,6 @@ class Pattern(ASTNode):
65
64
 
66
65
  @property
67
66
  def end_node(self) -> 'Node':
68
- from .node import Node
69
67
  if len(self._chain) == 0:
70
68
  raise ValueError("Pattern is empty")
71
69
  last = self._chain[-1]
@@ -73,7 +71,7 @@ class Pattern(ASTNode):
73
71
  return last
74
72
  raise ValueError("Pattern does not end with a node")
75
73
 
76
- def first_node(self) -> Optional['Node']:
74
+ def first_node(self) -> Optional[Union['Node', 'Relationship']]:
77
75
  if len(self._chain) > 0:
78
76
  return self._chain[0]
79
77
  return None
@@ -82,38 +80,30 @@ class Pattern(ASTNode):
82
80
  return list(self.values())
83
81
 
84
82
  def values(self) -> Generator[Any, None, None]:
85
- from .node import Node
86
- from .relationship import Relationship
87
-
88
- for element in self._chain:
83
+ for i, element in enumerate(self._chain):
89
84
  if isinstance(element, Node):
85
+ # Skip node if previous element was a zero-hop relationship (no matches)
86
+ prev = self._chain[i-1] if i > 0 else None
87
+ if isinstance(prev, Relationship) and len(prev.matches) == 0:
88
+ continue
90
89
  yield element.value()
91
90
  elif isinstance(element, Relationship):
92
- i = 0
93
- for match in element.matches:
91
+ for j, match in enumerate(element.matches):
94
92
  yield match
95
- if i < len(element.matches) - 1:
93
+ if j < len(element.matches) - 1:
96
94
  yield match["endNode"]
97
- i += 1
98
95
 
99
96
  async def fetch_data(self) -> None:
100
97
  """Loads data from the database for all elements."""
101
- from .database import Database
102
- from .node import Node
103
- from .relationship import Relationship
104
- from .node_reference import NodeReference
105
- from .relationship_reference import RelationshipReference
106
- from .node_data import NodeData
107
- from .relationship_data import RelationshipData
108
-
109
98
  db = Database.get_instance()
110
99
  for element in self._chain:
111
- if isinstance(element, (NodeReference, RelationshipReference)):
100
+ # Use type name comparison to avoid issues with module double-loading
101
+ if type(element).__name__ in ('NodeReference', 'RelationshipReference'):
112
102
  continue
113
103
  data = await db.get_data(element)
114
- if isinstance(element, Node):
104
+ if isinstance(element, Node) and isinstance(data, NodeData):
115
105
  element.set_data(data)
116
- elif isinstance(element, Relationship):
106
+ elif isinstance(element, Relationship) and isinstance(data, RelationshipData):
117
107
  element.set_data(data)
118
108
 
119
109
  async def initialize(self) -> None:
@@ -121,5 +111,5 @@ class Pattern(ASTNode):
121
111
 
122
112
  async def traverse(self) -> None:
123
113
  first = self.first_node()
124
- if first:
114
+ if first and isinstance(first, Node):
125
115
  await first.next()
@@ -1,25 +1,27 @@
1
1
  """Pattern expression for FlowQuery."""
2
2
 
3
- from typing import Any
3
+ from typing import Any, Union
4
4
 
5
5
  from ..parsing.ast_node import ASTNode
6
+ from .node import Node
6
7
  from .node_reference import NodeReference
7
8
  from .pattern import Pattern
9
+ from .relationship import Relationship
8
10
 
9
11
 
10
12
  class PatternExpression(Pattern):
11
13
  """Represents a pattern expression that can be evaluated.
12
-
14
+
13
15
  PatternExpression is used in WHERE clauses to test whether a graph pattern
14
16
  exists. It evaluates to True if the pattern is matched, False otherwise.
15
17
  """
16
18
 
17
- def __init__(self):
19
+ def __init__(self) -> None:
18
20
  super().__init__()
19
21
  self._fetched: bool = False
20
22
  self._evaluation: bool = False
21
23
 
22
- def add_element(self, element) -> None:
24
+ def add_element(self, element: Union[Node, Relationship]) -> None:
23
25
  super().add_element(element)
24
26
 
25
27
  def verify(self) -> None:
@@ -29,11 +31,11 @@ class PatternExpression(Pattern):
29
31
  raise ValueError("PatternExpression must contain at least one NodeReference")
30
32
 
31
33
  @property
32
- def identifier(self):
34
+ def identifier(self) -> None:
33
35
  return None
34
36
 
35
37
  @identifier.setter
36
- def identifier(self, value):
38
+ def identifier(self, value: str) -> None:
37
39
  raise ValueError("Cannot set identifier on PatternExpression")
38
40
 
39
41
  async def fetch_data(self) -> None:
@@ -45,18 +47,18 @@ class PatternExpression(Pattern):
45
47
 
46
48
  async def evaluate(self) -> None:
47
49
  """Evaluates the pattern expression by traversing the graph.
48
-
50
+
49
51
  Sets _evaluation to True if the pattern is matched, False otherwise.
50
52
  """
51
53
  self._evaluation = False
52
-
53
- async def set_evaluation_true():
54
+
55
+ async def set_evaluation_true() -> None:
54
56
  self._evaluation = True
55
-
57
+
56
58
  self.end_node.todo_next = set_evaluation_true
57
59
  await self.start_node.next()
58
60
 
59
- def value(self) -> bool:
61
+ def value(self) -> Any:
60
62
  """Returns the result of the pattern evaluation."""
61
63
  return self._evaluation
62
64
 
@@ -8,7 +8,7 @@ from .pattern import Pattern
8
8
  class Patterns:
9
9
  """Manages a collection of graph patterns."""
10
10
 
11
- def __init__(self, patterns: Optional[List[Pattern]] = None):
11
+ def __init__(self, patterns: Optional[List[Pattern]] = None) -> None:
12
12
  self._patterns = patterns or []
13
13
  self._to_do_next: Optional[Callable[[], Awaitable[None]]] = None
14
14
 
@@ -32,7 +32,7 @@ class Patterns:
32
32
  await pattern.fetch_data() # Ensure data is loaded
33
33
  if previous is not None:
34
34
  # Chain the patterns together
35
- async def next_pattern_start(p=pattern):
35
+ async def next_pattern_start(p: Pattern = pattern) -> None:
36
36
  await p.start_node.next()
37
37
  previous.end_node.todo_next = next_pattern_start
38
38
  previous = pattern
@@ -1,10 +1,10 @@
1
1
  """Physical node representation for FlowQuery."""
2
2
 
3
- from typing import Any, Dict, List, Optional, TYPE_CHECKING
3
+ from __future__ import annotations
4
4
 
5
- if TYPE_CHECKING:
6
- from ..parsing.ast_node import ASTNode
5
+ from typing import Any, Dict, List, Optional
7
6
 
7
+ from ..parsing.ast_node import ASTNode
8
8
  from .node import Node
9
9
 
10
10
 
@@ -34,6 +34,7 @@ class PhysicalNode(Node):
34
34
  async def data(self) -> List[Dict[str, Any]]:
35
35
  if self._statement is None:
36
36
  raise ValueError("Statement is null")
37
+ # Import at runtime to avoid circular dependency
37
38
  from ..compute.runner import Runner
38
39
  runner = Runner(ast=self._statement)
39
40
  await runner.run()
@@ -1,18 +1,17 @@
1
1
  """Physical relationship representation for FlowQuery."""
2
2
 
3
3
  from __future__ import annotations
4
- from typing import Any, Dict, List, Optional, TYPE_CHECKING
5
4
 
6
- from .relationship import Relationship
5
+ from typing import Any, Dict, List, Optional
7
6
 
8
- if TYPE_CHECKING:
9
- from ..parsing.ast_node import ASTNode
7
+ from ..parsing.ast_node import ASTNode
8
+ from .relationship import Relationship
10
9
 
11
10
 
12
11
  class PhysicalRelationship(Relationship):
13
12
  """Represents a physical relationship in the graph database."""
14
13
 
15
- def __init__(self):
14
+ def __init__(self) -> None:
16
15
  super().__init__()
17
16
  self._statement: Optional[ASTNode] = None
18
17
 
@@ -30,6 +29,7 @@ class PhysicalRelationship(Relationship):
30
29
  """Execute the statement and return results."""
31
30
  if self._statement is None:
32
31
  raise ValueError("Statement is null")
32
+ # Import at runtime to avoid circular dependency
33
33
  from ..compute.runner import Runner
34
34
  runner = Runner(None, self._statement)
35
35
  await runner.run()
@@ -1,26 +1,29 @@
1
1
  """Graph relationship representation for FlowQuery."""
2
2
 
3
- from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
4
6
 
5
7
  from ..parsing.ast_node import ASTNode
6
8
  from .hops import Hops
9
+ from .relationship_data import RelationshipData
7
10
  from .relationship_match_collector import RelationshipMatchCollector, RelationshipMatchRecord
8
11
 
9
12
  if TYPE_CHECKING:
10
13
  from .node import Node
11
- from .relationship_data import RelationshipData, RelationshipRecord
12
14
 
13
15
 
14
16
  class Relationship(ASTNode):
15
17
  """Represents a relationship in a graph pattern."""
16
18
 
17
- def __init__(self):
19
+ def __init__(self) -> None:
18
20
  super().__init__()
19
21
  self._identifier: Optional[str] = None
20
22
  self._type: Optional[str] = None
21
23
  self._hops: Hops = Hops()
22
24
  self._source: Optional['Node'] = None
23
25
  self._target: Optional['Node'] = None
26
+ self._direction: str = "right"
24
27
  self._data: Optional['RelationshipData'] = None
25
28
  self._value: Optional[Union[RelationshipMatchRecord, List[RelationshipMatchRecord]]] = None
26
29
  self._matches: RelationshipMatchCollector = RelationshipMatchCollector()
@@ -52,10 +55,26 @@ class Relationship(ASTNode):
52
55
 
53
56
  @property
54
57
  def properties(self) -> Dict[str, Any]:
55
- """Get properties from relationship data."""
56
- if self._data:
57
- return self._data.properties() or {}
58
- return {}
58
+ return self._properties
59
+
60
+ @properties.setter
61
+ def properties(self, value: Dict[str, Any]) -> None:
62
+ self._properties = value
63
+
64
+ def _matches_properties(self, hop: int = 0) -> bool:
65
+ """Check if current record matches all constraint properties."""
66
+ if not self._properties:
67
+ return True
68
+ if self._data is None:
69
+ return True
70
+ for key, expression in self._properties.items():
71
+ record = self._data.current(hop)
72
+ if record is None:
73
+ raise ValueError("No current relationship data available")
74
+ if key not in record:
75
+ raise ValueError("Relationship does not have property")
76
+ return bool(record[key] == expression.value())
77
+ return True
59
78
 
60
79
  @property
61
80
  def source(self) -> Optional['Node']:
@@ -73,6 +92,14 @@ class Relationship(ASTNode):
73
92
  def target(self, value: 'Node') -> None:
74
93
  self._target = value
75
94
 
95
+ @property
96
+ def direction(self) -> str:
97
+ return self._direction
98
+
99
+ @direction.setter
100
+ def direction(self, value: str) -> None:
101
+ self._direction = value
102
+
76
103
  # Keep start/end aliases for backward compatibility
77
104
  @property
78
105
  def start(self) -> Optional['Node']:
@@ -93,6 +120,9 @@ class Relationship(ASTNode):
93
120
  def set_data(self, data: Optional['RelationshipData']) -> None:
94
121
  self._data = data
95
122
 
123
+ def get_data(self) -> Optional['RelationshipData']:
124
+ return self._data
125
+
96
126
  def set_value(self, relationship: 'Relationship') -> None:
97
127
  """Set value by pushing match to collector."""
98
128
  self._matches.push(relationship)
@@ -113,23 +143,41 @@ class Relationship(ASTNode):
113
143
  """Find relationships starting from the given node ID."""
114
144
  # Save original source node
115
145
  original = self._source
146
+ is_left = self._direction == "left"
116
147
  if hop > 0:
117
148
  # For hops greater than 0, the source becomes the target of the previous hop
118
149
  self._source = self._target
119
150
  if hop == 0:
120
- self._data.reset() if self._data else None
121
-
122
- while self._data and self._data.find(left_id, hop):
151
+ if self._data:
152
+ self._data.reset()
153
+
154
+ # Handle zero-hop case: when min is 0 on a variable-length relationship,
155
+ # match source node as target (no traversal)
156
+ if self._hops and self._hops.multi() and self._hops.min == 0 and self._target:
157
+ # For zero-hop, target finds the same node as source (left_id)
158
+ # No relationship match is pushed since no edge is traversed
159
+ await self._target.find(left_id, hop)
160
+
161
+ def find_match(id_: str, h: int) -> bool:
162
+ if self._data is None:
163
+ return False
164
+ if is_left:
165
+ return self._data.find_reverse(id_, h)
166
+ return self._data.find(id_, h)
167
+ follow_id = 'left_id' if is_left else 'right_id'
168
+ while self._data and find_match(left_id, hop):
123
169
  data = self._data.current(hop)
124
170
  if data and self._hops and hop >= self._hops.min:
125
171
  self.set_value(self)
126
- if self._target and 'right_id' in data:
127
- await self._target.find(data['right_id'], hop)
172
+ if not self._matches_properties(hop):
173
+ continue
174
+ if self._target and follow_id in data:
175
+ await self._target.find(data[follow_id], hop)
128
176
  if self._matches.is_circular():
129
177
  raise ValueError("Circular relationship detected")
130
178
  if self._hops and hop + 1 < self._hops.max:
131
- await self.find(data['right_id'], hop + 1)
179
+ await self.find(data[follow_id], hop + 1)
132
180
  self._matches.pop()
133
-
181
+
134
182
  # Restore original source node
135
183
  self._source = original
@@ -12,15 +12,20 @@ class RelationshipRecord(TypedDict, total=False):
12
12
 
13
13
 
14
14
  class RelationshipData(Data):
15
- """Relationship data class extending Data with left_id-based indexing."""
15
+ """Relationship data class extending Data with left_id and right_id indexing."""
16
16
 
17
17
  def __init__(self, records: Optional[List[Dict[str, Any]]] = None):
18
18
  super().__init__(records)
19
19
  self._build_index("left_id")
20
+ self._build_index("right_id")
20
21
 
21
22
  def find(self, left_id: str, hop: int = 0) -> bool:
22
23
  """Find a relationship by start node ID."""
23
- return self._find(left_id, hop)
24
+ return self._find(left_id, hop, "left_id")
25
+
26
+ def find_reverse(self, right_id: str, hop: int = 0) -> bool:
27
+ """Find a relationship by end node ID (reverse direction)."""
28
+ return self._find(right_id, hop, "right_id")
24
29
 
25
30
  def properties(self) -> Optional[Dict[str, Any]]:
26
31
  """Get properties of current relationship, excluding left_id and right_id."""
@@ -1,43 +1,47 @@
1
1
  """Collector for relationship match records."""
2
2
 
3
- from typing import Any, Dict, List, Optional, TYPE_CHECKING, TypedDict, Union
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union
4
6
 
5
7
  if TYPE_CHECKING:
6
- from .relationship import Relationship
7
8
  from .node import Node
9
+ from .relationship import Relationship
8
10
 
9
11
 
10
12
  class RelationshipMatchRecord(TypedDict, total=False):
11
13
  """Represents a matched relationship record."""
12
14
  type: str
13
- startNode: Dict[str, Any]
14
- endNode: Optional[Dict[str, Any]]
15
+ startNode: Any
16
+ endNode: Any
15
17
  properties: Dict[str, Any]
16
18
 
17
19
 
18
20
  class RelationshipMatchCollector:
19
21
  """Collects relationship matches during graph traversal."""
20
22
 
21
- def __init__(self):
23
+ def __init__(self) -> None:
22
24
  self._matches: List[RelationshipMatchRecord] = []
23
25
  self._node_ids: List[str] = []
24
26
 
25
27
  def push(self, relationship: 'Relationship') -> RelationshipMatchRecord:
26
28
  """Push a new match onto the collector."""
29
+ start_node_value = relationship.source.value() if relationship.source else None
30
+ rel_data = relationship.get_data()
31
+ rel_props: Dict[str, Any] = (rel_data.properties() or {}) if rel_data else {}
27
32
  match: RelationshipMatchRecord = {
28
33
  "type": relationship.type or "",
29
- "startNode": relationship.source.value() if relationship.source else {},
34
+ "startNode": start_node_value or {},
30
35
  "endNode": None,
31
- "properties": relationship.properties,
36
+ "properties": rel_props,
32
37
  }
33
38
  self._matches.append(match)
34
- start_node_value = match.get("startNode", {})
35
39
  if isinstance(start_node_value, dict):
36
40
  self._node_ids.append(start_node_value.get("id", ""))
37
41
  return match
38
42
 
39
43
  @property
40
- def end_node(self) -> Optional[Dict[str, Any]]:
44
+ def end_node(self) -> Any:
41
45
  """Get the end node of the last match."""
42
46
  if self._matches:
43
47
  return self._matches[-1].get("endNode")
@@ -47,7 +51,8 @@ class RelationshipMatchCollector:
47
51
  def end_node(self, node: 'Node') -> None:
48
52
  """Set the end node of the last match."""
49
53
  if self._matches:
50
- self._matches[-1]["endNode"] = node.value()
54
+ node_value = node.value()
55
+ self._matches[-1]["endNode"] = node_value if node_value else None
51
56
 
52
57
  def pop(self) -> Optional[RelationshipMatchRecord]:
53
58
  """Pop the last match from the collector."""
@@ -1,13 +1,13 @@
1
- """Relationship reference for FlowQuery."""
1
+ from typing import Any, Optional
2
2
 
3
- from .relationship import Relationship
4
3
  from ..parsing.ast_node import ASTNode
4
+ from .relationship import Relationship
5
5
 
6
6
 
7
7
  class RelationshipReference(Relationship):
8
8
  """Represents a reference to an existing relationship variable."""
9
9
 
10
- def __init__(self, relationship: Relationship, referred: ASTNode):
10
+ def __init__(self, relationship: Relationship, referred: ASTNode) -> None:
11
11
  super().__init__()
12
12
  self._referred = referred
13
13
  if relationship.type:
@@ -17,5 +17,5 @@ class RelationshipReference(Relationship):
17
17
  def referred(self) -> ASTNode:
18
18
  return self._referred
19
19
 
20
- def value(self):
20
+ def value(self) -> Optional[Any]:
21
21
  return self._referred.value() if self._referred else None
@@ -2,34 +2,33 @@
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
- from typing import Optional
6
5
 
7
6
  from ..compute.runner import Runner
8
7
 
9
8
 
10
9
  class CommandLine:
11
10
  """Interactive command-line interface for FlowQuery.
12
-
11
+
13
12
  Provides a REPL (Read-Eval-Print Loop) for executing FlowQuery statements
14
13
  and displaying results.
15
-
14
+
16
15
  Example:
17
16
  cli = CommandLine()
18
17
  cli.loop() # Starts interactive mode
19
-
18
+
20
19
  # Or execute a single query:
21
20
  cli.execute("load json from 'https://example.com/data' as d return d")
22
21
  """
23
22
 
24
23
  def execute(self, query: str) -> None:
25
24
  """Execute a single FlowQuery statement and print results.
26
-
25
+
27
26
  Args:
28
27
  query: The FlowQuery statement to execute.
29
28
  """
30
29
  # Remove the termination semicolon if present
31
30
  query = query.strip().rstrip(";")
32
-
31
+
33
32
  try:
34
33
  runner = Runner(query)
35
34
  asyncio.run(self._execute(runner))
@@ -38,13 +37,13 @@ class CommandLine:
38
37
 
39
38
  def loop(self) -> None:
40
39
  """Starts the interactive command loop.
41
-
40
+
42
41
  Prompts the user for FlowQuery statements, executes them, and displays results.
43
42
  Type "exit" to quit the loop. End multi-line queries with ";".
44
43
  """
45
44
  print('Welcome to FlowQuery! Type "exit" to quit.')
46
45
  print('End queries with ";" to execute. Multi-line input supported.')
47
-
46
+
48
47
  while True:
49
48
  try:
50
49
  lines = []
@@ -61,13 +60,13 @@ class CommandLine:
61
60
  prompt = "... "
62
61
  except EOFError:
63
62
  break
64
-
63
+
65
64
  if user_input.strip() == "":
66
65
  continue
67
-
66
+
68
67
  # Remove the termination semicolon before sending to the engine
69
68
  user_input = user_input.strip().rstrip(";")
70
-
69
+
71
70
  try:
72
71
  runner = Runner(user_input)
73
72
  asyncio.run(self._execute(runner))
@@ -83,7 +82,7 @@ class CommandLine:
83
82
 
84
83
  def main() -> None:
85
84
  """Entry point for the flowquery CLI command.
86
-
85
+
87
86
  Usage:
88
87
  flowquery # Start interactive mode
89
88
  flowquery -c "query" # Execute a single query
@@ -99,10 +98,10 @@ def main() -> None:
99
98
  metavar="QUERY",
100
99
  help="Execute a FlowQuery statement and exit"
101
100
  )
102
-
101
+
103
102
  args = parser.parse_args()
104
103
  cli = CommandLine()
105
-
104
+
106
105
  if args.command:
107
106
  cli.execute(args.command)
108
107
  else:
@@ -1,10 +1,10 @@
1
1
  """Parsing module for FlowQuery."""
2
2
 
3
- from .ast_node import ASTNode
4
- from .context import Context
5
3
  from .alias import Alias
6
4
  from .alias_option import AliasOption
5
+ from .ast_node import ASTNode
7
6
  from .base_parser import BaseParser
7
+ from .context import Context
8
8
  from .parser import Parser
9
9
 
10
10
  __all__ = [
@@ -5,7 +5,7 @@ from enum import Enum
5
5
 
6
6
  class AliasOption(Enum):
7
7
  """Enumeration of alias options for parsing."""
8
-
8
+
9
9
  NOT_ALLOWED = 0
10
10
  OPTIONAL = 1
11
11
  REQUIRED = 2