Strict DB mode

This commit is contained in:
Endeavorance 2025-04-18 15:37:59 -04:00
parent ff7879dee7
commit 11b0598d46
6 changed files with 82 additions and 78 deletions

View file

@ -1,3 +1,5 @@
import { SchemaError } from "./error";
type RedefineValueTypes<ShapeToRedefine, NewValueType> = { type RedefineValueTypes<ShapeToRedefine, NewValueType> = {
[K in keyof ShapeToRedefine]: NewValueType; [K in keyof ShapeToRedefine]: NewValueType;
}; };
@ -12,11 +14,11 @@ export type ColumnValue =
| null; | null;
/** Types that define the data type of a column in the DB */ /** 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 { export interface Column {
/** The data type to store this column */ /** The data type to store this column */
type: DataType; type: ColumnDataType;
nullable: boolean; nullable: boolean;
primary: boolean; primary: boolean;
@ -32,8 +34,7 @@ export interface Column {
check: string; check: string;
} }
export type ColumnShorthand = DataType | Partial<Column>; export type ColumnShorthand = ColumnDataType | Partial<Column>;
export type ColumnShorthandMap<T> = RedefineValueTypes<T, ColumnShorthand>; export type ColumnShorthandMap<T> = RedefineValueTypes<T, ColumnShorthand>;
export type ColumnMap<T> = RedefineValueTypes<T, Column>; export type ColumnMap<T> = RedefineValueTypes<T, Column>;
@ -98,6 +99,10 @@ function expandColumnShorthand(col: ColumnShorthand): Column {
}; };
} }
if (col.primary && col.nullable) {
throw new SchemaError("Column cannot be primary and nullable");
}
return { return {
type: col.type ?? "TEXT", type: col.type ?? "TEXT",
nullable: col.nullable ?? false, nullable: col.nullable ?? false,
@ -113,18 +118,14 @@ function expandColumnShorthand(col: ColumnShorthand): Column {
} }
export function normalizeColumns<T>(cols: ColumnShorthandMap<T>): ColumnMap<T> { export function normalizeColumns<T>(cols: ColumnShorthandMap<T>): ColumnMap<T> {
const keys = Object.keys(cols);
const retval: Record<string, Column> = {}; const retval: Record<string, Column> = {};
for (const key of keys) { for (const [key, val] of Object.entries(cols)) {
const val = cols[key as keyof typeof cols];
if (val === undefined) { if (val === undefined) {
throw new Error("Invariant: Undefined column descriptor"); throw new Error("Invariant: Undefined column descriptor");
} }
retval[key] = expandColumnShorthand(val); retval[key] = expandColumnShorthand(val as ColumnShorthand);
} }
return retval as ColumnMap<T>; return retval as ColumnMap<T>;

View file

@ -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) { constructor(message: string, table?: string, column?: string) {
if (typeof table === "string" && typeof column === "string") { if (typeof table === "string" && typeof column === "string") {
super(`SchemaError: ${message} ON ${table}.${column}`); 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}`,
);
}
}

View file

@ -1,5 +1,5 @@
import { ColumnOf } from "./column-presets"; import { ColumnOf } from "./column-presets";
import type { Column, ColumnShorthand, DataType } from "./columns"; import type { Column, ColumnShorthand, ColumnDataType } from "./columns";
import { Prequel } from "./prequel"; import { Prequel } from "./prequel";
import { Table } from "./table"; import { Table } from "./table";
@ -7,7 +7,7 @@ export {
Prequel, Prequel,
Table, Table,
ColumnOf, ColumnOf,
type DataType, type ColumnDataType as DataType,
type Column, type Column,
type ColumnShorthand, type ColumnShorthand,
}; };

View file

@ -118,7 +118,9 @@ export class Prequel {
* @param filename The filename of the database. Defaults to ":memory:" if not provided. * @param filename The filename of the database. Defaults to ":memory:" if not provided.
*/ */
constructor(filename = ":memory:") { constructor(filename = ":memory:") {
this.db = new Database(filename); this.db = new Database(filename, {
strict: true,
});
this.query = this.db.query.bind(this.db); this.query = this.db.query.bind(this.db);
this.prepare = this.db.prepare.bind(this.db); this.prepare = this.db.prepare.bind(this.db);
this.db.exec("PRAGMA foreign_keys=ON"); this.db.exec("PRAGMA foreign_keys=ON");
@ -173,7 +175,8 @@ export class Prequel {
* @param table The table to attach. * @param table The table to attach.
* @throws {SchemaError} If a table with the same name already exists. * @throws {SchemaError} If a table with the same name already exists.
*/ */
attachTable(table: Table<unknown>) { // biome-ignore lint/suspicious/noExplicitAny: <explanation>
attachTable(table: Table<any>) {
if (this.hasTable(table.name)) { if (this.hasTable(table.name)) {
throw new SchemaError("Duplicate table name", table.name); throw new SchemaError("Duplicate table name", table.name);
} }

View file

@ -3,36 +3,17 @@ import {
type ColumnMap, type ColumnMap,
type ColumnShorthandMap, type ColumnShorthandMap,
type ColumnValue, type ColumnValue,
type DataType, type ColumnDataType,
generateColumnDefinitionSQL, generateColumnDefinitionSQL,
normalizeColumns, normalizeColumns,
} from "./columns"; } from "./columns";
import { SchemaError } from "./error"; import { LookupFailure, SchemaError } from "./error";
import { Prequel } from "./prequel"; import { Prequel } from "./prequel";
export type ExtractRowShape<T> = T extends Table<infer R> ? R : never; export interface QueryOptions {
order?: string;
export class PrequelIDNotFoundError extends Error { limit?: number;
constructor(tableName: string, id: ColumnValue) { skip?: number;
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<T>(obj: Partial<T>): SQLQueryBindings {
const asInsertDataShape: SQLQueryBindings = {};
for (const [key, val] of Object.entries(obj)) {
const asQueryKey = `$${key}`;
asInsertDataShape[asQueryKey] = val as ColumnValue;
}
return asInsertDataShape;
} }
/** /**
@ -47,7 +28,7 @@ export class Table<RowShape> {
private _columnNames: string[]; private _columnNames: string[];
private _createTableSQL: string; private _createTableSQL: string;
private _primaryColumnName = "ROWID"; private _primaryColumnName = "ROWID";
private _primaryColumnType: DataType | null = null; private _primaryColumnType: ColumnDataType | null = null;
private _insertQuery: Statement<RowShape, [SQLQueryBindings]>; private _insertQuery: Statement<RowShape, [SQLQueryBindings]>;
private _updateQuery: Statement<RowShape, [SQLQueryBindings]>; private _updateQuery: Statement<RowShape, [SQLQueryBindings]>;
private _findQuery: Statement<RowShape, [string]>; private _findQuery: Statement<RowShape, [string]>;
@ -155,7 +136,7 @@ export class Table<RowShape> {
return this._columnNames.includes(colName); return this._columnNames.includes(colName);
} }
public primaryColumnType(): DataType { public primaryColumnType(): ColumnDataType {
return this._primaryColumnType ?? "INTEGER"; return this._primaryColumnType ?? "INTEGER";
} }
@ -191,8 +172,7 @@ export class Table<RowShape> {
* @returns The inserted row, conforming to the RowShape interface. * @returns The inserted row, conforming to the RowShape interface.
*/ */
public insert(data: RowShape): RowShape { public insert(data: RowShape): RowShape {
const asInsertDataShape = shapeForQuery<RowShape>(data); return this._insertQuery.get(data as SQLQueryBindings) as RowShape;
return this._insertQuery.get(asInsertDataShape) as RowShape;
} }
/** /**
@ -204,7 +184,6 @@ export class Table<RowShape> {
* @typeParam RowShape - The shape of the row in the table. * @typeParam RowShape - The shape of the row in the table.
*/ */
public insertPartial(data: Partial<RowShape>): RowShape { public insertPartial(data: Partial<RowShape>): RowShape {
const asInsertDataShape: SQLQueryBindings = shapeForQuery<RowShape>(data);
const keys = Object.keys(data); const keys = Object.keys(data);
const colNames = keys.join(", "); const colNames = keys.join(", ");
const varNames = keys.map((keyName) => `$${keyName}`); const varNames = keys.map((keyName) => `$${keyName}`);
@ -213,7 +192,7 @@ export class Table<RowShape> {
`INSERT INTO ${this} (${colNames}) VALUES (${varNames}) RETURNING *;`, `INSERT INTO ${this} (${colNames}) VALUES (${varNames}) RETURNING *;`,
); );
return query.get(asInsertDataShape) as RowShape; return query.get(data as SQLQueryBindings) as RowShape;
} }
public insertWithout<OmitKeys extends keyof RowShape>( public insertWithout<OmitKeys extends keyof RowShape>(
@ -236,8 +215,7 @@ export class Table<RowShape> {
); );
} }
const asInsertDataShape: SQLQueryBindings = shapeForQuery<RowShape>(data); return this._updateQuery.get(data as SQLQueryBindings) as RowShape;
return this._updateQuery.get(asInsertDataShape);
} }
/** /**
@ -256,19 +234,27 @@ export class Table<RowShape> {
const setClause = setParts.join(", "); const setClause = setParts.join(", ");
const whereKeys = Object.keys(conditions); 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 whereClause = whereParts.join(" AND ");
const whereData = Object.entries(conditions).reduce(
(acc, [key, value]) => {
acc[`where_${key}`] = value as ColumnValue;
return acc;
},
{} as Record<string, ColumnValue>,
);
const query = this._db.prepare<RowShape, SQLQueryBindings>( const query = this._db.prepare<RowShape, SQLQueryBindings>(
`UPDATE ${this} SET ${setClause} WHERE ${whereClause} RETURNING *;`, `UPDATE ${this} SET ${setClause} WHERE ${whereClause} RETURNING *;`,
); );
const shapedData = shapeForQuery({ const queryData = {
...data, ...data,
...conditions, ...whereData,
}); };
return query.all(shapedData); return query.all(queryData);
} }
/** /**
@ -287,7 +273,7 @@ export class Table<RowShape> {
`UPDATE ${this} SET ${setClause} RETURNING *;`, `UPDATE ${this} SET ${setClause} RETURNING *;`,
); );
return query.all(shapeForQuery(data)); return query.all(data as SQLQueryBindings);
} }
/** /**
@ -324,8 +310,9 @@ export class Table<RowShape> {
`SELECT count(*) as count FROM ${this} WHERE ${whereClause};`, `SELECT count(*) as count FROM ${this} WHERE ${whereClause};`,
); );
const shapedData = shapeForQuery(conditions); const countResult = query.get(conditions as SQLQueryBindings) ?? {
const countResult = query.get(shapedData) ?? { count: 0 }; count: 0,
};
return countResult.count; return countResult.count;
} }
@ -351,7 +338,9 @@ export class Table<RowShape> {
const found = this.findOneById(id); const found = this.findOneById(id);
if (!found) { if (!found) {
throw new PrequelIDNotFoundError(this.name, id); throw new LookupFailure(this.name, {
[this._primaryColumnName]: id,
});
} }
return found; return found;
@ -400,7 +389,7 @@ export class Table<RowShape> {
`SELECT * FROM ${this} WHERE ${whereClause}; `, `SELECT * FROM ${this} WHERE ${whereClause}; `,
); );
return query.get(shapeForQuery(conditions)); return query.get(conditions as SQLQueryBindings);
} }
/** /**
@ -415,7 +404,7 @@ export class Table<RowShape> {
const found = this.findOneWhere(conditions); const found = this.findOneWhere(conditions);
if (!found) { if (!found) {
throw new PrequelEmptyResultsError(); throw new LookupFailure(this.name, conditions);
} }
return found; return found;
@ -458,7 +447,7 @@ export class Table<RowShape> {
`SELECT * FROM ${this} WHERE ${whereClause} ${optionParts.join(" ")}; `, `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<RowShape> {
); );
} }
const asDeleteDataShape: SQLQueryBindings = shapeForQuery<RowShape>(data); this._deleteRowQuery.run(data as SQLQueryBindings);
this._deleteRowQuery.run(asDeleteDataShape);
} }
/** /**
@ -508,7 +496,7 @@ export class Table<RowShape> {
`DELETE FROM ${this} WHERE ${whereClause}; `, `DELETE FROM ${this} WHERE ${whereClause}; `,
); );
query.run(shapeForQuery(conditions)); query.run(conditions as SQLQueryBindings);
} }
/** /**

View file

@ -1,20 +1,16 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { expect, test } from "bun:test"; 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 { interface UserWithID {
user_id: number; user_id: number;
name: string; name: string;
} }
interface UserWithBio {
user_id: number;
bio: {
name: string;
age: number;
};
}
interface Post { interface Post {
post_id: number; post_id: number;
user_id: number; user_id: number;
@ -25,7 +21,7 @@ interface UserWithNullable extends UserWithID {
} }
function makeTestTable(): Table<UserWithID> { function makeTestTable(): Table<UserWithID> {
const db = new Database(); const db = getTestDB();
return new Table<UserWithID>(db, "Users", { return new Table<UserWithID>(db, "Users", {
user_id: { user_id: {
type: "INTEGER", type: "INTEGER",
@ -36,7 +32,7 @@ function makeTestTable(): Table<UserWithID> {
} }
test("constructor", () => { test("constructor", () => {
const db = new Database(); const db = getTestDB();
const table = new Table<UserWithID>(db, "Users", { const table = new Table<UserWithID>(db, "Users", {
user_id: { user_id: {
type: "INTEGER", type: "INTEGER",
@ -96,7 +92,7 @@ test(".insert() inserts a full row", () => {
}); });
test(".insert() is able to insert nulls to nullable fields", () => { test(".insert() is able to insert nulls to nullable fields", () => {
const table = new Table<UserWithNullable>(new Database(), "Users", { const table = new Table<UserWithNullable>(getTestDB(), "Users", {
user_id: { user_id: {
type: "INTEGER", type: "INTEGER",
primary: true, primary: true,
@ -438,7 +434,7 @@ test(".update() updates a full record", () => {
}); });
test(".update() throws if no primary column is set besides ROWID", () => { test(".update() throws if no primary column is set besides ROWID", () => {
const db = new Database(); const db = getTestDB();
const table = new Table<UserWithID>(db, "Users", { const table = new Table<UserWithID>(db, "Users", {
user_id: "INTEGER", user_id: "INTEGER",
name: "TEXT", 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", () => { test(".updateWhere() updates rows based on the condition", () => {
const db = new Database(); const db = getTestDB();
const table = new Table<UserWithID>(db, "Users", { const table = new Table<UserWithID>(db, "Users", {
user_id: "INTEGER", user_id: "INTEGER",
name: "TEXT", name: "TEXT",
@ -469,7 +465,7 @@ test(".updateWhere() updates rows based on the condition", () => {
}); });
test(".updateAll() updates all rows in the table", () => { test(".updateAll() updates all rows in the table", () => {
const db = new Database(); const db = getTestDB();
const table = new Table<UserWithID>(db, "Users", { const table = new Table<UserWithID>(db, "Users", {
user_id: "INTEGER", user_id: "INTEGER",
name: "TEXT", name: "TEXT",