flowquery 1.0.29 → 1.0.31

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 (39) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/node_reference.d.ts +3 -2
  3. package/dist/graph/node_reference.d.ts.map +1 -1
  4. package/dist/graph/node_reference.js.map +1 -1
  5. package/dist/graph/relationship.d.ts +2 -1
  6. package/dist/graph/relationship.d.ts.map +1 -1
  7. package/dist/graph/relationship.js +15 -15
  8. package/dist/graph/relationship.js.map +1 -1
  9. package/dist/graph/relationship_data.d.ts +1 -2
  10. package/dist/graph/relationship_data.d.ts.map +1 -1
  11. package/dist/graph/relationship_data.js +2 -5
  12. package/dist/graph/relationship_data.js.map +1 -1
  13. package/dist/graph/relationship_match_collector.d.ts +2 -2
  14. package/dist/graph/relationship_match_collector.d.ts.map +1 -1
  15. package/dist/graph/relationship_match_collector.js +6 -7
  16. package/dist/graph/relationship_match_collector.js.map +1 -1
  17. package/dist/parsing/parser.d.ts +12 -0
  18. package/dist/parsing/parser.d.ts.map +1 -1
  19. package/dist/parsing/parser.js +35 -6
  20. package/dist/parsing/parser.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/node_reference.py +5 -4
  24. package/flowquery-py/src/graph/relationship.py +20 -22
  25. package/flowquery-py/src/graph/relationship_data.py +4 -7
  26. package/flowquery-py/src/graph/relationship_match_collector.py +5 -7
  27. package/flowquery-py/src/parsing/operations/group_by.py +13 -2
  28. package/flowquery-py/src/parsing/parser.py +30 -7
  29. package/flowquery-py/tests/compute/test_runner.py +46 -5
  30. package/flowquery-py/tests/parsing/test_parser.py +58 -0
  31. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  32. package/package.json +1 -1
  33. package/src/graph/node_reference.ts +4 -3
  34. package/src/graph/relationship.ts +15 -15
  35. package/src/graph/relationship_data.ts +2 -5
  36. package/src/graph/relationship_match_collector.ts +6 -7
  37. package/src/parsing/parser.ts +41 -7
  38. package/tests/compute/runner.test.ts +109 -4
  39. package/tests/parsing/parser.test.ts +60 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowquery",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "A declarative query language for data processing pipelines.",
5
5
  "main": "dist/index.node.js",
6
6
  "types": "dist/index.node.d.ts",
@@ -1,8 +1,9 @@
1
+ import ASTNode from "../parsing/ast_node";
1
2
  import Node from "./node";
2
3
 
