node-red-contrib-prib-functions 0.23.2 → 0.26.0
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/.github/copilot-instructions.md +36 -0
- package/README.md +153 -140
- package/columnar/columnar.html +258 -0
- package/columnar/columnar.js +1055 -0
- package/columnar/icons/columnar.svg +38 -0
- package/fileSystem/filesystem.html +299 -0
- package/fileSystem/filesystem.js +170 -0
- package/gitlab/gitlab.html +191 -0
- package/gitlab/gitlab.js +248 -0
- package/gitlab/icons/gitlab.svg +17 -0
- package/lib/AlphaBeta.js +32 -0
- package/lib/GraphDB.js +40 -9
- package/lib/MinMax.js +17 -0
- package/lib/Tree.js +64 -0
- package/lib/objectExtensions.js +28 -5
- package/lib/timeDimension.js +36 -0
- package/lib/typedInput.js +18 -2
- package/logisticRegression/icons/logisticregression.svg +22 -0
- package/logisticRegression/logisticRegression.html +136 -0
- package/logisticRegression/logisticRegression.js +83 -0
- package/package.json +21 -9
- package/test/02-graphdb.js +46 -0
- package/test/columnar.js +509 -0
- package/test/data/.config.nodes.json +114 -70
- package/test/data/.config.nodes.json.backup +104 -71
- package/test/data/.config.runtime.json +2 -1
- package/test/data/.config.runtime.json.backup +2 -1
- package/test/data/.config.users.json +3 -2
- package/test/data/.config.users.json.backup +3 -2
- package/test/data/.flow.json.backup +1545 -369
- package/test/data/flow.json +1457 -270
- package/test/data/package-lock.json +11 -11
- package/test/data/shares/.config.nodes.json +611 -0
- package/test/data/shares/.config.nodes.json.backup +589 -0
- package/test/data/shares/.config.runtime.json +5 -0
- package/test/data/shares/.config.runtime.json.backup +4 -0
- package/test/data/shares/.config.users.json +33 -0
- package/test/data/shares/.config.users.json.backup +33 -0
- package/test/data/shares/.flow.json.backup +230 -0
- package/test/data/shares/.flow_cred.json.backup +3 -0
- package/test/data/shares/flow.json +267 -0
- package/test/data/shares/flow_cred.json +3 -0
- package/test/data/shares/package.json +6 -0
- package/test/data/shares/settings.js +544 -0
- package/test/dataAnalysisExtensions.js +93 -93
- package/test/logisticRegression.js +379 -0
- package/test/transform.js +11 -11
- package/test/transformConfluence.js +4 -2
- package/test/transformNumPy.js +3 -1
- package/test/transformXLSX.js +4 -2
- package/test/transformXML.js +4 -2
- package/test-runner.js +400 -0
- package/test.parq +0 -0
- package/test_select.js +37 -0
- package/testing/test.js +8 -7
- package/transform/transform.html +23 -2
- package/transform/transform.js +239 -283
- package/transform/xlsx2.js +74 -0
package/test/columnar.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
const should = require("should");
|
|
2
|
+
const { SimpleColumnarStore } = require("../columnar/columnar.js");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
describe("Columnar Store", function () {
|
|
8
|
+
it("writes, reads and queries using columnar rid maps or filters", async function () {
|
|
9
|
+
// methods are static; we call them directly on the class
|
|
10
|
+
const tmp = path.join(os.tmpdir(), "prib-test.columnar");
|
|
11
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
12
|
+
|
|
13
|
+
const records = [
|
|
14
|
+
{ id: 1, name: "a", age: 20, dept: "eng" },
|
|
15
|
+
{ id: 2, name: "b", age: 30, dept: "hr" },
|
|
16
|
+
{ id: 3, name: "c", age: 40, dept: "eng" },
|
|
17
|
+
{ id: 4, name: "d", age: 35, dept: "hr" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// write and then read the full set back
|
|
21
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
22
|
+
const all = await SimpleColumnarStore.readRecords(tmp);
|
|
23
|
+
all.should.have.property("records");
|
|
24
|
+
all.records.length.should.equal(4);
|
|
25
|
+
// schema is an object mapping field names to types
|
|
26
|
+
Object.keys(all.schema).should.containEql("age");
|
|
27
|
+
|
|
28
|
+
// filter using a javascript predicate
|
|
29
|
+
const filtered = await SimpleColumnarStore.queryRecords(tmp, {
|
|
30
|
+
filter: (r) => r.age > 25,
|
|
31
|
+
});
|
|
32
|
+
filtered.records.length.should.equal(3);
|
|
33
|
+
|
|
34
|
+
// filter using a ridMap on the 'age' column
|
|
35
|
+
const ridMap = { age: [1, 2, 3] };
|
|
36
|
+
const filtered2 = await SimpleColumnarStore.queryRecords(tmp, { ridMap });
|
|
37
|
+
filtered2.records.length.should.equal(3);
|
|
38
|
+
// ensure the rows returned correspond to the expected indices
|
|
39
|
+
filtered2.records[0].should.deepEqual(records[1]);
|
|
40
|
+
|
|
41
|
+
// Basic SQL query
|
|
42
|
+
const sqlResult = await SimpleColumnarStore.sqlQuery(
|
|
43
|
+
tmp,
|
|
44
|
+
"SELECT * FROM ? WHERE age > 25",
|
|
45
|
+
);
|
|
46
|
+
sqlResult.length.should.equal(3);
|
|
47
|
+
|
|
48
|
+
// SQL with column projection
|
|
49
|
+
const projected = await SimpleColumnarStore.sqlQuery(
|
|
50
|
+
tmp,
|
|
51
|
+
"SELECT id, name FROM ? WHERE age > 30",
|
|
52
|
+
);
|
|
53
|
+
projected.length.should.equal(2);
|
|
54
|
+
projected[0].should.have.property("id");
|
|
55
|
+
projected[0].should.have.property("name");
|
|
56
|
+
projected[0].should.not.have.property("age");
|
|
57
|
+
|
|
58
|
+
// SQL with ORDER BY
|
|
59
|
+
const ordered = await SimpleColumnarStore.sqlQuery(
|
|
60
|
+
tmp,
|
|
61
|
+
"SELECT * FROM ? ORDER BY age DESC",
|
|
62
|
+
);
|
|
63
|
+
ordered.length.should.equal(4);
|
|
64
|
+
ordered[0].age.should.equal(40);
|
|
65
|
+
ordered[3].age.should.equal(20);
|
|
66
|
+
|
|
67
|
+
// SQL with LIMIT
|
|
68
|
+
const limited = await SimpleColumnarStore.sqlQuery(
|
|
69
|
+
tmp,
|
|
70
|
+
"SELECT * FROM ? ORDER BY age DESC LIMIT 2",
|
|
71
|
+
);
|
|
72
|
+
limited.length.should.equal(2);
|
|
73
|
+
|
|
74
|
+
// SQL with GROUP BY and COUNT
|
|
75
|
+
const grouped = await SimpleColumnarStore.sqlQuery(
|
|
76
|
+
tmp,
|
|
77
|
+
"SELECT dept, COUNT(*) AS cnt FROM ? GROUP BY dept",
|
|
78
|
+
);
|
|
79
|
+
grouped.length.should.equal(2);
|
|
80
|
+
const engGroup = grouped.find((g) => g.dept === "eng");
|
|
81
|
+
engGroup.should.have.property("cnt", 2);
|
|
82
|
+
|
|
83
|
+
// SQL with aggregates
|
|
84
|
+
const agg = await SimpleColumnarStore.sqlQuery(
|
|
85
|
+
tmp,
|
|
86
|
+
"SELECT AVG(age) AS avg_age, MAX(age) AS max_age, MIN(age) AS min_age FROM ?",
|
|
87
|
+
);
|
|
88
|
+
agg.length.should.equal(1);
|
|
89
|
+
agg[0].avg_age.should.be.approximately(31.25, 0.01);
|
|
90
|
+
agg[0].max_age.should.equal(40);
|
|
91
|
+
agg[0].min_age.should.equal(20);
|
|
92
|
+
|
|
93
|
+
// SQL with GROUP BY and SUM
|
|
94
|
+
const sumAgg = await SimpleColumnarStore.sqlQuery(
|
|
95
|
+
tmp,
|
|
96
|
+
"SELECT dept, SUM(age) AS total_age FROM ? GROUP BY dept",
|
|
97
|
+
);
|
|
98
|
+
sumAgg.length.should.equal(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("supports INNER, LEFT, RIGHT, and FULL OUTER JOINs", async function () {
|
|
102
|
+
const tmpDir = os.tmpdir();
|
|
103
|
+
const file1 = path.join(tmpDir, "join-test-1.columnar");
|
|
104
|
+
const file2 = path.join(tmpDir, "join-test-2.columnar");
|
|
105
|
+
[file1, file2].forEach((f) => {
|
|
106
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Table 1: users
|
|
110
|
+
const users = [
|
|
111
|
+
{ id: 1, name: "Alice" },
|
|
112
|
+
{ id: 2, name: "Bob" },
|
|
113
|
+
{ id: 3, name: "Charlie" },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Table 2: orders (only users 1 and 2 have orders)
|
|
117
|
+
const orders = [
|
|
118
|
+
{ user_id: 1, amount: 100 },
|
|
119
|
+
{ user_id: 1, amount: 200 },
|
|
120
|
+
{ user_id: 2, amount: 150 },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
await SimpleColumnarStore.writeRecords(users, file1);
|
|
124
|
+
await SimpleColumnarStore.writeRecords(orders, file2);
|
|
125
|
+
|
|
126
|
+
// INNER JOIN: only matching rows
|
|
127
|
+
const innerJoin = await SimpleColumnarStore.sqlQuery(
|
|
128
|
+
file1,
|
|
129
|
+
`SELECT u.id, u.name, o.amount FROM ? u INNER JOIN '${file2}' o ON u.id = o.user_id`,
|
|
130
|
+
);
|
|
131
|
+
innerJoin.length.should.equal(3); // 2 orders for user 1, 1 order for user 2
|
|
132
|
+
innerJoin[0].should.have.property("u.name", "Alice");
|
|
133
|
+
innerJoin[0].should.have.property("o.amount");
|
|
134
|
+
|
|
135
|
+
// LEFT JOIN: all from left, matching from right
|
|
136
|
+
const leftJoin = await SimpleColumnarStore.sqlQuery(
|
|
137
|
+
file1,
|
|
138
|
+
`SELECT u.id, u.name, o.amount FROM ? u LEFT JOIN '${file2}' o ON u.id = o.user_id`,
|
|
139
|
+
);
|
|
140
|
+
leftJoin.length.should.equal(4); // 3 orders for users 1&2 + 1 unmatched user 3
|
|
141
|
+
leftJoin[3].should.have.property("u.name", "Charlie");
|
|
142
|
+
should.not.exist(leftJoin[3]["o.amount"]);
|
|
143
|
+
|
|
144
|
+
// RIGHT JOIN: all from right, matching from left
|
|
145
|
+
const rightJoin = await SimpleColumnarStore.sqlQuery(
|
|
146
|
+
file1,
|
|
147
|
+
`SELECT u.id, u.name, o.amount FROM ? u RIGHT JOIN '${file2}' o ON u.id = o.user_id`,
|
|
148
|
+
);
|
|
149
|
+
rightJoin.length.should.equal(3); // all 3 orders have matching users
|
|
150
|
+
|
|
151
|
+
// FULL OUTER JOIN: all rows from both tables
|
|
152
|
+
const fullJoin = await SimpleColumnarStore.sqlQuery(
|
|
153
|
+
file1,
|
|
154
|
+
`SELECT u.id, u.name, o.amount FROM ? u FULL OUTER JOIN '${file2}' o ON u.id = o.user_id`,
|
|
155
|
+
);
|
|
156
|
+
fullJoin.length.should.equal(4); // 3 matched + 1 unmatched from left
|
|
157
|
+
|
|
158
|
+
// Clean up
|
|
159
|
+
[file1, file2].forEach((f) => {
|
|
160
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("supports appending records to existing files", async function () {
|
|
165
|
+
const tmp = path.join(os.tmpdir(), "append-test.columnar");
|
|
166
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
167
|
+
|
|
168
|
+
// Initial records
|
|
169
|
+
const initialRecords = [
|
|
170
|
+
{ id: 1, name: "Alice", age: 25 },
|
|
171
|
+
{ id: 2, name: "Bob", age: 30 },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// Write initial records
|
|
175
|
+
const writeResult = await SimpleColumnarStore.writeRecords(
|
|
176
|
+
initialRecords,
|
|
177
|
+
tmp,
|
|
178
|
+
);
|
|
179
|
+
writeResult.recordsWritten.should.equal(2);
|
|
180
|
+
writeResult.totalRecords.should.equal(2);
|
|
181
|
+
writeResult.appended.should.be.false();
|
|
182
|
+
|
|
183
|
+
// Verify initial records
|
|
184
|
+
const read1 = await SimpleColumnarStore.readRecords(tmp);
|
|
185
|
+
read1.records.length.should.equal(2);
|
|
186
|
+
|
|
187
|
+
// Append more records
|
|
188
|
+
const appendRecords = [
|
|
189
|
+
{ id: 3, name: "Charlie", age: 35 },
|
|
190
|
+
{ id: 4, name: "Diana", age: 28 },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const appendResult = await SimpleColumnarStore.appendRecords(
|
|
194
|
+
appendRecords,
|
|
195
|
+
tmp,
|
|
196
|
+
);
|
|
197
|
+
appendResult.recordsWritten.should.equal(2);
|
|
198
|
+
appendResult.totalRecords.should.equal(4);
|
|
199
|
+
appendResult.appended.should.be.true();
|
|
200
|
+
|
|
201
|
+
// Verify all records are present
|
|
202
|
+
const read2 = await SimpleColumnarStore.readRecords(tmp);
|
|
203
|
+
read2.records.length.should.equal(4);
|
|
204
|
+
read2.records[0].name.should.equal("Alice");
|
|
205
|
+
read2.records[2].name.should.equal("Charlie");
|
|
206
|
+
read2.records[3].name.should.equal("Diana");
|
|
207
|
+
|
|
208
|
+
// Test appending to non-existent file (should create it)
|
|
209
|
+
const newFile = path.join(os.tmpdir(), "new-append-test.columnar");
|
|
210
|
+
if (fs.existsSync(newFile)) fs.unlinkSync(newFile);
|
|
211
|
+
|
|
212
|
+
const newAppendResult = await SimpleColumnarStore.appendRecords(
|
|
213
|
+
[{ id: 5, name: "Eve" }],
|
|
214
|
+
newFile,
|
|
215
|
+
);
|
|
216
|
+
newAppendResult.recordsWritten.should.equal(1);
|
|
217
|
+
newAppendResult.totalRecords.should.equal(1);
|
|
218
|
+
newAppendResult.appended.should.be.false(); // File didn't exist, so not appended
|
|
219
|
+
|
|
220
|
+
// Clean up
|
|
221
|
+
[tmp, newFile].forEach((f) => {
|
|
222
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("supports hardcoded SQL queries in node configuration", async function () {
|
|
227
|
+
const tmp = path.join(os.tmpdir(), "hardcoded-sql-test.columnar");
|
|
228
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
229
|
+
|
|
230
|
+
const records = [
|
|
231
|
+
{ id: 1, name: "Alice", age: 25, dept: "eng" },
|
|
232
|
+
{ id: 2, name: "Bob", age: 30, dept: "hr" },
|
|
233
|
+
{ id: 3, name: "Charlie", age: 35, dept: "eng" },
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
237
|
+
|
|
238
|
+
// Simulate Node-RED node with hardcoded SQL
|
|
239
|
+
const mockNode = {
|
|
240
|
+
filePath: tmp,
|
|
241
|
+
sqlQuery: "SELECT name, age FROM ? WHERE age > 28 ORDER BY age DESC",
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Simulate message without SQL (should use hardcoded query)
|
|
245
|
+
const mockMsg = {
|
|
246
|
+
payload: {
|
|
247
|
+
filePath: tmp,
|
|
248
|
+
// No sql property - should use node.sqlQuery
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Simulate the sqlQuery action logic
|
|
253
|
+
const filePath = mockMsg.payload.filePath || mockNode.filePath;
|
|
254
|
+
const sql = mockMsg.payload.sql || mockNode.sqlQuery;
|
|
255
|
+
|
|
256
|
+
const result = await SimpleColumnarStore.sqlQuery(filePath, sql);
|
|
257
|
+
result.length.should.equal(2);
|
|
258
|
+
result[0].name.should.equal("Charlie");
|
|
259
|
+
result[0].age.should.equal(35);
|
|
260
|
+
result[1].name.should.equal("Bob");
|
|
261
|
+
result[1].age.should.equal(30);
|
|
262
|
+
|
|
263
|
+
// Test that msg.payload.sql takes precedence over hardcoded query
|
|
264
|
+
mockMsg.payload.sql = "SELECT COUNT(*) as total FROM ?";
|
|
265
|
+
const result2 = await SimpleColumnarStore.sqlQuery(
|
|
266
|
+
filePath,
|
|
267
|
+
mockMsg.payload.sql,
|
|
268
|
+
);
|
|
269
|
+
result2.length.should.equal(1);
|
|
270
|
+
result2[0].total.should.equal(3);
|
|
271
|
+
|
|
272
|
+
// Clean up
|
|
273
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("caches parsed SQL tokens for hardcoded queries", async function () {
|
|
277
|
+
const tmp = path.join(os.tmpdir(), "cached-sql-test.columnar");
|
|
278
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
279
|
+
|
|
280
|
+
const records = [
|
|
281
|
+
{ id: 1, name: "Alice", age: 25 },
|
|
282
|
+
{ id: 2, name: "Bob", age: 30 },
|
|
283
|
+
{ id: 3, name: "Charlie", age: 35 },
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
287
|
+
|
|
288
|
+
// Test that parsing works normally
|
|
289
|
+
const sql = "SELECT name FROM ? WHERE age > 28 ORDER BY age DESC";
|
|
290
|
+
const result1 = await SimpleColumnarStore.sqlQuery(tmp, sql);
|
|
291
|
+
result1.length.should.equal(2);
|
|
292
|
+
result1[0].name.should.equal("Charlie");
|
|
293
|
+
|
|
294
|
+
// Test with pre-parsed tokens (simulate node caching)
|
|
295
|
+
const parsedTokens = SimpleColumnarStore._parseSql(sql);
|
|
296
|
+
const result2 = await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
|
|
297
|
+
result2.length.should.equal(2);
|
|
298
|
+
result2[0].name.should.equal("Charlie");
|
|
299
|
+
|
|
300
|
+
// Results should be identical
|
|
301
|
+
result1.should.deepEqual(result2);
|
|
302
|
+
|
|
303
|
+
// Clean up
|
|
304
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("demonstrates SQL parsing performance improvement with caching", async function () {
|
|
308
|
+
const tmp = path.join(os.tmpdir(), "perf-sql-test.columnar");
|
|
309
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
310
|
+
|
|
311
|
+
// Create a larger dataset for performance testing
|
|
312
|
+
const records = [];
|
|
313
|
+
for (let i = 0; i < 1000; i++) {
|
|
314
|
+
records.push({
|
|
315
|
+
id: i,
|
|
316
|
+
name: `User${i}`,
|
|
317
|
+
age: 20 + (i % 50),
|
|
318
|
+
dept: ["eng", "hr", "sales", "marketing"][i % 4],
|
|
319
|
+
salary: 50000 + i * 100,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
324
|
+
|
|
325
|
+
const sql =
|
|
326
|
+
"SELECT dept, COUNT(*) as cnt, AVG(salary) as avg_salary FROM ? GROUP BY dept ORDER BY cnt DESC";
|
|
327
|
+
|
|
328
|
+
// Time parsing + execution without cache
|
|
329
|
+
const start1 = Date.now();
|
|
330
|
+
for (let i = 0; i < 10; i++) {
|
|
331
|
+
await SimpleColumnarStore.sqlQuery(tmp, sql);
|
|
332
|
+
}
|
|
333
|
+
const time1 = Date.now() - start1;
|
|
334
|
+
|
|
335
|
+
// Time execution with pre-parsed tokens
|
|
336
|
+
const parsedTokens = SimpleColumnarStore._parseSql(sql);
|
|
337
|
+
const start2 = Date.now();
|
|
338
|
+
for (let i = 0; i < 10; i++) {
|
|
339
|
+
await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
|
|
340
|
+
}
|
|
341
|
+
const time2 = Date.now() - start2;
|
|
342
|
+
|
|
343
|
+
// Cached version should be faster (parsing is skipped)
|
|
344
|
+
console.log(`Without cache: ${time1}ms, With cache: ${time2}ms`);
|
|
345
|
+
time2.should.be.below(time1); // Cached should be faster
|
|
346
|
+
|
|
347
|
+
// Results should be identical
|
|
348
|
+
const result1 = await SimpleColumnarStore.sqlQuery(tmp, sql);
|
|
349
|
+
const result2 = await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
|
|
350
|
+
result1.should.deepEqual(result2);
|
|
351
|
+
|
|
352
|
+
// Clean up
|
|
353
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("supports configurable output property for results", async function () {
|
|
357
|
+
const tmp = path.join(os.tmpdir(), "output-prop-test.columnar");
|
|
358
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
359
|
+
|
|
360
|
+
const records = [
|
|
361
|
+
{ id: 1, name: "Alice", age: 25 },
|
|
362
|
+
{ id: 2, name: "Bob", age: 30 },
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
366
|
+
|
|
367
|
+
// Test that sqlQuery returns results correctly
|
|
368
|
+
const sqlResult = await SimpleColumnarStore.sqlQuery(
|
|
369
|
+
tmp,
|
|
370
|
+
"SELECT name FROM ? WHERE age > 26",
|
|
371
|
+
);
|
|
372
|
+
sqlResult.length.should.equal(1);
|
|
373
|
+
sqlResult[0].name.should.equal("Bob");
|
|
374
|
+
|
|
375
|
+
// Simulate node behavior with different output properties
|
|
376
|
+
const mockMsg1 = { payload: {} };
|
|
377
|
+
const outputProperty1 = "result";
|
|
378
|
+
mockMsg1[outputProperty1] = sqlResult;
|
|
379
|
+
mockMsg1.should.have.property("result");
|
|
380
|
+
mockMsg1.result[0].name.should.equal("Bob");
|
|
381
|
+
|
|
382
|
+
const mockMsg2 = { payload: {} };
|
|
383
|
+
const outputProperty2 = "myData";
|
|
384
|
+
mockMsg2[outputProperty2] = sqlResult;
|
|
385
|
+
mockMsg2.should.have.property("myData");
|
|
386
|
+
mockMsg2.should.not.have.property("result");
|
|
387
|
+
mockMsg2.myData[0].name.should.equal("Bob");
|
|
388
|
+
|
|
389
|
+
// Test default behavior (empty outputProperty should default to 'result')
|
|
390
|
+
const mockMsg3 = { payload: {} };
|
|
391
|
+
const outputProperty3 = null; // should default to 'result'
|
|
392
|
+
const defaultProp = outputProperty3 || "result";
|
|
393
|
+
mockMsg3[defaultProp] = sqlResult;
|
|
394
|
+
mockMsg3.should.have.property("result");
|
|
395
|
+
mockMsg3.result[0].name.should.equal("Bob");
|
|
396
|
+
|
|
397
|
+
// Clean up
|
|
398
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("supports parameter substitution in SQL queries", async function () {
|
|
402
|
+
const tmp = path.join(os.tmpdir(), "prib-test-params.columnar");
|
|
403
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
404
|
+
|
|
405
|
+
const records = [
|
|
406
|
+
{ id: 1, name: "Alice", age: 25, dept: "eng", salary: 50000 },
|
|
407
|
+
{ id: 2, name: "Bob", age: 30, dept: "hr", salary: 60000 },
|
|
408
|
+
{ id: 3, name: "Charlie", age: 35, dept: "eng", salary: 70000 },
|
|
409
|
+
{ id: 4, name: "Diana", age: 28, dept: "hr", salary: 55000 },
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
await SimpleColumnarStore.writeRecords(records, tmp);
|
|
413
|
+
|
|
414
|
+
// Test explicit :msg., :flow., :global. parameter markers
|
|
415
|
+
const msg = { payload: { minAge: 28, personName: "Bob" } };
|
|
416
|
+
const flow = { minSalary: 60000 };
|
|
417
|
+
const global = { dept: "eng" };
|
|
418
|
+
const context = { msg, flow, global };
|
|
419
|
+
|
|
420
|
+
// :msg. marker
|
|
421
|
+
const result1 = await SimpleColumnarStore.sqlQuery(
|
|
422
|
+
tmp,
|
|
423
|
+
"SELECT * FROM ? WHERE age > :msg.payload.minAge",
|
|
424
|
+
null,
|
|
425
|
+
context,
|
|
426
|
+
);
|
|
427
|
+
result1.length.should.equal(2);
|
|
428
|
+
result1[0].name.should.equal("Bob");
|
|
429
|
+
result1[1].name.should.equal("Charlie");
|
|
430
|
+
|
|
431
|
+
// :flow. marker
|
|
432
|
+
const result2 = await SimpleColumnarStore.sqlQuery(
|
|
433
|
+
tmp,
|
|
434
|
+
"SELECT * FROM ? WHERE salary > :flow.minSalary",
|
|
435
|
+
null,
|
|
436
|
+
context,
|
|
437
|
+
);
|
|
438
|
+
result2.length.should.equal(1);
|
|
439
|
+
result2[0].name.should.equal("Charlie");
|
|
440
|
+
|
|
441
|
+
// :global. marker
|
|
442
|
+
const result3 = await SimpleColumnarStore.sqlQuery(
|
|
443
|
+
tmp,
|
|
444
|
+
"SELECT * FROM ? WHERE dept = :global.dept",
|
|
445
|
+
null,
|
|
446
|
+
context,
|
|
447
|
+
);
|
|
448
|
+
result3.length.should.equal(2);
|
|
449
|
+
result3[0].name.should.equal("Alice");
|
|
450
|
+
result3[1].name.should.equal("Charlie");
|
|
451
|
+
|
|
452
|
+
// Multiple explicit markers
|
|
453
|
+
const result4 = await SimpleColumnarStore.sqlQuery(
|
|
454
|
+
tmp,
|
|
455
|
+
"SELECT * FROM ? WHERE age >= :msg.payload.minAge AND dept = :global.dept",
|
|
456
|
+
null,
|
|
457
|
+
context,
|
|
458
|
+
);
|
|
459
|
+
result4.length.should.equal(1);
|
|
460
|
+
result4[0].name.should.equal("Charlie");
|
|
461
|
+
|
|
462
|
+
// Test missing parameter should throw error
|
|
463
|
+
try {
|
|
464
|
+
await SimpleColumnarStore.sqlQuery(
|
|
465
|
+
tmp,
|
|
466
|
+
"SELECT * FROM ? WHERE age > :msg.payload.missingParam",
|
|
467
|
+
null,
|
|
468
|
+
context,
|
|
469
|
+
);
|
|
470
|
+
should.fail("Should have thrown error for missing parameter");
|
|
471
|
+
} catch (error) {
|
|
472
|
+
error.message.should.match(
|
|
473
|
+
/Parameter ':msg.payload.missingParam' not provided/,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
// SQL injection attempt should not succeed
|
|
477
|
+
{
|
|
478
|
+
const injectionContext = {
|
|
479
|
+
msg: { payload: { personName: "Bob' OR 1=1 --" } },
|
|
480
|
+
flow: {},
|
|
481
|
+
global: {},
|
|
482
|
+
};
|
|
483
|
+
// This should only match a name exactly equal to the injected string, not all rows
|
|
484
|
+
const injResult = await SimpleColumnarStore.sqlQuery(
|
|
485
|
+
tmp,
|
|
486
|
+
"SELECT * FROM ? WHERE name = :msg.payload.personName",
|
|
487
|
+
null,
|
|
488
|
+
injectionContext,
|
|
489
|
+
);
|
|
490
|
+
injResult.length.should.equal(0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Test parameter substitution in SELECT clause
|
|
494
|
+
{
|
|
495
|
+
const result = await SimpleColumnarStore.sqlQuery(
|
|
496
|
+
tmp,
|
|
497
|
+
"SELECT :msg.payload.minAge as age_threshold, name FROM ? LIMIT 5",
|
|
498
|
+
null,
|
|
499
|
+
{ msg: { payload: { minAge: 28 } }, flow: {}, global: {} },
|
|
500
|
+
);
|
|
501
|
+
result.length.should.equal(4);
|
|
502
|
+
result[0].should.have.property("age_threshold", 28);
|
|
503
|
+
result[0].should.have.property("name");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Clean up
|
|
507
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
508
|
+
});
|
|
509
|
+
});
|