flowquery 1.0.34 → 1.0.35

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 (66) hide show
  1. package/dist/flowquery.min.js +1 -1
  2. package/dist/graph/database.d.ts +1 -0
  3. package/dist/graph/database.d.ts.map +1 -1
  4. package/dist/graph/database.js +43 -6
  5. package/dist/graph/database.js.map +1 -1
  6. package/dist/graph/relationship.d.ts +3 -1
  7. package/dist/graph/relationship.d.ts.map +1 -1
  8. package/dist/graph/relationship.js +12 -4
  9. package/dist/graph/relationship.js.map +1 -1
  10. package/dist/graph/relationship_data.js +1 -1
  11. package/dist/graph/relationship_data.js.map +1 -1
  12. package/dist/graph/relationship_match_collector.d.ts.map +1 -1
  13. package/dist/graph/relationship_match_collector.js +6 -3
  14. package/dist/graph/relationship_match_collector.js.map +1 -1
  15. package/dist/graph/relationship_reference.js +1 -1
  16. package/dist/graph/relationship_reference.js.map +1 -1
  17. package/dist/parsing/functions/function_factory.d.ts +1 -0
  18. package/dist/parsing/functions/function_factory.d.ts.map +1 -1
  19. package/dist/parsing/functions/function_factory.js +1 -0
  20. package/dist/parsing/functions/function_factory.js.map +1 -1
  21. package/dist/parsing/functions/predicate_sum.d.ts.map +1 -1
  22. package/dist/parsing/functions/predicate_sum.js +13 -10
  23. package/dist/parsing/functions/predicate_sum.js.map +1 -1
  24. package/dist/parsing/functions/schema.d.ts +5 -2
  25. package/dist/parsing/functions/schema.d.ts.map +1 -1
  26. package/dist/parsing/functions/schema.js +7 -4
  27. package/dist/parsing/functions/schema.js.map +1 -1
  28. package/dist/parsing/functions/trim.d.ts +7 -0
  29. package/dist/parsing/functions/trim.d.ts.map +1 -0
  30. package/dist/parsing/functions/trim.js +37 -0
  31. package/dist/parsing/functions/trim.js.map +1 -0
  32. package/dist/parsing/operations/group_by.d.ts.map +1 -1
  33. package/dist/parsing/operations/group_by.js +4 -2
  34. package/dist/parsing/operations/group_by.js.map +1 -1
  35. package/dist/parsing/parser.d.ts.map +1 -1
  36. package/dist/parsing/parser.js +15 -2
  37. package/dist/parsing/parser.js.map +1 -1
  38. package/docs/flowquery.min.js +1 -1
  39. package/flowquery-py/pyproject.toml +1 -1
  40. package/flowquery-py/src/graph/database.py +44 -11
  41. package/flowquery-py/src/graph/relationship.py +11 -3
  42. package/flowquery-py/src/graph/relationship_data.py +2 -1
  43. package/flowquery-py/src/graph/relationship_match_collector.py +7 -1
  44. package/flowquery-py/src/graph/relationship_reference.py +2 -2
  45. package/flowquery-py/src/parsing/functions/__init__.py +2 -0
  46. package/flowquery-py/src/parsing/functions/predicate_sum.py +3 -6
  47. package/flowquery-py/src/parsing/functions/schema.py +9 -5
  48. package/flowquery-py/src/parsing/functions/trim.py +35 -0
  49. package/flowquery-py/src/parsing/operations/group_by.py +2 -0
  50. package/flowquery-py/src/parsing/parser.py +12 -2
  51. package/flowquery-py/tests/compute/test_runner.py +249 -4
  52. package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
  53. package/package.json +1 -1
  54. package/src/graph/database.ts +42 -4
  55. package/src/graph/relationship.ts +12 -4
  56. package/src/graph/relationship_data.ts +1 -1
  57. package/src/graph/relationship_match_collector.ts +6 -2
  58. package/src/graph/relationship_reference.ts +1 -1
  59. package/src/parsing/functions/function_factory.ts +1 -0
  60. package/src/parsing/functions/predicate_sum.ts +17 -12
  61. package/src/parsing/functions/schema.ts +7 -4
  62. package/src/parsing/functions/trim.ts +25 -0
  63. package/src/parsing/operations/group_by.ts +4 -1
  64. package/src/parsing/parser.ts +15 -2
  65. package/tests/compute/runner.test.ts +279 -3
  66. package/tests/parsing/parser.test.ts +37 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowquery",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
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",
@@ -34,20 +34,34 @@ class Database {
34
34
  }
