flowquery 1.0.17 → 1.0.20

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 (56) hide show
  1. package/.gitattributes +3 -0
  2. package/.github/workflows/python-publish.yml +58 -7
  3. package/.github/workflows/release.yml +25 -18
  4. package/README.md +37 -32
  5. package/dist/flowquery.min.js +1 -1
  6. package/dist/graph/data.d.ts.map +1 -1
  7. package/dist/graph/data.js +5 -3
  8. package/dist/graph/data.js.map +1 -1
  9. package/dist/graph/pattern.d.ts.map +1 -1
  10. package/dist/graph/pattern.js +11 -4
  11. package/dist/graph/pattern.js.map +1 -1
  12. package/dist/graph/pattern_expression.d.ts +1 -0
  13. package/dist/graph/pattern_expression.d.ts.map +1 -1
  14. package/dist/graph/pattern_expression.js +14 -3
  15. package/dist/graph/pattern_expression.js.map +1 -1
  16. package/dist/graph/relationship.d.ts.map +1 -1
  17. package/dist/graph/relationship.js +11 -4
  18. package/dist/graph/relationship.js.map +1 -1
  19. package/dist/index.d.ts +0 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -5
  22. package/dist/index.js.map +1 -1
  23. package/dist/parsing/parser.d.ts +5 -0
  24. package/dist/parsing/parser.d.ts.map +1 -1
  25. package/dist/parsing/parser.js +94 -73
  26. package/dist/parsing/parser.js.map +1 -1
  27. package/docs/flowquery.min.js +1 -1
  28. package/flowquery-py/CONTRIBUTING.md +127 -0
  29. package/flowquery-py/README.md +13 -112
  30. package/flowquery-py/misc/data/test.json +10 -0
  31. package/flowquery-py/misc/data/users.json +242 -0
  32. package/flowquery-py/notebooks/TestFlowQuery.ipynb +440 -0
  33. package/flowquery-py/pyproject.toml +4 -1
  34. package/flowquery-py/src/__init__.py +2 -0
  35. package/flowquery-py/src/graph/data.py +4 -3
  36. package/flowquery-py/src/graph/pattern.py +7 -4
  37. package/flowquery-py/src/graph/pattern_expression.py +6 -3
  38. package/flowquery-py/src/graph/relationship.py +7 -0
  39. package/flowquery-py/src/io/command_line.py +44 -2
  40. package/flowquery-py/src/parsing/base_parser.py +2 -2
  41. package/flowquery-py/src/parsing/operations/load.py +6 -0
  42. package/flowquery-py/src/parsing/parser.py +78 -62
  43. package/flowquery-py/src/tokenization/token.py +122 -176
  44. package/flowquery-py/src/tokenization/tokenizer.py +4 -4
  45. package/flowquery-py/tests/compute/test_runner.py +10 -7
  46. package/flowquery-py/tests/parsing/test_parser.py +6 -0
  47. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  48. package/package.json +1 -1
  49. package/src/graph/data.ts +5 -3
  50. package/src/graph/pattern.ts +13 -4
  51. package/src/graph/pattern_expression.ts +14 -3
  52. package/src/graph/relationship.ts +8 -0
  53. package/src/index.ts +5 -6
  54. package/src/parsing/parser.ts +93 -69
  55. package/tests/compute/runner.test.ts +71 -79
  56. package/tests/parsing/parser.test.ts +8 -0
@@ -0,0 +1,440 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": null,
6
+ "id": "0",
7
+ "metadata": {},
8
+ "outputs": [],
9
+ "source": [
10
+ "%reload_ext autoreload\n",
11
+ "%autoreload 2\n",
12
+ "\n",
13
+ "from flowquery import Runner"
14
+ ]
15
+ },
16
+ {
17
+ "cell_type": "markdown",
18
+ "id": "1",
19
+ "metadata": {},
20
+ "source": [
21
+ "Create virtual node test"
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "code",
26
+ "execution_count": null,
27
+ "id": "2",
28
+ "metadata": {},
29
+ "outputs": [],
30
+ "source": [
31
+ "query: str = \"\"\"\n",
32
+ " create virtual (:Fact) as {\n",
33
+ " unwind range(0,10) as i\n",
34
+ " load json from \"https://catfact.ninja/fact\" as item\n",
35
+ " return i as id, item.fact\n",
36
+ " }\n",
37
+ "\"\"\"\n",
38
+ "\n",
39
+ "runner: Runner = Runner(query)\n",
40
+ "await runner.run()"
41
+ ]
42
+ },
43
+ {
44
+ "cell_type": "markdown",
45
+ "id": "3",
46
+ "metadata": {},
47
+ "source": [
48
+ "Query virtual graph node"
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "id": "4",
55
+ "metadata": {},
56
+ "outputs": [],
57
+ "source": [
58
+ "runner: Runner = Runner(\"match (n:Fact) return n\")\n",
59
+ "await runner.run()\n",
60
+ "for record in runner.results:\n",
61
+ " print(record)"
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "markdown",
66
+ "id": "5",
67
+ "metadata": {},
68
+ "source": [
69
+ "Test extensibility"
70
+ ]
71
+ },
72
+ {
73
+ "cell_type": "code",
74
+ "execution_count": null,
75
+ "id": "6",
76
+ "metadata": {},
77
+ "outputs": [],
78
+ "source": [
79
+ "from flowquery.extensibility import (\n",
80
+ " Function,\n",
81
+ " FunctionDef,\n",
82
+ " AggregateFunction,\n",
83
+ " ReducerElement,\n",
84
+ " AsyncFunction,\n",
85
+ " PredicateFunction\n",
86
+ ")\n",
87
+ "import aiohttp\n",
88
+ "import json\n",
89
+ "from typing import Any, List, Iterator, Union, Dict\n",
90
+ "\n",
91
+ "@FunctionDef({\n",
92
+ " \"description\": \"Converts a string to uppercase\",\n",
93
+ " \"category\": \"string\",\n",
94
+ " \"parameters\": [\n",
95
+ " {\"name\": \"text\", \"description\": \"String to convert\", \"type\": \"string\"}\n",
96
+ " ],\n",
97
+ " \"output\": {\"description\": \"Uppercase string\", \"type\": \"string\"}\n",
98
+ "})\n",
99
+ "class UpperCase(Function):\n",
100
+ " def __init__(self):\n",
101
+ " super().__init__(\"uppercase\")\n",
102
+ " self._expected_parameter_count = 1\n",
103
+ "\n",
104
+ " def value(self) -> str:\n",
105
+ " return str(self.get_children()[0].value()).upper()\n",
106
+ " \n",
107
+ "@FunctionDef({\n",
108
+ " \"description\": \"Extracts nodes from a collection\",\n",
109
+ " \"category\": \"scalar\",\n",
110
+ " \"parameters\": [\n",
111
+ " {\"name\": \"collection\", \"description\": \"Collection to extract nodes from\", \"type\": \"any[]\"}\n",
112
+ " ],\n",
113
+ " \"output\": {\"description\": \"List of nodes extracted from the collection\", \"type\": \"node[]\"}\n",
114
+ "})\n",
115
+ "class Nodes(Function):\n",
116
+ " def __init__(self):\n",
117
+ " super().__init__(\"nodes\")\n",
118
+ " self._expected_parameter_count = 1\n",
119
+ "\n",
120
+ " def value(self) -> List[Dict[str, Any]]:\n",
121
+ " pattern: List[Dict[str, Any]] = self.get_children()[0].value()\n",
122
+ " return list(self._nodes(pattern))\n",
123
+ "\n",
124
+ " def _nodes(self, pattern: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:\n",
125
+ " for element in pattern:\n",
126
+ " if isinstance(element, dict) and \"id\" in element:\n",
127
+ " yield element\n",
128
+ " \n",
129
+ "\n",
130
+ "class ProductElement(ReducerElement):\n",
131
+ " def __init__(self):\n",
132
+ " self._value: float = 1.0\n",
133
+ "\n",
134
+ " @property\n",
135
+ " def value(self) -> float:\n",
136
+ " return self._value\n",
137
+ "\n",
138
+ " @value.setter\n",
139
+ " def value(self, v: float) -> None:\n",
140
+ " self._value = v\n",
141
+ " \n",
142
+ "@FunctionDef({\n",
143
+ " \"description\": \"Calculates the product of a list of numbers\",\n",
144
+ " \"category\": \"aggregate\",\n",
145
+ " \"parameters\": [\n",
146
+ " {\"name\": \"numbers\", \"description\": \"List of numbers to multiply\", \"type\": \"number[]\"}\n",
147
+ " ],\n",
148
+ " \"output\": {\"description\": \"Product of the numbers\", \"type\": \"number\"}\n",
149
+ "})\n",
150
+ "class Product(AggregateFunction):\n",
151
+ " def __init__(self):\n",
152
+ " super().__init__(\"product\")\n",
153
+ "\n",
154
+ " def reduce(self, element: ReducerElement) -> None:\n",
155
+ " element.value *= self.first_child().value()\n",
156
+ "\n",
157
+ " def element(self) -> ReducerElement:\n",
158
+ " return ProductElement()\n",
159
+ "\n",
160
+ "@FunctionDef({\n",
161
+ " \"description\": \"Asynchronous function that fetches data from a URL\",\n",
162
+ " \"category\": \"async\",\n",
163
+ " \"parameters\": [\n",
164
+ " {\"name\": \"url\", \"description\": \"URL to fetch data from\", \"type\": \"string\"}\n",
165
+ " ],\n",
166
+ " \"output\": {\"description\": \"Fetched data\", \"type\": \"string\"}\n",
167
+ "})\n",
168
+ "class get(AsyncFunction):\n",
169
+ " async def generate(self, url: str):\n",
170
+ " async with aiohttp.ClientSession() as session:\n",
171
+ " async with session.get(url) as response:\n",
172
+ " yield await response.json()\n",
173
+ "\n",
174
+ "@FunctionDef({\n",
175
+ " \"description\": \"Fetch json data from a file path\",\n",
176
+ " \"category\": \"async\",\n",
177
+ " \"parameters\": [\n",
178
+ " {\"name\": \"path\", \"description\": \"File path to fetch data from\", \"type\": \"string\"}\n",
179
+ " ],\n",
180
+ " \"output\": {\"description\": \"Fetched data\", \"type\": \"string\"}\n",
181
+ "})\n",
182
+ "class json_file(AsyncFunction):\n",
183
+ " async def generate(self, path: str):\n",
184
+ " with open(path, \"r\") as file:\n",
185
+ " yield json.load(file)\n",
186
+ "\n",
187
+ "@FunctionDef({\n",
188
+ " \"description\": \"Extracts values from an array with optional filtering. Uses list comprehension syntax: extract(variable IN array [WHERE condition] | expression)\",\n",
189
+ " \"category\": \"predicate\",\n",
190
+ " \"parameters\": [\n",
191
+ " {\"name\": \"variable\", \"description\": \"Variable name to bind each element\", \"type\": \"string\"},\n",
192
+ " {\"name\": \"array\", \"description\": \"Array to iterate over\", \"type\": \"array\"},\n",
193
+ " {\"name\": \"expression\", \"description\": \"Expression to return for each element\", \"type\": \"any\"},\n",
194
+ " {\"name\": \"where\", \"description\": \"Optional filter condition\", \"type\": \"boolean\", \"required\": False}\n",
195
+ " ],\n",
196
+ " \"output\": {\"description\": \"Extracted values from the array after applying the optional filter\", \"type\": \"array\", \"example\": [2, 4]},\n",
197
+ " \"examples\": [\n",
198
+ " \"WITH [1, 2, 3] AS nums RETURN extract(n IN nums | n)\",\n",
199
+ " \"WITH [1, 2, 3, 4] AS nums RETURN extract(n IN nums WHERE n > 1 | n * 2)\"\n",
200
+ " ]\n",
201
+ "})\n",
202
+ "class PredicateExtract(PredicateFunction):\n",
203
+ " \"\"\"PredicateExtract function.\n",
204
+ " \n",
205
+ " Extracts values from an array with optional filtering.\n",
206
+ " \"\"\"\n",
207
+ "\n",
208
+ " def __init__(self):\n",
209
+ " super().__init__(\"extract\")\n",
210
+ "\n",
211
+ " def value(self) -> List[Any]:\n",
212
+ " return list(self._extract())\n",
213
+ " \n",
214
+ " def _extract(self) -> Iterator[Any]:\n",
215
+ " self.reference.referred = self._value_holder\n",
216
+ " array = self.array.value()\n",
217
+ " if array is None or not isinstance(array, list):\n",
218
+ " raise ValueError(\"Invalid array for extract function\")\n",
219
+ " \n",
220
+ " for item in array:\n",
221
+ " self._value_holder.holder = item\n",
222
+ " if self.where is None or self.where.value():\n",
223
+ " yield self._return.value()\n",
224
+ "\n",
225
+ "@FunctionDef({\n",
226
+ " \"description\": \"Checks if any element in the array satisfies the condition. Uses list comprehension syntax: any(variable IN array [WHERE condition])\",\n",
227
+ " \"category\": \"predicate\",\n",
228
+ " \"parameters\": [\n",
229
+ " {\"name\": \"variable\", \"description\": \"Variable name to bind each element\", \"type\": \"string\"},\n",
230
+ " {\"name\": \"array\", \"description\": \"Array to iterate over\", \"type\": \"array\"},\n",
231
+ " {\"name\": \"where\", \"description\": \"Condition to check for each element\", \"type\": \"boolean\", \"required\": False}\n",
232
+ " ],\n",
233
+ " \"output\": {\"description\": \"True if any element satisfies the condition, otherwise false\", \"type\": \"boolean\", \"example\": True},\n",
234
+ " \"examples\": [\n",
235
+ " \"WITH [1, 2, 3] AS nums RETURN any(n IN nums | n > 2)\",\n",
236
+ " \"WITH [1, 2, 3] AS nums RETURN any(n IN nums | n > 5)\"\n",
237
+ " ]\n",
238
+ "})\n",
239
+ "class Any(PredicateFunction):\n",
240
+ " \"\"\"Any function.\n",
241
+ " \n",
242
+ " Returns true if any element in the array satisfies the condition.\n",
243
+ " \"\"\"\n",
244
+ "\n",
245
+ " def __init__(self):\n",
246
+ " super().__init__(\"any\")\n",
247
+ "\n",
248
+ " def value(self) -> bool:\n",
249
+ " return any(self._any())\n",
250
+ " \n",
251
+ " def _any(self) -> Iterator[bool]:\n",
252
+ " self.reference.referred = self._value_holder\n",
253
+ " array = self.array.value()\n",
254
+ " if array is None or not isinstance(array, list):\n",
255
+ " raise ValueError(\"Invalid array for any function\")\n",
256
+ " \n",
257
+ " for item in array:\n",
258
+ " self._value_holder.holder = item\n",
259
+ " if self.where is None or self.where.value():\n",
260
+ " yield True"
261
+ ]
262
+ },
263
+ {
264
+ "cell_type": "markdown",
265
+ "id": "7",
266
+ "metadata": {},
267
+ "source": [
268
+ "Test functions just created"
269
+ ]
270
+ },
271
+ {
272
+ "cell_type": "code",
273
+ "execution_count": null,
274
+ "id": "8",
275
+ "metadata": {},
276
+ "outputs": [],
277
+ "source": [
278
+ "runner: Runner = Runner(\"\"\"\n",
279
+ " return uppercase(\"hello world\") as uppercased\n",
280
+ "\"\"\")\n",
281
+ "await runner.run()\n",
282
+ "for record in runner.results:\n",
283
+ " print(record)\n",
284
+ "\n",
285
+ "runner: Runner = Runner(\"\"\"\n",
286
+ " unwind [1, 2, 3, 4, 5] as num\n",
287
+ " return product(num) as total_product\n",
288
+ "\"\"\")\n",
289
+ "await runner.run()\n",
290
+ "for record in runner.results:\n",
291
+ " print(record)\n",
292
+ "\n",
293
+ "runner: Runner = Runner(\"\"\"\n",
294
+ " load json from get(\"https://catfact.ninja/fact\") as result\n",
295
+ " return result.fact as cat_fact\n",
296
+ "\"\"\")\n",
297
+ "await runner.run()\n",
298
+ "for record in runner.results:\n",
299
+ " print(record)\n",
300
+ "\n",
301
+ "runner: Runner = Runner(\"\"\"\n",
302
+ " load json from json_file(\"../misc/data/test.json\") as result\n",
303
+ " unwind result as entry\n",
304
+ " return entry\n",
305
+ "\"\"\")\n",
306
+ "await runner.run()\n",
307
+ "for record in runner.results:\n",
308
+ " print(record)\n",
309
+ "\n",
310
+ "runner: Runner = Runner(\"\"\"\n",
311
+ " with [\n",
312
+ " {\"age\": 25, \"name\": \"Alice\"},\n",
313
+ " {\"age\": 30, \"name\": \"Bob\"},\n",
314
+ " {\"age\": 22, \"name\": \"Charlie\"},\n",
315
+ " {\"age\": 28, \"name\": \"Diana\"}\n",
316
+ " ] as people\n",
317
+ " return extract(p IN people | p.name WHERE p.age > 25) as names_over_25\n",
318
+ "\"\"\")\n",
319
+ "await runner.run()\n",
320
+ "for record in runner.results:\n",
321
+ " print(record)\n",
322
+ "\n",
323
+ "runner: Runner = Runner(\"\"\"\n",
324
+ " with [1, 2, 3, 4, 5] as numbers\n",
325
+ " return any(n IN numbers | n where n > 6) as has_greater_than_3\n",
326
+ "\"\"\")\n",
327
+ "await runner.run()\n",
328
+ "for record in runner.results:\n",
329
+ " print(record)"
330
+ ]
331
+ },
332
+ {
333
+ "cell_type": "markdown",
334
+ "id": "9",
335
+ "metadata": {},
336
+ "source": [
337
+ "List functions"
338
+ ]
339
+ },
340
+ {
341
+ "cell_type": "code",
342
+ "execution_count": null,
343
+ "id": "10",
344
+ "metadata": {},
345
+ "outputs": [],
346
+ "source": [
347
+ "runner: Runner = Runner(\"\"\"\n",
348
+ " unwind functions() as func\n",
349
+ " return func\n",
350
+ "\"\"\")\n",
351
+ "await runner.run()\n",
352
+ "for record in runner.results:\n",
353
+ " print(record)"
354
+ ]
355
+ },
356
+ {
357
+ "cell_type": "markdown",
358
+ "id": "11",
359
+ "metadata": {},
360
+ "source": [
361
+ "Test virtual graph"
362
+ ]
363
+ },
364
+ {
365
+ "cell_type": "code",
366
+ "execution_count": null,
367
+ "id": "12",
368
+ "metadata": {},
369
+ "outputs": [],
370
+ "source": [
371
+ "await Runner(\"\"\"\n",
372
+ " create virtual (:User) as {\n",
373
+ " load json from json_file(\n",
374
+ " \"../misc/data/users.json\"\n",
375
+ " ) as users\n",
376
+ " unwind users as user\n",
377
+ " return user.id as id,\n",
378
+ " user.name as name,\n",
379
+ " user.title as title,\n",
380
+ " user.department as department,\n",
381
+ " user.email as email,\n",
382
+ " user.managerId as managerId\n",
383
+ " }\n",
384
+ "\"\"\").run()\n",
385
+ "\n",
386
+ "await Runner(\"\"\"\n",
387
+ " create virtual (:User)-[:MANAGED_BY]->(:User) as {\n",
388
+ " load json from json_file(\n",
389
+ " \"../misc/data/users.json\"\n",
390
+ " ) as users\n",
391
+ " unwind users as user\n",
392
+ " return user.id as left_id, user.managerId as right_id\n",
393
+ " }\n",
394
+ "\"\"\").run()\n",
395
+ "\n",
396
+ "runner: Runner = Runner(\"\"\"\n",
397
+ " MATCH p=(u:User)-[:MANAGED_BY*]->(ceo:User)\n",
398
+ " WHERE NOT (ceo)-[:MANAGED_BY]->(:User)\n",
399
+ " and any(n IN nodes(p) | n where n.department = \"Litigation\")\n",
400
+ " RETURN\n",
401
+ " u.name as employee,\n",
402
+ " extract(n IN nodes(p) | n.name) as management_chain\n",
403
+ "\"\"\")\n",
404
+ "await runner.run()\n",
405
+ "print(f\"Total results: {len(runner.results)}\")\n",
406
+ "for record in runner.results:\n",
407
+ " print(record)"
408
+ ]
409
+ },
410
+ {
411
+ "cell_type": "code",
412
+ "execution_count": null,
413
+ "id": "13",
414
+ "metadata": {},
415
+ "outputs": [],
416
+ "source": []
417
+ }
418
+ ],
419
+ "metadata": {
420
+ "kernelspec": {
421
+ "display_name": "flowquery",
422
+ "language": "python",
423
+ "name": "python3"
424
+ },
425
+ "language_info": {
426
+ "codemirror_mode": {
427
+ "name": "ipython",
428
+ "version": 3
429
+ },
430
+ "file_extension": ".py",
431
+ "mimetype": "text/x-python",
432
+ "name": "python",
433
+ "nbconvert_exporter": "python",
434
+ "pygments_lexer": "ipython3",
435
+ "version": "3.10.19"
436
+ }
437
+ },
438
+ "nbformat": 4,
439
+ "nbformat_minor": 5
440
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.0"
3
+ version = "1.0.9"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -38,6 +38,9 @@ Issues = "https://github.com/microsoft/FlowQuery/issues"
38
38
  dev = [
39
39
  "pytest>=7.0.0",
40
40
  "pytest-asyncio>=0.21.0",
41
+ "jupyter>=1.0.0",
42
+ "ipykernel>=6.0.0",
43
+ "nbstripout>=0.6.0",
41
44
  ]
42
45
 
43
46
  [build-system]
@@ -2,6 +2,8 @@
2
2
  FlowQuery - A declarative query language for data processing pipelines.
3
3
 
4
4
  This is the Python implementation of FlowQuery.
5
+
6
+ This module provides the core components for defining, parsing, and executing FlowQuery queries.
5
7
  """
6
8
 
7
9
  from .compute.runner import Runner
@@ -100,9 +100,10 @@ class Data:
100
100
 
101
101
  def reset(self) -> None:
102
102
  """Reset iteration to the beginning."""
103
- self.layer(0).current = -1
104
- for entry in self.layer(0).index.values():
105
- entry.reset()
103
+ for layer in self._layers.values():
104
+ layer.current = -1
105
+ for entry in layer.index.values():
106
+ entry.reset()
106
107
 
107
108
  def next(self, level: int = 0) -> bool:
108
109
  """Move to the next record. Returns True if successful."""
@@ -85,16 +85,19 @@ class Pattern(ASTNode):
85
85
  from .node import Node
86
86
  from .relationship import Relationship
87
87
 
88
- for element in self._chain:
88
+ for i, element in enumerate(self._chain):
89
89
  if isinstance(element, Node):
90
+ # Skip node if previous element was a zero-hop relationship (no matches)
91
+ if i > 0 and isinstance(self._chain[i-1], Relationship) and len(self._chain[i-1].matches) == 0:
92
+ continue
90
93
  yield element.value()
91
94
  elif isinstance(element, Relationship):
92
- i = 0
95
+ j = 0
93
96
  for match in element.matches:
94
97
  yield match
95
- if i < len(element.matches) - 1:
98
+ if j < len(element.matches) - 1:
96
99
  yield match["endNode"]
97
- i += 1
100
+ j += 1
98
101
 
99
102
  async def fetch_data(self) -> None:
100
103
  """Loads data from the database for all elements."""
@@ -20,11 +20,14 @@ class PatternExpression(Pattern):
20
20
  self._evaluation: bool = False
21
21
 
22
22
  def add_element(self, element) -> None:
23
- """Add an element to the pattern, ensuring it starts with a NodeReference."""
24
- if len(self._chain) == 0 and not isinstance(element, NodeReference):
25
- raise ValueError("PatternExpression must start with a NodeReference")
26
23
  super().add_element(element)
27
24
 
25
+ def verify(self) -> None:
26
+ if(len(self._chain) == 0):
27
+ raise ValueError("PatternExpression cannot be empty")
28
+ if not(any(isinstance(el, NodeReference) for el in self._chain if isinstance(el, ASTNode))):
29
+ raise ValueError("PatternExpression must contain at least one NodeReference")
30
+
28
31
  @property
29
32
  def identifier(self):
30
33
  return None
@@ -118,6 +118,13 @@ class Relationship(ASTNode):
118
118
  self._source = self._target
119
119
  if hop == 0:
120
120
  self._data.reset() if self._data else None
121
+
122
+ # Handle zero-hop case: when min is 0 on a variable-length relationship,
123
+ # match source node as target (no traversal)
124
+ if self._hops and self._hops.multi() and self._hops.min == 0 and self._target:
125
+ # For zero-hop, target finds the same node as source (left_id)
126
+ # No relationship match is pushed since no edge is traversed
127
+ await self._target.find(left_id, hop)
121
128
 
122
129
  while self._data and self._data.find(left_id, hop):
123
130
  data = self._data.current(hop)
@@ -1,5 +1,6 @@
1
1
  """Interactive command-line interface for FlowQuery."""
2
2
 
3
+ import argparse
3
4
  import asyncio
4
5
  from typing import Optional
5
6
 
@@ -15,8 +16,26 @@ class CommandLine:
15
16
  Example:
16
17
  cli = CommandLine()
17
18
  cli.loop() # Starts interactive mode
19
+
20
+ # Or execute a single query:
21
+ cli.execute("load json from 'https://example.com/data' as d return d")
18
22
  """
19
23
 
24
+ def execute(self, query: str) -> None:
25
+ """Execute a single FlowQuery statement and print results.
26
+
27
+ Args:
28
+ query: The FlowQuery statement to execute.
29
+ """
30
+ # Remove the termination semicolon if present
31
+ query = query.strip().rstrip(";")
32
+
33
+ try:
34
+ runner = Runner(query)
35
+ asyncio.run(self._execute(runner))
36
+ except Exception as e:
37
+ print(f"Error: {e}")
38
+
20
39
  def loop(self) -> None:
21
40
  """Starts the interactive command loop.
22
41
 
@@ -63,5 +82,28 @@ class CommandLine:
63
82
 
64
83
 
65
84
  def main() -> None:
66
- """Entry point for the flowquery CLI command."""
67
- CommandLine().loop()
85
+ """Entry point for the flowquery CLI command.
86
+
87
+ Usage:
88
+ flowquery # Start interactive mode
89
+ flowquery -c "query" # Execute a single query
90
+ flowquery --command "query"
91
+ """
92
+ parser = argparse.ArgumentParser(
93
+ description="FlowQuery - A declarative query language for data processing pipelines",
94
+ prog="flowquery"
95
+ )
96
+ parser.add_argument(
97
+ "-c", "--command",
98
+ type=str,
99
+ metavar="QUERY",
100
+ help="Execute a FlowQuery statement and exit"
101
+ )
102
+
103
+ args = parser.parse_args()
104
+ cli = CommandLine()
105
+
106
+ if args.command:
107
+ cli.execute(args.command)
108
+ else:
109
+ cli.loop()
@@ -69,7 +69,7 @@ class BaseParser:
69
69
  The current token, or EOF if at the end
70
70
  """
71
71
  if self._token_index >= len(self._tokens):
72
- return Token.EOF
72
+ return Token.EOF()
73
73
  return self._tokens[self._token_index]
74
74
 
75
75
  @property
@@ -80,5 +80,5 @@ class BaseParser:
80
80
  The previous token, or EOF if at the beginning
81
81
  """
82
82
  if self._token_index - 1 < 0:
83
- return Token.EOF
83
+ return Token.EOF()
84
84
  return self._tokens[self._token_index - 1]
@@ -96,6 +96,12 @@ class Load(Operation):
96
96
  headers = options.pop("headers", {})
97
97
  body = options.pop("body", None)
98
98
 
99
+ # Set Accept-Encoding to support common compression formats
100
+ # Note: brotli (br) is excluded due to API incompatibility between
101
+ # aiohttp 3.13+ and the brotli package's Decompressor.decompress() method
102
+ if "Accept-Encoding" not in headers:
103
+ headers["Accept-Encoding"] = "gzip, deflate"
104
+
99
105
  async with session.request(
100
106
  method,
101
107
  self.from_,