diff --git a/src/columns.ts b/src/columns.ts index 6c7e6f4..06ee772 100644 --- a/src/columns.ts +++ b/src/columns.ts @@ -1,3 +1,5 @@ +import { SchemaError } from "./error"; + type RedefineValueTypes = { [K in keyof ShapeToRedefine]: NewValueType; }; @@ -12,11 +14,11 @@ export type ColumnValue = | null; /** Types that define the data type of a column in the DB */ -export type DataType = "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL"; +export type ColumnDataType = "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL"; export interface Column { /** The data type to store this column */ - type: DataType; + type: ColumnDataType; nullable: boolean; primary: boolean; @@ -32,8 +34,7 @@ export interface Column { check: string; } -export type ColumnShorthand = DataType | Partial; - +export type ColumnShorthand = ColumnDataType | Partial; export type ColumnShorthandMap = RedefineValueTypes; export type ColumnMap = RedefineValueTypes; @@ -98,6 +99,10 @@ function expandColumnShorthand(col: ColumnShorthand): Column { }; } + if (col.primary && col.nullable) { + throw new SchemaError("Column cannot be primary and nullable"); + } + return { type: col.type ?? "TEXT", nullable: col.nullable ?? false, @@ -113,18 +118,14 @@ function expandColumnShorthand(col: ColumnShorthand): Column { } 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]; - + for (const [key, val] of Object.entries(cols)) { if (val === undefined) { throw new Error("Invariant: Undefined column descriptor"); } - retval[key] = expandColumnShorthand(val); + retval[key] = expandColumnShorthand(val as ColumnShorthand); } return retval as ColumnMap; diff --git a/src/error.ts b/src/error.ts index b6ea192..e57c52f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,12 @@ -export class SchemaError extends Error { +class PrequelError extends Error { + name = "PrequelError"; + + constructor(message: string) { + super(`PrequelError: ${message}`); + } +} + +export class SchemaError extends PrequelError { constructor(message: string, table?: string, column?: string) { if (typeof table === "string" && typeof column === "string") { super(`SchemaError: ${message} ON ${table}.${column}`); @@ -9,3 +17,11 @@ export class SchemaError extends Error { } } } + +export class LookupFailure extends PrequelError { + constructor(tableName: string, conditions: unknown) { + super( + `Conditions ${JSON.stringify(conditions)} returned no results from ${tableName}`, + ); + } +} diff --git a/src/index.ts b/src/index.ts index ec4937d..285fd50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { ColumnOf } from "./column-presets"; -import type { Column, ColumnShorthand, DataType } from "./columns"; +import type { Column, ColumnShorthand, ColumnDataType } from "./columns"; import { Prequel } from "./prequel"; import { Table } from "./table"; @@ -7,7 +7,7 @@ export { Prequel, Table, ColumnOf, - type DataType, + type ColumnDataType as DataType, type Column, type ColumnShorthand, }; diff --git a/src/prequel.ts b/src/prequel.ts index f7d19ed..89f52a6 100644 --- a/src/prequel.ts +++ b/src/prequel.ts @@ -118,7 +118,9 @@ export class Prequel { * @param filename The filename of the database. Defaults to ":memory:" if not provided. */ constructor(filename = ":memory:") { - this.db = new Database(filename); + this.db = new Database(filename, { + strict: true, + }); this.query = this.db.query.bind(this.db); this.prepare = this.db.prepare.bind(this.db); this.db.exec("PRAGMA foreign_keys=ON"); @@ -173,7 +175,8 @@ export class Prequel { * @param table The table to attach. * @throws {SchemaError} If a table with the same name already exists. */ - attachTable(table: Table) { + // biome-ignore lint/suspicious/noExplicitAny: + attachTable(table: Table) { if (this.hasTable(table.name)) { throw new SchemaError("Duplicate table name", table.name); } diff --git a/src/table.ts b/src/table.ts index 7c76cbb..566e401 100644 --- a/src/table.ts +++ b/src/table.ts @@ -3,36 +3,17 @@ import { type ColumnMap, type ColumnShorthandMap, type ColumnValue, - type DataType, + type ColumnDataType, generateColumnDefinitionSQL, normalizeColumns, } from "./columns"; -import { SchemaError } from "./error"; +import { LookupFailure, SchemaError } from "./error"; import { Prequel } from "./prequel"; -export type ExtractRowShape = T extends Table ? R : never; - -export class PrequelIDNotFoundError extends Error { - constructor(tableName: string, id: ColumnValue) { - super(`ID ${id} not found in ${tableName}`); - } -} - -export class PrequelEmptyResultsError extends Error { - constructor() { - super("No results found on a query which requires results"); - } -} - -function shapeForQuery(obj: Partial): SQLQueryBindings { - const asInsertDataShape: SQLQueryBindings = {}; - - for (const [key, val] of Object.entries(obj)) { - const asQueryKey = `$${key}`; - asInsertDataShape[asQueryKey] = val as ColumnValue; - } - - return asInsertDataShape; +export interface QueryOptions { + order?: string; + limit?: number; + skip?: number; } /** @@ -47,7 +28,7 @@ export class Table { private _columnNames: string[]; private _createTableSQL: string; private _primaryColumnName = "ROWID"; - private _primaryColumnType: DataType | null = null; + private _primaryColumnType: ColumnDataType | null = null; private _insertQuery: Statement; private _updateQuery: Statement; private _findQuery: Statement; @@ -155,7 +136,7 @@ export class Table { return this._columnNames.includes(colName); } - public primaryColumnType(): DataType { + public primaryColumnType(): ColumnDataType { return this._primaryColumnType ?? "INTEGER"; } @@ -191,8 +172,7 @@ export class Table { * @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; + return this._insertQuery.get(data as SQLQueryBindings) as RowShape; } /** @@ -204,7 +184,6 @@ export class Table { * @typeParam RowShape - The shape of the row in the table. */ public insertPartial(data: Partial): RowShape { - const asInsertDataShape: SQLQueryBindings = shapeForQuery(data); const keys = Object.keys(data); const colNames = keys.join(", "); const varNames = keys.map((keyName) => `$${keyName}`); @@ -213,7 +192,7 @@ export class Table { `INSERT INTO ${this} (${colNames}) VALUES (${varNames}) RETURNING *;`, ); - return query.get(asInsertDataShape) as RowShape; + return query.get(data as SQLQueryBindings) as RowShape; } public insertWithout( @@ -236,8 +215,7 @@ export class Table { ); } - const asInsertDataShape: SQLQueryBindings = shapeForQuery(data); - return this._updateQuery.get(asInsertDataShape); + return this._updateQuery.get(data as SQLQueryBindings) as RowShape; } /** @@ -256,19 +234,27 @@ export class Table { const setClause = setParts.join(", "); const whereKeys = Object.keys(conditions); - const whereParts = whereKeys.map((key) => `"${key}" = $${key}`); + const whereParts = whereKeys.map((key) => `"${key}" = $where_${key}`); const whereClause = whereParts.join(" AND "); + const whereData = Object.entries(conditions).reduce( + (acc, [key, value]) => { + acc[`where_${key}`] = value as ColumnValue; + return acc; + }, + {} as Record, + ); + const query = this._db.prepare( `UPDATE ${this} SET ${setClause} WHERE ${whereClause} RETURNING *;`, ); - const shapedData = shapeForQuery({ + const queryData = { ...data, - ...conditions, - }); + ...whereData, + }; - return query.all(shapedData); + return query.all(queryData); } /** @@ -287,7 +273,7 @@ export class Table { `UPDATE ${this} SET ${setClause} RETURNING *;`, ); - return query.all(shapeForQuery(data)); + return query.all(data as SQLQueryBindings); } /** @@ -324,8 +310,9 @@ export class Table { `SELECT count(*) as count FROM ${this} WHERE ${whereClause};`, ); - const shapedData = shapeForQuery(conditions); - const countResult = query.get(shapedData) ?? { count: 0 }; + const countResult = query.get(conditions as SQLQueryBindings) ?? { + count: 0, + }; return countResult.count; } @@ -351,7 +338,9 @@ export class Table { const found = this.findOneById(id); if (!found) { - throw new PrequelIDNotFoundError(this.name, id); + throw new LookupFailure(this.name, { + [this._primaryColumnName]: id, + }); } return found; @@ -400,7 +389,7 @@ export class Table { `SELECT * FROM ${this} WHERE ${whereClause}; `, ); - return query.get(shapeForQuery(conditions)); + return query.get(conditions as SQLQueryBindings); } /** @@ -415,7 +404,7 @@ export class Table { const found = this.findOneWhere(conditions); if (!found) { - throw new PrequelEmptyResultsError(); + throw new LookupFailure(this.name, conditions); } return found; @@ -458,7 +447,7 @@ export class Table { `SELECT * FROM ${this} WHERE ${whereClause} ${optionParts.join(" ")}; `, ); - return query.all(shapeForQuery(conditions)); + return query.all(conditions as SQLQueryBindings); } /** @@ -475,8 +464,7 @@ export class Table { ); } - const asDeleteDataShape: SQLQueryBindings = shapeForQuery(data); - this._deleteRowQuery.run(asDeleteDataShape); + this._deleteRowQuery.run(data as SQLQueryBindings); } /** @@ -508,7 +496,7 @@ export class Table { `DELETE FROM ${this} WHERE ${whereClause}; `, ); - query.run(shapeForQuery(conditions)); + query.run(conditions as SQLQueryBindings); } /** diff --git a/test/table.test.ts b/test/table.test.ts index 03550b6..24c59e1 100644 --- a/test/table.test.ts +++ b/test/table.test.ts @@ -1,20 +1,16 @@ import { Database } from "bun:sqlite"; import { expect, test } from "bun:test"; -import { ColumnOf, Prequel, Table } from "../src/index"; +import { Prequel, Table } from "../src/index"; + +function getTestDB() { + return new Database(":memory:", { strict: true }); +} interface UserWithID { user_id: number; name: string; } -interface UserWithBio { - user_id: number; - bio: { - name: string; - age: number; - }; -} - interface Post { post_id: number; user_id: number; @@ -25,7 +21,7 @@ interface UserWithNullable extends UserWithID { } function makeTestTable(): Table { - const db = new Database(); + const db = getTestDB(); return new Table(db, "Users", { user_id: { type: "INTEGER", @@ -36,7 +32,7 @@ function makeTestTable(): Table { } test("constructor", () => { - const db = new Database(); + const db = getTestDB(); const table = new Table(db, "Users", { user_id: { type: "INTEGER", @@ -96,7 +92,7 @@ test(".insert() inserts a full row", () => { }); test(".insert() is able to insert nulls to nullable fields", () => { - const table = new Table(new Database(), "Users", { + const table = new Table(getTestDB(), "Users", { user_id: { type: "INTEGER", primary: true, @@ -438,7 +434,7 @@ test(".update() updates a full record", () => { }); test(".update() throws if no primary column is set besides ROWID", () => { - const db = new Database(); + const db = getTestDB(); const table = new Table(db, "Users", { user_id: "INTEGER", name: "TEXT", @@ -454,7 +450,7 @@ test(".update() throws if no primary column is set besides ROWID", () => { }); test(".updateWhere() updates rows based on the condition", () => { - const db = new Database(); + const db = getTestDB(); const table = new Table(db, "Users", { user_id: "INTEGER", name: "TEXT", @@ -469,7 +465,7 @@ test(".updateWhere() updates rows based on the condition", () => { }); test(".updateAll() updates all rows in the table", () => { - const db = new Database(); + const db = getTestDB(); const table = new Table(db, "Users", { user_id: "INTEGER", name: "TEXT",