node-red-contrib-prib-functions
Version:
Node-RED added node functions.
510 lines (434 loc) • 17.2 kB
JavaScript
const should = require("should");
const { SimpleColumnarStore } = require("../columnar/columnar.js");
const fs = require("fs");
const path = require("path");
const os = require("os");
describe("Columnar Store", function () {
it("writes, reads and queries using columnar rid maps or filters", async function () {
// methods are static; we call them directly on the class
const tmp = path.join(os.tmpdir(), "prib-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
const records = [
{ id: 1, name: "a", age: 20, dept: "eng" },
{ id: 2, name: "b", age: 30, dept: "hr" },
{ id: 3, name: "c", age: 40, dept: "eng" },
{ id: 4, name: "d", age: 35, dept: "hr" },
];
// write and then read the full set back
await SimpleColumnarStore.writeRecords(records, tmp);
const all = await SimpleColumnarStore.readRecords(tmp);
all.should.have.property("records");
all.records.length.should.equal(4);
// schema is an object mapping field names to types
Object.keys(all.schema).should.containEql("age");
// filter using a javascript predicate
const filtered = await SimpleColumnarStore.queryRecords(tmp, {
filter: (r) => r.age > 25,
});
filtered.records.length.should.equal(3);
// filter using a ridMap on the 'age' column
const ridMap = { age: [1, 2, 3] };
const filtered2 = await SimpleColumnarStore.queryRecords(tmp, { ridMap });
filtered2.records.length.should.equal(3);
// ensure the rows returned correspond to the expected indices
filtered2.records[0].should.deepEqual(records[1]);
// Basic SQL query
const sqlResult = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE age > 25",
);
sqlResult.length.should.equal(3);
// SQL with column projection
const projected = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT id, name FROM ? WHERE age > 30",
);
projected.length.should.equal(2);
projected[0].should.have.property("id");
projected[0].should.have.property("name");
projected[0].should.not.have.property("age");
// SQL with ORDER BY
const ordered = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? ORDER BY age DESC",
);
ordered.length.should.equal(4);
ordered[0].age.should.equal(40);
ordered[3].age.should.equal(20);
// SQL with LIMIT
const limited = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? ORDER BY age DESC LIMIT 2",
);
limited.length.should.equal(2);
// SQL with GROUP BY and COUNT
const grouped = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT dept, COUNT(*) AS cnt FROM ? GROUP BY dept",
);
grouped.length.should.equal(2);
const engGroup = grouped.find((g) => g.dept === "eng");
engGroup.should.have.property("cnt", 2);
// SQL with aggregates
const agg = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT AVG(age) AS avg_age, MAX(age) AS max_age, MIN(age) AS min_age FROM ?",
);
agg.length.should.equal(1);
agg[0].avg_age.should.be.approximately(31.25, 0.01);
agg[0].max_age.should.equal(40);
agg[0].min_age.should.equal(20);
// SQL with GROUP BY and SUM
const sumAgg = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT dept, SUM(age) AS total_age FROM ? GROUP BY dept",
);
sumAgg.length.should.equal(2);
});
it("supports INNER, LEFT, RIGHT, and FULL OUTER JOINs", async function () {
const tmpDir = os.tmpdir();
const file1 = path.join(tmpDir, "join-test-1.columnar");
const file2 = path.join(tmpDir, "join-test-2.columnar");
[file1, file2].forEach((f) => {
if (fs.existsSync(f)) fs.unlinkSync(f);
});
// Table 1: users
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
// Table 2: orders (only users 1 and 2 have orders)
const orders = [
{ user_id: 1, amount: 100 },
{ user_id: 1, amount: 200 },
{ user_id: 2, amount: 150 },
];
await SimpleColumnarStore.writeRecords(users, file1);
await SimpleColumnarStore.writeRecords(orders, file2);
// INNER JOIN: only matching rows
const innerJoin = await SimpleColumnarStore.sqlQuery(
file1,
`SELECT u.id, u.name, o.amount FROM ? u INNER JOIN '${file2}' o ON u.id = o.user_id`,
);
innerJoin.length.should.equal(3); // 2 orders for user 1, 1 order for user 2
innerJoin[0].should.have.property("u.name", "Alice");
innerJoin[0].should.have.property("o.amount");
// LEFT JOIN: all from left, matching from right
const leftJoin = await SimpleColumnarStore.sqlQuery(
file1,
`SELECT u.id, u.name, o.amount FROM ? u LEFT JOIN '${file2}' o ON u.id = o.user_id`,
);
leftJoin.length.should.equal(4); // 3 orders for users 1&2 + 1 unmatched user 3
leftJoin[3].should.have.property("u.name", "Charlie");
should.not.exist(leftJoin[3]["o.amount"]);
// RIGHT JOIN: all from right, matching from left
const rightJoin = await SimpleColumnarStore.sqlQuery(
file1,
`SELECT u.id, u.name, o.amount FROM ? u RIGHT JOIN '${file2}' o ON u.id = o.user_id`,
);
rightJoin.length.should.equal(3); // all 3 orders have matching users
// FULL OUTER JOIN: all rows from both tables
const fullJoin = await SimpleColumnarStore.sqlQuery(
file1,
`SELECT u.id, u.name, o.amount FROM ? u FULL OUTER JOIN '${file2}' o ON u.id = o.user_id`,
);
fullJoin.length.should.equal(4); // 3 matched + 1 unmatched from left
// Clean up
[file1, file2].forEach((f) => {
if (fs.existsSync(f)) fs.unlinkSync(f);
});
});
it("supports appending records to existing files", async function () {
const tmp = path.join(os.tmpdir(), "append-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
// Initial records
const initialRecords = [
{ id: 1, name: "Alice", age: 25 },
{ id: 2, name: "Bob", age: 30 },
];
// Write initial records
const writeResult = await SimpleColumnarStore.writeRecords(
initialRecords,
tmp,
);
writeResult.recordsWritten.should.equal(2);
writeResult.totalRecords.should.equal(2);
writeResult.appended.should.be.false();
// Verify initial records
const read1 = await SimpleColumnarStore.readRecords(tmp);
read1.records.length.should.equal(2);
// Append more records
const appendRecords = [
{ id: 3, name: "Charlie", age: 35 },
{ id: 4, name: "Diana", age: 28 },
];
const appendResult = await SimpleColumnarStore.appendRecords(
appendRecords,
tmp,
);
appendResult.recordsWritten.should.equal(2);
appendResult.totalRecords.should.equal(4);
appendResult.appended.should.be.true();
// Verify all records are present
const read2 = await SimpleColumnarStore.readRecords(tmp);
read2.records.length.should.equal(4);
read2.records[0].name.should.equal("Alice");
read2.records[2].name.should.equal("Charlie");
read2.records[3].name.should.equal("Diana");
// Test appending to non-existent file (should create it)
const newFile = path.join(os.tmpdir(), "new-append-test.columnar");
if (fs.existsSync(newFile)) fs.unlinkSync(newFile);
const newAppendResult = await SimpleColumnarStore.appendRecords(
[{ id: 5, name: "Eve" }],
newFile,
);
newAppendResult.recordsWritten.should.equal(1);
newAppendResult.totalRecords.should.equal(1);
newAppendResult.appended.should.be.false(); // File didn't exist, so not appended
// Clean up
[tmp, newFile].forEach((f) => {
if (fs.existsSync(f)) fs.unlinkSync(f);
});
});
it("supports hardcoded SQL queries in node configuration", async function () {
const tmp = path.join(os.tmpdir(), "hardcoded-sql-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
const records = [
{ id: 1, name: "Alice", age: 25, dept: "eng" },
{ id: 2, name: "Bob", age: 30, dept: "hr" },
{ id: 3, name: "Charlie", age: 35, dept: "eng" },
];
await SimpleColumnarStore.writeRecords(records, tmp);
// Simulate Node-RED node with hardcoded SQL
const mockNode = {
filePath: tmp,
sqlQuery: "SELECT name, age FROM ? WHERE age > 28 ORDER BY age DESC",
};
// Simulate message without SQL (should use hardcoded query)
const mockMsg = {
payload: {
filePath: tmp,
// No sql property - should use node.sqlQuery
},
};
// Simulate the sqlQuery action logic
const filePath = mockMsg.payload.filePath || mockNode.filePath;
const sql = mockMsg.payload.sql || mockNode.sqlQuery;
const result = await SimpleColumnarStore.sqlQuery(filePath, sql);
result.length.should.equal(2);
result[0].name.should.equal("Charlie");
result[0].age.should.equal(35);
result[1].name.should.equal("Bob");
result[1].age.should.equal(30);
// Test that msg.payload.sql takes precedence over hardcoded query
mockMsg.payload.sql = "SELECT COUNT(*) as total FROM ?";
const result2 = await SimpleColumnarStore.sqlQuery(
filePath,
mockMsg.payload.sql,
);
result2.length.should.equal(1);
result2[0].total.should.equal(3);
// Clean up
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
});
it("caches parsed SQL tokens for hardcoded queries", async function () {
const tmp = path.join(os.tmpdir(), "cached-sql-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
const records = [
{ id: 1, name: "Alice", age: 25 },
{ id: 2, name: "Bob", age: 30 },
{ id: 3, name: "Charlie", age: 35 },
];
await SimpleColumnarStore.writeRecords(records, tmp);
// Test that parsing works normally
const sql = "SELECT name FROM ? WHERE age > 28 ORDER BY age DESC";
const result1 = await SimpleColumnarStore.sqlQuery(tmp, sql);
result1.length.should.equal(2);
result1[0].name.should.equal("Charlie");
// Test with pre-parsed tokens (simulate node caching)
const parsedTokens = SimpleColumnarStore._parseSql(sql);
const result2 = await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
result2.length.should.equal(2);
result2[0].name.should.equal("Charlie");
// Results should be identical
result1.should.deepEqual(result2);
// Clean up
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
});
it("demonstrates SQL parsing performance improvement with caching", async function () {
const tmp = path.join(os.tmpdir(), "perf-sql-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
// Create a larger dataset for performance testing
const records = [];
for (let i = 0; i < 1000; i++) {
records.push({
id: i,
name: `User${i}`,
age: 20 + (i % 50),
dept: ["eng", "hr", "sales", "marketing"][i % 4],
salary: 50000 + i * 100,
});
}
await SimpleColumnarStore.writeRecords(records, tmp);
const sql =
"SELECT dept, COUNT(*) as cnt, AVG(salary) as avg_salary FROM ? GROUP BY dept ORDER BY cnt DESC";
// Time parsing + execution without cache
const start1 = Date.now();
for (let i = 0; i < 10; i++) {
await SimpleColumnarStore.sqlQuery(tmp, sql);
}
const time1 = Date.now() - start1;
// Time execution with pre-parsed tokens
const parsedTokens = SimpleColumnarStore._parseSql(sql);
const start2 = Date.now();
for (let i = 0; i < 10; i++) {
await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
}
const time2 = Date.now() - start2;
// Cached version should be faster (parsing is skipped)
console.log(`Without cache: ${time1}ms, With cache: ${time2}ms`);
time2.should.be.below(time1); // Cached should be faster
// Results should be identical
const result1 = await SimpleColumnarStore.sqlQuery(tmp, sql);
const result2 = await SimpleColumnarStore.sqlQuery(tmp, sql, parsedTokens);
result1.should.deepEqual(result2);
// Clean up
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
});
it("supports configurable output property for results", async function () {
const tmp = path.join(os.tmpdir(), "output-prop-test.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
const records = [
{ id: 1, name: "Alice", age: 25 },
{ id: 2, name: "Bob", age: 30 },
];
await SimpleColumnarStore.writeRecords(records, tmp);
// Test that sqlQuery returns results correctly
const sqlResult = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT name FROM ? WHERE age > 26",
);
sqlResult.length.should.equal(1);
sqlResult[0].name.should.equal("Bob");
// Simulate node behavior with different output properties
const mockMsg1 = { payload: {} };
const outputProperty1 = "result";
mockMsg1[outputProperty1] = sqlResult;
mockMsg1.should.have.property("result");
mockMsg1.result[0].name.should.equal("Bob");
const mockMsg2 = { payload: {} };
const outputProperty2 = "myData";
mockMsg2[outputProperty2] = sqlResult;
mockMsg2.should.have.property("myData");
mockMsg2.should.not.have.property("result");
mockMsg2.myData[0].name.should.equal("Bob");
// Test default behavior (empty outputProperty should default to 'result')
const mockMsg3 = { payload: {} };
const outputProperty3 = null; // should default to 'result'
const defaultProp = outputProperty3 || "result";
mockMsg3[defaultProp] = sqlResult;
mockMsg3.should.have.property("result");
mockMsg3.result[0].name.should.equal("Bob");
// Clean up
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
});
it("supports parameter substitution in SQL queries", async function () {
const tmp = path.join(os.tmpdir(), "prib-test-params.columnar");
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
const records = [
{ id: 1, name: "Alice", age: 25, dept: "eng", salary: 50000 },
{ id: 2, name: "Bob", age: 30, dept: "hr", salary: 60000 },
{ id: 3, name: "Charlie", age: 35, dept: "eng", salary: 70000 },
{ id: 4, name: "Diana", age: 28, dept: "hr", salary: 55000 },
];
await SimpleColumnarStore.writeRecords(records, tmp);
// Test explicit :msg., :flow., :global. parameter markers
const msg = { payload: { minAge: 28, personName: "Bob" } };
const flow = { minSalary: 60000 };
const global = { dept: "eng" };
const context = { msg, flow, global };
// :msg. marker
const result1 = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE age > :msg.payload.minAge",
null,
context,
);
result1.length.should.equal(2);
result1[0].name.should.equal("Bob");
result1[1].name.should.equal("Charlie");
// :flow. marker
const result2 = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE salary > :flow.minSalary",
null,
context,
);
result2.length.should.equal(1);
result2[0].name.should.equal("Charlie");
// :global. marker
const result3 = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE dept = :global.dept",
null,
context,
);
result3.length.should.equal(2);
result3[0].name.should.equal("Alice");
result3[1].name.should.equal("Charlie");
// Multiple explicit markers
const result4 = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE age >= :msg.payload.minAge AND dept = :global.dept",
null,
context,
);
result4.length.should.equal(1);
result4[0].name.should.equal("Charlie");
// Test missing parameter should throw error
try {
await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE age > :msg.payload.missingParam",
null,
context,
);
should.fail("Should have thrown error for missing parameter");
} catch (error) {
error.message.should.match(
/Parameter ':msg.payload.missingParam' not provided/,
);
}
// SQL injection attempt should not succeed
{
const injectionContext = {
msg: { payload: { personName: "Bob' OR 1=1 --" } },
flow: {},
global: {},
};
// This should only match a name exactly equal to the injected string, not all rows
const injResult = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT * FROM ? WHERE name = :msg.payload.personName",
null,
injectionContext,
);
injResult.length.should.equal(0);
}
// Test parameter substitution in SELECT clause
{
const result = await SimpleColumnarStore.sqlQuery(
tmp,
"SELECT :msg.payload.minAge as age_threshold, name FROM ? LIMIT 5",
null,
{ msg: { payload: { minAge: 28 } }, flow: {}, global: {} },
);
result.length.should.equal(4);
result[0].should.have.property("age_threshold", 28);
result[0].should.have.property("name");
}
// Clean up
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
});
});