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> = {
[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<Column>;
export type ColumnShorthand = ColumnDataType | Partial<Column>;
export type ColumnShorthandMap<T> = RedefineValueTypes<T, ColumnShorthand>;
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 {
type: col.type ?? "TEXT",
nullable: col.nullable ?? false,
@ -113,18 +118,14 @@ function expandColumnShorthand(col: ColumnShorthand): Column {
}
export function normalizeColumns<T>(cols: ColumnShorthandMap<T>): ColumnMap<T> {
const keys = Object.keys(cols);
const retval: Record<string, Column> = {};
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<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) {
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}`,
);
}
}

View file

@ -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,
};

View file

@ -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<unknown>) {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
attachTable(table: Table<any>) {
if (this.hasTable(table.name)) {
throw new SchemaError("Duplicate table name", table.name);
}

View file

@ -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> = T extends Table<infer R> ? 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<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;
export interface QueryOptions {
order?: string;
limit?: number;
skip?: number;
}
/**
@ -47,7 +28,7 @@ export class Table<RowShape> {
private _columnNames: string[];
private _createTableSQL: string;
private _primaryColumnName = "ROWID";
private _primaryColumnType: DataType | null = null;
private _primaryColumnType: ColumnDataType | null = null;
private _insertQuery: Statement<RowShape, [SQLQueryBindings]>;
private _updateQuery: Statement<RowShape, [SQLQueryBindings]>;
private _findQuery: Statement<RowShape, [string]>;
@ -155,7 +136,7 @@ export class Table<RowShape> {
return this._columnNames.includes(colName);
}
public primaryColumnType(): DataType {
public primaryColumnType(): ColumnDataType {
return this._primaryColumnType ?? "INTEGER";
}
@ -191,8 +172,7 @@ export class Table<RowShape> {
* @returns The inserted row, conforming to the RowShape interface.
*/
public insert(data: RowShape): RowShape {
const asInsertDataShape = shapeForQuery<RowShape>(data);
return this._insertQuery.get(asInsertDataShape) as RowShape;
return this._insertQuery.get(data as SQLQueryBindings) as RowShape;
}
/**
@ -204,7 +184,6 @@ export class Table<RowShape> {
* @typeParam RowShape - The shape of the row in the table.
*/
public insertPartial(data: Partial<RowShape>): RowShape {
const asInsertDataShape: SQLQueryBindings = shapeForQuery<RowShape>(data);
const keys = Object.keys(data);
const colNames = keys.join(", ");
const varNames = keys.map((keyName) => `$${keyName}`);
@ -213,7 +192,7 @@ export class Table<RowShape> {
`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>(
@ -236,8 +215,7 @@ export class Table<RowShape> {
);
}
const asInsertDataShape: SQLQueryBindings = shapeForQuery<RowShape>(data);
return this._updateQuery.get(asInsertDataShape);
return this._updateQuery.get(data as SQLQueryBindings) as RowShape;
}
/**
@ -256,19 +234,27 @@ export class Table<RowShape> {
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<string, ColumnValue>,
);
const query = this._db.prepare<RowShape, SQLQueryBindings>(
`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<RowShape> {
`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};`,
);
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<RowShape> {
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<RowShape> {
`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);
if (!found) {
throw new PrequelEmptyResultsError();
throw new LookupFailure(this.name, conditions);
}
return found;
@ -458,7 +447,7 @@ export class Table<RowShape> {
`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(asDeleteDataShape);
this._deleteRowQuery.run(data as SQLQueryBindings);
}
/**
@ -508,7 +496,7 @@ export class Table<RowShape> {
`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 { 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<UserWithID> {
const db = new Database();
const db = getTestDB();
return new Table<UserWithID>(db, "Users", {
user_id: {
type: "INTEGER",
@ -36,7 +32,7 @@ function makeTestTable(): Table<UserWithID> {
}
test("constructor", () => {
const db = new Database();
const db = getTestDB();
const table = new Table<UserWithID>(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<UserWithNullable>(new Database(), "Users", {
const table = new Table<UserWithNullable>(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<UserWithID>(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<UserWithID>(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<UserWithID>(db, "Users", {
user_id: "INTEGER",
name: "TEXT",