35
35
  const physical = new PhysicalRelationship(null, relationship.type);
36
36
  physical.statement = statement;
37
+ physical.source = relationship.source;
38
+ physical.target = relationship.target;
37
39
  Database.relationships.set(relationship.type, physical);
38
40
  }
39
41
  public getRelationship(relationship: Relationship): PhysicalRelationship | null {
40
42
  return Database.relationships.get(relationship.type!) || null;
41
43
  }
44
+ public getRelationships(relationship: Relationship): PhysicalRelationship[] {
45
+ const result: PhysicalRelationship[] = [];
46
+ for (const type of relationship.types) {
47
+ const physical = Database.relationships.get(type);
48
+ if (physical) {
49
+ result.push(physical);
50
+ }
51
+ }
52
+ return result;
53
+ }
42
54
  public async schema(): Promise<Record<string, any>[]> {
43
55
  const result: Record<string, any>[] = [];
44
56
 
45
57
  for (const [label, physical] of Database.nodes) {
46
58
  const records = await physical.data();
47
- const entry: Record<string, any> = { kind: "node", label };
59
+ const entry: Record<string, any> = { kind: "Node", label };
48
60
  if (records.length > 0) {
49
61
  const { id, ...sample } = records[0];
50
- if (Object.keys(sample).length > 0) {
62
+ const properties = Object.keys(sample);
63
+ if (properties.length > 0) {
64
+ entry.properties = properties;
51
65
  entry.sample = sample;
52
66
  }
53
67
  }
@@ -56,10 +70,17 @@ class Database {
56
70
 
57
71
  for (const [type, physical] of Database.relationships) {
58
72
  const records = await physical.data();
59
- const entry: Record<string, any> = { kind: "relationship", type };
73
+ const entry: Record<string, any> = {
74
+ kind: "Relationship",
75
+ type,
76
+ from_label: physical.source?.label || null,
77
+ to_label: physical.target?.label || null,
78
+ };
60
79
  if (records.length > 0) {
61
80
  const { left_id, right_id, ...sample } = records[0];
62
- if (Object.keys(sample).length > 0) {
81
+ const properties = Object.keys(sample);
82
+ if (properties.length > 0) {
83
+ entry.properties = properties;
63
84
  entry.sample = sample;
64
85
  }
65
86
  }
@@ -78,6 +99,23 @@ class Database {
78
99
  const data = await node.data();
79
100
  return new NodeData(data as NodeRecord[]);
80
101
  } else if (element instanceof Relationship) {
102
+ if (element.types.length > 1) {
103
+ const physicals = this.getRelationships(element);
104
+ if (physicals.length === 0) {
105
+ throw new Error(
106
+ `No physical relationships found for types ${element.types.join(", ")}`
107
+ );
108
+ }
109
+ const allRecords: RelationshipRecord[] = [];
110
+ for (let i = 0; i < physicals.length; i++) {
111
+ const records = (await physicals[i].data()) as RelationshipRecord[];
112
+ const typeName = element.types[i];
113
+ for (const record of records) {
114
+ allRecords.push({ ...record, _type: typeName });
115
+ }
116
+ }
117
+ return new RelationshipData(allRecords);
118
+ }
81
119
  const relationship = this.getRelationship(element);
82
120
  if (relationship === null) {
83
121
  throw new Error(`Physical relationship not found for type ${element.type}`);
@@ -9,7 +9,7 @@ import RelationshipMatchCollector, {
9
9
 
10
10
  class Relationship extends ASTNode {
11
11
  protected _identifier: string | null = null;
12
- protected _type: string | null = null;
12
+ protected _types: string[] = [];
13
13
  protected _properties: Map<string, Expression> = new Map();
14
14
  protected _hops: Hops = new Hops();
15
15
 
@@ -25,7 +25,9 @@ class Relationship extends ASTNode {
25
25
  constructor(identifier: string | null = null, type: string | null = null) {
26
26
  super();
27
27
  this._identifier = identifier;
28
- this._type = type;
28
+ if (type !== null) {
29
+ this._types = [type];
30
+ }
29
31
  }
30
32
  public set identifier(identifier: string) {
31
33
  this._identifier = identifier;
@@ -34,10 +36,16 @@ class Relationship extends ASTNode {
34
36
  return this._identifier;
35
37
  }
36
38
  public set type(type: string) {
37
- this._type = type;
39
+ this._types = [type];
38
40
  }
39
41
  public get type(): string | null {
40
- return this._type;
42
+ return this._types.length > 0 ? this._types[0] : null;
43
+ }
44
+ public set types(types: string[]) {
45
+ this._types = types;
46
+ }
47
+ public get types(): string[] {
48
+ return this._types;
41
49
  }
42
50
  public get properties(): Map<string, Expression> {
43
51
  return this._properties;
@@ -18,7 +18,7 @@ class RelationshipData extends Data {
18
18
  public properties(): Record<string, any> | null {
19
19
  const current = this.current();
20
20
  if (current) {
21
- const { left_id, right_id, ...props } = current;
21
+ const { left_id, right_id, _type, ...props } = current;
22
22
  return props;
23
23
  }
24
24
  return null;
@@ -12,11 +12,15 @@ class RelationshipMatchCollector {
12
12
  private _nodeIds: Array<string> = [];
13
13
 
14
14
  public push(relationship: Relationship, traversalId: string): RelationshipMatchRecord {
15
+ const data = relationship.getData();
16
+ const currentRecord = data?.current();
17
+ const actualType =
18
+ currentRecord && "_type" in currentRecord ? currentRecord["_type"] : relationship.type!;
15
19
  const match: RelationshipMatchRecord = {
16
- type: relationship.type!,
20
+ type: actualType,
17
21
  startNode: relationship.source?.value() || {},
18
22
  endNode: null,
19
- properties: relationship.getData()?.properties() as Record<string, any>,
23
+ properties: data?.properties() as Record<string, any>,
20
24
  };
21
25
  this._matches.push(match);
22
26
  this._nodeIds.push(traversalId);
@@ -6,7 +6,7 @@ class RelationshipReference extends Relationship {
6
6
  constructor(base: Relationship, reference: Relationship) {
7
7
  super();
8
8
  this._identifier = base.identifier;
9
- this._type = base.type;
9
+ this._types = base.types;
10
10
  this._hops = base.hops!;
11
11
  this._source = base.source;
12
12
  this._target = base.target;
@@ -29,6 +29,7 @@ import "./sum";
29
29
  import "./to_json";
30
30
  import "./to_lower";
31
31
  import "./to_string";
32
+ import "./trim";
32
33
  import "./type";
33
34
 
34
35
  // Re-export AsyncDataProvider for backwards compatibility
@@ -1,17 +1,26 @@
1
- import PredicateFunction from "./predicate_function";
2
1
  import { FunctionDef } from "./function_metadata";
2
+ import PredicateFunction from "./predicate_function";
3
3
 
4
4
  @FunctionDef({
5
- description: "Calculates the sum of values in an array with optional filtering. Uses list comprehension syntax: sum(variable IN array [WHERE condition] | expression)",
5
+ description:
6
+ "Calculates the sum of values in an array with optional filtering. Uses list comprehension syntax: sum(variable IN array [WHERE condition] | expression)",
6
7
  category: "predicate",
7
8
  parameters: [
8
9
  { name: "variable", description: "Variable name to bind each element", type: "string" },
9
10
  { name: "array", description: "Array to iterate over", type: "array" },
10
11
  { name: "expression", description: "Expression to sum for each element", type: "any" },
11
- { name: "where", description: "Optional filter condition", type: "boolean", required: false }
12
+ {
13
+ name: "where",
14
+ description: "Optional filter condition",
15
+ type: "boolean",
16
+ required: false,
17
+ },
12
18
  ],
13
19
  output: { description: "Sum of the evaluated expressions", type: "number", example: 6 },
14
- examples: ["WITH [1, 2, 3] AS nums RETURN sum(n IN nums | n)", "WITH [1, 2, 3, 4] AS nums RETURN sum(n IN nums WHERE n > 1 | n * 2)"]
20
+ examples: [
21
+ "WITH [1, 2, 3] AS nums RETURN sum(n IN nums | n)",
22
+ "WITH [1, 2, 3, 4] AS nums RETURN sum(n IN nums WHERE n > 1 | n * 2)",
23
+ ],
15
24
  })
16
25
  class PredicateSum extends PredicateFunction {
17
26
  constructor() {
@@ -24,19 +33,15 @@ class PredicateSum extends PredicateFunction {
24
33
  if (array === null || !Array.isArray(array)) {
25
34
  throw new Error("Invalid array for sum function");
26
35
  }
27
- let _sum: any | null = null;
28
- for(let i = 0; i < array.length; i++) {
36
+ let _sum: number = 0;
37
+ for (let i = 0; i < array.length; i++) {
29
38
  this._valueHolder.holder = array[i];
30
39
  if (this.where === null || this.where.value()) {
31
- if (_sum === null) {
32
- _sum = this._return.value();
33
- } else {
34
- _sum += this._return.value();
35
- }
40
+ _sum += this._return.value();
36
41
  }
37
42
  }
38
43
  return _sum;
39
44
  }
40
45
  }
41
46
 
42
- export default PredicateSum;
47
+ export default PredicateSum;
@@ -5,8 +5,11 @@ import { FunctionDef } from "./function_metadata";
5
5
  /**
6
6
  * Built-in function that returns the graph schema of the database.
7
7
  *
8
- * Lists all nodes and relationships with their labels/types and a sample
9
- * of their data (excluding id from nodes, left_id and right_id from relationships).
8
+ * Lists all nodes and relationships with their labels/types, properties,
9
+ * and a sample of their data (excluding id from nodes, left_id and right_id from relationships).
10
+ *
11
+ * Nodes: {label, properties, sample}
12
+ * Relationships: {type, from_label, to_label, properties, sample}
10
13
  *
11
14
  * @example
12
15
  * ```
@@ -15,11 +18,11 @@ import { FunctionDef } from "./function_metadata";
15
18
  */
16
19
  @FunctionDef({
17
20
  description:
18
- "Returns the graph schema listing all nodes and relationships with a sample of their data.",
21
+ "Returns the graph schema listing all nodes and relationships with their properties and a sample of their data.",
19
22
  category: "async",
20
23
  parameters: [],
21
24
  output: {
22
- description: "Schema entry with kind, label/type, and optional sample data",
25
+ description: "Schema entry with label/type, properties, and optional sample data",
23
26
  type: "object",
24
27
  },
25
28
  examples: ["LOAD FROM schema() AS s RETURN s"],
@@ -0,0 +1,25 @@
1
+ import Function from "./function";
2
+ import { FunctionDef } from "./function_metadata";
3
+
4
+ @FunctionDef({
5
+ description: "Removes leading and trailing whitespace from a string",
6
+ category: "scalar",
7
+ parameters: [{ name: "text", description: "String to trim", type: "string" }],
8
+ output: { description: "Trimmed string", type: "string", example: "hello" },
9
+ examples: ["WITH ' hello ' AS s RETURN trim(s)", "WITH '\\tfoo\\n' AS s RETURN trim(s)"],
10
+ })
11
+ class Trim extends Function {
12
+ constructor() {
13
+ super("trim");
14
+ this._expectedParameterCount = 1;
15
+ }
16
+ public value(): any {
17
+ const val = this.getChildren()[0].value();
18
+ if (typeof val !== "string") {
19
+ throw new Error("Invalid argument for trim function: expected a string");
20
+ }
21
+ return val.trim();
22
+ }
23
+ }
24
+
25
+ export default Trim;
@@ -106,7 +106,10 @@ class GroupBy extends Projection {
106
106
  yield* this.generate_results(mapperIndex + 1, child);
107
107
  }
108
108
  } else {
109
- node.elements?.forEach((element, reducerIndex) => {
109
+ if (node.elements === null) {
110
+ node.elements = this.reducers.map((reducer) => reducer.element());
111
+ }
112
+ node.elements.forEach((element, reducerIndex) => {
110
113
  this.reducers[reducerIndex].overridden = element.value;
111
114
  });
112
115
  const record: Record<string, any> = {};
@@ -420,6 +420,8 @@ class Parser extends BaseParser {
420
420
  }
421
421
  relationship = new Relationship();
422
422
  relationship.type = type;
423
+ relationship.source = node;
424
+ relationship.target = target;
423
425
  }
424
426
  this.expectAndSkipWhitespaceAndComments();
425
427
  if (!this.token.isAs()) {
@@ -673,8 +675,19 @@ class Parser extends BaseParser {
673
675
  if (!this.token.isIdentifierOrKeyword()) {
674
676
  throw new Error("Expected relationship type identifier");
675
677
  }
676
- const type: string = this.token.value || "";
678
+ const types: string[] = [this.token.value || ""];
677
679
  this.setNextToken();
680
+ while (this.token.isPipe()) {
681
+ this.setNextToken();
682
+ if (this.token.isColon()) {
683
+ this.setNextToken();
684
+ }
685
+ if (!this.token.isIdentifierOrKeyword()) {
686
+ throw new Error("Expected relationship type identifier after '|'");
687
+ }
688
+ types.push(this.token.value || "");
689
+ this.setNextToken();
690
+ }
678
691
  const hops: Hops | null = this.parseRelationshipHops();
679
692
  const properties: Map<string, Expression> = new Map(this.parseProperties());
680
693
  if (!this.token.isClosingBracket()) {
@@ -711,7 +724,7 @@ class Parser extends BaseParser {
711
724
  if (hops !== null) {
712
725
  relationship.hops = hops;
713
726
  }
714
- relationship.type = type;
727
+ relationship.types = types;
715
728
  return relationship;
716
729
  }
717
730
 
@@ -613,6 +613,38 @@ test("Test toLower function with all uppercase", async () => {
613
613
  expect(results[0]).toEqual({ result: "foo bar" });
614
614
  });
615
615
 
616
+ test("Test trim function", async () => {
617
+ const runner = new Runner('RETURN trim(" hello ") as result');
618
+ await runner.run();
619
+ const results = runner.results;
620
+ expect(results.length).toBe(1);
621
+ expect(results[0]).toEqual({ result: "hello" });
622
+ });
623
+
624
+ test("Test trim function with tabs and newlines", async () => {
625
+ const runner = new Runner('WITH "\tfoo\n" AS s RETURN trim(s) as result');
626
+ await runner.run();
627
+ const results = runner.results;
628
+ expect(results.length).toBe(1);
629
+ expect(results[0]).toEqual({ result: "foo" });
630
+ });
631
+
632
+ test("Test trim function with no whitespace", async () => {
633
+ const runner = new Runner('RETURN trim("hello") as result');
634
+ await runner.run();
635
+ const results = runner.results;
636
+ expect(results.length).toBe(1);
637
+ expect(results[0]).toEqual({ result: "hello" });
638
+ });
639
+
640
+ test("Test trim function with empty string", async () => {
641
+ const runner = new Runner('RETURN trim("") as result');
642
+ await runner.run();
643
+ const results = runner.results;
644
+ expect(results.length).toBe(1);
645
+ expect(results[0]).toEqual({ result: "" });
646
+ });
647
+
616
648
  test("Test associative array with key which is keyword", async () => {
617
649
  const runner = new Runner("RETURN {return: 1} as aa");
618
650
  await runner.run();
@@ -1964,20 +1996,24 @@ test("Test schema() returns nodes and relationships with sample data", async ()
1964
1996
  `).run();
1965
1997
 
1966
1998
  const runner = new Runner(
1967
- "CALL schema() YIELD kind, label, type, sample RETURN kind, label, type, sample"
1999
+ "CALL schema() YIELD kind, label, type, from_label, to_label, properties, sample RETURN kind, label, type, from_label, to_label, properties, sample"
1968
2000
  );
1969
2001
  await runner.run();
1970
2002
  const results = runner.results;
1971
2003
 
1972
- const animal = results.find((r: any) => r.kind === "node" && r.label === "Animal");
2004
+ const animal = results.find((r: any) => r.kind === "Node" && r.label === "Animal");
1973
2005
  expect(animal).toBeDefined();
2006
+ expect(animal.properties).toEqual(["species", "legs"]);
1974
2007
  expect(animal.sample).toBeDefined();
1975
2008
  expect(animal.sample).not.toHaveProperty("id");
1976
2009
  expect(animal.sample).toHaveProperty("species");
1977
2010
  expect(animal.sample).toHaveProperty("legs");
1978
2011
 
1979
- const chases = results.find((r: any) => r.kind === "relationship" && r.type === "CHASES");
2012
+ const chases = results.find((r: any) => r.kind === "Relationship" && r.type === "CHASES");
1980
2013
  expect(chases).toBeDefined();
2014
+ expect(chases.from_label).toBe("Animal");
2015
+ expect(chases.to_label).toBe("Animal");
2016
+ expect(chases.properties).toEqual(["speed"]);
1981
2017
  expect(chases.sample).toBeDefined();
1982
2018
  expect(chases.sample).not.toHaveProperty("left_id");
1983
2019
  expect(chases.sample).not.toHaveProperty("right_id");
@@ -2690,3 +2726,243 @@ test("Test UNION with empty right side", async () => {
2690
2726
  expect(results.length).toBe(1);
2691
2727
  expect(results).toEqual([{ x: 1 }]);
2692
2728
  });
2729
+
2730
+ test("Test language name hits query with virtual graph", async () => {
2731
+ // Create Language nodes
2732
+ await new Runner(`
2733
+ CREATE VIRTUAL (:Language) AS {
2734
+ UNWIND [
2735
+ {id: 1, name: 'Python'},
2736
+ {id: 2, name: 'JavaScript'},
2737
+ {id: 3, name: 'TypeScript'}
2738
+ ] AS record
2739
+ RETURN record.id AS id, record.name AS name
2740
+ }
2741
+ `).run();
2742
+
2743
+ // Create Chat nodes with messages
2744
+ await new Runner(`
2745
+ CREATE VIRTUAL (:Chat) AS {
2746
+ UNWIND [
2747
+ {id: 1, name: 'Dev Discussion', messages: [
2748
+ {From: 'Alice', SentDateTime: '2025-01-01T10:00:00', Content: 'I love Python and JavaScript'},
2749
+ {From: 'Bob', SentDateTime: '2025-01-01T10:05:00', Content: 'What languages do you prefer?'}
2750
+ ]},
2751
+ {id: 2, name: 'General', messages: [
2752
+ {From: 'Charlie', SentDateTime: '2025-01-02T09:00:00', Content: 'The weather is nice today'},
2753
+ {From: 'Alice', SentDateTime: '2025-01-02T09:05:00', Content: 'TypeScript is great for language tooling'}
2754
+ ]}
2755
+ ] AS record
2756
+ RETURN record.id AS id, record.name AS name, record.messages AS messages
2757
+ }
2758
+ `).run();
2759
+
2760
+ // Create User nodes
2761
+ await new Runner(`
2762
+ CREATE VIRTUAL (:User) AS {
2763
+ UNWIND [
2764
+ {id: 1, displayName: 'Alice'},
2765
+ {id: 2, displayName: 'Bob'},
2766
+ {id: 3, displayName: 'Charlie'}
2767
+ ] AS record
2768
+ RETURN record.id AS id, record.displayName AS displayName
2769
+ }
2770
+ `).run();
2771
+
2772
+ // Create PARTICIPATES_IN relationships
2773
+ await new Runner(`
2774
+ CREATE VIRTUAL (:User)-[:PARTICIPATES_IN]-(:Chat) AS {
2775
+ UNWIND [
2776
+ {left_id: 1, right_id: 1},
2777
+ {left_id: 2, right_id: 1},
2778
+ {left_id: 3, right_id: 2},
2779
+ {left_id: 1, right_id: 2}
2780
+ ] AS record
2781
+ RETURN record.left_id AS left_id, record.right_id AS right_id
2782
+ }
2783
+ `).run();
2784
+
2785
+ // Run the original query (using 'sender' alias since 'from' is a reserved keyword)
2786
+ const runner = new Runner(`
2787
+ MATCH (l:Language)
2788
+ WITH collect(distinct l.name) AS langs
2789
+ MATCH (c:Chat)
2790
+ UNWIND c.messages AS msg
2791
+ WITH c, msg, langs,
2792
+ sum(lang IN langs | 1 where toLower(msg.Content) CONTAINS toLower(lang)) AS langNameHits
2793
+ WHERE toLower(msg.Content) CONTAINS "language"
2794
+ OR toLower(msg.Content) CONTAINS "languages"
2795
+ OR langNameHits > 0
2796
+ OPTIONAL MATCH (u:User)-[:PARTICIPATES_IN]->(c)
2797
+ RETURN
2798
+ c.name AS chat,
2799
+ collect(distinct u.displayName) AS participants,
2800
+ msg.From AS sender,
2801
+ msg.SentDateTime AS sentDateTime,
2802
+ msg.Content AS message
2803
+ `);
2804
+ await runner.run();
2805
+ const results = runner.results;
2806
+
2807
+ // Messages that mention a language name or the word "language(s)":
2808
+ // 1. "I love Python and JavaScript" - langNameHits=2 (matches Python and JavaScript)
2809
+ // 2. "What languages do you prefer?" - contains "languages"
2810
+ // 3. "TypeScript is great for language tooling" - langNameHits=1, also contains "language"
2811
+ expect(results.length).toBe(3);
2812
+ expect(results[0].chat).toBe("Dev Discussion");
2813
+ expect(results[0].message).toBe("I love Python and JavaScript");
2814
+ expect(results[0].sender).toBe("Alice");
2815
+ expect(results[1].chat).toBe("Dev Discussion");
2816
+ expect(results[1].message).toBe("What languages do you prefer?");
2817
+ expect(results[1].sender).toBe("Bob");
2818
+ expect(results[2].chat).toBe("General");
2819
+ expect(results[2].message).toBe("TypeScript is great for language tooling");
2820
+ expect(results[2].sender).toBe("Alice");
2821
+ });
2822
+
2823
+ test("Test sum with empty collected array", async () => {
2824
+ // Reproduces the original bug: collect on empty input should yield []
2825
+ // and sum over that empty array should return 0, not throw
2826
+ const runner = new Runner(`
2827
+ UNWIND [] AS lang
2828
+ WITH collect(distinct lang) AS langs
2829
+ UNWIND ['hello', 'world'] AS msg
2830
+ WITH msg, langs, sum(l IN langs | 1 where toLower(msg) CONTAINS toLower(l)) AS hits
2831
+ RETURN msg, hits
2832
+ `);
2833
+ await runner.run();
2834
+ const results = runner.results;
2835
+ expect(results.length).toBe(2);
2836
+ expect(results[0]).toEqual({ msg: "hello", hits: 0 });
2837
+ expect(results[1]).toEqual({ msg: "world", hits: 0 });
2838
+ });
2839
+
2840
+ test("Test sum where all elements filtered returns 0", async () => {
2841
+ const runner = new Runner("RETURN sum(n in [1, 2, 3] | n where n > 100) as sum");
2842
+ await runner.run();
2843
+ const results = runner.results;
2844
+ expect(results.length).toBe(1);
2845
+ expect(results[0]).toEqual({ sum: 0 });
2846
+ });
2847
+
2848
+ test("Test sum over empty array returns 0", async () => {
2849
+ const runner = new Runner("WITH [] AS arr RETURN sum(n in arr | n) as sum");
2850
+ await runner.run();
2851
+ const results = runner.results;
2852
+ expect(results.length).toBe(1);
2853
+ expect(results[0]).toEqual({ sum: 0 });
2854
+ });
2855
+
2856
+ test("Test match with ORed relationship types", async () => {
2857
+ await new Runner(`
2858
+ CREATE VIRTUAL (:Person) AS {
2859
+ unwind [
2860
+ {id: 1, name: 'Alice'},
2861
+ {id: 2, name: 'Bob'},
2862
+ {id: 3, name: 'Charlie'}
2863
+ ] as record
2864
+ RETURN record.id as id, record.name as name
2865
+ }
2866
+ `).run();
2867
+ await new Runner(`
2868
+ CREATE VIRTUAL (:Person)-[:KNOWS]-(:Person) AS {
2869
+ unwind [
2870
+ {left_id: 1, right_id: 2}
2871
+ ] as record
2872
+ RETURN record.left_id as left_id, record.right_id as right_id
2873
+ }
2874
+ `).run();
2875
+ await new Runner(`
2876
+ CREATE VIRTUAL (:Person)-[:FOLLOWS]-(:Person) AS {
2877
+ unwind [
2878
+ {left_id: 2, right_id: 3}
2879
+ ] as record
2880
+ RETURN record.left_id as left_id, record.right_id as right_id
2881
+ }
2882
+ `).run();
2883
+ const match = new Runner(`
2884
+ MATCH (a:Person)-[:KNOWS|FOLLOWS]->(b:Person)
2885
+ RETURN a.name AS name1, b.name AS name2
2886
+ `);
2887
+ await match.run();
2888
+ const results = match.results;
2889
+ expect(results.length).toBe(2);
2890
+ expect(results[0]).toEqual({ name1: "Alice", name2: "Bob" });
2891
+ expect(results[1]).toEqual({ name1: "Bob", name2: "Charlie" });
2892
+ });
2893
+
2894
+ test("Test match with ORed relationship types with optional colon syntax", async () => {
2895
+ await new Runner(`
2896
+ CREATE VIRTUAL (:Animal) AS {
2897
+ unwind [
2898
+ {id: 1, name: 'Cat'},
2899
+ {id: 2, name: 'Dog'},
2900
+ {id: 3, name: 'Fish'}
2901
+ ] as record
2902
+ RETURN record.id as id, record.name as name
2903
+ }
2904
+ `).run();
2905
+ await new Runner(`
2906
+ CREATE VIRTUAL (:Animal)-[:CHASES]-(:Animal) AS {
2907
+ unwind [
2908
+ {left_id: 1, right_id: 2}
2909
+ ] as record
2910
+ RETURN record.left_id as left_id, record.right_id as right_id
2911
+ }
2912
+ `).run();
2913
+ await new Runner(`
2914
+ CREATE VIRTUAL (:Animal)-[:EATS]-(:Animal) AS {
2915
+ unwind [
2916
+ {left_id: 1, right_id: 3}
2917
+ ] as record
2918
+ RETURN record.left_id as left_id, record.right_id as right_id
2919
+ }
2920
+ `).run();
2921
+ const match = new Runner(`
2922
+ MATCH (a:Animal)-[:CHASES|:EATS]->(b:Animal)
2923
+ RETURN a.name AS name1, b.name AS name2
2924
+ `);
2925
+ await match.run();
2926
+ const results = match.results;
2927
+ expect(results.length).toBe(2);
2928
+ expect(results[0]).toEqual({ name1: "Cat", name2: "Dog" });
2929
+ expect(results[1]).toEqual({ name1: "Cat", name2: "Fish" });
2930
+ });
2931
+
2932
+ test("Test match with ORed relationship types returns correct type in relationship variable", async () => {
2933
+ await new Runner(`
2934
+ CREATE VIRTUAL (:City) AS {
2935
+ unwind [
2936
+ {id: 1, name: 'NYC'},
2937
+ {id: 2, name: 'LA'},
2938
+ {id: 3, name: 'Chicago'}
2939
+ ] as record
2940
+ RETURN record.id as id, record.name as name
2941
+ }
2942
+ `).run();
2943
+ await new Runner(`
2944
+ CREATE VIRTUAL (:City)-[:FLIGHT]-(:City) AS {
2945
+ unwind [
2946
+ {left_id: 1, right_id: 2, airline: 'Delta'}
2947
+ ] as record
2948
+ RETURN record.left_id as left_id, record.right_id as right_id, record.airline as airline
2949
+ }
2950
+ `).run();
2951
+ await new Runner(`
2952
+ CREATE VIRTUAL (:City)-[:TRAIN]-(:City) AS {
2953
+ unwind [
2954
+ {left_id: 1, right_id: 3, line: 'Amtrak'}
2955
+ ] as record
2956
+ RETURN record.left_id as left_id, record.right_id as right_id, record.line as line
2957
+ }
2958
+ `).run();
2959
+ const match = new Runner(`
2960
+ MATCH (a:City)-[r:FLIGHT|TRAIN]->(b:City)
2961
+ RETURN a.name AS from, b.name AS to, r.type AS type
2962
+ `);
2963
+ await match.run();
2964
+ const results = match.results;
2965
+ expect(results.length).toBe(2);
2966
+ expect(results[0]).toEqual({ from: "NYC", to: "LA", type: "FLIGHT" });
2967
+ expect(results[1]).toEqual({ from: "NYC", to: "Chicago", type: "TRAIN" });
2968
+ });