flowquery 1.0.44 → 1.0.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowquery",
3
- "version": "1.0.44",
3
+ "version": "1.0.45",
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,11 @@
1
+ import Expression from "../expressions/expression";
2
+ import Reference from "../expressions/reference";
1
3
  import Operation from "./operation";
2
4
 
3
5
  export interface SortField {
4
- field: string;
5
6
  direction: "asc" | "desc";
7
+ /** The parsed expression to evaluate for this sort field. */
8
+ expression: Expression;
6
9
  }
7
10
 
8
11
  /**
@@ -11,14 +14,23 @@ export interface SortField {
11
14
  * Can be attached to a RETURN operation (sorting its results),
12
15
  * or used as a standalone accumulating operation after a non-aggregate WITH.
13
16
  *
17
+ * Supports both simple field references and arbitrary expressions:
14
18
  * @example
15
19
  * ```
16
20
  * RETURN x ORDER BY x DESC
21
+ * RETURN x ORDER BY toLower(x.name) ASC
22
+ * RETURN x ORDER BY string_distance(toLower(x.name), toLower('Thomas')) ASC
17
23
  * ```
18
24
  */
19
25
  class OrderBy extends Operation {
20
26
  private _fields: SortField[];
21
27
  private _results: Record<string, any>[] = [];
28
+ /**
29
+ * Parallel array of pre-computed sort-key tuples, one entry per
30
+ * accumulated result row. Each inner array has one value per sort
31
+ * field, in the same order as `_fields`.
32
+ */
33
+ private _sortKeys: any[][] = [];
22
34
 
23
35
  constructor(fields: SortField[]) {
24
36
  super();
@@ -30,13 +42,50 @@ class OrderBy extends Operation {
30
42
  }
31
43
 
32
44
  /**
33
- * Sorts an array of records according to the sort fields.
45
+ * Evaluates every sort-field expression against the current runtime
46
+ * context and stores the resulting values. Must be called once per
47
+ * accumulated row (from `Return.run()`).
48
+ */
49
+ public captureSortKeys(): void {
50
+ this._sortKeys.push(this._fields.map((f) => f.expression.value()));
51
+ }
52
+
53
+ /**
54
+ * Sorts an array of records using the pre-computed sort keys captured
55
+ * during accumulation. When no keys have been captured (e.g.
56
+ * aggregated returns), falls back to looking up simple reference
57
+ * identifiers in each record.
34
58
  */
35
59
  public sort(records: Record<string, any>[]): Record<string, any>[] {
36
- return records.sort((a, b) => {
37
- for (const { field, direction } of this._fields) {
38
- const aVal = a[field];
39
- const bVal = b[field];
60
+ const useKeys = this._sortKeys.length === records.length;
61
+ // Build an index array so we can sort records and keys together.
62
+ const indices = records.map((_, i) => i);
63
+ const keys = this._sortKeys;
64
+
65
+ // Pre-compute fallback field names for when sort keys aren't
66
+ // available (aggregated returns). Simple references like `x`
67
+ // map to the column name; complex expressions have no fallback.
68
+ const fallbackFields: (string | null)[] = this._fields.map((f) => {
69
+ const root = f.expression.firstChild();
70
+ if (root instanceof Reference && f.expression.childCount() === 1) {
71
+ return (root as Reference).identifier;
72
+ }
73
+ return null;
74
+ });
75
+
76
+ indices.sort((ai, bi) => {
77
+ for (let f = 0; f < this._fields.length; f++) {
78
+ let aVal: any;
79
+ let bVal: any;
80
+ if (useKeys) {
81
+ aVal = keys[ai][f];
82
+ bVal = keys[bi][f];
83
+ } else if (fallbackFields[f] !== null) {
84
+ aVal = records[ai][fallbackFields[f]!];
85
+ bVal = records[bi][fallbackFields[f]!];
86
+ } else {
87
+ continue;
88
+ }
40
89
  let cmp = 0;
41
90
  if (aVal == null && bVal == null) cmp = 0;
42
91
  else if (aVal == null) cmp = -1;
@@ -44,11 +93,12 @@ class OrderBy extends Operation {
44
93
  else if (aVal < bVal) cmp = -1;
45
94
  else if (aVal > bVal) cmp = 1;
46
95
  if (cmp !== 0) {
47
- return direction === "desc" ? -cmp : cmp;
96
+ return this._fields[f].direction === "desc" ? -cmp : cmp;
48
97
  }
49
98
  }
50
99
  return 0;
51
100
  });
101
+ return indices.map((i) => records[i]);
52
102
  }
53
103
 
54
104
  /**
@@ -64,6 +114,7 @@ class OrderBy extends Operation {
64
114
 
65
115
  public async initialize(): Promise<void> {
66
116
  this._results = [];
117
+ this._sortKeys = [];
67
118
  await this.next?.initialize();
68
119
  }
69
120
 
@@ -49,6 +49,10 @@ class Return extends Projection {
49
49
  const value: any = typeof raw === "object" && raw !== null ? structuredClone(raw) : raw;
50
50
  record.set(alias, value);
51
51
  }
52
+ // Capture sort-key values while expression bindings are still live.
53
+ if (this._orderBy !== null) {
54
+ this._orderBy.captureSortKeys();
55
+ }
52
56
  this._results.push(Object.fromEntries(record));
53
57
  if (this._orderBy === null && this._limit !== null) {
54
58
  this._limit.increment();
@@ -876,11 +876,10 @@ class Parser extends BaseParser {
876
876
  this.expectAndSkipWhitespaceAndComments();
877
877
  const fields: SortField[] = [];
878
878
  while (true) {
879
- if (!this.token.isIdentifierOrKeyword()) {
880
- throw new Error("Expected field name in ORDER BY");
879
+ const expression: Expression | null = this.parseExpression();
880
+ if (expression === null) {
881
+ throw new Error("Expected expression in ORDER BY");
881
882
  }
882
- const field = this.token.value!;
883
- this.setNextToken();
884
883
  this.skipWhitespaceAndComments();
885
884
  let direction: "asc" | "desc" = "asc";
886
885
  if (this.token.isAsc()) {
@@ -892,7 +891,7 @@ class Parser extends BaseParser {
892
891
  this.setNextToken();
893
892
  this.skipWhitespaceAndComments();
894
893
  }
895
- fields.push({ field, direction });
894
+ fields.push({ direction, expression });
896
895
  if (this.token.isComma()) {
897
896
  this.setNextToken();
898
897
  this.skipWhitespaceAndComments();
@@ -4114,6 +4114,126 @@ test("Test order by with where", async () => {
4114
4114
  expect(results[4]).toEqual({ x: 3 });
4115
4115
  });
4116
4116
 
4117
+ test("Test order by with property access expression", async () => {
4118
+ const runner = new Runner(`
4119
+ unwind [{name: 'Charlie', age: 30}, {name: 'Alice', age: 25}, {name: 'Bob', age: 35}] as person
4120
+ return person.name as name, person.age as age
4121
+ order by person.name asc
4122
+ `);
4123
+ await runner.run();
4124
+ const results = runner.results;
4125
+ expect(results.length).toBe(3);
4126
+ expect(results[0]).toEqual({ name: "Alice", age: 25 });
4127
+ expect(results[1]).toEqual({ name: "Bob", age: 35 });
4128
+ expect(results[2]).toEqual({ name: "Charlie", age: 30 });
4129
+ });
4130
+
4131
+ test("Test order by with function expression", async () => {
4132
+ const runner = new Runner(`
4133
+ unwind ['BANANA', 'apple', 'Cherry'] as fruit
4134
+ return fruit
4135
+ order by toLower(fruit)
4136
+ `);
4137
+ await runner.run();
4138
+ const results = runner.results;
4139
+ expect(results.length).toBe(3);
4140
+ expect(results[0]).toEqual({ fruit: "apple" });
4141
+ expect(results[1]).toEqual({ fruit: "BANANA" });
4142
+ expect(results[2]).toEqual({ fruit: "Cherry" });
4143
+ });
4144
+
4145
+ test("Test order by with function expression descending", async () => {
4146
+ const runner = new Runner(`
4147
+ unwind ['BANANA', 'apple', 'Cherry'] as fruit
4148
+ return fruit
4149
+ order by toLower(fruit) desc
4150
+ `);
4151
+ await runner.run();
4152
+ const results = runner.results;
4153
+ expect(results.length).toBe(3);
4154
+ expect(results[0]).toEqual({ fruit: "Cherry" });
4155
+ expect(results[1]).toEqual({ fruit: "BANANA" });
4156
+ expect(results[2]).toEqual({ fruit: "apple" });
4157
+ });
4158
+
4159
+ test("Test order by with nested function expression", async () => {
4160
+ const runner = new Runner(`
4161
+ unwind ['Alice', 'Bob', 'ALICE', 'bob'] as name
4162
+ return name
4163
+ order by string_distance(toLower(name), toLower('alice')) asc
4164
+ `);
4165
+ await runner.run();
4166
+ const results = runner.results;
4167
+ expect(results.length).toBe(4);
4168
+ // 'Alice' and 'ALICE' have distance 0 from 'alice', should come first
4169
+ expect(results[0].name).toBe("Alice");
4170
+ expect(results[1].name).toBe("ALICE");
4171
+ // 'Bob' and 'bob' have higher distance from 'alice'
4172
+ expect(results[2].name).toBe("Bob");
4173
+ expect(results[3].name).toBe("bob");
4174
+ });
4175
+
4176
+ test("Test order by with arithmetic expression", async () => {
4177
+ const runner = new Runner(`
4178
+ unwind [{a: 3, b: 1}, {a: 1, b: 5}, {a: 2, b: 2}] as item
4179
+ return item.a as a, item.b as b
4180
+ order by item.a + item.b asc
4181
+ `);
4182
+ await runner.run();
4183
+ const results = runner.results;
4184
+ expect(results.length).toBe(3);
4185
+ expect(results[0]).toEqual({ a: 3, b: 1 }); // sum = 4
4186
+ expect(results[1]).toEqual({ a: 2, b: 2 }); // sum = 4
4187
+ expect(results[2]).toEqual({ a: 1, b: 5 }); // sum = 6
4188
+ });
4189
+
4190
+ test("Test order by expression does not leak synthetic keys", async () => {
4191
+ const runner = new Runner(`
4192
+ unwind ['B', 'a', 'C'] as x
4193
+ return x
4194
+ order by toLower(x) asc
4195
+ `);
4196
+ await runner.run();
4197
+ const results = runner.results;
4198
+ expect(results.length).toBe(3);
4199
+ // Results should only contain 'x', no __orderBy_ keys
4200
+ for (const r of results) {
4201
+ expect(Object.keys(r)).toEqual(["x"]);
4202
+ }
4203
+ expect(results[0]).toEqual({ x: "a" });
4204
+ expect(results[1]).toEqual({ x: "B" });
4205
+ expect(results[2]).toEqual({ x: "C" });
4206
+ });
4207
+
4208
+ test("Test order by with expression and limit", async () => {
4209
+ const runner = new Runner(`
4210
+ unwind ['BANANA', 'apple', 'Cherry', 'date', 'ELDERBERRY'] as fruit
4211
+ return fruit
4212
+ order by toLower(fruit) asc
4213
+ limit 3
4214
+ `);
4215
+ await runner.run();
4216
+ const results = runner.results;
4217
+ expect(results.length).toBe(3);
4218
+ expect(results[0]).toEqual({ fruit: "apple" });
4219
+ expect(results[1]).toEqual({ fruit: "BANANA" });
4220
+ expect(results[2]).toEqual({ fruit: "Cherry" });
4221
+ });
4222
+
4223
+ test("Test order by with mixed simple and expression fields", async () => {
4224
+ const runner = new Runner(`
4225
+ unwind [{name: 'Alice', score: 3}, {name: 'Alice', score: 1}, {name: 'Bob', score: 2}] as item
4226
+ return item.name as name, item.score as score
4227
+ order by name asc, item.score desc
4228
+ `);
4229
+ await runner.run();
4230
+ const results = runner.results;
4231
+ expect(results.length).toBe(3);
4232
+ expect(results[0]).toEqual({ name: "Alice", score: 3 }); // Alice, score 3 desc
4233
+ expect(results[1]).toEqual({ name: "Alice", score: 1 }); // Alice, score 1 desc
4234
+ expect(results[2]).toEqual({ name: "Bob", score: 2 }); // Bob
4235
+ });
4236
+
4117
4237
  test("Test delete virtual node operation", async () => {
4118
4238
  const db = Database.getInstance();
4119
4239
  // Create a virtual node first
@@ -1269,3 +1269,59 @@ test("OPTIONAL without MATCH throws error", () => {
1269
1269
  const parser = new Parser();
1270
1270
  expect(() => parser.parse("OPTIONAL RETURN 1")).toThrow("Expected MATCH after OPTIONAL");
1271
1271
  });
1272
+
1273
+ // ORDER BY expression tests
1274
+
1275
+ test("ORDER BY with simple identifier parses correctly", () => {
1276
+ const parser = new Parser();
1277
+ const ast = parser.parse("unwind [1, 2] as x return x order by x");
1278
+ expect(ast).toBeDefined();
1279
+ });
1280
+
1281
+ test("ORDER BY with property access parses correctly", () => {
1282
+ const parser = new Parser();
1283
+ const ast = parser.parse(
1284
+ "unwind [{name: 'Bob'}, {name: 'Alice'}] as person return person.name as name order by person.name asc"
1285
+ );
1286
+ expect(ast).toBeDefined();
1287
+ });
1288
+
1289
+ test("ORDER BY with function call parses correctly", () => {
1290
+ const parser = new Parser();
1291
+ const ast = parser.parse(
1292
+ "unwind ['HELLO', 'WORLD'] as word return word order by toLower(word) asc"
1293
+ );
1294
+ expect(ast).toBeDefined();
1295
+ });
1296
+
1297
+ test("ORDER BY with nested function calls parses correctly", () => {
1298
+ const parser = new Parser();
1299
+ const ast = parser.parse(
1300
+ "unwind ['Alice', 'Bob'] as name return name order by string_distance(toLower(name), toLower('alice')) asc"
1301
+ );
1302
+ expect(ast).toBeDefined();
1303
+ });
1304
+
1305
+ test("ORDER BY with arithmetic expression parses correctly", () => {
1306
+ const parser = new Parser();
1307
+ const ast = parser.parse(
1308
+ "unwind [{a: 3, b: 1}, {a: 1, b: 5}] as item return item.a as a, item.b as b order by item.a + item.b desc"
1309
+ );
1310
+ expect(ast).toBeDefined();
1311
+ });
1312
+
1313
+ test("ORDER BY with multiple expression fields parses correctly", () => {
1314
+ const parser = new Parser();
1315
+ const ast = parser.parse(
1316
+ "unwind [{a: 1, b: 2}] as item return item.a as a, item.b as b order by toLower(item.a) asc, item.b desc"
1317
+ );
1318
+ expect(ast).toBeDefined();
1319
+ });
1320
+
1321
+ test("ORDER BY with expression and LIMIT parses correctly", () => {
1322
+ const parser = new Parser();
1323
+ const ast = parser.parse(
1324
+ "unwind ['c', 'a', 'b'] as x return x order by toLower(x) asc limit 2"
1325
+ );
1326
+ expect(ast).toBeDefined();
1327
+ });