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> = {
|
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>;
|
||||||
|
|
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) {
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
84
src/table.ts
84
src/table.ts
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue