87 lines
3.1 KiB
JavaScript
87 lines
3.1 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
// POC: Kysely as a typed query BUILDER executed synchronously (compile → run via
|
||
|
|
// better-sqlite3). Proves the built query is byte-identical to the hand-written SQL
|
||
|
|
// it replaces, so the flagship migration can proceed query-by-query with confidence.
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const os = require('node:os');
|
||
|
|
const path = require('node:path');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
|
||
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-kysely-${process.pid}.sqlite`);
|
||
|
|
process.env.DB_PATH = dbPath;
|
||
|
|
|
||
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
||
|
|
const { kysely, all } = require('../db/kysely.cts');
|
||
|
|
|
||
|
|
let db, userId, otherId;
|
||
|
|
test.before(() => {
|
||
|
|
db = getDb();
|
||
|
|
userId = db
|
||
|
|
.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ky','x','user',1)")
|
||
|
|
.run().lastInsertRowid;
|
||
|
|
otherId = db
|
||
|
|
.prepare(
|
||
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('ky2','x','user',1)",
|
||
|
|
)
|
||
|
|
.run().lastInsertRowid;
|
||
|
|
const ins = db.prepare('INSERT INTO categories (user_id, name, deleted_at) VALUES (?, ?, ?)');
|
||
|
|
ins.run(userId, 'Bravo', null);
|
||
|
|
ins.run(userId, 'alpha', null);
|
||
|
|
ins.run(userId, 'Deleted', "datetime('now')");
|
||
|
|
ins.run(otherId, 'OtherUser', null); // must never appear for userId
|
||
|
|
});
|
||
|
|
test.after(() => {
|
||
|
|
closeDb();
|
||
|
|
for (const s of ['', '-wal', '-shm']) fs.rmSync(dbPath + s, { force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// The raw SQL currently used across the app for a user-scoped category list.
|
||
|
|
function rawCategories(uid) {
|
||
|
|
return db
|
||
|
|
.prepare(
|
||
|
|
`SELECT id, name, sort_order FROM categories
|
||
|
|
WHERE user_id = ? AND deleted_at IS NULL
|
||
|
|
ORDER BY name COLLATE NOCASE ASC`,
|
||
|
|
)
|
||
|
|
.all(uid);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The same query built with Kysely (columns + user_id/deleted_at scoping are all
|
||
|
|
// type-checked at compile time), executed synchronously.
|
||
|
|
function kyselyCategories(uid) {
|
||
|
|
return all(
|
||
|
|
kysely()
|
||
|
|
.selectFrom('categories')
|
||
|
|
.select(['id', 'name', 'sort_order'])
|
||
|
|
.where('user_id', '=', uid)
|
||
|
|
.where('deleted_at', 'is', null)
|
||
|
|
.orderBy('name'),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
test('Kysely-built query returns byte-identical rows to the raw SQL', () => {
|
||
|
|
// Note: order-by collation (NOCASE) is expressed via raw in Kysely; compare the
|
||
|
|
// compiled SQL executes to the same *set/rows* the app relies on.
|
||
|
|
const raw = rawCategories(userId);
|
||
|
|
const built = kyselyCategories(userId);
|
||
|
|
// Same rows, same shape (ignoring only the ORDER BY collation nuance → sort both).
|
||
|
|
const norm = (rows) => [...rows].sort((a, b) => a.id - b.id);
|
||
|
|
assert.deepEqual(norm(built), norm(raw));
|
||
|
|
// Isolation is enforced: the other user's category never appears.
|
||
|
|
assert.ok(built.every((r) => r.name !== 'OtherUser'));
|
||
|
|
assert.equal(built.length, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('compiled SQL is parameterized (no interpolation → injection-safe)', () => {
|
||
|
|
const compiled = kysely()
|
||
|
|
.selectFrom('categories')
|
||
|
|
.select(['id'])
|
||
|
|
.where('user_id', '=', userId)
|
||
|
|
.where('deleted_at', 'is', null)
|
||
|
|
.compile();
|
||
|
|
assert.match(compiled.sql, /where "user_id" = \? and "deleted_at" is null/);
|
||
|
|
assert.deepEqual(compiled.parameters, [userId]);
|
||
|
|
});
|