flowquery 1.0.21 → 1.0.23
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/.github/workflows/python-publish.yml +0 -5
- 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 +39 -0
- package/dist/graph/database.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/schema.d.ts +17 -0
- package/dist/parsing/functions/schema.d.ts.map +1 -0
- package/dist/parsing/functions/schema.js +62 -0
- package/dist/parsing/functions/schema.js.map +1 -0
- package/dist/parsing/parser.js +11 -11
- package/dist/parsing/parser.js.map +1 -1
- package/dist/tokenization/token.d.ts +2 -0
- package/dist/tokenization/token.d.ts.map +1 -1
- package/dist/tokenization/token.js +12 -0
- package/dist/tokenization/token.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 +25 -1
- package/flowquery-py/src/parsing/functions/__init__.py +2 -0
- package/flowquery-py/src/parsing/functions/schema.py +36 -0
- package/flowquery-py/src/parsing/parser.py +12 -12
- package/flowquery-py/src/tokenization/token.py +18 -0
- package/flowquery-py/tests/compute/test_runner.py +105 -1
- package/flowquery-py/tests/parsing/test_parser.py +9 -0
- package/flowquery-py/tests/tokenization/test_tokenizer.py +34 -0
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/graph/database.ts +30 -0
- package/src/parsing/functions/function_factory.ts +1 -0
- package/src/parsing/functions/schema.ts +36 -0
- package/src/parsing/parser.ts +11 -11
- package/src/tokenization/token.ts +16 -0
- package/tests/compute/runner.test.ts +96 -0
- package/tests/parsing/parser.test.ts +9 -0
- package/tests/tokenization/tokenizer.test.ts +34 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Dict, Optional, Union
|
|
5
|
+
from typing import Any, Dict, Optional, Union
|
|
6
6
|
|
|
7
7
|
from ..parsing.ast_node import ASTNode
|
|
8
8
|
from .node import Node
|
|
@@ -54,6 +54,30 @@ class Database:
|
|
|
54
54
|
"""Gets a relationship from the database."""
|
|
55
55
|
return Database._relationships.get(relationship.type) if relationship.type else None
|
|
56
56
|
|
|
57
|
+
async def schema(self) -> list[dict[str, Any]]:
|
|
58
|
+
"""Returns the graph schema with node/relationship labels and sample data."""
|
|
59
|
+
result: list[dict[str, Any]] = []
|
|
60
|
+
|
|
61
|
+
for label, physical_node in Database._nodes.items():
|
|
62
|
+
records = await physical_node.data()
|
|
63
|
+
entry: dict[str, Any] = {"kind": "node", "label": label}
|
|
64
|
+
if records:
|
|
65
|
+
sample = {k: v for k, v in records[0].items() if k != "id"}
|
|
66
|
+
if sample:
|
|
67
|
+
entry["sample"] = sample
|
|
68
|
+
result.append(entry)
|
|
69
|
+
|
|
70
|
+
for rel_type, physical_rel in Database._relationships.items():
|
|
71
|
+
records = await physical_rel.data()
|
|
72
|
+
entry_rel: dict[str, Any] = {"kind": "relationship", "type": rel_type}
|
|
73
|
+
if records:
|
|
74
|
+
sample = {k: v for k, v in records[0].items() if k not in ("left_id", "right_id")}
|
|
75
|
+
if sample:
|
|
76
|
+
entry_rel["sample"] = sample
|
|
77
|
+
result.append(entry_rel)
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
57
81
|
async def get_data(self, element: Union['Node', 'Relationship']) -> Union['NodeData', 'RelationshipData']:
|
|
58
82
|
"""Gets data for a node or relationship."""
|
|
59
83
|
if isinstance(element, Node):
|
|
@@ -27,6 +27,7 @@ from .range_ import Range
|
|
|
27
27
|
from .reducer_element import ReducerElement
|
|
28
28
|
from .replace import Replace
|
|
29
29
|
from .round_ import Round
|
|
30
|
+
from .schema import Schema
|
|
30
31
|
from .size import Size
|
|
31
32
|
from .split import Split
|
|
32
33
|
from .stringify import Stringify
|
|
@@ -71,5 +72,6 @@ __all__ = [
|
|
|
71
72
|
"ToJson",
|
|
72
73
|
"Type",
|
|
73
74
|
"Functions",
|
|
75
|
+
"Schema",
|
|
74
76
|
"PredicateSum",
|
|
75
77
|
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Schema introspection function."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from .async_function import AsyncFunction
|
|
6
|
+
from .function_metadata import FunctionDef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@FunctionDef({
|
|
10
|
+
"description": (
|
|
11
|
+
"Returns the graph schema listing all nodes and relationships "
|
|
12
|
+
"with a sample of their data."
|
|
13
|
+
),
|
|
14
|
+
"category": "async",
|
|
15
|
+
"parameters": [],
|
|
16
|
+
"output": {
|
|
17
|
+
"description": "Schema entry with kind, label/type, and optional sample data",
|
|
18
|
+
"type": "object",
|
|
19
|
+
},
|
|
20
|
+
"examples": [
|
|
21
|
+
"CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample",
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
class Schema(AsyncFunction):
|
|
25
|
+
"""Returns the graph schema of the database.
|
|
26
|
+
|
|
27
|
+
Lists all nodes and relationships with their labels/types and a sample
|
|
28
|
+
of their data (excluding id from nodes, left_id and right_id from relationships).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
async def generate(self) -> AsyncGenerator[Any, None]:
|
|
32
|
+
# Import at runtime to avoid circular dependency
|
|
33
|
+
from ...graph.database import Database
|
|
34
|
+
entries = await Database.get_instance().schema()
|
|
35
|
+
for entry in entries:
|
|
36
|
+
yield entry
|
|
@@ -326,7 +326,7 @@ class Parser(BaseParser):
|
|
|
326
326
|
if not self.token.is_colon():
|
|
327
327
|
raise ValueError("Expected ':' for relationship type")
|
|
328
328
|
self.set_next_token()
|
|
329
|
-
if not self.token.
|
|
329
|
+
if not self.token.is_identifier_or_keyword():
|
|
330
330
|
raise ValueError("Expected relationship type identifier")
|
|
331
331
|
rel_type = self.token.value or ""
|
|
332
332
|
self.set_next_token()
|
|
@@ -450,17 +450,17 @@ class Parser(BaseParser):
|
|
|
450
450
|
self.set_next_token()
|
|
451
451
|
self._skip_whitespace_and_comments()
|
|
452
452
|
identifier: Optional[str] = None
|
|
453
|
-
if self.token.
|
|
453
|
+
if self.token.is_identifier_or_keyword():
|
|
454
454
|
identifier = self.token.value
|
|
455
455
|
self.set_next_token()
|
|
456
456
|
self._skip_whitespace_and_comments()
|
|
457
457
|
label: Optional[str] = None
|
|
458
458
|
peek = self.peek()
|
|
459
|
-
if not self.token.is_colon() and peek is not None and peek.
|
|
459
|
+
if not self.token.is_colon() and peek is not None and peek.is_identifier_or_keyword():
|
|
460
460
|
raise ValueError("Expected ':' for node label")
|
|
461
|
-
if self.token.is_colon() and (peek is None or not peek.
|
|
461
|
+
if self.token.is_colon() and (peek is None or not peek.is_identifier_or_keyword()):
|
|
462
462
|
raise ValueError("Expected node label identifier")
|
|
463
|
-
if self.token.is_colon() and peek is not None and peek.
|
|
463
|
+
if self.token.is_colon() and peek is not None and peek.is_identifier_or_keyword():
|
|
464
464
|
self.set_next_token()
|
|
465
465
|
label = cast(str, self.token.value) # Guaranteed by is_identifier check
|
|
466
466
|
self.set_next_token()
|
|
@@ -495,13 +495,13 @@ class Parser(BaseParser):
|
|
|
495
495
|
return None
|
|
496
496
|
self.set_next_token()
|
|
497
497
|
variable: Optional[str] = None
|
|
498
|
-
if self.token.
|
|
498
|
+
if self.token.is_identifier_or_keyword():
|
|
499
499
|
variable = self.token.value
|
|
500
500
|
self.set_next_token()
|
|
501
501
|
if not self.token.is_colon():
|
|
502
502
|
raise ValueError("Expected ':' for relationship type")
|
|
503
503
|
self.set_next_token()
|
|
504
|
-
if not self.token.
|
|
504
|
+
if not self.token.is_identifier_or_keyword():
|
|
505
505
|
raise ValueError("Expected relationship type identifier")
|
|
506
506
|
rel_type: str = self.token.value or ""
|
|
507
507
|
self.set_next_token()
|
|
@@ -633,14 +633,14 @@ class Parser(BaseParser):
|
|
|
633
633
|
def _parse_operand(self, expression: Expression) -> bool:
|
|
634
634
|
"""Parse a single operand (without operators). Returns True if an operand was parsed."""
|
|
635
635
|
self._skip_whitespace_and_comments()
|
|
636
|
-
if self.token.
|
|
636
|
+
if self.token.is_identifier_or_keyword() and (self.peek() is None or not self.peek().is_left_parenthesis()):
|
|
637
637
|
identifier = self.token.value or ""
|
|
638
638
|
reference = Reference(identifier, self._variables.get(identifier))
|
|
639
639
|
self.set_next_token()
|
|
640
640
|
lookup = self._parse_lookup(reference)
|
|
641
641
|
expression.add_node(lookup)
|
|
642
642
|
return True
|
|
643
|
-
elif self.token.
|
|
643
|
+
elif self.token.is_identifier_or_keyword() and self.peek() is not None and self.peek().is_left_parenthesis():
|
|
644
644
|
func = self._parse_predicate_function() or self._parse_function()
|
|
645
645
|
if func is not None:
|
|
646
646
|
lookup = self._parse_lookup(func)
|
|
@@ -650,7 +650,7 @@ class Parser(BaseParser):
|
|
|
650
650
|
self.token.is_left_parenthesis()
|
|
651
651
|
and self.peek() is not None
|
|
652
652
|
and (
|
|
653
|
-
self.peek().
|
|
653
|
+
self.peek().is_identifier_or_keyword()
|
|
654
654
|
or self.peek().is_colon()
|
|
655
655
|
or self.peek().is_right_parenthesis()
|
|
656
656
|
)
|
|
@@ -734,7 +734,7 @@ class Parser(BaseParser):
|
|
|
734
734
|
while True:
|
|
735
735
|
if self.token.is_dot():
|
|
736
736
|
self.set_next_token()
|
|
737
|
-
if not self.token.
|
|
737
|
+
if not self.token.is_identifier_or_keyword():
|
|
738
738
|
raise ValueError("Expected identifier")
|
|
739
739
|
lookup = Lookup()
|
|
740
740
|
lookup.index = Identifier(self.token.value or "")
|
|
@@ -847,7 +847,7 @@ class Parser(BaseParser):
|
|
|
847
847
|
self._expect_previous_token_to_be_whitespace_or_comment()
|
|
848
848
|
self.set_next_token()
|
|
849
849
|
self._expect_and_skip_whitespace_and_comments()
|
|
850
|
-
if not self.token.
|
|
850
|
+
if not self.token.is_identifier_or_keyword():
|
|
851
851
|
raise ValueError("Expected identifier")
|
|
852
852
|
alias = Alias(self.token.value or "")
|
|
853
853
|
self.set_next_token()
|
|
@@ -106,6 +106,24 @@ class Token:
|
|
|
106
106
|
def is_identifier(self) -> bool:
|
|
107
107
|
return self._type == TokenType.IDENTIFIER or self._type == TokenType.BACKTICK_STRING
|
|
108
108
|
|
|
109
|
+
def is_keyword_that_cannot_be_identifier(self) -> bool:
|
|
110
|
+
"""Returns True for keywords that have special expression-level roles
|
|
111
|
+
and should not be treated as identifiers (NULL, CASE, WHEN, THEN, ELSE, END)."""
|
|
112
|
+
return self.is_keyword() and (
|
|
113
|
+
self.is_null()
|
|
114
|
+
or self.is_case()
|
|
115
|
+
or self.is_when()
|
|
116
|
+
or self.is_then()
|
|
117
|
+
or self.is_else()
|
|
118
|
+
or self.is_end()
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def is_identifier_or_keyword(self) -> bool:
|
|
122
|
+
"""Returns True if the token is an identifier or a keyword that can be used as an identifier."""
|
|
123
|
+
return self.is_identifier() or (
|
|
124
|
+
self.is_keyword() and not self.is_keyword_that_cannot_be_identifier()
|
|
125
|
+
)
|
|
126
|
+
|
|
109
127
|
# String token
|
|
110
128
|
|
|
111
129
|
@staticmethod
|
|
@@ -1539,4 +1539,108 @@ class TestRunner:
|
|
|
1539
1539
|
await match.run()
|
|
1540
1540
|
results = match.results
|
|
1541
1541
|
assert len(results) == 1
|
|
1542
|
-
assert results[0]["name"] == "Employee 1"
|
|
1542
|
+
assert results[0]["name"] == "Employee 1"
|
|
1543
|
+
|
|
1544
|
+
@pytest.mark.asyncio
|
|
1545
|
+
async def test_schema_returns_nodes_and_relationships_with_sample_data(self):
|
|
1546
|
+
"""Test schema() returns nodes and relationships with sample data."""
|
|
1547
|
+
await Runner(
|
|
1548
|
+
"""
|
|
1549
|
+
CREATE VIRTUAL (:Animal) AS {
|
|
1550
|
+
UNWIND [
|
|
1551
|
+
{id: 1, species: 'Cat', legs: 4},
|
|
1552
|
+
{id: 2, species: 'Dog', legs: 4}
|
|
1553
|
+
] AS record
|
|
1554
|
+
RETURN record.id AS id, record.species AS species, record.legs AS legs
|
|
1555
|
+
}
|
|
1556
|
+
"""
|
|
1557
|
+
).run()
|
|
1558
|
+
await Runner(
|
|
1559
|
+
"""
|
|
1560
|
+
CREATE VIRTUAL (:Animal)-[:CHASES]-(:Animal) AS {
|
|
1561
|
+
UNWIND [
|
|
1562
|
+
{left_id: 2, right_id: 1, speed: 'fast'}
|
|
1563
|
+
] AS record
|
|
1564
|
+
RETURN record.left_id AS left_id, record.right_id AS right_id, record.speed AS speed
|
|
1565
|
+
}
|
|
1566
|
+
"""
|
|
1567
|
+
).run()
|
|
1568
|
+
|
|
1569
|
+
runner = Runner(
|
|
1570
|
+
"CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample"
|
|
1571
|
+
)
|
|
1572
|
+
await runner.run()
|
|
1573
|
+
results = runner.results
|
|
1574
|
+
|
|
1575
|
+
animal = next((r for r in results if r.get("kind") == "node" and r.get("label") == "Animal"), None)
|
|
1576
|
+
assert animal is not None
|
|
1577
|
+
assert animal["sample"] is not None
|
|
1578
|
+
assert "id" not in animal["sample"]
|
|
1579
|
+
assert "species" in animal["sample"]
|
|
1580
|
+
assert "legs" in animal["sample"]
|
|
1581
|
+
|
|
1582
|
+
chases = next((r for r in results if r.get("kind") == "relationship" and r.get("type") == "CHASES"), None)
|
|
1583
|
+
assert chases is not None
|
|
1584
|
+
assert chases["sample"] is not None
|
|
1585
|
+
assert "left_id" not in chases["sample"]
|
|
1586
|
+
assert "right_id" not in chases["sample"]
|
|
1587
|
+
assert "speed" in chases["sample"]
|
|
1588
|
+
|
|
1589
|
+
@pytest.mark.asyncio
|
|
1590
|
+
async def test_reserved_keywords_as_identifiers(self):
|
|
1591
|
+
"""Test reserved keywords as identifiers."""
|
|
1592
|
+
runner = Runner("""
|
|
1593
|
+
WITH 1 AS return
|
|
1594
|
+
RETURN return
|
|
1595
|
+
""")
|
|
1596
|
+
await runner.run()
|
|
1597
|
+
results = runner.results
|
|
1598
|
+
assert len(results) == 1
|
|
1599
|
+
assert results[0]["return"] == 1
|
|
1600
|
+
|
|
1601
|
+
@pytest.mark.asyncio
|
|
1602
|
+
async def test_reserved_keywords_as_parts_of_identifiers(self):
|
|
1603
|
+
"""Test reserved keywords as parts of identifiers."""
|
|
1604
|
+
runner = Runner("""
|
|
1605
|
+
unwind [
|
|
1606
|
+
{from: "Alice", to: "Bob", organizer: "Charlie"},
|
|
1607
|
+
{from: "Bob", to: "Charlie", organizer: "Alice"},
|
|
1608
|
+
{from: "Charlie", to: "Alice", organizer: "Bob"}
|
|
1609
|
+
] as data
|
|
1610
|
+
return data.from as from, data.to as to, data.organizer as organizer
|
|
1611
|
+
""")
|
|
1612
|
+
await runner.run()
|
|
1613
|
+
results = runner.results
|
|
1614
|
+
assert len(results) == 3
|
|
1615
|
+
assert results[0] == {"from": "Alice", "to": "Bob", "organizer": "Charlie"}
|
|
1616
|
+
assert results[1] == {"from": "Bob", "to": "Charlie", "organizer": "Alice"}
|
|
1617
|
+
assert results[2] == {"from": "Charlie", "to": "Alice", "organizer": "Bob"}
|
|
1618
|
+
|
|
1619
|
+
@pytest.mark.asyncio
|
|
1620
|
+
async def test_reserved_keywords_as_relationship_types_and_labels(self):
|
|
1621
|
+
"""Test reserved keywords as relationship types and labels."""
|
|
1622
|
+
await Runner("""
|
|
1623
|
+
CREATE VIRTUAL (:Return) AS {
|
|
1624
|
+
unwind [
|
|
1625
|
+
{id: 1, name: 'Node 1'},
|
|
1626
|
+
{id: 2, name: 'Node 2'}
|
|
1627
|
+
] as record
|
|
1628
|
+
RETURN record.id as id, record.name as name
|
|
1629
|
+
}
|
|
1630
|
+
""").run()
|
|
1631
|
+
await Runner("""
|
|
1632
|
+
CREATE VIRTUAL (:Return)-[:With]-(:Return) AS {
|
|
1633
|
+
unwind [
|
|
1634
|
+
{left_id: 1, right_id: 2}
|
|
1635
|
+
] as record
|
|
1636
|
+
RETURN record.left_id as left_id, record.right_id as right_id
|
|
1637
|
+
}
|
|
1638
|
+
""").run()
|
|
1639
|
+
runner = Runner("""
|
|
1640
|
+
MATCH (a:Return)-[:With]->(b:Return)
|
|
1641
|
+
RETURN a.name AS name1, b.name AS name2
|
|
1642
|
+
""")
|
|
1643
|
+
await runner.run()
|
|
1644
|
+
results = runner.results
|
|
1645
|
+
assert len(results) == 1
|
|
1646
|
+
assert results[0] == {"name1": "Node 1", "name2": "Node 2"}
|
|
@@ -719,3 +719,12 @@ class TestParser:
|
|
|
719
719
|
assert isinstance(relationship, Relationship)
|
|
720
720
|
assert relationship.properties.get("since") is not None
|
|
721
721
|
assert relationship.properties["since"].value() == 2022
|
|
722
|
+
|
|
723
|
+
def test_case_statement_with_keywords_as_identifiers(self):
|
|
724
|
+
"""Test that CASE/WHEN/THEN/ELSE/END are not treated as identifiers."""
|
|
725
|
+
parser = Parser()
|
|
726
|
+
ast = parser.parse("RETURN CASE WHEN 1 THEN 2 ELSE 3 END")
|
|
727
|
+
assert "Case" in ast.print()
|
|
728
|
+
assert "When" in ast.print()
|
|
729
|
+
assert "Then" in ast.print()
|
|
730
|
+
assert "Else" in ast.print()
|
|
@@ -162,3 +162,37 @@ class TestTokenizer:
|
|
|
162
162
|
tokens = tokenizer.tokenize()
|
|
163
163
|
assert tokens is not None
|
|
164
164
|
assert len(tokens) > 0
|
|
165
|
+
|
|
166
|
+
def test_reserved_keywords_as_identifiers(self):
|
|
167
|
+
"""Test reserved keywords as identifiers."""
|
|
168
|
+
tokenizer = Tokenizer("""
|
|
169
|
+
WITH 1 AS return
|
|
170
|
+
RETURN return
|
|
171
|
+
""")
|
|
172
|
+
tokens = tokenizer.tokenize()
|
|
173
|
+
assert tokens is not None
|
|
174
|
+
assert len(tokens) > 0
|
|
175
|
+
|
|
176
|
+
def test_reserved_keywords_as_part_of_identifiers(self):
|
|
177
|
+
"""Test reserved keywords as part of identifiers."""
|
|
178
|
+
tokenizer = Tokenizer("""
|
|
179
|
+
unwind [
|
|
180
|
+
{from: "Alice", to: "Bob", organizer: "Charlie"},
|
|
181
|
+
{from: "Bob", to: "Charlie", organizer: "Alice"},
|
|
182
|
+
{from: "Charlie", to: "Alice", organizer: "Bob"}
|
|
183
|
+
] as data
|
|
184
|
+
return data.from, data.to
|
|
185
|
+
""")
|
|
186
|
+
tokens = tokenizer.tokenize()
|
|
187
|
+
assert tokens is not None
|
|
188
|
+
assert len(tokens) > 0
|
|
189
|
+
|
|
190
|
+
def test_reserved_keywords_as_relationship_types_and_labels(self):
|
|
191
|
+
"""Test reserved keywords as relationship types and labels."""
|
|
192
|
+
tokenizer = Tokenizer("""
|
|
193
|
+
MATCH (a:RETURN)-[r:WITH]->(b:RETURN)
|
|
194
|
+
RETURN a, b
|
|
195
|
+
""")
|
|
196
|
+
tokens = tokenizer.tokenize()
|
|
197
|
+
assert tokens is not None
|
|
198
|
+
assert len(tokens) > 0
|