Strict DB mode
This commit is contained in:
parent
ff7879dee7
commit
11b0598d46
6 changed files with 82 additions and 78 deletions
|
@ -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>;
|
||||
|
|
18
src/error.ts
18
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
84
src/table.ts
84
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> = 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue