Initial commit
This commit is contained in:
commit
47d0ec687b
15 changed files with 1704 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Dist dirs
|
||||
dist
|
||||
|
||||
# Databases
|
||||
*.sqlite*
|
||||
|
||||
# Top level configs
|
||||
.config
|
140
README.md
Normal file
140
README.md
Normal file
|
@ -0,0 +1,140 @@
|
|||
# Prequel
|
||||
|
||||
Streamlined, type-safe SQLite without too much _cruft_.
|
||||
|
||||
_(I figured I should add on to the massive pile of sql-related projects called "prequel")_
|
||||
|
||||
## Usage
|
||||
|
||||
Prequel is designed to handle some of the boilerplate for getting going with a SQLite db. It handles setting up tables and basic CRUD statements and otherwise wraps a SQLite DB to allow for using raw SQL alongside the pre-built queries.
|
||||
|
||||
```typescript
|
||||
import { Prequel } from "@foundry/prequel";
|
||||
|
||||
interface UserRow {
|
||||
user_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const db = new Prequel("db.sqlite");
|
||||
const users = db.table("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
users.insertPartial({
|
||||
name: "Flynn",
|
||||
});
|
||||
|
||||
users.findAll(); // [{user_id: 1, name: "Flynn"}]
|
||||
|
||||
const findByName = db.prepare<UserRow>(`SELECT * FROM ${users} WHERE name = ?`);
|
||||
findByName.get("Flynn"); // {user_id: 1, name: "Flynn"}
|
||||
```
|
||||
|
||||
## Defining columns
|
||||
|
||||
When defining the shape of a table, you can specify either a string containing a valid SQLite data type (limited to the five main types), or an object with further options.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "TEXT", // or "INTEGER", "REAL", "BLOB", "NULL"
|
||||
nullable: true, // Default is non-nullable columns
|
||||
primary: true, // Default is non-primary column
|
||||
misc: "" // Specify misc sql to append to the column def
|
||||
references: othertable.reference() // Specifies a foreign key ref
|
||||
cascade: true, // Cascading deletes. Default is off
|
||||
default: "" // Optionally provide a default value
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `new Prequel(filename?: string)`
|
||||
|
||||
Creates a new `Prequel` object which wraps a SQLite db.
|
||||
|
||||
### `prequel.table<RowShape>(name: string, columns: ColumnDefs)`
|
||||
|
||||
Ensures the existance of the specified table in the DB
|
||||
|
||||
### `prequel.prepare<RowShape, Params>(query: string)`
|
||||
|
||||
Alias for the underlying `database.query` call, preparing a SQL statement and caching the statement if possible.
|
||||
|
||||
### `prequel.enableWAL()`
|
||||
|
||||
Executes the pragma statement to enable [WAL](https://www.sqlite.org/wal.html) mode
|
||||
|
||||
### `prequel.close()`
|
||||
|
||||
Safely close the database
|
||||
|
||||
### `prequel.transaction(fn: () => void)`
|
||||
|
||||
Execute a function within a transaction. If the function throws an error, the transaction is rolled back.
|
||||
|
||||
Returns `true` when the transaction was successful. Throws any errors that occur during the transaction.
|
||||
|
||||
### `new Table<RowShape>(db: Database | Prequel, name: string, columns: ColumnDefs)`
|
||||
|
||||
Create a new table. Can be used without a Prequel object. If you pass in a Prequel object instead of a raw Database object, the table will be attached to the Prequel object as if it were created with `prequel.table()`
|
||||
|
||||
### `table.reference(colName?: string)`
|
||||
|
||||
Returns a reference usable with the `reference:` field in a column definition. Optionally provide a column name to reference. If no column name is provided, the tables primary key column is used.
|
||||
|
||||
### `table.insert(data: RowShape)`
|
||||
|
||||
Insert a fully formed row into the table
|
||||
|
||||
### `table.insertPartial(data: Partial<RowShape>)`
|
||||
|
||||
Insert a partial row into the table. Useful for generating IDs and leverage defaults.
|
||||
|
||||
### `table.update(data: RowShape)`
|
||||
|
||||
Given a complete row with a valid primary key value, update the row in the db.
|
||||
|
||||
This can only be used if a column has been set to `primary: true`.
|
||||
|
||||
### `table.findOnebyId(id: string | number)`
|
||||
|
||||
Return a single row with the given ID
|
||||
|
||||
### `table.findAll()`
|
||||
|
||||
Return all of the rows in this table
|
||||
|
||||
### `table.deleteById(id: string | number)`
|
||||
|
||||
Delete a row with the given ID
|
||||
|
||||
### `table.size()`
|
||||
|
||||
Count the rows in the table
|
||||
|
||||
### `table.toString()`
|
||||
|
||||
The `toString()` behavior of a `Table` object returns the name of the table. This allows you to use the table object in queries for consistency:
|
||||
|
||||
`SELECT * FROM ${users} WHERE user_id = ?`
|
||||
|
||||
### `table.name`
|
||||
|
||||
The name of the table
|
||||
|
||||
### `table.db`
|
||||
|
||||
The Database object this Table is a part of
|
||||
|
||||
### `table.columns`
|
||||
|
||||
A list of the column names on this table in the order they exist in the DB
|
||||
|
||||
### `table.schema`
|
||||
|
||||
The raw SQL that was used to generate this table
|
8
build.ts
Normal file
8
build.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import dts from "bun-plugin-dts";
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
outdir: "./dist",
|
||||
plugins: [dts()],
|
||||
target: "bun",
|
||||
});
|
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
22
package.json
Normal file
22
package.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@endeavorance/prequel",
|
||||
"version": "1.0.0",
|
||||
"exports": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "bun run ./build.ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Endeavorance <hello@endeavorance.camp> (https://endeavorance.camp)",
|
||||
"license": "CC BY-NC-SA 4.0",
|
||||
"files": ["dist"],
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"bun-plugin-dts": "^0.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
115
src/columns.ts
Normal file
115
src/columns.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
type RedefineValueTypes<ShapeToRedefine, NewValueType> = {
|
||||
[K in keyof ShapeToRedefine]: NewValueType;
|
||||
};
|
||||
|
||||
/** Types that define the data type of a column in the DB */
|
||||
export type DataType = "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL";
|
||||
|
||||
export interface Column {
|
||||
/** The data type to store this column */
|
||||
type: DataType;
|
||||
nullable: boolean;
|
||||
primary: boolean;
|
||||
|
||||
/** Any additional freeform SQL */
|
||||
misc: string;
|
||||
|
||||
/** If set, creates a foreign key association. See Table.reference() */
|
||||
references: string;
|
||||
cascade: boolean;
|
||||
default: string | number | undefined;
|
||||
unique: boolean;
|
||||
autoincrement: boolean;
|
||||
}
|
||||
|
||||
export type ColumnShorthand = DataType | Partial<Column>;
|
||||
|
||||
export type ColumnShorthandMap<T> = RedefineValueTypes<T, ColumnShorthand>;
|
||||
export type ColumnMap<T> = RedefineValueTypes<T, Column>;
|
||||
|
||||
export function generateColumnDefinitionSQL(
|
||||
name: string,
|
||||
colDef: Column,
|
||||
): string {
|
||||
const parts: string[] = [`"${name}"`, colDef.type];
|
||||
|
||||
if (colDef.primary) {
|
||||
parts.push("PRIMARY KEY");
|
||||
}
|
||||
|
||||
if (!colDef.primary && !colDef.nullable) {
|
||||
parts.push("NOT NULL");
|
||||
}
|
||||
|
||||
if (colDef.unique) {
|
||||
parts.push("UNIQUE");
|
||||
}
|
||||
|
||||
if (colDef.autoincrement) {
|
||||
parts.push("AUTOINCREMENT");
|
||||
}
|
||||
|
||||
if (colDef.default !== undefined) {
|
||||
parts.push(`DEFAULT (${colDef.default})`);
|
||||
}
|
||||
|
||||
if (colDef.references.length) {
|
||||
parts.push(`REFERENCES ${colDef.references}`);
|
||||
|
||||
if (colDef.cascade) {
|
||||
parts.push("ON DELETE CASCADE");
|
||||
}
|
||||
}
|
||||
|
||||
if (colDef.misc.length) {
|
||||
parts.push(colDef.misc);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function expandColumnShorthand(col: ColumnShorthand): Column {
|
||||
if (typeof col === "string") {
|
||||
return {
|
||||
type: col,
|
||||
nullable: false,
|
||||
primary: false,
|
||||
misc: "",
|
||||
references: "",
|
||||
cascade: false,
|
||||
default: undefined,
|
||||
unique: false,
|
||||
autoincrement: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: col.type ?? "TEXT",
|
||||
nullable: col.nullable ?? false,
|
||||
primary: col.primary ?? false,
|
||||
misc: col.misc ?? "",
|
||||
references: col.references ?? "",
|
||||
cascade: col.cascade ?? false,
|
||||
default: col.default ?? undefined,
|
||||
unique: col.unique ?? false,
|
||||
autoincrement: col.autoincrement ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
if (val === undefined) {
|
||||
throw new Error("Invariant: Undefined column descriptor");
|
||||
}
|
||||
|
||||
retval[key] = expandColumnShorthand(val);
|
||||
}
|
||||
|
||||
return retval as ColumnMap<T>;
|
||||
}
|
11
src/error.ts
Normal file
11
src/error.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export class SchemaError extends Error {
|
||||
constructor(message: string, table?: string, column?: string) {
|
||||
if (typeof table === "string" && typeof column === "string") {
|
||||
super(`SchemaError: ${message} ON ${table}.${column}`);
|
||||
} else if (typeof table === "string") {
|
||||
super(`SchemaError: ${message} ON ${table}`);
|
||||
} else {
|
||||
super(`SchemaError: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
14
src/index.ts
Normal file
14
src/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import type { Column, ColumnShorthand, DataType } from "./columns";
|
||||
import { type ExtractRowShape, Instance } from "./instance";
|
||||
import { Prequel } from "./prequel";
|
||||
import { Table } from "./table";
|
||||
|
||||
export {
|
||||
Prequel,
|
||||
Table,
|
||||
Instance,
|
||||
type DataType,
|
||||
type Column,
|
||||
type ColumnShorthand,
|
||||
type ExtractRowShape,
|
||||
};
|
49
src/instance.ts
Normal file
49
src/instance.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import type { Table } from "./table";
|
||||
|
||||
export type ExtractRowShape<T> = T extends Table<infer R> ? R : never;
|
||||
|
||||
/**
|
||||
* Represents an instance of a row in a table of a database.
|
||||
* Wraps the raw Row data and provides methods for interacting with it.
|
||||
*/
|
||||
export abstract class Instance<
|
||||
TableType extends Table<unknown>,
|
||||
SerializedInstanceType = void,
|
||||
> {
|
||||
protected row: ExtractRowShape<TableType>;
|
||||
protected table: TableType;
|
||||
|
||||
constructor(table: TableType, row: ExtractRowShape<TableType>) {
|
||||
this.row = row;
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves any changes made to the row back to the database.
|
||||
*/
|
||||
save() {
|
||||
this.table.update(this.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the instance into a generic format
|
||||
*/
|
||||
serialize(): SerializedInstanceType {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The row data as a JSON string.
|
||||
*/
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the raw row data.
|
||||
* @returns The raw row data.
|
||||
*/
|
||||
export(): ExtractRowShape<TableType> {
|
||||
return this.row;
|
||||
}
|
||||
}
|
193
src/prequel.ts
Normal file
193
src/prequel.ts
Normal file
|
@ -0,0 +1,193 @@
|
|||
import Database from "bun:sqlite";
|
||||
import type { ColumnShorthandMap } from "./columns";
|
||||
import { SchemaError } from "./error";
|
||||
import { Table } from "./table";
|
||||
|
||||
interface KVRow {
|
||||
kv_id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a key-value store using Prequel.
|
||||
*/
|
||||
export class PrequelKVStore {
|
||||
private table: Table<KVRow>;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the PrequelKVStore class.
|
||||
* @param pq The Prequel instance.
|
||||
* @param tableName The name of the table.
|
||||
*/
|
||||
constructor(pq: Prequel, tableName: string) {
|
||||
this.table = pq.table<KVRow>(tableName, {
|
||||
kv_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
autoincrement: true,
|
||||
},
|
||||
key: "TEXT",
|
||||
value: "TEXT",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates a key-value pair in the store.
|
||||
* @param key The key.
|
||||
* @param value The value.
|
||||
*/
|
||||
put<T>(key: string, value: T): void {
|
||||
const lookup = this.table.findOneWhere({
|
||||
key,
|
||||
});
|
||||
|
||||
if (lookup === null) {
|
||||
this.table.insertPartial({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
} else {
|
||||
lookup.value = JSON.stringify(value);
|
||||
this.table.update(lookup);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the value associated with the specified key from the store.
|
||||
* @param key The key.
|
||||
* @returns The value associated with the key, or null if the key is not found.
|
||||
*/
|
||||
get<T>(key: string): T | null {
|
||||
const lookup = this.table.findOneWhere({ key });
|
||||
|
||||
if (lookup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(lookup.value) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a key exists in the table.
|
||||
* @param key - The key to check.
|
||||
* @returns `true` if the key exists, `false` otherwise.
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return (
|
||||
this.table.findOneWhere({
|
||||
key,
|
||||
}) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all of the keys in the KV store
|
||||
* @returns The keys in the store.
|
||||
*/
|
||||
keys(): string[] {
|
||||
return this.table.findAll().map((row) => row.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all of the values in the KV store
|
||||
* @returns The values in the store.
|
||||
*/
|
||||
values(): unknown[] {
|
||||
return this.table.findAll().map((row) => JSON.parse(row.value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Prequel database instance.
|
||||
*/
|
||||
export class Prequel {
|
||||
public db: Database;
|
||||
public kv: PrequelKVStore;
|
||||
public tables: Record<string, Table<unknown>> = {};
|
||||
|
||||
/**
|
||||
* Creates a new Prequel database instance.
|
||||
* @param filename The filename of the database. Defaults to ":memory:" if not provided.
|
||||
*/
|
||||
constructor(filename = ":memory:") {
|
||||
this.db = new Database(filename);
|
||||
this.db.exec("PRAGMA foreign_keys=ON");
|
||||
this.kv = new PrequelKVStore(this, "PrequelManagedKVStore");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables Write-Ahead Logging (WAL) mode for the database.
|
||||
*/
|
||||
enableWAL() {
|
||||
this.db.exec("PRAGMA journal_mode=WAL;");
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection.
|
||||
*/
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a table with the given name exists in the database.
|
||||
* @param name The name of the table.
|
||||
* @returns `true` if the table exists, `false` otherwise.
|
||||
*/
|
||||
hasTable(name: string): boolean {
|
||||
return Object.keys(this.tables).includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new table in the database.
|
||||
* @param name The name of the table.
|
||||
* @param cols The column definitions of the table.
|
||||
* @returns The created table.
|
||||
* @throws {SchemaError} If a table with the same name already exists.
|
||||
*/
|
||||
table<RowShape>(
|
||||
name: string,
|
||||
cols: ColumnShorthandMap<RowShape>,
|
||||
): Table<RowShape> {
|
||||
if (this.hasTable(name)) {
|
||||
throw new SchemaError("Duplicate table name", name);
|
||||
}
|
||||
|
||||
const newTable = new Table(this, name, cols);
|
||||
this.tables[name] = newTable;
|
||||
return newTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an existing table to the database.
|
||||
* @param table The table to attach.
|
||||
* @throws {SchemaError} If a table with the same name already exists.
|
||||
*/
|
||||
attachTable(table: Table<unknown>) {
|
||||
if (this.hasTable(table.name)) {
|
||||
throw new SchemaError("Duplicate table name", table.name);
|
||||
}
|
||||
|
||||
this.tables[table.name] = table;
|
||||
}
|
||||
/**
|
||||
* Executes a function within a database transaction.
|
||||
* If the function throws an error, the transaction is rolled back.
|
||||
* Otherwise, the transaction is committed.
|
||||
* @param transactionFn The function to execute within the transaction.
|
||||
* @returns `true` if the transaction was committed successfully, otherwise `false`.
|
||||
* @throws The error thrown by the transaction function if the transaction fails.
|
||||
*/
|
||||
transaction<T>(transactionFn: () => T): T {
|
||||
try {
|
||||
this.db.exec("BEGIN TRANSACTION;");
|
||||
const result: T = transactionFn();
|
||||
this.db.exec("COMMIT;");
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.db.exec("ROLLBACK;");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
515
src/table.ts
Normal file
515
src/table.ts
Normal file
|
@ -0,0 +1,515 @@
|
|||
import type { Database, Statement } from "bun:sqlite";
|
||||
import {
|
||||
type ColumnMap,
|
||||
type ColumnShorthandMap,
|
||||
generateColumnDefinitionSQL,
|
||||
normalizeColumns,
|
||||
} from "./columns";
|
||||
import { SchemaError } from "./error";
|
||||
import { Prequel } from "./prequel";
|
||||
|
||||
/** Types that may appear in a row */
|
||||
type Value = string | number | null;
|
||||
|
||||
export class PrequelIDNotFoundError extends Error {
|
||||
constructor(tableName: string, id: Value) {
|
||||
super(`ID ${id} not found in ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PrequelEmptyResultsError extends Error {
|
||||
constructor() {
|
||||
super("No results found on a query which requires results");
|
||||
}
|
||||
}
|
||||
|
||||
/** An arbitrarily shaped Row */
|
||||
interface ArbitraryRow {
|
||||
[key: string]: Value;
|
||||
}
|
||||
|
||||
function shapeForQuery<T>(obj: Partial<T>): Record<string, Value> {
|
||||
const asInsertDataShape: Record<string, Value> = {};
|
||||
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
for (const key of keys) {
|
||||
const val = obj[key as keyof T];
|
||||
|
||||
if (typeof val !== "string" && typeof val !== "number" && val !== null) {
|
||||
throw new SchemaError(
|
||||
`Invalid value in query: ${key}: ${val} (type: ${typeof val})`,
|
||||
);
|
||||
}
|
||||
|
||||
const asQueryKey = `$${key}`;
|
||||
asInsertDataShape[asQueryKey] = val as Value;
|
||||
}
|
||||
|
||||
return asInsertDataShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a table in a database.
|
||||
*
|
||||
* @typeParam RowShape - The shape of the rows in the table.
|
||||
*/
|
||||
export class Table<RowShape> {
|
||||
private _name: string;
|
||||
private _db: Database;
|
||||
private _columnDefinitions: ColumnMap<RowShape>;
|
||||
private _columnNames: string[];
|
||||
private _createTableSQL: string;
|
||||
private _primaryColumnName = "ROWID";
|
||||
private _insertQuery: Statement<RowShape, [ArbitraryRow]>;
|
||||
private _updateQuery: Statement<RowShape, [ArbitraryRow]>;
|
||||
private _findQuery: Statement<RowShape, [string]>;
|
||||
private _deleteQuery: Statement<void, [string]>;
|
||||
private _deleteRowQuery: Statement<void, [ArbitraryRow]>;
|
||||
private _sizeQuery: Statement<{ count: number }, []>;
|
||||
private _truncateQuery: Statement<void, []>;
|
||||
|
||||
constructor(
|
||||
db: Database | Prequel,
|
||||
name: string,
|
||||
cols: ColumnShorthandMap<RowShape>,
|
||||
) {
|
||||
if (db instanceof Prequel) {
|
||||
this._db = db.db;
|
||||
} else {
|
||||
this._db = db;
|
||||
}
|
||||
this._name = name;
|
||||
this._columnDefinitions = normalizeColumns(cols);
|
||||
this._columnNames = Object.keys(this._columnDefinitions);
|
||||
|
||||
const columnSQLParts: string[] = [];
|
||||
const updateColumnSQLParts: string[] = [];
|
||||
|
||||
for (const columnName of this._columnNames) {
|
||||
const columnDefinition =
|
||||
this._columnDefinitions[
|
||||
columnName as keyof typeof this._columnDefinitions
|
||||
];
|
||||
|
||||
// Identify a primary column besides ROWID if specified
|
||||
if (columnDefinition.primary && this._primaryColumnName === "ROWID") {
|
||||
this._primaryColumnName = columnName;
|
||||
}
|
||||
|
||||
// Add non-primary columns to the generic update query
|
||||
if (!columnDefinition.primary) {
|
||||
updateColumnSQLParts.push(`"${columnName}" = $${columnName}`);
|
||||
}
|
||||
|
||||
// Generate column definitions for CREATE TABLE
|
||||
columnSQLParts.push(
|
||||
generateColumnDefinitionSQL(columnName, columnDefinition),
|
||||
);
|
||||
}
|
||||
|
||||
// Generate SQL for common queries
|
||||
const columnSQL = columnSQLParts.join(", ");
|
||||
const updateColumnSQL = updateColumnSQLParts.join(", ");
|
||||
const allCols = this._columnNames.join(", ");
|
||||
const allColVars = this._columnNames
|
||||
.map((colName) => `$${colName}`)
|
||||
.join(", ");
|
||||
|
||||
this._createTableSQL = `CREATE TABLE IF NOT EXISTS "${name}" (${columnSQL});`;
|
||||
const insertSql = `INSERT INTO "${name}" (${allCols}) VALUES (${allColVars}) RETURNING *;`;
|
||||
const updateSql = `UPDATE "${name}" SET ${updateColumnSQL} WHERE "${this._primaryColumnName}" = $${this._primaryColumnName} RETURNING *;`;
|
||||
const getByIdSql = `SELECT * FROM "${name}" WHERE "${this._primaryColumnName}" = ? LIMIT 1;`;
|
||||
const delByIdSql = `DELETE FROM "${name}" WHERE "${this._primaryColumnName}" = ?;`;
|
||||
const deleteRowSql = `DELETE FROM "${name}" WHERE "${this._primaryColumnName}" = $${this._primaryColumnName};`;
|
||||
const truncateQuery = `DELETE FROM "${name}";`;
|
||||
|
||||
// Ensure the table exists in the database
|
||||
this.db.exec(this._createTableSQL);
|
||||
|
||||
// Prepare common queries
|
||||
this._insertQuery = this.db.query<RowShape, ArbitraryRow>(insertSql);
|
||||
this._updateQuery = this.db.query<RowShape, ArbitraryRow>(updateSql);
|
||||
this._findQuery = this.db.query<RowShape, string>(getByIdSql);
|
||||
this._deleteQuery = this.db.query<void, string>(delByIdSql);
|
||||
this._sizeQuery = this.db.query<{ count: number }, []>(
|
||||
`SELECT count(*) as count FROM ${name}`,
|
||||
);
|
||||
this._deleteRowQuery = this.db.query<void, ArbitraryRow>(deleteRowSql);
|
||||
this._truncateQuery = this.db.query<void, []>(truncateQuery);
|
||||
|
||||
// If using with a Prequel instance, ensure the table is attached
|
||||
// This is only for when creating tables directly and passing in a Prequel instance
|
||||
if (db instanceof Prequel) {
|
||||
if (!db.hasTable(name)) {
|
||||
db.attachTable(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected hasColumn(colName: string) {
|
||||
return this._columnNames.includes(colName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a reference string for a column in the table.
|
||||
*
|
||||
* @param colName - The name of the column to reference. If not provided, the primary column name is used.
|
||||
* @returns A string representing the reference to the specified column or the primary column if no column name is provided.
|
||||
* @throws {SchemaError} If the specified column name does not exist in the table schema.
|
||||
*/
|
||||
public reference(colName?: keyof RowShape): string {
|
||||
if (colName === undefined) {
|
||||
return `${this._name}(${this._primaryColumnName})`;
|
||||
}
|
||||
|
||||
const asString = String(colName);
|
||||
|
||||
if (!this.hasColumn(asString)) {
|
||||
throw new SchemaError(
|
||||
"No such column available for reference",
|
||||
this._name,
|
||||
asString,
|
||||
);
|
||||
}
|
||||
|
||||
return `${this._name}(${asString})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new row into the table.
|
||||
*
|
||||
* @param data - The data to be inserted, conforming to the RowShape interface.
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a partial row into the table and returns the inserted row.
|
||||
*
|
||||
* @param data - An object containing the partial data to be inserted. The keys should match the column names of the table.
|
||||
* @returns The inserted row as an object of type `RowShape`.
|
||||
*
|
||||
* @typeParam RowShape - The shape of the row in the table.
|
||||
*/
|
||||
public insertPartial(data: Partial<RowShape>): RowShape {
|
||||
const asInsertDataShape: ArbitraryRow = shapeForQuery<RowShape>(data);
|
||||
const keys = Object.keys(data);
|
||||
const colNames = keys.join(", ");
|
||||
const varNames = keys.map((keyName) => `$${keyName}`);
|
||||
|
||||
const query = this._db.prepare(
|
||||
`INSERT INTO ${this} (${colNames}) VALUES (${varNames}) RETURNING *;`,
|
||||
);
|
||||
|
||||
return query.get(asInsertDataShape) as RowShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a row in the table with the provided data.
|
||||
*
|
||||
* @param data - The data to update the row with, conforming to the RowShape interface.
|
||||
* @returns The updated row data if the update is successful, or `null` if it fails.
|
||||
* @throws Will throw an error if the primary column name is "ROWID".
|
||||
*/
|
||||
public update(data: RowShape): RowShape | null {
|
||||
if (this._primaryColumnName === "ROWID") {
|
||||
throw new Error(
|
||||
"Cannot use `Table.update()` without setting a primary column",
|
||||
);
|
||||
}
|
||||
|
||||
const asInsertDataShape: ArbitraryRow = shapeForQuery<RowShape>(data);
|
||||
return this._updateQuery.get(asInsertDataShape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates rows in the table that match the specified conditions with the provided data.
|
||||
*
|
||||
* @param data - An object containing the data to update. The keys should correspond to the column names.
|
||||
* @param conditions - An object containing the conditions to match the rows that need to be updated. The keys should correspond to the column names.
|
||||
* @returns An array of updated rows.
|
||||
*/
|
||||
public updateWhere(
|
||||
data: Partial<RowShape>,
|
||||
conditions: Partial<RowShape>,
|
||||
): RowShape[] {
|
||||
const keys = Object.keys(data);
|
||||
const setParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const setClause = setParts.join(", ");
|
||||
|
||||
const whereKeys = Object.keys(conditions);
|
||||
const whereParts = whereKeys.map((key) => `"${key}" = $${key}`);
|
||||
const whereClause = whereParts.join(" AND ");
|
||||
|
||||
const query = this._db.prepare<RowShape, ArbitraryRow>(
|
||||
`UPDATE ${this} SET ${setClause} WHERE ${whereClause} RETURNING *;`,
|
||||
);
|
||||
|
||||
const shapedData = shapeForQuery({
|
||||
...data,
|
||||
...conditions,
|
||||
});
|
||||
|
||||
return query.all(shapedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all rows in the table with the provided data and returns the updated rows.
|
||||
*
|
||||
* @param data - An object containing the partial data to update the rows with.
|
||||
* The keys of this object should match the column names of the table.
|
||||
* @returns An array of updated rows.
|
||||
*/
|
||||
public updateAll(data: Partial<RowShape>): RowShape[] {
|
||||
const keys = Object.keys(data);
|
||||
const setParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const setClause = setParts.join(", ");
|
||||
|
||||
const query = this._db.prepare<RowShape, ArbitraryRow>(
|
||||
`UPDATE ${this} SET ${setClause} RETURNING *;`,
|
||||
);
|
||||
|
||||
return query.all(shapeForQuery(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an entry with the specified ID exists in the table.
|
||||
*
|
||||
* @param id - The ID of the entry to check for existence.
|
||||
* @returns `true` if an entry with the specified ID exists, otherwise `false`.
|
||||
*/
|
||||
public exists(id: Value): boolean {
|
||||
return this.findOneById(id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a row exists in the table that matches the given conditions.
|
||||
*
|
||||
* @param conditions - An object representing the conditions to match against.
|
||||
* Each key-value pair represents a column and the value to match.
|
||||
* @returns `true` if a matching row is found, otherwise `false`.
|
||||
*/
|
||||
public existsWhere(conditions: Partial<RowShape>): boolean {
|
||||
return this.findOneWhere(conditions) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of rows in the table that match the specified conditions.
|
||||
* @param conditions The shape of data to count rows by
|
||||
* @returns The number of rows that match the specified conditions.
|
||||
*/
|
||||
public countWhere(conditions: Partial<RowShape>): number {
|
||||
const keys = Object.keys(conditions);
|
||||
const whereParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const whereClause = whereParts.join(" AND ");
|
||||
const query = this._db.prepare<{ count: number }, ArbitraryRow>(
|
||||
`SELECT count(*) as count FROM ${this} WHERE ${whereClause};`,
|
||||
);
|
||||
|
||||
const shapedData = shapeForQuery(conditions);
|
||||
const countResult = query.get(shapedData) ?? { count: 0 };
|
||||
return countResult.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single row by its unique identifier.
|
||||
*
|
||||
* @param id - The unique identifier of the row to find.
|
||||
* @returns The row shape if found, otherwise `null`.
|
||||
*/
|
||||
public findOneById(id: Value): RowShape | null {
|
||||
return this._findQuery.get(`${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single row by its unique identifier. Throws an error if no row is found.
|
||||
*
|
||||
* @param id - The unique identifier of the row to find.
|
||||
* @returns The row shape if found, otherwise `null`.
|
||||
*
|
||||
* @throws {Error} If no row is found with the specified ID.
|
||||
*/
|
||||
public findOneByIdOrFail(id: Value): RowShape {
|
||||
const found = this.findOneById(id);
|
||||
|
||||
if (!found) {
|
||||
throw new PrequelIDNotFoundError(this.name, id);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all rows from the table.
|
||||
*
|
||||
* @returns {RowShape[]} An array of all rows in the table.
|
||||
*/
|
||||
public findAll(): RowShape[] {
|
||||
return this._db.prepare<RowShape, []>(`SELECT * FROM ${this}`).all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single row in the table that matches the specified conditions.
|
||||
*
|
||||
* @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match.
|
||||
* @returns The first row that matches the conditions, or `null` if no matching row is found.
|
||||
*/
|
||||
public findOneWhere(conditions: Partial<RowShape>): RowShape | null {
|
||||
const keys = Object.keys(conditions);
|
||||
const whereParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const whereClause = whereParts.join(" AND ");
|
||||
const query = this._db.prepare<RowShape, ArbitraryRow>(
|
||||
`SELECT * FROM ${this} WHERE ${whereClause};`,
|
||||
);
|
||||
|
||||
return query.get(shapeForQuery(conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a single row in the table that matches the specified conditions. Throws an error if no row is found.
|
||||
*
|
||||
* @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match.
|
||||
* @returns The first row that matches the conditions, or `null` if no matching row is found.
|
||||
*
|
||||
* @throws {Error} If no row is found that matches the specified conditions.
|
||||
*/
|
||||
public findOneWhereOrFail(conditions: Partial<RowShape>): RowShape {
|
||||
const found = this.findOneWhere(conditions);
|
||||
|
||||
if (!found) {
|
||||
throw new PrequelEmptyResultsError();
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all rows in the table that match the specified conditions.
|
||||
*
|
||||
* @param conditions - An object containing key-value pairs that represent the conditions for the query.
|
||||
* The keys are column names and the values are the values to match.
|
||||
* @returns An array of rows that match the specified conditions.
|
||||
*/
|
||||
public findAllWhere(conditions: Partial<RowShape>): RowShape[] {
|
||||
const keys = Object.keys(conditions);
|
||||
const whereParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const whereClause = whereParts.join(" AND ");
|
||||
const query = this._db.prepare<RowShape, ArbitraryRow>(
|
||||
`SELECT * FROM ${this} WHERE ${whereClause};`,
|
||||
);
|
||||
|
||||
return query.all(shapeForQuery(conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a row
|
||||
*
|
||||
* @param row - The row in the table to delete
|
||||
* @returns The updated row data if the update is successful, or `null` if it fails.
|
||||
* @throws Will throw an error if the primary column name is "ROWID".
|
||||
*/
|
||||
public delete(data: RowShape): void {
|
||||
if (this._primaryColumnName === "ROWID") {
|
||||
throw new Error(
|
||||
"Cannot use `Table.delete()` without setting a primary column",
|
||||
);
|
||||
}
|
||||
|
||||
const asDeleteDataShape: ArbitraryRow = shapeForQuery<RowShape>(data);
|
||||
this._deleteRowQuery.run(asDeleteDataShape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record from the table by its ID.
|
||||
*
|
||||
* @param id - The unique identifier of the record to be deleted.
|
||||
* @returns void
|
||||
*/
|
||||
public deleteById = (id: Value): void => {
|
||||
if (this._primaryColumnName === "ROWID") {
|
||||
throw new Error(
|
||||
"Cannot use `Table.deleteById()` without setting a primary column",
|
||||
);
|
||||
}
|
||||
|
||||
this._deleteQuery.run(`${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all rows in the table that match the specified conditions.
|
||||
*
|
||||
* @param conditions - An object representing the conditions to match. The keys are column names and the values are the values to match.
|
||||
*/
|
||||
public deleteWhere(conditions: Partial<RowShape>): void {
|
||||
const keys = Object.keys(conditions);
|
||||
const whereParts = keys.map((key) => `"${key}" = $${key}`);
|
||||
const whereClause = whereParts.join(" AND ");
|
||||
const query = this._db.prepare<void, ArbitraryRow>(
|
||||
`DELETE FROM ${this} WHERE ${whereClause};`,
|
||||
);
|
||||
|
||||
query.run(shapeForQuery(conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all rows in the table.
|
||||
*/
|
||||
public deleteAll(): void {
|
||||
this._truncateQuery.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the size of the table.
|
||||
*
|
||||
* @deprecated Use `count()` instead.
|
||||
* @returns {number} The number of rows in the table.
|
||||
* @throws {Error} If the row count query fails.
|
||||
*/
|
||||
public size(): number {
|
||||
return this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the size of the table.
|
||||
*
|
||||
* @returns {number} The number of rows in the table.
|
||||
* @throws {Error} If the row count query fails.
|
||||
*/
|
||||
public count(): number {
|
||||
const res = this._sizeQuery.get();
|
||||
|
||||
if (res === null) {
|
||||
throw new Error("Failed to count rows");
|
||||
}
|
||||
|
||||
return res.count;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
return `"${this._name}"`;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get db() {
|
||||
return this._db;
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this._columnNames;
|
||||
}
|
||||
|
||||
get schema(): string {
|
||||
return this._createTableSQL;
|
||||
}
|
||||
|
||||
get idColumnName(): string {
|
||||
return this._primaryColumnName;
|
||||
}
|
||||
}
|
53
test/instance.test.ts
Normal file
53
test/instance.test.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import { expect, test } from "bun:test";
|
||||
import { Instance, Prequel, Table } from "../src/index";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SerializedUser {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const db = new Database();
|
||||
const table = new Table<User>(db, "Users", {
|
||||
id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
class UserInstance extends Instance<typeof table, SerializedUser> {
|
||||
get name(): string {
|
||||
return this.row.name;
|
||||
}
|
||||
|
||||
set name(val: string) {
|
||||
this.row.name = val;
|
||||
}
|
||||
|
||||
serialize(): SerializedUser {
|
||||
return {
|
||||
name: this.row.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test("setting values on an instance", () => {
|
||||
table.insert({
|
||||
id: 1,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
const alice = table.findOneByIdOrFail(1);
|
||||
const inst = new UserInstance(table, alice);
|
||||
|
||||
inst.name = "Bob";
|
||||
inst.save();
|
||||
|
||||
const bob = table.findOneByIdOrFail(1);
|
||||
expect(bob.name).toEqual("Bob");
|
||||
});
|
137
test/prequel.test.ts
Normal file
137
test/prequel.test.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { expect, test } from "bun:test";
|
||||
import { SchemaError } from "../src/error";
|
||||
import { Prequel, Table } from "../src/index";
|
||||
|
||||
interface User {
|
||||
user_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
test("constructor", () => {
|
||||
const pq = new Prequel();
|
||||
const users = pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
users.insertPartial({ name: "Yondu" });
|
||||
|
||||
expect(users.findAll().length).toBe(1);
|
||||
});
|
||||
|
||||
test(".hasTable() knows if a table exists in the DB", () => {
|
||||
const pq = new Prequel();
|
||||
|
||||
expect(pq.hasTable("Users")).toBeFalse();
|
||||
expect(pq.hasTable("Notfound")).toBeFalse();
|
||||
|
||||
pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
expect(pq.hasTable("Users")).toBeTrue();
|
||||
expect(pq.hasTable("Notfound")).toBeFalse();
|
||||
});
|
||||
|
||||
test(".table() creates and attaches a table", () => {
|
||||
const pq = new Prequel();
|
||||
|
||||
// Already has one table because of KV
|
||||
expect(Object.keys(pq.tables)).toHaveLength(1);
|
||||
|
||||
const table = pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
expect(Object.keys(pq.tables)).toHaveLength(2);
|
||||
expect(Object.values(pq.tables)).toContain(table);
|
||||
});
|
||||
|
||||
test(".table() throws when creating a duplicate table", () => {
|
||||
const pq = new Prequel();
|
||||
pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
const oopsie = () => {
|
||||
pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
};
|
||||
|
||||
expect(oopsie).toThrow(SchemaError);
|
||||
});
|
||||
|
||||
test(".attachTable() attaches a table", () => {
|
||||
const pq = new Prequel();
|
||||
const table = new Table<User>(pq.db, "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
expect(pq.hasTable("Users")).toBeFalse();
|
||||
|
||||
pq.attachTable(table);
|
||||
|
||||
expect(pq.hasTable("Users")).toBeTrue();
|
||||
expect(Object.values(pq.tables)).toContain(table);
|
||||
});
|
||||
|
||||
test(".attachTable() throws on duplicate table names", () => {
|
||||
const pq = new Prequel();
|
||||
pq.table<User>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
const secondTable = new Table<User>(pq.db, "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
const doAttachment = () => {
|
||||
pq.attachTable(secondTable);
|
||||
};
|
||||
|
||||
expect(doAttachment).toThrow(SchemaError);
|
||||
});
|
||||
|
||||
test("kv store can set and retrieve arbitrary values", () => {
|
||||
const pq = new Prequel();
|
||||
|
||||
pq.kv.put<number>("answer", 42);
|
||||
pq.kv.put<{ enabled: boolean }>("config", { enabled: true });
|
||||
pq.kv.put<string[]>("list", ["a", "b", "c"]);
|
||||
|
||||
expect(pq.kv.get<number>("answer")).toEqual(42);
|
||||
expect(pq.kv.get<{ enabled: boolean }>("config")).toEqual({ enabled: true });
|
||||
expect(pq.kv.get<string[]>("list")).toEqual(["a", "b", "c"]);
|
||||
});
|
386
test/table.test.ts
Normal file
386
test/table.test.ts
Normal file
|
@ -0,0 +1,386 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import { expect, test } from "bun:test";
|
||||
import { Prequel, Table } from "../src/index";
|
||||
|
||||
interface UserWithID {
|
||||
user_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
post_id: number;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
interface UserWithNullable extends UserWithID {
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
function makeTestTable(): Table<UserWithID> {
|
||||
const db = new Database();
|
||||
return new Table<UserWithID>(db, "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
}
|
||||
|
||||
test("constructor", () => {
|
||||
const db = new Database();
|
||||
const table = new Table<UserWithID>(db, "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
expect(table.name).toEqual("Users");
|
||||
expect(table.db).toBe(db);
|
||||
expect(table.columns).toEqual(["user_id", "name"]);
|
||||
});
|
||||
|
||||
test("constructor -> auto-attach to a prequel isntance", () => {
|
||||
const db = new Prequel();
|
||||
const table = new Table<UserWithID>(db, "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
expect(Object.values(db.tables)).toContain(table);
|
||||
});
|
||||
|
||||
test(".toString() resolves to the table name for embedding in queries", () => {
|
||||
const table = makeTestTable();
|
||||
expect(`${table}`).toEqual('"Users"');
|
||||
});
|
||||
|
||||
test(".reference() creates a default reference to the primary column", () => {
|
||||
const table = makeTestTable();
|
||||
expect(table.reference()).toEqual("Users(user_id)");
|
||||
});
|
||||
|
||||
test(".reference() creates a reference to the specified column", () => {
|
||||
const table = makeTestTable();
|
||||
expect(table.reference("name")).toEqual("Users(name)");
|
||||
});
|
||||
|
||||
test(".insert() inserts a full row", () => {
|
||||
const table = makeTestTable();
|
||||
table.insert({
|
||||
user_id: 100,
|
||||
name: "Jack",
|
||||
});
|
||||
|
||||
const lookup = table.db.prepare<UserWithID, number>(
|
||||
`SELECT * FROM ${table} WHERE user_id = ?`,
|
||||
);
|
||||
const found = lookup.get(100);
|
||||
expect(found).toEqual({
|
||||
user_id: 100,
|
||||
name: "Jack",
|
||||
});
|
||||
});
|
||||
|
||||
test(".insert() is able to insert nulls to nullable fields", () => {
|
||||
const table = new Table<UserWithNullable>(new Database(), "Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
bio: {
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
},
|
||||
});
|
||||
|
||||
table.insert({
|
||||
user_id: 1,
|
||||
name: "Jack",
|
||||
bio: null,
|
||||
});
|
||||
|
||||
const lookup = table.db.prepare<UserWithNullable, number>(
|
||||
`SELECT * FROM ${table} WHERE user_id = ?`,
|
||||
);
|
||||
const found = lookup.get(1);
|
||||
|
||||
expect(found?.bio).toBeNull();
|
||||
});
|
||||
|
||||
test(".insertPartial() inserts a partial row", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({
|
||||
name: "Jack",
|
||||
});
|
||||
|
||||
const lookup = table.db.prepare<UserWithID, string>(
|
||||
`SELECT * FROM ${table} WHERE name = ?`,
|
||||
);
|
||||
const found = lookup.get("Jack");
|
||||
expect(found).toEqual({
|
||||
user_id: 1,
|
||||
name: "Jack",
|
||||
});
|
||||
});
|
||||
|
||||
test(".count() gets the current number of rows in the table", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
expect(table.count()).toBe(3);
|
||||
});
|
||||
|
||||
test(".countWhere() gets the current number of rows that match a condition", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
expect(table.countWhere({ name: "Jack" })).toBe(1);
|
||||
});
|
||||
|
||||
test(".findAll() returns the full table", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
const allUsers = table.findAll();
|
||||
|
||||
expect(allUsers).toHaveLength(3);
|
||||
expect(allUsers[0]).toEqual({ user_id: 1, name: "Jack" });
|
||||
expect(allUsers[1]).toEqual({ user_id: 2, name: "Kate" });
|
||||
expect(allUsers[2]).toEqual({ user_id: 3, name: "Sayid" });
|
||||
});
|
||||
|
||||
test(".findOneWhere() returns a row based on basic criteria", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
const sayid = table.findOneWhere({ name: "Sayid" });
|
||||
|
||||
expect(sayid).toEqual({ user_id: 3, name: "Sayid" });
|
||||
});
|
||||
|
||||
test(".findOneWhereOrFail() throws if it finds nothing", () => {
|
||||
const table = makeTestTable();
|
||||
|
||||
expect(() => {
|
||||
table.findOneWhereOrFail({ name: "Sayid" });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test(".findAllWhere() returns a row based on basic criteria", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Jack" });
|
||||
|
||||
const manyJacks = table.findAllWhere({ name: "Jack" });
|
||||
|
||||
expect(manyJacks).toHaveLength(3);
|
||||
expect(manyJacks[0]).toEqual({ user_id: 1, name: "Jack" });
|
||||
expect(manyJacks[1]).toEqual({ user_id: 2, name: "Jack" });
|
||||
expect(manyJacks[2]).toEqual({ user_id: 3, name: "Jack" });
|
||||
});
|
||||
|
||||
test(".findOneById() returns a single element by its primary column", () => {
|
||||
const table = makeTestTable();
|
||||
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
const sayid = table.findOneById(3);
|
||||
expect(sayid).toEqual({ user_id: 3, name: "Sayid" });
|
||||
});
|
||||
|
||||
test(".findOneById() returns null when nothing was found", () => {
|
||||
const table = makeTestTable();
|
||||
const nobody = table.findOneById(3);
|
||||
expect(nobody).toBeNull();
|
||||
});
|
||||
|
||||
test(".findOneByIdOrFail() returns a single element by its primary column", () => {
|
||||
const table = makeTestTable();
|
||||
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
const sayid = table.findOneById(3);
|
||||
expect(sayid).toEqual({ user_id: 3, name: "Sayid" });
|
||||
});
|
||||
|
||||
test(".findOneByIdOrFail() throws an error when nothing is found", () => {
|
||||
const table = makeTestTable();
|
||||
expect(() => {
|
||||
table.findOneByIdOrFail(3);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test(".delete() deletes a full record", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
expect(table.size()).toBe(3);
|
||||
|
||||
table.delete(table.findOneByIdOrFail(2));
|
||||
|
||||
expect(table.size()).toBe(2);
|
||||
|
||||
expect(table.findAll()).toEqual([
|
||||
{ user_id: 1, name: "Jack" },
|
||||
{ user_id: 3, name: "Sayid" },
|
||||
]);
|
||||
});
|
||||
|
||||
test(".deleteById() deletes the element with the given primary value", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
expect(table.size()).toBe(3);
|
||||
|
||||
table.deleteById(2);
|
||||
|
||||
expect(table.size()).toBe(2);
|
||||
|
||||
expect(table.findAll()).toEqual([
|
||||
{ user_id: 1, name: "Jack" },
|
||||
{ user_id: 3, name: "Sayid" },
|
||||
]);
|
||||
});
|
||||
|
||||
test(".deleteWhere() deletes all rows that match", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
expect(table.size()).toBe(3);
|
||||
|
||||
table.deleteWhere({ name: "Jack" });
|
||||
|
||||
expect(table.size()).toBe(1);
|
||||
|
||||
expect(table.findAll()).toEqual([{ user_id: 3, name: "Sayid" }]);
|
||||
});
|
||||
|
||||
test(".deleteById() respects when references are set to cascade", () => {
|
||||
const pq = new Prequel();
|
||||
const users = pq.table<UserWithID>("Users", {
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
const posts = pq.table<Post>("Posts", {
|
||||
post_id: {
|
||||
type: "INTEGER",
|
||||
primary: true,
|
||||
},
|
||||
user_id: {
|
||||
type: "INTEGER",
|
||||
references: users.reference(),
|
||||
cascade: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = users.insertPartial({ name: "Bob" });
|
||||
posts.insertPartial({
|
||||
user_id: user.user_id,
|
||||
});
|
||||
|
||||
users.deleteById(user.user_id);
|
||||
expect(posts.size()).toBe(0);
|
||||
});
|
||||
|
||||
test(".deleteAll() deletes all rows in the table", () => {
|
||||
const table = makeTestTable();
|
||||
table.insertPartial({ name: "Jack" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
expect(table.count()).toBe(3);
|
||||
|
||||
table.deleteAll();
|
||||
|
||||
expect(table.count()).toBe(0);
|
||||
});
|
||||
|
||||
test(".update() updates a full record", () => {
|
||||
const table = makeTestTable();
|
||||
const locke = table.insertPartial({ name: "Locke" });
|
||||
table.insertPartial({ name: "Kate" });
|
||||
table.insertPartial({ name: "Sayid" });
|
||||
|
||||
locke.name = "Jeremy Bentham";
|
||||
table.update(locke);
|
||||
|
||||
const newLocke = table.findOneById(1);
|
||||
expect(newLocke?.name).toEqual("Jeremy Bentham");
|
||||
});
|
||||
|
||||
test(".update() throws if no primary column is set besides ROWID", () => {
|
||||
const db = new Database();
|
||||
const table = new Table<UserWithID>(db, "Users", {
|
||||
user_id: "INTEGER",
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
const user = table.insert({ user_id: 1, name: "User" });
|
||||
|
||||
const oops = () => {
|
||||
table.update(user);
|
||||
};
|
||||
|
||||
expect(oops).toThrow(/primary column/);
|
||||
});
|
||||
|
||||
test(".updateWhere() updates rows based on the condition", () => {
|
||||
const db = new Database();
|
||||
const table = new Table<UserWithID>(db, "Users", {
|
||||
user_id: "INTEGER",
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
table.insert({ user_id: 1, name: "Jack" });
|
||||
table.insert({ user_id: 2, name: "Kate" });
|
||||
table.insert({ user_id: 3, name: "Sayid" });
|
||||
|
||||
table.updateWhere({ name: "Sawyer" }, { user_id: 2 });
|
||||
expect(table.findOneById(2)?.name).toEqual("Sawyer");
|
||||
});
|
||||
|
||||
test(".updateAll() updates all rows in the table", () => {
|
||||
const db = new Database();
|
||||
const table = new Table<UserWithID>(db, "Users", {
|
||||
user_id: "INTEGER",
|
||||
name: "TEXT",
|
||||
});
|
||||
|
||||
table.insert({ user_id: 1, name: "Jack" });
|
||||
table.insert({ user_id: 2, name: "Kate" });
|
||||
table.insert({ user_id: 3, name: "Sayid" });
|
||||
|
||||
table.updateAll({ name: "Sawyer" });
|
||||
expect(table.findAll().map((u) => u.name)).toEqual([
|
||||
"Sawyer",
|
||||
"Sawyer",
|
||||
"Sawyer",
|
||||
]);
|
||||
});
|
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"composite": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue