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.
- package/.gitattributes +3 -0
- package/.github/workflows/python-publish.yml +58 -7
- package/.github/workflows/release.yml +25 -18
- package/README.md +37 -32
- package/dist/flowquery.min.js +1 -1
- package/dist/graph/data.d.ts.map +1 -1
- package/dist/graph/data.js +5 -3
- package/dist/graph/data.js.map +1 -1
- package/dist/graph/pattern.d.ts.map +1 -1
- package/dist/graph/pattern.js +11 -4
- package/dist/graph/pattern.js.map +1 -1
- package/dist/graph/pattern_expression.d.ts +1 -0
- package/dist/graph/pattern_expression.d.ts.map +1 -1
- package/dist/graph/pattern_expression.js +14 -3
- package/dist/graph/pattern_expression.js.map +1 -1
- package/dist/graph/relationship.d.ts.map +1 -1
- package/dist/graph/relationship.js +11 -4
- package/dist/graph/relationship.js.map +1 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/parsing/parser.d.ts +5 -0
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +94 -73
- package/dist/parsing/parser.js.map +1 -1
- package/docs/flowquery.min.js +1 -1
- package/flowquery-py/CONTRIBUTING.md +127 -0
- package/flowquery-py/README.md +13 -112
- package/flowquery-py/misc/data/test.json +10 -0
- package/flowquery-py/misc/data/users.json +242 -0
- package/flowquery-py/notebooks/TestFlowQuery.ipynb +440 -0
- package/flowquery-py/pyproject.toml +4 -1
- package/flowquery-py/src/__init__.py +2 -0
- package/flowquery-py/src/graph/data.py +4 -3
- package/flowquery-py/src/graph/pattern.py +7 -4
- package/flowquery-py/src/graph/pattern_expression.py +6 -3
- package/flowquery-py/src/graph/relationship.py +7 -0
- package/flowquery-py/src/io/command_line.py +44 -2
- package/flowquery-py/src/parsing/base_parser.py +2 -2
- package/flowquery-py/src/parsing/operations/load.py +6 -0
- package/flowquery-py/src/parsing/parser.py +78 -62
- package/flowquery-py/src/tokenization/token.py +122 -176
- package/flowquery-py/src/tokenization/tokenizer.py +4 -4
- package/flowquery-py/tests/compute/test_runner.py +10 -7
- package/flowquery-py/tests/parsing/test_parser.py +6 -0
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/graph/data.ts +5 -3
- package/src/graph/pattern.ts +13 -4
- package/src/graph/pattern_expression.ts +14 -3
- package/src/graph/relationship.ts +8 -0
- package/src/index.ts +5 -6
- package/src/parsing/parser.ts +93 -69
- package/tests/compute/runner.test.ts +71 -79
- 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.
|
|
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.
|
|
104
|
-
|
|
105
|
-
entry.
|
|
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
|
-
|
|
95
|
+
j = 0
|
|
93
96
|
for match in element.matches:
|
|
94
97
|
yield match
|
|
95
|
-
if
|
|
98
|
+
if j < len(element.matches) - 1:
|
|
96
99
|
yield match["endNode"]
|
|
97
|
-
|
|
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
|
-
|
|
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_,
|