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.
Files changed (40) hide show
  1. package/.github/workflows/python-publish.yml +0 -5
  2. package/dist/flowquery.min.js +1 -1
  3. package/dist/graph/database.d.ts +1 -0
  4. package/dist/graph/database.d.ts.map +1 -1
  5. package/dist/graph/database.js +39 -0
  6. package/dist/graph/database.js.map +1 -1
  7. package/dist/parsing/functions/function_factory.d.ts +1 -0
  8. package/dist/parsing/functions/function_factory.d.ts.map +1 -1
  9. package/dist/parsing/functions/function_factory.js +1 -0
  10. package/dist/parsing/functions/function_factory.js.map +1 -1
  11. package/dist/parsing/functions/schema.d.ts +17 -0
  12. package/dist/parsing/functions/schema.d.ts.map +1 -0
  13. package/dist/parsing/functions/schema.js +62 -0
  14. package/dist/parsing/functions/schema.js.map +1 -0
  15. package/dist/parsing/parser.js +11 -11
  16. package/dist/parsing/parser.js.map +1 -1
  17. package/dist/tokenization/token.d.ts +2 -0
  18. package/dist/tokenization/token.d.ts.map +1 -1
  19. package/dist/tokenization/token.js +12 -0
  20. package/dist/tokenization/token.js.map +1 -1
  21. package/docs/flowquery.min.js +1 -1
  22. package/flowquery-py/pyproject.toml +1 -1
  23. package/flowquery-py/src/graph/database.py +25 -1
  24. package/flowquery-py/src/parsing/functions/__init__.py +2 -0
  25. package/flowquery-py/src/parsing/functions/schema.py +36 -0
  26. package/flowquery-py/src/parsing/parser.py +12 -12
  27. package/flowquery-py/src/tokenization/token.py +18 -0
  28. package/flowquery-py/tests/compute/test_runner.py +105 -1
  29. package/flowquery-py/tests/parsing/test_parser.py +9 -0
  30. package/flowquery-py/tests/tokenization/test_tokenizer.py +34 -0
  31. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  32. package/package.json +1 -1
  33. package/src/graph/database.ts +30 -0
  34. package/src/parsing/functions/function_factory.ts +1 -0
  35. package/src/parsing/functions/schema.ts +36 -0
  36. package/src/parsing/parser.ts +11 -11
  37. package/src/tokenization/token.ts +16 -0
  38. package/tests/compute/runner.test.ts +96 -0
  39. package/tests/parsing/parser.test.ts +9 -0
  40. package/tests/tokenization/tokenizer.test.ts +34 -0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowquery"
3
- version = "1.0.11"
3
+ version = "1.0.13"
4
4
  description = "A declarative query language for data processing pipelines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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.is_identifier():
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.is_identifier():
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.is_identifier():
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.is_identifier()):
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.is_identifier():
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.is_identifier():
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.is_identifier():
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.is_identifier() and (self.peek() is None or not self.peek().is_left_parenthesis()):
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.is_identifier() and self.peek() is not None and self.peek().is_left_parenthesis():
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().is_identifier()
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.is_identifier() and not self.token.is_keyword():
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.is_identifier():
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