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/dist/flowquery.min.js +1 -1
- package/dist/parsing/operations/order_by.d.ts +22 -2
- package/dist/parsing/operations/order_by.d.ts.map +1 -1
- package/dist/parsing/operations/order_by.js +54 -6
- package/dist/parsing/operations/order_by.js.map +1 -1
- package/dist/parsing/operations/return.d.ts.map +1 -1
- package/dist/parsing/operations/return.js +4 -0
- package/dist/parsing/operations/return.js.map +1 -1
- package/dist/parsing/parser.d.ts.map +1 -1
- package/dist/parsing/parser.js +4 -5
- package/dist/parsing/parser.js.map +1 -1
- package/docs/flowquery.min.js +1 -1
- package/flowquery-py/pyproject.toml +1 -1
- package/flowquery-py/src/parsing/operations/order_by.py +55 -13
- package/flowquery-py/src/parsing/operations/return_op.py +3 -0
- package/flowquery-py/src/parsing/parser.py +4 -5
- package/flowquery-py/tests/compute/test_runner.py +127 -0
- package/flowquery-py/tests/parsing/test_parser.py +63 -0
- package/flowquery-vscode/flowQueryEngine/flowquery.min.js +1 -1
- package/package.json +1 -1
- package/src/parsing/operations/order_by.ts +58 -7
- package/src/parsing/operations/return.ts +4 -0
- package/src/parsing/parser.ts +4 -5
- package/tests/compute/runner.test.ts +120 -0
- package/tests/parsing/parser.test.ts +56 -0
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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();
|
package/src/parsing/parser.ts
CHANGED
|
@@ -876,11 +876,10 @@ class Parser extends BaseParser {
|
|
|
876
876
|
this.expectAndSkipWhitespaceAndComments();
|
|
877
877
|
const fields: SortField[] = [];
|
|
878
878
|
while (true) {
|
|
879
|
-
|
|
880
|
-
|
|
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({
|
|
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
|
+
});
|