diff options
author | EuAndreh <eu@euandre.org> | 2024-02-23 11:11:38 -0300 |
---|---|---|
committer | EuAndreh <eu@euandre.org> | 2024-02-23 11:21:21 -0300 |
commit | cd50a23dab8623f232da7d6b1b9511f4590e9788 (patch) | |
tree | 349e111763eac7853d9f1dd7a61e9bce117f3993 | |
parent | Big cleanup (diff) | |
download | papod-cd50a23dab8623f232da7d6b1b9511f4590e9788.tar.gz papod-cd50a23dab8623f232da7d6b1b9511f4590e9788.tar.xz |
Implement accretion.runMigrations() and wrappings of node-sqlite3
-rw-r--r-- | Makefile | 1 | ||||
-rw-r--r-- | deps.mk | 2 | ||||
-rw-r--r-- | src/accretion.mjs | 43 | ||||
-rw-r--r-- | src/db.mjs | 87 | ||||
-rw-r--r-- | src/sqlite.cjs | 1 | ||||
-rw-r--r-- | tests/js/accretion.mjs | 35 | ||||
-rw-r--r-- | tests/js/db.mjs | 126 | ||||
-rw-r--r-- | tests/js/utils.mjs | 45 |
8 files changed, 298 insertions, 42 deletions
@@ -47,6 +47,7 @@ sources = \ installable = \ $(sources.sql) \ $(sources.mjs) \ + src/sqlite.cjs \ src/package.json \ @@ -1,4 +1,5 @@ sources.mjs = \ + src/accretion.mjs \ src/api.mjs \ src/bin.mjs \ src/db.mjs \ @@ -9,6 +10,7 @@ sources.mjs = \ src/web.mjs \ tests.mjs = \ + tests/js/accretion.mjs \ tests/js/db.mjs \ tests/js/escape.mjs \ tests/js/hero.mjs \ diff --git a/src/accretion.mjs b/src/accretion.mjs new file mode 100644 index 0000000..117e291 --- /dev/null +++ b/src/accretion.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +import sqlite from "./sqlite.cjs"; + +import { difference, log } from "./utils.mjs"; + + +const DIRNAME = path.dirname(url.fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = DIRNAME + "/sql/migrations/"; + +export const runMigrations = async db => { + assert(db); + + await db.exec(` + BEGIN TRANSACTION; + CREATE TABLE IF NOT EXISTS migrations ( + filename TEXT NOT NULL PRIMARY KEY + ); + COMMIT TRANSACTION; + `); + + const done = [...(await db.all("SELECT filename FROM migrations;"))] + .map(row => row.filename); + const allFiles = fs.readdirSync(MIGRATIONS_DIR, "UTF-8"); + const pending = difference(new Set(allFiles), new Set(done)); + const sortedPending = [...pending] + .sort((a, b) => a.localeCompare(b, "POSIX")); + + for (const filename of sortedPending) { + log({ log: "exec-migration", filename }); + const sql = fs.readFileSync(MIGRATIONS_DIR + filename, "UTF-8"); + await db.exec("BEGIN TRANSACTION;"); + await db.exec(sql); + await db.run( + "INSERT INTO migrations (filename) VALUES ($filename);", + { $filename: filename }, + ); + await db.exec("COMMIT TRANSACTION;") + } +}; @@ -3,47 +3,56 @@ import fs from "node:fs"; import path from "node:path"; import url from "node:url"; -import { difference, log } from "./utils.mjs"; +import sqlite from "./sqlite.cjs"; +import { runMigrations } from "./accretion.mjs"; -const DIRNAME = path.dirname(url.fileURLToPath(import.meta.url)); -const MIGRATIONS_DIR = DIRNAME + "/sql/migrations/"; -const MIGRATION_FILENAMES = fs.readdirSync(MIGRATIONS_DIR, "UTF-8"); -let db = null; +export const promisify = nativeFn => (...args) => + new Promise((resolve, reject) => + nativeFn(...args, (err, data) => err ? reject(err) : resolve(data))); + +export const promisifyDb = dbHandle => ({ + ref: dbHandle, + all: promisify((...args) => dbHandle.all(...args)), + exec: promisify((...args) => dbHandle.exec(...args)), + run: promisify((...args) => dbHandle.run(...args)), + each: (...args) => + new Promise((resolve, reject) => { + const cb = args[args.length - 1]; + dbHandle.each( + ...args.slice(0, args.length - 1), + (err, row) => err ? reject(err) : cb(row), + (err, data) => err ? reject(err) : resolve(data), + ); + }), + prepare: (...args) => + new Promise((resolve, reject) => { + const mkStmt = stmtRef => ({ + ref: stmtRef, + run: promisify((...args) => stmtRef.run(...args)), + finalize: promisify((...args) => stmtRef.finalize(...args)), + }); + const ref = dbHandle.prepare( + ...args, + err => err ? reject(err) : resolve(mkStmt(ref)), + ); + }), +}); + +export const open = (...args) => + new Promise((resolve, reject) => { + const dbName = args[0]; + assert(dbName); + const ref = promisifyDb(new sqlite.Database( + ...args, + err => err ? reject(err) : resolve(ref), + )); + }); + +export let db = null; + export const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { - assert(dbName); - - db = await sqlite.open(dbName); - await db.exec(` - BEGIN TRANSACTION; - CREATE TABLE IF NOT EXISTS migrations ( - filename TEXT PRIMARY KEY - ); - COMMIT TRANSACTION; - `); - - const done = await db.all(` - SELECT filename FROM migrations; - `).map(row => row.filename); - const pending = difference( - new Set(MIGRATION_FILENAMES), - new Set(done) - ); - const sortedPending = [...pending].sort((a, b) => a.localeCompare(b)); - - await db.exec("BEGIN TRANSACTION;"); - for (const filename of sortedPending) { - log({ - log: "exec-migration", - filename, - }); - const sql = fs.readFileSync(MIGRATIONS_DIR + filename, "UTF-8"); - await db.exec(sql); - await db.run( - `INSERT INTO migrations (filename) VALUES ($filename);`, - { filename }, - ); - } - await db.exec("COMMIT TRANSACTION;"); + db = await open(dbName); + await runMigrations(db); }; diff --git a/src/sqlite.cjs b/src/sqlite.cjs new file mode 100644 index 0000000..a1b8ab7 --- /dev/null +++ b/src/sqlite.cjs @@ -0,0 +1 @@ +module.exports = require("sqlite3"); diff --git a/tests/js/accretion.mjs b/tests/js/accretion.mjs new file mode 100644 index 0000000..3ea90c3 --- /dev/null +++ b/tests/js/accretion.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; + +import sqlite from "../../src/sqlite.cjs"; + +import { runTests } from "../runner.mjs"; +import { promisifyDb, open } from "../../src/db.mjs"; +import { runMigrations } from "../../src/accretion.mjs"; + + +const test_runMigrations = t => { + t.start("runMigrations()"); + + t.test("running twice is a noop", async () => { + const db = await open(":memory:"); + const migrationsFn = () => db.all("SELECT filename FROM migrations;"); + + assert.rejects( + migrationsFn, + { message: "SQLITE_ERROR: no such table: migrations" }, + ); + + await runMigrations(db); + const filled = await migrationsFn(); + + await runMigrations(db); + const unchanged = await migrationsFn(); + + assert.deepEqual(filled, unchanged); + }); +}; + + +await runTests([ + test_runMigrations, +]); diff --git a/tests/js/db.mjs b/tests/js/db.mjs index e4a8a51..372f64a 100644 --- a/tests/js/db.mjs +++ b/tests/js/db.mjs @@ -1,15 +1,135 @@ +import assert from "node:assert/strict"; + +import sqlite from "../../src/sqlite.cjs"; + import { runTests } from "../runner.mjs"; -import { init } from "../../src/db.mjs"; +import { promisify, promisifyDb, open, db, init } from "../../src/db.mjs"; + + +const test_promisify = t => { + t.start("promisify()"); + + t.test("we wrap the callbacky function", async () => { + const okFn1 = (a, b, cb) => + setTimeout(() => cb(null, { a, b, ok: true })); + const errFn1 = (a, b, c, cb) => + setTimeout(() => cb({ err: true, a }, "ignored")); + + const okFn2 = promisify(okFn1); + const errFn2 = promisify(errFn1); + + assert.deepEqual( + await okFn2("a-value", "b-value"), + { + a: "a-value", + b: "b-value", + ok: true, + }, + ); + + assert.rejects( + async () => await errFn2("aa", "bb", "cc"), + { + err: true, + a: "aa", + }, + ); + }); +}; +const test_promisifyDb = t => { + t.start("promisifyDb()"); + + const createTable = "CREATE TABLE table_ (column INTEGER NOT NULL PRIMARY KEY);"; + + t.test("we can access the underlying database and statement ref", async () => { + const db = await open(":memory:"); + assert.ok(db.ref instanceof sqlite.Database); + + const stmt = await db.prepare("SELECT 1;"); + assert.ok(stmt.ref instanceof sqlite.Statement); + }); + + t.test("we can run the wrapped fns", async () => { + const db = await open(":memory:"); + + await db.exec(createTable); + const stmt = await db.prepare("INSERT INTO table_ (column) VALUES ($column)"); + await stmt.run({ $column: 3 }); + await stmt.run({ $column: 1 }); + await stmt.finalize(); + assert.rejects( + async () => await stmt.run({ $column: 2 }), + { + message: "SQLITE_MISUSE: Statement is already finalized", + code: "SQLITE_MISUSE", + }, + ); + + const selectAll = "SELECT column FROM table_ ORDER BY column;"; + + const all = await db.all(selectAll); + assert.deepEqual(all, [{ column: 1 }, { column: 3 }]); + + const contents = []; + const cb = row => contents.push(row); + await db.each(selectAll, cb); + assert.deepEqual(contents, [{ column: 1 }, { column: 3 }]); + }); +}; + +const test_open = t => { + t.start("open()"); + + t.test("we must provide a name", () => { + assert.rejects( + async () => await open(), + assert.AssertionError, + ); + + assert.rejects( + async () => await open(undefined), + assert.AssertionError, + ); + + assert.rejects( + async () => await open(null), + assert.AssertionError, + ); + + assert.rejects( + async () => await open(""), + assert.AssertionError, + ); + }); + + t.test("failure to open causes a promise rejection", () => { + assert.rejects( + async () => await open("tests/non/existing/directory/and/file"), + { + message: "SQLITE_CANTOPEN: unable to open database file", + code: "SQLITE_CANTOPEN", + }, + ); + }); +}; const test_init = t => { t.start("init()"); - t.test("FIXME", () => { - // init(); + t.test("we only know how to deal with 1 database", async () => { + await init(); + const ref1 = db; + await init(); + const ref2 = db; + + assert.notDeepEqual(ref1, ref2); }); }; await runTests([ + test_promisify, + test_promisifyDb, + test_open, test_init, ]); diff --git a/tests/js/utils.mjs b/tests/js/utils.mjs index 1875ce5..94e8eae 100644 --- a/tests/js/utils.mjs +++ b/tests/js/utils.mjs @@ -4,6 +4,7 @@ import { runTests } from "../runner.mjs"; import { eq, keys, + difference, assocIn, getIn, first, @@ -126,6 +127,49 @@ const test_keys = t => { }); }; +const test_difference = t => { + t.start("difference()"); + + t.test("empty values", () => { + assert.deepEqual( + difference(new Set(), new Set()), + new Set(), + ); + + assert.deepEqual( + difference(new Set(), new Set([1, 2])), + new Set(), + ); + + assert.deepEqual( + difference(new Set([1, 2]), new Set()), + new Set([1, 2]), + ); + }); + + t.test("different subsets", () => { + assert.deepEqual( + difference(new Set([1, 2]), new Set([3, 4])), + new Set([1, 2]), + ); + + assert.deepEqual( + difference(new Set([1, 2, 3]), new Set([2, 4, 5])), + new Set([1, 3]), + ); + + assert.deepEqual( + difference(new Set([1]), new Set([1, 2, 3, 4, 5])), + new Set(), + ); + + assert.deepEqual( + difference(new Set([1, 2, 3]), new Set([1, 2, 3])), + new Set(), + ); + }); +}; + const test_assocIn = t => { t.start("assocIn()"); @@ -218,6 +262,7 @@ const test_log = t => { await runTests([ test_eq, test_keys, + test_difference, test_assocIn, test_getIn, test_first, |