3
4
  class NodeReference extends Node {
4
- private _reference: Node | null = null;
5
- constructor(base: Node, reference: Node) {
5
+ private _reference: ASTNode | null = null;
6
+ constructor(base: Node, reference: ASTNode) {
6
7
  super();
7
8
  this._identifier = base.identifier;
8
9
  this._label = base.label;
@@ -11,7 +12,7 @@ class NodeReference extends Node {
11
12
  this._incoming = base.incoming;
12
13
  this._reference = reference;
13
14
  }
14
- public get reference(): Node | null {
15
+ public get reference(): ASTNode | null {
15
16
  return this._reference;
16
17
  }
17
18
  public async next(): Promise<void> {
@@ -71,8 +71,8 @@ class Relationship extends ASTNode {
71
71
  public get hops(): Hops | null {
72
72
  return this._hops;
73
73
  }
74
- public setValue(relationship: Relationship): void {
75
- const match: RelationshipMatchRecord = this._matches.push(relationship);
74
+ public setValue(relationship: Relationship, traversalId: string = ""): void {
75
+ const match: RelationshipMatchRecord = this._matches.push(relationship, traversalId);
76
76
  this._value = this._matches.value();
77
77
  }
78
78
  public set source(node: Node | null) {
@@ -108,10 +108,12 @@ class Relationship extends ASTNode {
108
108
  public setEndNode(node: Node): void {
109
109
  this._matches.endNode = node;
110
110
  }
111
+ public _left_id_or_right_id(): string {
112
+ return this._direction === "left" ? "left_id" : "right_id";
113
+ }
111
114
  public async find(left_id: string, hop: number = 0): Promise<void> {
112
115
  // Save original source node
113
116
  const original = this._source;
114
- const isLeft = this._direction === "left";
115
117
  if (hop > 0) {
116
118
  // For hops greater than 0, the source becomes the target of the previous hop
117
119
  this._source = this._target;
@@ -127,28 +129,26 @@ class Relationship extends ASTNode {
127
129
  await this._target.find(left_id, hop);
128
130
  }
129
131
  }
130
- const findMatch = isLeft
131
- ? (id: string, h: number) => this._data!.findReverse(id, h)
132
- : (id: string, h: number) => this._data!.find(id, h);
133
- const followId = isLeft ? "left_id" : "right_id";
134
- while (findMatch(left_id, hop)) {
132
+ while (this._data!.find(left_id, hop, this._direction)) {
135
133
  const data: RelationshipRecord = this._data?.current(hop) as RelationshipRecord;
134
+ const id = data[this._left_id_or_right_id()];
136
135
  if (hop + 1 >= this.hops!.min) {
137
- this.setValue(this);
136
+ this.setValue(this, left_id);
138
137
  if (!this._matchesProperties(hop)) {
139
138
  continue;
140
139
  }
141
- await this._target?.find(data[followId], hop);
142
- if (this._matches.isCircular()) {
143
- throw new Error("Circular relationship detected");
144
- }
140
+ await this._target?.find(id, hop);
145
141
  if (hop + 1 < this.hops!.max) {
146
- await this.find(data[followId], hop + 1);
142
+ if (this._matches.isCircular(id)) {
143
+ this._matches.pop();
144
+ continue;
145
+ }
146
+ await this.find(id, hop + 1);
147
147
  }
148
148
  this._matches.pop();
149
149
  } else {
150
150
  // Below minimum hops: traverse the edge without yielding a match
151
- await this.find(data[followId], hop + 1);
151
+ await this.find(id, hop + 1);
152
152
  }
153
153
  }
154
154
  // Restore original source node
@@ -8,11 +8,8 @@ class RelationshipData extends Data {
8
8
  super._buildIndex("left_id");
9
9
  super._buildIndex("right_id");
10
10
  }
11
- public find(left_id: string, hop: number = 0): boolean {
12
- return super._find(left_id, hop, "left_id");
13
- }
14
- public findReverse(right_id: string, hop: number = 0): boolean {
15
- return super._find(right_id, hop, "right_id");
11
+ public find(id: string, hop: number = 0, direction: "left" | "right" = "right"): boolean {
12
+ return super._find(id, hop, direction === "left" ? "right_id" : "left_id");
16
13
  }
17
14
  /*
18
15
  ** Get the properties of the current relationship record
@@ -11,7 +11,7 @@ class RelationshipMatchCollector {
11
11
  private _matches: RelationshipMatchRecord[] = [];
12
12
  private _nodeIds: Array<string> = [];
13
13
 
14
- public push(relationship: Relationship): RelationshipMatchRecord {
14
+ public push(relationship: Relationship, traversalId: string): RelationshipMatchRecord {
15
15
  const match: RelationshipMatchRecord = {
16
16
  type: relationship.type!,
17
17
  startNode: relationship.source?.value() || {},
@@ -19,7 +19,7 @@ class RelationshipMatchCollector {
19
19
  properties: relationship.getData()?.properties() as Record<string, any>,
20
20
  };
21
21
  this._matches.push(match);
22
- this._nodeIds.push(match.startNode.id);
22
+ this._nodeIds.push(traversalId);
23
23
  return match;
24
24
  }
25
25
  public set endNode(node: any) {
@@ -46,12 +46,11 @@ class RelationshipMatchCollector {
46
46
  return this._matches;
47
47
  }
48
48
  /*
49
- ** Checks if the collected relationships form a circular pattern
50
- ** meaning the same node id occur more than 2 times in the collected matches
49
+ ** Checks if traversing to the given node id would form a cycle
50
+ ** in the current traversal path
51
51
  */
52
- public isCircular(): boolean {
53
- const seen = new Set(this._nodeIds);
54
- return seen.size < this._nodeIds.length;
52
+ public isCircular(nextId: string): boolean {
53
+ return this._nodeIds.includes(nextId);
55
54
  }
56
55
  }
57
56
 
@@ -467,7 +467,10 @@ class Parser extends BaseParser {
467
467
  reference = inner;
468
468
  }
469
469
  }
470
- if (reference === undefined || !(reference instanceof Node)) {
470
+ if (
471
+ reference === undefined ||
472
+ (!(reference instanceof Node) && !(reference instanceof Unwind))
473
+ ) {
471
474
  throw new Error(`Undefined node reference: ${identifier}`);
472
475
  }
473
476
  node = new NodeReference(node, reference);
@@ -798,12 +801,7 @@ class Parser extends BaseParser {
798
801
  expression.addNode(lookup);
799
802
  return true;
800
803
  }
801
- } else if (
802
- this.token.isLeftParenthesis() &&
803
- (this.peek()?.isIdentifierOrKeyword() ||
804
- this.peek()?.isColon() ||
805
- this.peek()?.isRightParenthesis())
806
- ) {
804
+ } else if (this.token.isLeftParenthesis() && this.looksLikeNodePattern()) {
807
805
  // Possible graph pattern expression
808
806
  const pattern = this.parsePatternExpression();
809
807
  if (pattern !== null) {
@@ -865,6 +863,42 @@ class Parser extends BaseParser {
865
863
  return false;
866
864
  }
867
865
 
866
+ /**
867
+ * Peeks ahead from a left parenthesis to determine whether the
868
+ * upcoming tokens form a graph-node pattern (e.g. (n:Label), (n),
869
+ * (:Label), ()) rather than a parenthesised expression (e.g.
870
+ * (variable.property), (a + b)).
871
+ *
872
+ * The heuristic is:
873
+ * • ( followed by `:` or `)` → node pattern
874
+ * • ( identifier, then `:` or `{` or `)` → node pattern
875
+ * • anything else → parenthesised expression
876
+ */
877
+ private looksLikeNodePattern(): boolean {
878
+ const savedIndex = this.tokenIndex;
879
+ this.setNextToken(); // skip '('
880
+ this.skipWhitespaceAndComments();
881
+
882
+ if (this.token.isColon() || this.token.isRightParenthesis()) {
883
+ this.tokenIndex = savedIndex;
884
+ return true;
885
+ }
886
+
887
+ if (this.token.isIdentifierOrKeyword()) {
888
+ this.setNextToken(); // skip identifier
889
+ this.skipWhitespaceAndComments();
890
+ const result =
891
+ this.token.isColon() ||
892
+ this.token.isOpeningBrace() ||
893
+ this.token.isRightParenthesis();
894
+ this.tokenIndex = savedIndex;
895
+ return result;
896
+ }
897
+
898
+ this.tokenIndex = savedIndex;
899
+ return false;
900
+ }
901
+
868
902
  private parseExpression(): Expression | null {
869
903
  const expression = new Expression();
870
904
  while (true) {
@@ -1138,7 +1138,7 @@ test("Test circular graph pattern", async () => {
1138
1138
  expect(results[0].pattern[4].id).toBe(1);
1139
1139
  });
1140
1140
 
1141
- test("Test circular graph pattern with variable length should throw error", async () => {
1141
+ test("Test circular graph pattern with variable length should not revisit nodes", async () => {
1142
1142
  await new Runner(`
1143
1143
  CREATE VIRTUAL (:Person) AS {
1144
1144
  unwind [
@@ -1161,9 +1161,10 @@ test("Test circular graph pattern with variable length should throw error", asyn
1161
1161
  MATCH p=(:Person)-[:KNOWS*]-(:Person)
1162
1162
  RETURN p AS pattern
1163
1163
  `);
1164
- await expect(async () => {
1165
- await match.run();
1166
- }).rejects.toThrow("Circular relationship detected");
1164
+ await match.run();
1165
+ const results = match.results;
1166
+ // Circular graph 1↔2: cycles are skipped, only acyclic paths are returned
1167
+ expect(results.length).toBe(6);
1167
1168
  });
1168
1169
 
1169
1170
  test("Test multi-hop match with min hops constraint *1..", async () => {
@@ -2064,3 +2065,107 @@ test("Test WHERE with CONTAINS combined with AND", async () => {
2064
2065
  expect(results.length).toBe(1);
2065
2066
  expect(results[0].fruit).toBe("pineapple");
2066
2067
  });
2068
+
2069
+ test("Test collected nodes and re-matching", async () => {
2070
+ await new Runner(`
2071
+ CREATE VIRTUAL (:Person) AS {
2072
+ unwind [
2073
+ {id: 1, name: 'Person 1'},
2074
+ {id: 2, name: 'Person 2'},
2075
+ {id: 3, name: 'Person 3'},
2076
+ {id: 4, name: 'Person 4'}
2077
+ ] as record
2078
+ RETURN record.id as id, record.name as name
2079
+ }
2080
+ `).run();
2081
+ await new Runner(`
2082
+ CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
2083
+ unwind [
2084
+ {left_id: 1, right_id: 2},
2085
+ {left_id: 2, right_id: 3},
2086
+ {left_id: 3, right_id: 4}
2087
+ ] as record
2088
+ RETURN record.left_id as left_id, record.right_id as right_id
2089
+ }
2090
+ `).run();
2091
+ const match = new Runner(`
2092
+ MATCH (a:Person)-[:KNOWS*0..3]->(b:Person)
2093
+ WITH collect(a) AS persons, b
2094
+ UNWIND persons AS p
2095
+ match (p)-[:KNOWS]->(:Person)
2096
+ return p.name AS name
2097
+ `);
2098
+ await match.run();
2099
+ const results = match.results;
2100
+ expect(results.length).toBe(9);
2101
+ const names = results.map((r: any) => r.name);
2102
+ expect(names).toContain("Person 1");
2103
+ expect(names).toContain("Person 2");
2104
+ expect(names).toContain("Person 3");
2105
+ });
2106
+
2107
+ test("Test collected patterns and unwind", async () => {
2108
+ await new Runner(`
2109
+ CREATE VIRTUAL (:Person) AS {
2110
+ unwind [
2111
+ {id: 1, name: 'Person 1'},
2112
+ {id: 2, name: 'Person 2'},
2113
+ {id: 3, name: 'Person 3'},
2114
+ {id: 4, name: 'Person 4'}
2115
+ ] as record
2116
+ RETURN record.id as id, record.name as name
2117
+ }
2118
+ `).run();
2119
+ await new Runner(`
2120
+ CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
2121
+ unwind [
2122
+ {left_id: 1, right_id: 2},
2123
+ {left_id: 2, right_id: 3},
2124
+ {left_id: 3, right_id: 4}
2125
+ ] as record
2126
+ RETURN record.left_id as left_id, record.right_id as right_id
2127
+ }
2128
+ `).run();
2129
+ const match = new Runner(`
2130
+ MATCH p=(a:Person)-[:KNOWS*0..3]->(b:Person)
2131
+ WITH collect(p) AS patterns
2132
+ UNWIND patterns AS pattern
2133
+ RETURN pattern
2134
+ `);
2135
+ await match.run();
2136
+ const results = match.results;
2137
+ expect(results.length).toBe(10);
2138
+ // Index 0: Person 1 zero-hop - pattern = [node1] (single node)
2139
+ expect(results[0].pattern.length).toBe(1);
2140
+ expect(results[0].pattern[0].id).toBe(1);
2141
+
2142
+ // Index 1: Person 1 -> Person 2 (1-hop)
2143
+ expect(results[1].pattern.length).toBe(3);
2144
+
2145
+ // Index 2: Person 1 -> Person 2 -> Person 3 (2-hop)
2146
+ expect(results[2].pattern.length).toBe(5);
2147
+
2148
+ // Index 3: Person 1 -> Person 2 -> Person 3 -> Person 4 (3-hop)
2149
+ expect(results[3].pattern.length).toBe(7);
2150
+
2151
+ // Index 4: Person 2 zero-hop - pattern = [node2]
2152
+ expect(results[4].pattern.length).toBe(1);
2153
+ expect(results[4].pattern[0].id).toBe(2);
2154
+
2155
+ // Index 5: Person 2 -> Person 3 (1-hop)
2156
+ expect(results[5].pattern.length).toBe(3);
2157
+
2158
+ // Index 6: Person 2 -> Person 3 -> Person 4 (2-hop)
2159
+ expect(results[6].pattern.length).toBe(5);
2160
+
2161
+ // Index 7: Person 3 zero-hop - pattern = [node3]
2162
+ expect(results[7].pattern.length).toBe(1);
2163
+ expect(results[7].pattern[0].id).toBe(3);
2164
+
2165
+ // Index 8: Person 3 -> Person 4 (1-hop)
2166
+ expect(results[8].pattern.length).toBe(3);
2167
+
2168
+ // Index 9: Person 4 zero-hop - pattern = [node4]
2169
+ expect(results[9].pattern.length).toBe(1);
2170
+ expect(results[9].pattern[0].id).toBe(4);
2171
+ });
@@ -839,7 +839,7 @@ test("Parse statement with graph pattern in where clause", () => {
839
839
  const source = pattern.chain[0] as NodeReference;
840
840
  const relationship = pattern.chain[1] as Relationship;
841
841
  const target = pattern.chain[2] as Node;
842
- expect(source.reference?.identifier).toBe("a");
842
+ expect((source.reference as Node)?.identifier).toBe("a");
843
843
  expect(relationship.type).toBe("KNOWS");
844
844
  expect(target.label).toBe("Person");
845
845
  });
@@ -909,7 +909,7 @@ test("Test node reference with label creates NodeReference instead of new node",
909
909
  expect(firstNode.identifier).toBe("n");
910
910
  expect(firstNode.label).toBe("Person");
911
911
  expect(secondNode).toBeInstanceOf(NodeReference);
912
- expect(secondNode.reference!.identifier).toBe("n");
912
+ expect((secondNode.reference! as Node).identifier).toBe("n");
913
913
  expect(secondNode.label).toBe("Person");
914
914
  });
915
915
 
@@ -1098,3 +1098,61 @@ test("Test WHERE with NOT ENDS WITH", () => {
1098
1098
  "--- Reference (s)"
1099
1099
  );
1100
1100
  });
1101
+
1102
+ test("Test parenthesized expression with addition", () => {
1103
+ const parser = new Parser();
1104
+ const ast = parser.parse("WITH 1 AS n RETURN (n + 2)");
1105
+ // prettier-ignore
1106
+ expect(ast.print()).toBe(
1107
+ "ASTNode\n" +
1108
+ "- With\n" +
1109
+ "-- Expression (n)\n" +
1110
+ "--- Number (1)\n" +
1111
+ "- Return\n" +
1112
+ "-- Expression\n" +
1113
+ "--- Expression\n" +
1114
+ "---- Add\n" +
1115
+ "----- Reference (n)\n" +
1116
+ "----- Number (2)"
1117
+ );
1118
+ });
1119
+
1120
+ test("Test parenthesized expression with property access", () => {
1121
+ const parser = new Parser();
1122
+ const ast = parser.parse("WITH {a: 1} AS obj RETURN (obj.a)");
1123
+ // prettier-ignore
1124
+ expect(ast.print()).toBe(
1125
+ "ASTNode\n" +
1126
+ "- With\n" +
1127
+ "-- Expression (obj)\n" +
1128
+ "--- AssociativeArray\n" +
1129
+ "---- KeyValuePair\n" +
1130
+ "----- String (a)\n" +
1131
+ "----- Expression\n" +
1132
+ "------ Number (1)\n" +
1133
+ "- Return\n" +
1134
+ "-- Expression\n" +
1135
+ "--- Expression\n" +
1136
+ "---- Lookup\n" +
1137
+ "----- Identifier (a)\n" +
1138
+ "----- Reference (obj)"
1139
+ );
1140
+ });
1141
+
1142
+ test("Test parenthesized expression with multiplication", () => {
1143
+ const parser = new Parser();
1144
+ const ast = parser.parse("WITH 5 AS x RETURN (x * 3)");
1145
+ // prettier-ignore
1146
+ expect(ast.print()).toBe(
1147
+ "ASTNode\n" +
1148
+ "- With\n" +
1149
+ "-- Expression (x)\n" +
1150
+ "--- Number (5)\n" +
1151
+ "- Return\n" +
1152
+ "-- Expression\n" +
1153
+ "--- Expression\n" +
1154
+ "---- Multiply\n" +
1155
+ "----- Reference (x)\n" +
1156
+ "----- Number (3)"
1157
+ );
1158
+ });