From 47d0ec687bc5caa6823f16803b4f2d6d0d769820 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Sun, 29 Dec 2024 11:15:46 -0500 Subject: [PATCH] Initial commit --- .gitignore | 33 +++ README.md | 140 ++++++++++++ build.ts | 8 + bun.lockb | Bin 0 -> 10560 bytes package.json | 22 ++ src/columns.ts | 115 ++++++++++ src/error.ts | 11 + src/index.ts | 14 ++ src/instance.ts | 49 ++++ src/prequel.ts | 193 ++++++++++++++++ src/table.ts | 515 ++++++++++++++++++++++++++++++++++++++++++ test/instance.test.ts | 53 +++++ test/prequel.test.ts | 137 +++++++++++ test/table.test.ts | 386 +++++++++++++++++++++++++++++++ tsconfig.json | 28 +++ 15 files changed, 1704 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.ts create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/columns.ts create mode 100644 src/error.ts create mode 100644 src/index.ts create mode 100644 src/instance.ts create mode 100644 src/prequel.ts create mode 100644 src/table.ts create mode 100644 test/instance.test.ts create mode 100644 test/prequel.test.ts create mode 100644 test/table.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e089a37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependency directories +node_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Output of 'npm pack' +*.tgz + +# dotenv environment variable files +.env + +# Next.js build output +.next +out + +# FuseBox cache +.fusebox/ + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Finder (MacOS) folder config +.DS_Store + +# Dist dirs +dist + +# Databases +*.sqlite* + +# Top level configs +.config \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2061db6 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Prequel + +Streamlined, type-safe SQLite without too much _cruft_. + +_(I figured I should add on to the massive pile of sql-related projects called "prequel")_ + +## Usage + +Prequel is designed to handle some of the boilerplate for getting going with a SQLite db. It handles setting up tables and basic CRUD statements and otherwise wraps a SQLite DB to allow for using raw SQL alongside the pre-built queries. + +```typescript +import { Prequel } from "@foundry/prequel"; + +interface UserRow { + user_id: number; + name: string; +} + +const db = new Prequel("db.sqlite"); +const users = db.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", +}); + +users.insertPartial({ + name: "Flynn", +}); + +users.findAll(); // [{user_id: 1, name: "Flynn"}] + +const findByName = db.prepare(`SELECT * FROM ${users} WHERE name = ?`); +findByName.get("Flynn"); // {user_id: 1, name: "Flynn"} +``` + +## Defining columns + +When defining the shape of a table, you can specify either a string containing a valid SQLite data type (limited to the five main types), or an object with further options. + +```typescript +{ + type: "TEXT", // or "INTEGER", "REAL", "BLOB", "NULL" + nullable: true, // Default is non-nullable columns + primary: true, // Default is non-primary column + misc: "" // Specify misc sql to append to the column def + references: othertable.reference() // Specifies a foreign key ref + cascade: true, // Cascading deletes. Default is off + default: "" // Optionally provide a default value +} +``` + +## API + +### `new Prequel(filename?: string)` + +Creates a new `Prequel` object which wraps a SQLite db. + +### `prequel.table(name: string, columns: ColumnDefs)` + +Ensures the existance of the specified table in the DB + +### `prequel.prepare(query: string)` + +Alias for the underlying `database.query` call, preparing a SQL statement and caching the statement if possible. + +### `prequel.enableWAL()` + +Executes the pragma statement to enable [WAL](https://www.sqlite.org/wal.html) mode + +### `prequel.close()` + +Safely close the database + +### `prequel.transaction(fn: () => void)` + +Execute a function within a transaction. If the function throws an error, the transaction is rolled back. + +Returns `true` when the transaction was successful. Throws any errors that occur during the transaction. + +### `new Table(db: Database | Prequel, name: string, columns: ColumnDefs)` + +Create a new table. Can be used without a Prequel object. If you pass in a Prequel object instead of a raw Database object, the table will be attached to the Prequel object as if it were created with `prequel.table()` + +### `table.reference(colName?: string)` + +Returns a reference usable with the `reference:` field in a column definition. Optionally provide a column name to reference. If no column name is provided, the tables primary key column is used. + +### `table.insert(data: RowShape)` + +Insert a fully formed row into the table + +### `table.insertPartial(data: Partial)` + +Insert a partial row into the table. Useful for generating IDs and leverage defaults. + +### `table.update(data: RowShape)` + +Given a complete row with a valid primary key value, update the row in the db. + +This can only be used if a column has been set to `primary: true`. + +### `table.findOnebyId(id: string | number)` + +Return a single row with the given ID + +### `table.findAll()` + +Return all of the rows in this table + +### `table.deleteById(id: string | number)` + +Delete a row with the given ID + +### `table.size()` + +Count the rows in the table + +### `table.toString()` + +The `toString()` behavior of a `Table` object returns the name of the table. This allows you to use the table object in queries for consistency: + +`SELECT * FROM ${users} WHERE user_id = ?` + +### `table.name` + +The name of the table + +### `table.db` + +The Database object this Table is a part of + +### `table.columns` + +A list of the column names on this table in the order they exist in the DB + +### `table.schema` + +The raw SQL that was used to generate this table diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..db8aa0d --- /dev/null +++ b/build.ts @@ -0,0 +1,8 @@ +import dts from "bun-plugin-dts"; + +await Bun.build({ + entrypoints: ["./src/index.ts"], + outdir: "./dist", + plugins: [dts()], + target: "bun", +}); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..da595480330b6c51423f09d0bc0ac074386801fe GIT binary patch literal 10560 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 D;Ppl0 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d532f11 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@endeavorance/prequel", + "version": "1.0.0", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "bun run ./build.ts", + "clean": "rm -rf dist" + }, + "keywords": [], + "author": "Endeavorance (https://endeavorance.camp)", + "license": "CC BY-NC-SA 4.0", + "files": ["dist"], + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "bun-plugin-dts": "^0.2.3" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/src/columns.ts b/src/columns.ts new file mode 100644 index 0000000..39e73e4 --- /dev/null +++ b/src/columns.ts @@ -0,0 +1,115 @@ +type RedefineValueTypes = { + [K in keyof ShapeToRedefine]: NewValueType; +}; + +/** Types that define the data type of a column in the DB */ +export type DataType = "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL"; + +export interface Column { + /** The data type to store this column */ + type: DataType; + nullable: boolean; + primary: boolean; + + /** Any additional freeform SQL */ + misc: string; + + /** If set, creates a foreign key association. See Table.reference() */ + references: string; + cascade: boolean; + default: string | number | undefined; + unique: boolean; + autoincrement: boolean; +} + +export type ColumnShorthand = DataType | Partial; + +export type ColumnShorthandMap = RedefineValueTypes; +export type ColumnMap = RedefineValueTypes; + +export function generateColumnDefinitionSQL( + name: string, + colDef: Column, +): string { + const parts: string[] = [`"${name}"`, colDef.type]; + + if (colDef.primary) { + parts.push("PRIMARY KEY"); + } + + if (!colDef.primary && !colDef.nullable) { + parts.push("NOT NULL"); + } + + if (colDef.unique) { + parts.push("UNIQUE"); + } + + if (colDef.autoincrement) { + parts.push("AUTOINCREMENT"); + } + + if (colDef.default !== undefined) { + parts.push(`DEFAULT (${colDef.default})`); + } + + if (colDef.references.length) { + parts.push(`REFERENCES ${colDef.references}`); + + if (colDef.cascade) { + parts.push("ON DELETE CASCADE"); + } + } + + if (colDef.misc.length) { + parts.push(colDef.misc); + } + + return parts.join(" "); +} + +function expandColumnShorthand(col: ColumnShorthand): Column { + if (typeof col === "string") { + return { + type: col, + nullable: false, + primary: false, + misc: "", + references: "", + cascade: false, + default: undefined, + unique: false, + autoincrement: false, + }; + } + + return { + type: col.type ?? "TEXT", + nullable: col.nullable ?? false, + primary: col.primary ?? false, + misc: col.misc ?? "", + references: col.references ?? "", + cascade: col.cascade ?? false, + default: col.default ?? undefined, + unique: col.unique ?? false, + autoincrement: col.autoincrement ?? false, + }; +} + +export function normalizeColumns(cols: ColumnShorthandMap): ColumnMap { + const keys = Object.keys(cols); + + const retval: Record = {}; + + for (const key of keys) { + const val = cols[key as keyof typeof cols]; + + if (val === undefined) { + throw new Error("Invariant: Undefined column descriptor"); + } + + retval[key] = expandColumnShorthand(val); + } + + return retval as ColumnMap; +} diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..b6ea192 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,11 @@ +export class SchemaError extends Error { + constructor(message: string, table?: string, column?: string) { + if (typeof table === "string" && typeof column === "string") { + super(`SchemaError: ${message} ON ${table}.${column}`); + } else if (typeof table === "string") { + super(`SchemaError: ${message} ON ${table}`); + } else { + super(`SchemaError: ${message}`); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3456f97 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import type { Column, ColumnShorthand, DataType } from "./columns"; +import { type ExtractRowShape, Instance } from "./instance"; +import { Prequel } from "./prequel"; +import { Table } from "./table"; + +export { + Prequel, + Table, + Instance, + type DataType, + type Column, + type ColumnShorthand, + type ExtractRowShape, +}; diff --git a/src/instance.ts b/src/instance.ts new file mode 100644 index 0000000..49b322f --- /dev/null +++ b/src/instance.ts @@ -0,0 +1,49 @@ +import type { Table } from "./table"; + +export type ExtractRowShape = T extends Table ? R : never; + +/** + * Represents an instance of a row in a table of a database. + * Wraps the raw Row data and provides methods for interacting with it. + */ +export abstract class Instance< + TableType extends Table, + SerializedInstanceType = void, +> { + protected row: ExtractRowShape; + protected table: TableType; + + constructor(table: TableType, row: ExtractRowShape) { + this.row = row; + this.table = table; + } + + /** + * Saves any changes made to the row back to the database. + */ + save() { + this.table.update(this.row); + } + + /** + * Serializes the instance into a generic format + */ + serialize(): SerializedInstanceType { + throw new Error("Not implemented"); + } + + /** + * @returns The row data as a JSON string. + */ + toJSON(): string { + return JSON.stringify(this.row); + } + + /** + * Exports the raw row data. + * @returns The raw row data. + */ + export(): ExtractRowShape { + return this.row; + } +} diff --git a/src/prequel.ts b/src/prequel.ts new file mode 100644 index 0000000..ad12cf7 --- /dev/null +++ b/src/prequel.ts @@ -0,0 +1,193 @@ +import Database from "bun:sqlite"; +import type { ColumnShorthandMap } from "./columns"; +import { SchemaError } from "./error"; +import { Table } from "./table"; + +interface KVRow { + kv_id: number; + key: string; + value: string; +} + +/** + * Represents a key-value store using Prequel. + */ +export class PrequelKVStore { + private table: Table; + + /** + * Creates a new instance of the PrequelKVStore class. + * @param pq The Prequel instance. + * @param tableName The name of the table. + */ + constructor(pq: Prequel, tableName: string) { + this.table = pq.table(tableName, { + kv_id: { + type: "INTEGER", + primary: true, + autoincrement: true, + }, + key: "TEXT", + value: "TEXT", + }); + } + + /** + * Inserts or updates a key-value pair in the store. + * @param key The key. + * @param value The value. + */ + put(key: string, value: T): void { + const lookup = this.table.findOneWhere({ + key, + }); + + if (lookup === null) { + this.table.insertPartial({ + key, + value: JSON.stringify(value), + }); + } else { + lookup.value = JSON.stringify(value); + this.table.update(lookup); + } + } + + /** + * Retrieves the value associated with the specified key from the store. + * @param key The key. + * @returns The value associated with the key, or null if the key is not found. + */ + get(key: string): T | null { + const lookup = this.table.findOneWhere({ key }); + + if (lookup === null) { + return null; + } + + return JSON.parse(lookup.value) as T; + } + + /** + * Checks if a key exists in the table. + * @param key - The key to check. + * @returns `true` if the key exists, `false` otherwise. + */ + has(key: string): boolean { + return ( + this.table.findOneWhere({ + key, + }) !== null + ); + } + + /** + * Get an array of all of the keys in the KV store + * @returns The keys in the store. + */ + keys(): string[] { + return this.table.findAll().map((row) => row.key); + } + + /** + * Get an array of all of the values in the KV store + * @returns The values in the store. + */ + values(): unknown[] { + return this.table.findAll().map((row) => JSON.parse(row.value)); + } +} + +/** + * Represents a Prequel database instance. + */ +export class Prequel { + public db: Database; + public kv: PrequelKVStore; + public tables: Record> = {}; + + /** + * Creates a new Prequel database instance. + * @param filename The filename of the database. Defaults to ":memory:" if not provided. + */ + constructor(filename = ":memory:") { + this.db = new Database(filename); + this.db.exec("PRAGMA foreign_keys=ON"); + this.kv = new PrequelKVStore(this, "PrequelManagedKVStore"); + } + + /** + * Enables Write-Ahead Logging (WAL) mode for the database. + */ + enableWAL() { + this.db.exec("PRAGMA journal_mode=WAL;"); + } + + /** + * Closes the database connection. + */ + close() { + this.db.close(); + } + + /** + * Checks if a table with the given name exists in the database. + * @param name The name of the table. + * @returns `true` if the table exists, `false` otherwise. + */ + hasTable(name: string): boolean { + return Object.keys(this.tables).includes(name); + } + + /** + * Creates a new table in the database. + * @param name The name of the table. + * @param cols The column definitions of the table. + * @returns The created table. + * @throws {SchemaError} If a table with the same name already exists. + */ + table( + name: string, + cols: ColumnShorthandMap, + ): Table { + if (this.hasTable(name)) { + throw new SchemaError("Duplicate table name", name); + } + + const newTable = new Table(this, name, cols); + this.tables[name] = newTable; + return newTable; + } + + /** + * Attaches an existing table to the database. + * @param table The table to attach. + * @throws {SchemaError} If a table with the same name already exists. + */ + attachTable(table: Table) { + if (this.hasTable(table.name)) { + throw new SchemaError("Duplicate table name", table.name); + } + + this.tables[table.name] = table; + } + /** + * Executes a function within a database transaction. + * If the function throws an error, the transaction is rolled back. + * Otherwise, the transaction is committed. + * @param transactionFn The function to execute within the transaction. + * @returns `true` if the transaction was committed successfully, otherwise `false`. + * @throws The error thrown by the transaction function if the transaction fails. + */ + transaction(transactionFn: () => T): T { + try { + this.db.exec("BEGIN TRANSACTION;"); + const result: T = transactionFn(); + this.db.exec("COMMIT;"); + return result; + } catch (error) { + this.db.exec("ROLLBACK;"); + throw error; + } + } +} diff --git a/src/table.ts b/src/table.ts new file mode 100644 index 0000000..8aac434 --- /dev/null +++ b/src/table.ts @@ -0,0 +1,515 @@ +import type { Database, Statement } from "bun:sqlite"; +import { + type ColumnMap, + type ColumnShorthandMap, + generateColumnDefinitionSQL, + normalizeColumns, +} from "./columns"; +import { SchemaError } from "./error"; +import { Prequel } from "./prequel"; + +/** Types that may appear in a row */ +type Value = string | number | null; + +export class PrequelIDNotFoundError extends Error { + constructor(tableName: string, id: Value) { + super(`ID ${id} not found in ${tableName}`); + } +} + +export class PrequelEmptyResultsError extends Error { + constructor() { + super("No results found on a query which requires results"); + } +} + +/** An arbitrarily shaped Row */ +interface ArbitraryRow { + [key: string]: Value; +} + +function shapeForQuery(obj: Partial): Record { + const asInsertDataShape: Record = {}; + + const keys = Object.keys(obj); + + for (const key of keys) { + const val = obj[key as keyof T]; + + if (typeof val !== "string" && typeof val !== "number" && val !== null) { + throw new SchemaError( + `Invalid value in query: ${key}: ${val} (type: ${typeof val})`, + ); + } + + const asQueryKey = `$${key}`; + asInsertDataShape[asQueryKey] = val as Value; + } + + return asInsertDataShape; +} + +/** + * Represents a table in a database. + * + * @typeParam RowShape - The shape of the rows in the table. + */ +export class Table { + private _name: string; + private _db: Database; + private _columnDefinitions: ColumnMap; + private _columnNames: string[]; + private _createTableSQL: string; + private _primaryColumnName = "ROWID"; + private _insertQuery: Statement; + private _updateQuery: Statement; + private _findQuery: Statement; + private _deleteQuery: Statement; + private _deleteRowQuery: Statement; + private _sizeQuery: Statement<{ count: number }, []>; + private _truncateQuery: Statement; + + constructor( + db: Database | Prequel, + name: string, + cols: ColumnShorthandMap, + ) { + if (db instanceof Prequel) { + this._db = db.db; + } else { + this._db = db; + } + this._name = name; + this._columnDefinitions = normalizeColumns(cols); + this._columnNames = Object.keys(this._columnDefinitions); + + const columnSQLParts: string[] = []; + const updateColumnSQLParts: string[] = []; + + for (const columnName of this._columnNames) { + const columnDefinition = + this._columnDefinitions[ + columnName as keyof typeof this._columnDefinitions + ]; + + // Identify a primary column besides ROWID if specified + if (columnDefinition.primary && this._primaryColumnName === "ROWID") { + this._primaryColumnName = columnName; + } + + // Add non-primary columns to the generic update query + if (!columnDefinition.primary) { + updateColumnSQLParts.push(`"${columnName}" = $${columnName}`); + } + + // Generate column definitions for CREATE TABLE + columnSQLParts.push( + generateColumnDefinitionSQL(columnName, columnDefinition), + ); + } + + // Generate SQL for common queries + const columnSQL = columnSQLParts.join(", "); + const updateColumnSQL = updateColumnSQLParts.join(", "); + const allCols = this._columnNames.join(", "); + const allColVars = this._columnNames + .map((colName) => `$${colName}`) + .join(", "); + + this._createTableSQL = `CREATE TABLE IF NOT EXISTS "${name}" (${columnSQL});`; + const insertSql = `INSERT INTO "${name}" (${allCols}) VALUES (${allColVars}) RETURNING *;`; + const updateSql = `UPDATE "${name}" SET ${updateColumnSQL} WHERE "${this._primaryColumnName}" = $${this._primaryColumnName} RETURNING *;`; + const getByIdSql = `SELECT * FROM "${name}" WHERE "${this._primaryColumnName}" = ? LIMIT 1;`; + const delByIdSql = `DELETE FROM "${name}" WHERE "${this._primaryColumnName}" = ?;`; + const deleteRowSql = `DELETE FROM "${name}" WHERE "${this._primaryColumnName}" = $${this._primaryColumnName};`; + const truncateQuery = `DELETE FROM "${name}";`; + + // Ensure the table exists in the database + this.db.exec(this._createTableSQL); + + // Prepare common queries + this._insertQuery = this.db.query(insertSql); + this._updateQuery = this.db.query(updateSql); + this._findQuery = this.db.query(getByIdSql); + this._deleteQuery = this.db.query(delByIdSql); + this._sizeQuery = this.db.query<{ count: number }, []>( + `SELECT count(*) as count FROM ${name}`, + ); + this._deleteRowQuery = this.db.query(deleteRowSql); + this._truncateQuery = this.db.query(truncateQuery); + + // If using with a Prequel instance, ensure the table is attached + // This is only for when creating tables directly and passing in a Prequel instance + if (db instanceof Prequel) { + if (!db.hasTable(name)) { + db.attachTable(this); + } + } + } + + protected hasColumn(colName: string) { + return this._columnNames.includes(colName); + } + + /** + * Generates a reference string for a column in the table. + * + * @param colName - The name of the column to reference. If not provided, the primary column name is used. + * @returns A string representing the reference to the specified column or the primary column if no column name is provided. + * @throws {SchemaError} If the specified column name does not exist in the table schema. + */ + public reference(colName?: keyof RowShape): string { + if (colName === undefined) { + return `${this._name}(${this._primaryColumnName})`; + } + + const asString = String(colName); + + if (!this.hasColumn(asString)) { + throw new SchemaError( + "No such column available for reference", + this._name, + asString, + ); + } + + return `${this._name}(${asString})`; + } + + /** + * Inserts a new row into the table. + * + * @param data - The data to be inserted, conforming to the RowShape interface. + * @returns The inserted row, conforming to the RowShape interface. + */ + public insert(data: RowShape): RowShape { + const asInsertDataShape = shapeForQuery(data); + return this._insertQuery.get(asInsertDataShape) as RowShape; + } + + /** + * Inserts a partial row into the table and returns the inserted row. + * + * @param data - An object containing the partial data to be inserted. The keys should match the column names of the table. + * @returns The inserted row as an object of type `RowShape`. + * + * @typeParam RowShape - The shape of the row in the table. + */ + public insertPartial(data: Partial): RowShape { + const asInsertDataShape: ArbitraryRow = shapeForQuery(data); + const keys = Object.keys(data); + const colNames = keys.join(", "); + const varNames = keys.map((keyName) => `$${keyName}`); + + const query = this._db.prepare( + `INSERT INTO ${this} (${colNames}) VALUES (${varNames}) RETURNING *;`, + ); + + return query.get(asInsertDataShape) as RowShape; + } + + /** + * Updates a row in the table with the provided data. + * + * @param data - The data to update the row with, conforming to the RowShape interface. + * @returns The updated row data if the update is successful, or `null` if it fails. + * @throws Will throw an error if the primary column name is "ROWID". + */ + public update(data: RowShape): RowShape | null { + if (this._primaryColumnName === "ROWID") { + throw new Error( + "Cannot use `Table.update()` without setting a primary column", + ); + } + + const asInsertDataShape: ArbitraryRow = shapeForQuery(data); + return this._updateQuery.get(asInsertDataShape); + } + + /** + * Updates rows in the table that match the specified conditions with the provided data. + * + * @param data - An object containing the data to update. The keys should correspond to the column names. + * @param conditions - An object containing the conditions to match the rows that need to be updated. The keys should correspond to the column names. + * @returns An array of updated rows. + */ + public updateWhere( + data: Partial, + conditions: Partial, + ): RowShape[] { + const keys = Object.keys(data); + const setParts = keys.map((key) => `"${key}" = $${key}`); + const setClause = setParts.join(", "); + + const whereKeys = Object.keys(conditions); + const whereParts = whereKeys.map((key) => `"${key}" = $${key}`); + const whereClause = whereParts.join(" AND "); + + const query = this._db.prepare( + `UPDATE ${this} SET ${setClause} WHERE ${whereClause} RETURNING *;`, + ); + + const shapedData = shapeForQuery({ + ...data, + ...conditions, + }); + + return query.all(shapedData); + } + + /** + * Updates all rows in the table with the provided data and returns the updated rows. + * + * @param data - An object containing the partial data to update the rows with. + * The keys of this object should match the column names of the table. + * @returns An array of updated rows. + */ + public updateAll(data: Partial): RowShape[] { + const keys = Object.keys(data); + const setParts = keys.map((key) => `"${key}" = $${key}`); + const setClause = setParts.join(", "); + + const query = this._db.prepare( + `UPDATE ${this} SET ${setClause} RETURNING *;`, + ); + + return query.all(shapeForQuery(data)); + } + + /** + * Checks if an entry with the specified ID exists in the table. + * + * @param id - The ID of the entry to check for existence. + * @returns `true` if an entry with the specified ID exists, otherwise `false`. + */ + public exists(id: Value): boolean { + return this.findOneById(id) !== null; + } + + /** + * Checks if a row exists in the table that matches the given conditions. + * + * @param conditions - An object representing the conditions to match against. + * Each key-value pair represents a column and the value to match. + * @returns `true` if a matching row is found, otherwise `false`. + */ + public existsWhere(conditions: Partial): boolean { + return this.findOneWhere(conditions) !== null; + } + + /** + * Counts the number of rows in the table that match the specified conditions. + * @param conditions The shape of data to count rows by + * @returns The number of rows that match the specified conditions. + */ + public countWhere(conditions: Partial): number { + const keys = Object.keys(conditions); + const whereParts = keys.map((key) => `"${key}" = $${key}`); + const whereClause = whereParts.join(" AND "); + const query = this._db.prepare<{ count: number }, ArbitraryRow>( + `SELECT count(*) as count FROM ${this} WHERE ${whereClause};`, + ); + + const shapedData = shapeForQuery(conditions); + const countResult = query.get(shapedData) ?? { count: 0 }; + return countResult.count; + } + + /** + * Finds a single row by its unique identifier. + * + * @param id - The unique identifier of the row to find. + * @returns The row shape if found, otherwise `null`. + */ + public findOneById(id: Value): RowShape | null { + return this._findQuery.get(`${id}`); + } + + /** + * Finds a single row by its unique identifier. Throws an error if no row is found. + * + * @param id - The unique identifier of the row to find. + * @returns The row shape if found, otherwise `null`. + * + * @throws {Error} If no row is found with the specified ID. + */ + public findOneByIdOrFail(id: Value): RowShape { + const found = this.findOneById(id); + + if (!found) { + throw new PrequelIDNotFoundError(this.name, id); + } + + return found; + } + + /** + * Retrieves all rows from the table. + * + * @returns {RowShape[]} An array of all rows in the table. + */ + public findAll(): RowShape[] { + return this._db.prepare(`SELECT * FROM ${this}`).all(); + } + + /** + * Finds a single row in the table that matches the specified conditions. + * + * @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match. + * @returns The first row that matches the conditions, or `null` if no matching row is found. + */ + public findOneWhere(conditions: Partial): RowShape | null { + const keys = Object.keys(conditions); + const whereParts = keys.map((key) => `"${key}" = $${key}`); + const whereClause = whereParts.join(" AND "); + const query = this._db.prepare( + `SELECT * FROM ${this} WHERE ${whereClause};`, + ); + + return query.get(shapeForQuery(conditions)); + } + + /** + * Finds a single row in the table that matches the specified conditions. Throws an error if no row is found. + * + * @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match. + * @returns The first row that matches the conditions, or `null` if no matching row is found. + * + * @throws {Error} If no row is found that matches the specified conditions. + */ + public findOneWhereOrFail(conditions: Partial): RowShape { + const found = this.findOneWhere(conditions); + + if (!found) { + throw new PrequelEmptyResultsError(); + } + + return found; + } + + /** + * Finds all rows in the table that match the specified conditions. + * + * @param conditions - An object containing key-value pairs that represent the conditions for the query. + * The keys are column names and the values are the values to match. + * @returns An array of rows that match the specified conditions. + */ + public findAllWhere(conditions: Partial): RowShape[] { + const keys = Object.keys(conditions); + const whereParts = keys.map((key) => `"${key}" = $${key}`); + const whereClause = whereParts.join(" AND "); + const query = this._db.prepare( + `SELECT * FROM ${this} WHERE ${whereClause};`, + ); + + return query.all(shapeForQuery(conditions)); + } + + /** + * Deletes a row + * + * @param row - The row in the table to delete + * @returns The updated row data if the update is successful, or `null` if it fails. + * @throws Will throw an error if the primary column name is "ROWID". + */ + public delete(data: RowShape): void { + if (this._primaryColumnName === "ROWID") { + throw new Error( + "Cannot use `Table.delete()` without setting a primary column", + ); + } + + const asDeleteDataShape: ArbitraryRow = shapeForQuery(data); + this._deleteRowQuery.run(asDeleteDataShape); + } + + /** + * Deletes a record from the table by its ID. + * + * @param id - The unique identifier of the record to be deleted. + * @returns void + */ + public deleteById = (id: Value): void => { + if (this._primaryColumnName === "ROWID") { + throw new Error( + "Cannot use `Table.deleteById()` without setting a primary column", + ); + } + + this._deleteQuery.run(`${id}`); + }; + + /** + * Deletes all rows in the table that match the specified conditions. + * + * @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match. + */ + public deleteWhere(conditions: Partial): void { + const keys = Object.keys(conditions); + const whereParts = keys.map((key) => `"${key}" = $${key}`); + const whereClause = whereParts.join(" AND "); + const query = this._db.prepare( + `DELETE FROM ${this} WHERE ${whereClause};`, + ); + + query.run(shapeForQuery(conditions)); + } + + /** + * Deletes all rows in the table. + */ + public deleteAll(): void { + this._truncateQuery.run(); + } + + /** + * Retrieves the size of the table. + * + * @deprecated Use `count()` instead. + * @returns {number} The number of rows in the table. + * @throws {Error} If the row count query fails. + */ + public size(): number { + return this.count(); + } + + /** + * Retrieves the size of the table. + * + * @returns {number} The number of rows in the table. + * @throws {Error} If the row count query fails. + */ + public count(): number { + const res = this._sizeQuery.get(); + + if (res === null) { + throw new Error("Failed to count rows"); + } + + return res.count; + } + + public toString() { + return `"${this._name}"`; + } + + get name() { + return this._name; + } + + get db() { + return this._db; + } + + get columns() { + return this._columnNames; + } + + get schema(): string { + return this._createTableSQL; + } + + get idColumnName(): string { + return this._primaryColumnName; + } +} diff --git a/test/instance.test.ts b/test/instance.test.ts new file mode 100644 index 0000000..9b5fc97 --- /dev/null +++ b/test/instance.test.ts @@ -0,0 +1,53 @@ +import { Database } from "bun:sqlite"; +import { expect, test } from "bun:test"; +import { Instance, Prequel, Table } from "../src/index"; + +interface User { + id: number; + name: string; +} + +interface SerializedUser { + name: string; +} + +const db = new Database(); +const table = new Table(db, "Users", { + id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", +}); + +class UserInstance extends Instance { + get name(): string { + return this.row.name; + } + + set name(val: string) { + this.row.name = val; + } + + serialize(): SerializedUser { + return { + name: this.row.name, + }; + } +} + +test("setting values on an instance", () => { + table.insert({ + id: 1, + name: "Alice", + }); + + const alice = table.findOneByIdOrFail(1); + const inst = new UserInstance(table, alice); + + inst.name = "Bob"; + inst.save(); + + const bob = table.findOneByIdOrFail(1); + expect(bob.name).toEqual("Bob"); +}); diff --git a/test/prequel.test.ts b/test/prequel.test.ts new file mode 100644 index 0000000..a5b429f --- /dev/null +++ b/test/prequel.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { SchemaError } from "../src/error"; +import { Prequel, Table } from "../src/index"; + +interface User { + user_id: number; + name: string; +} + +test("constructor", () => { + const pq = new Prequel(); + const users = pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + users.insertPartial({ name: "Yondu" }); + + expect(users.findAll().length).toBe(1); +}); + +test(".hasTable() knows if a table exists in the DB", () => { + const pq = new Prequel(); + + expect(pq.hasTable("Users")).toBeFalse(); + expect(pq.hasTable("Notfound")).toBeFalse(); + + pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + expect(pq.hasTable("Users")).toBeTrue(); + expect(pq.hasTable("Notfound")).toBeFalse(); +}); + +test(".table() creates and attaches a table", () => { + const pq = new Prequel(); + + // Already has one table because of KV + expect(Object.keys(pq.tables)).toHaveLength(1); + + const table = pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + expect(Object.keys(pq.tables)).toHaveLength(2); + expect(Object.values(pq.tables)).toContain(table); +}); + +test(".table() throws when creating a duplicate table", () => { + const pq = new Prequel(); + pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + const oopsie = () => { + pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + }; + + expect(oopsie).toThrow(SchemaError); +}); + +test(".attachTable() attaches a table", () => { + const pq = new Prequel(); + const table = new Table(pq.db, "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + expect(pq.hasTable("Users")).toBeFalse(); + + pq.attachTable(table); + + expect(pq.hasTable("Users")).toBeTrue(); + expect(Object.values(pq.tables)).toContain(table); +}); + +test(".attachTable() throws on duplicate table names", () => { + const pq = new Prequel(); + pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + const secondTable = new Table(pq.db, "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + const doAttachment = () => { + pq.attachTable(secondTable); + }; + + expect(doAttachment).toThrow(SchemaError); +}); + +test("kv store can set and retrieve arbitrary values", () => { + const pq = new Prequel(); + + pq.kv.put("answer", 42); + pq.kv.put<{ enabled: boolean }>("config", { enabled: true }); + pq.kv.put("list", ["a", "b", "c"]); + + expect(pq.kv.get("answer")).toEqual(42); + expect(pq.kv.get<{ enabled: boolean }>("config")).toEqual({ enabled: true }); + expect(pq.kv.get("list")).toEqual(["a", "b", "c"]); +}); diff --git a/test/table.test.ts b/test/table.test.ts new file mode 100644 index 0000000..054c26b --- /dev/null +++ b/test/table.test.ts @@ -0,0 +1,386 @@ +import { Database } from "bun:sqlite"; +import { expect, test } from "bun:test"; +import { Prequel, Table } from "../src/index"; + +interface UserWithID { + user_id: number; + name: string; +} + +interface Post { + post_id: number; + user_id: number; +} + +interface UserWithNullable extends UserWithID { + bio: string | null; +} + +function makeTestTable(): Table { + const db = new Database(); + return new Table(db, "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); +} + +test("constructor", () => { + const db = new Database(); + const table = new Table(db, "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + expect(table.name).toEqual("Users"); + expect(table.db).toBe(db); + expect(table.columns).toEqual(["user_id", "name"]); +}); + +test("constructor -> auto-attach to a prequel isntance", () => { + const db = new Prequel(); + const table = new Table(db, "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + expect(Object.values(db.tables)).toContain(table); +}); + +test(".toString() resolves to the table name for embedding in queries", () => { + const table = makeTestTable(); + expect(`${table}`).toEqual('"Users"'); +}); + +test(".reference() creates a default reference to the primary column", () => { + const table = makeTestTable(); + expect(table.reference()).toEqual("Users(user_id)"); +}); + +test(".reference() creates a reference to the specified column", () => { + const table = makeTestTable(); + expect(table.reference("name")).toEqual("Users(name)"); +}); + +test(".insert() inserts a full row", () => { + const table = makeTestTable(); + table.insert({ + user_id: 100, + name: "Jack", + }); + + const lookup = table.db.prepare( + `SELECT * FROM ${table} WHERE user_id = ?`, + ); + const found = lookup.get(100); + expect(found).toEqual({ + user_id: 100, + name: "Jack", + }); +}); + +test(".insert() is able to insert nulls to nullable fields", () => { + const table = new Table(new Database(), "Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + bio: { + type: "TEXT", + nullable: true, + }, + }); + + table.insert({ + user_id: 1, + name: "Jack", + bio: null, + }); + + const lookup = table.db.prepare( + `SELECT * FROM ${table} WHERE user_id = ?`, + ); + const found = lookup.get(1); + + expect(found?.bio).toBeNull(); +}); + +test(".insertPartial() inserts a partial row", () => { + const table = makeTestTable(); + table.insertPartial({ + name: "Jack", + }); + + const lookup = table.db.prepare( + `SELECT * FROM ${table} WHERE name = ?`, + ); + const found = lookup.get("Jack"); + expect(found).toEqual({ + user_id: 1, + name: "Jack", + }); +}); + +test(".count() gets the current number of rows in the table", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + expect(table.count()).toBe(3); +}); + +test(".countWhere() gets the current number of rows that match a condition", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + expect(table.countWhere({ name: "Jack" })).toBe(1); +}); + +test(".findAll() returns the full table", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + const allUsers = table.findAll(); + + expect(allUsers).toHaveLength(3); + expect(allUsers[0]).toEqual({ user_id: 1, name: "Jack" }); + expect(allUsers[1]).toEqual({ user_id: 2, name: "Kate" }); + expect(allUsers[2]).toEqual({ user_id: 3, name: "Sayid" }); +}); + +test(".findOneWhere() returns a row based on basic criteria", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + const sayid = table.findOneWhere({ name: "Sayid" }); + + expect(sayid).toEqual({ user_id: 3, name: "Sayid" }); +}); + +test(".findOneWhereOrFail() throws if it finds nothing", () => { + const table = makeTestTable(); + + expect(() => { + table.findOneWhereOrFail({ name: "Sayid" }); + }).toThrow(); +}); + +test(".findAllWhere() returns a row based on basic criteria", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Jack" }); + + const manyJacks = table.findAllWhere({ name: "Jack" }); + + expect(manyJacks).toHaveLength(3); + expect(manyJacks[0]).toEqual({ user_id: 1, name: "Jack" }); + expect(manyJacks[1]).toEqual({ user_id: 2, name: "Jack" }); + expect(manyJacks[2]).toEqual({ user_id: 3, name: "Jack" }); +}); + +test(".findOneById() returns a single element by its primary column", () => { + const table = makeTestTable(); + + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + const sayid = table.findOneById(3); + expect(sayid).toEqual({ user_id: 3, name: "Sayid" }); +}); + +test(".findOneById() returns null when nothing was found", () => { + const table = makeTestTable(); + const nobody = table.findOneById(3); + expect(nobody).toBeNull(); +}); + +test(".findOneByIdOrFail() returns a single element by its primary column", () => { + const table = makeTestTable(); + + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + const sayid = table.findOneById(3); + expect(sayid).toEqual({ user_id: 3, name: "Sayid" }); +}); + +test(".findOneByIdOrFail() throws an error when nothing is found", () => { + const table = makeTestTable(); + expect(() => { + table.findOneByIdOrFail(3); + }).toThrow(); +}); + +test(".delete() deletes a full record", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + expect(table.size()).toBe(3); + + table.delete(table.findOneByIdOrFail(2)); + + expect(table.size()).toBe(2); + + expect(table.findAll()).toEqual([ + { user_id: 1, name: "Jack" }, + { user_id: 3, name: "Sayid" }, + ]); +}); + +test(".deleteById() deletes the element with the given primary value", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + expect(table.size()).toBe(3); + + table.deleteById(2); + + expect(table.size()).toBe(2); + + expect(table.findAll()).toEqual([ + { user_id: 1, name: "Jack" }, + { user_id: 3, name: "Sayid" }, + ]); +}); + +test(".deleteWhere() deletes all rows that match", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Sayid" }); + + expect(table.size()).toBe(3); + + table.deleteWhere({ name: "Jack" }); + + expect(table.size()).toBe(1); + + expect(table.findAll()).toEqual([{ user_id: 3, name: "Sayid" }]); +}); + +test(".deleteById() respects when references are set to cascade", () => { + const pq = new Prequel(); + const users = pq.table("Users", { + user_id: { + type: "INTEGER", + primary: true, + }, + name: "TEXT", + }); + + const posts = pq.table("Posts", { + post_id: { + type: "INTEGER", + primary: true, + }, + user_id: { + type: "INTEGER", + references: users.reference(), + cascade: true, + }, + }); + + const user = users.insertPartial({ name: "Bob" }); + posts.insertPartial({ + user_id: user.user_id, + }); + + users.deleteById(user.user_id); + expect(posts.size()).toBe(0); +}); + +test(".deleteAll() deletes all rows in the table", () => { + const table = makeTestTable(); + table.insertPartial({ name: "Jack" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + expect(table.count()).toBe(3); + + table.deleteAll(); + + expect(table.count()).toBe(0); +}); + +test(".update() updates a full record", () => { + const table = makeTestTable(); + const locke = table.insertPartial({ name: "Locke" }); + table.insertPartial({ name: "Kate" }); + table.insertPartial({ name: "Sayid" }); + + locke.name = "Jeremy Bentham"; + table.update(locke); + + const newLocke = table.findOneById(1); + expect(newLocke?.name).toEqual("Jeremy Bentham"); +}); + +test(".update() throws if no primary column is set besides ROWID", () => { + const db = new Database(); + const table = new Table(db, "Users", { + user_id: "INTEGER", + name: "TEXT", + }); + + const user = table.insert({ user_id: 1, name: "User" }); + + const oops = () => { + table.update(user); + }; + + expect(oops).toThrow(/primary column/); +}); + +test(".updateWhere() updates rows based on the condition", () => { + const db = new Database(); + const table = new Table(db, "Users", { + user_id: "INTEGER", + name: "TEXT", + }); + + table.insert({ user_id: 1, name: "Jack" }); + table.insert({ user_id: 2, name: "Kate" }); + table.insert({ user_id: 3, name: "Sayid" }); + + table.updateWhere({ name: "Sawyer" }, { user_id: 2 }); + expect(table.findOneById(2)?.name).toEqual("Sawyer"); +}); + +test(".updateAll() updates all rows in the table", () => { + const db = new Database(); + const table = new Table(db, "Users", { + user_id: "INTEGER", + name: "TEXT", + }); + + table.insert({ user_id: 1, name: "Jack" }); + table.insert({ user_id: 2, name: "Kate" }); + table.insert({ user_id: 3, name: "Sayid" }); + + table.updateAll({ name: "Sawyer" }); + expect(table.findAll().map((u) => u.name)).toEqual([ + "Sawyer", + "Sawyer", + "Sawyer", + ]); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9eb9fac --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "composite": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": true + }, +}