UNPKG

d1mapper

Version:

A simple ORM-like wrapper for Cloudflare D1 databases in TypeScript

323 lines (295 loc) 11.4 kB
import { D1Database } from '@cloudflare/workers-types'; /** * The result of a database operation. */ export interface DatabaseResult { success: boolean; changes?: number; } /** * Options for constructing a Database instance. * @template T - Record type representing table schema. */ export interface DatabaseOptions<T extends Record<string, any>> { db: D1Database; tableName: string; defaultProperties?: Partial<T>; primaryKeyName: keyof T; } /** * A wrapper around Cloudflare D1 providing basic CRUD operations. * @template T - Record type representing table schema. */ export class Database<T extends Record<string, any>> { db: D1Database; tableName: string; defaultProperties: Partial<T>; primaryKeyName: keyof T; /** * Construct a Database instance. * @param options - Options for the database instance. */ constructor(options: DatabaseOptions<T>) { this.db = options.db; this.tableName = options.tableName; this.defaultProperties = options.defaultProperties ?? {}; this.primaryKeyName = options.primaryKeyName; } /** * Execute a SQL query with positional parameters. * @param query - SQL query string. * @param params - Array of parameters to bind. * @returns DatabaseResult containing success status and change count. */ private async exec(query: string, params: any[]): Promise<DatabaseResult> { const result = await this.db.prepare(query).bind(...params).run(); if (!result.success) { throw new Error(`Query failed: ${query}`); } return { success: true, changes: result.meta?.changes }; } /** * Insert a record into the table. Missing props use defaults. * @param record - Partial record to insert. * @param inPlaceModify - Whether to modify the record in place (default true). * @returns Result of the insert operation. */ async insert(record: Partial<T>, inPlaceModify: boolean = true): Promise<DatabaseResult> { // merge any defaults with provided record, omit undefined keys if (inPlaceModify) { Object.assign(record, this.defaultProperties, record); } else { record = { ...this.defaultProperties, ...record }; } const keys = Object.keys(record) as (keyof T)[]; const values = keys.map(k => record[k]!); const placeholders = keys.map((_, i) => `?${i + 1}`).join(', '); const query = `INSERT INTO ${this.tableName} (${keys.join(', ')}) VALUES (${placeholders})`; return this.exec(query, values as any[]); } /** * Find one record matching a condition and return the full record. * * @param props - An empty array indicating all columns should be selected (`SELECT *`). * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns The full record (`T`) if found, or null. */ async findOne( props: [], conditionKey: keyof T, conditionValue: T[keyof T] ): Promise<T | null>; /** * Find one record matching a condition and select specific columns. * * @param props - Column(s) to select. * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns The record with selected properties if found, or null. */ async findOne<K extends keyof T>( props: K | K[], conditionKey: keyof T, conditionValue: T[keyof T] ): Promise<Pick<T, K> | null>; async findOne<K extends keyof T>( props: K | K[] | [], conditionKey: keyof T, conditionValue: T[keyof T] ): Promise<any> { let columns: string; if (Array.isArray(props)) { columns = props.length > 0 ? props.join(', ') : '*'; } else { columns = props as string; } const query = `SELECT ${columns} FROM ${this.tableName} WHERE ${String(conditionKey)} = ?1 LIMIT 1`; return await this.db.prepare(query).bind(conditionValue).first<Pick<T, K>>(); } /** * Retrieve all records with all columns. * * @param props - An empty array indicating all columns should be selected (`SELECT *`). * @param paginationOptions - (optional) Object containing `limit` and `offset` properties for pagination. * @returns Array of full records (`T[]`). */ async findAll(props: [], paginationOptions?: { limit?: number; offset?: number }): Promise<T[]>; /** * Retrieve all records with selected properties. * * @param props - Column(s) to select. * @param paginationOptions - (optional) Object containing `limit` and `offset` properties for pagination. * @returns Array of records with selected properties. */ async findAll<K extends keyof T>(props: K | K[], paginationOptions?: { limit?: number; offset?: number }): Promise<Pick<T, K>[]>; async findAll<K extends keyof T>(props: K | K[] | [], paginationOptions?: { limit?: number; offset?: number }): Promise<any[]> { let columns: string; if (Array.isArray(props)) { columns = props.length > 0 ? props.join(', ') : '*'; } else { columns = props as string; } let query = `SELECT ${columns} FROM ${this.tableName}`; const params: any[] = []; if (paginationOptions?.limit !== undefined) { query += ` LIMIT ?1`; params.push(paginationOptions.limit); } if (paginationOptions?.offset !== undefined) { query += ` OFFSET ?2`; params.push(paginationOptions.offset); } const { results } = await this.db.prepare(query).bind(...params).all<any>(); return results; } /** * Find records matching a filter (all columns). * @param props - An empty array indicating all columns should be selected (`SELECT *`). * @param filter - Partial record for WHERE clause. * @param paginationOptions - (optional) Object containing `limit` and `offset` properties for pagination. */ async findMany(props: [], filter: Partial<T>, paginationOptions?: { limit?: number; offset?: number }): Promise<T[]>; /** * Find records matching a filter with selected properties. * @param props - Column(s) to select. * @param filter - Partial record for WHERE clause. * @param paginationOptions - (optional) Object containing `limit` and `offset` properties for pagination. */ async findMany<K extends keyof T>(props: K | K[], filter: Partial<T>, paginationOptions?: { limit?: number; offset?: number }): Promise<Pick<T, K>[]>; async findMany<K extends keyof T>( props: K | K[] | [], filter: Partial<T>, paginationOptions?: { limit?: number; offset?: number } ): Promise<any[]> { let columns: string; if (Array.isArray(props)) { columns = props.length > 0 ? props.join(', ') : '*'; } else { columns = props as string; } const keys = Object.keys(filter) as (keyof T)[]; const clauses = keys.map((k, i) => `${String(k)} = ?${i + 1}`).join(' AND '); let query = `SELECT ${columns} FROM ${this.tableName}` + (clauses ? ` WHERE ${clauses}` : ''); let params: any[] = []; let paramIndex = keys.length + 1; if (paginationOptions?.limit !== undefined) { query += ` LIMIT ?${paramIndex}`; params.push(paginationOptions.limit); paramIndex++; } if (paginationOptions?.offset !== undefined) { query += ` OFFSET ?${paramIndex}`; params.push(paginationOptions.offset); paramIndex++; } const values = keys.map(k => filter[k]); const { results } = await this.db.prepare(query).bind(...values, ...params).all<any>(); return results; } /** * Update records matching a condition. * @param record - Partial properties to update. * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns Result containing change count. */ async update( record: Partial<T>, conditionKey: keyof T, conditionValue: T[keyof T] ): Promise<DatabaseResult> { const keys = Object.keys(record) as (keyof T)[]; if (keys.length === 0) { return { success: true, changes: 0 }; } const setters = keys.map((key, i) => `${String(key)} = ?${i + 2}`).join(', '); const query = `UPDATE ${this.tableName} SET ${setters} WHERE ${String(conditionKey)} = ?1`; const values = [conditionValue, ...keys.map(k => record[k])]; return this.exec(query, values); } /** * Delete records matching a condition. * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns Result containing change count. */ async delete(conditionKey: keyof T, conditionValue: T[keyof T]): Promise<DatabaseResult> { const query = `DELETE FROM ${this.tableName} WHERE ${String(conditionKey)} = ?1`; return this.exec(query, [conditionValue]); } /** * Increment a numeric column by a step for matching records. * @param column - Column to increment. * @param step - Amount to add. * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns Result containing change count. */ async increment( column: keyof T, step: number, conditionKey: keyof T, conditionValue: T[keyof T] ): Promise<DatabaseResult> { const query = `UPDATE ${this.tableName} SET ${String(column)} = ${String(column)} + ?1 WHERE ${String(conditionKey)} = ?2`; return this.exec(query, [step, conditionValue]); } /** * Check existence of a record matching a condition. * @param conditionKey - Column to filter by. * @param conditionValue - Value to match. * @returns True if record exists, else false. */ async exists(conditionKey: keyof T, conditionValue: T[keyof T]): Promise<boolean> { const query = `SELECT 1 FROM ${this.tableName} WHERE ${String(conditionKey)} = ?1 LIMIT 1`; const result = await this.db.prepare(query).bind(conditionValue).first<{ '1': number }>(); return Boolean(result); } /** * Find a record by primary key returning full record. * @param props - An empty array to select all columns. * @param id - Primary key value. */ async findById<K extends keyof T>(props: [], id: T[keyof T]): Promise<T | null>; /** * Find a record by primary key. * @param props - Column(s) to select. * @param id - Primary key value. */ async findById<K extends keyof T>(props: K | K[], id: T[keyof T]): Promise<Pick<T, K> | null> async findById<K extends keyof T>(props: K | K[], id: T[keyof T]): Promise<any> { return this.findOne(props, this.primaryKeyName, id); } /** * Update a record by primary key. * @param record - Partial properties to update. * @param id - Primary key value. */ async updateById(record: Partial<T>, id: T[keyof T]): Promise<DatabaseResult> { return this.update(record, this.primaryKeyName, id); } /** * Delete a record by primary key. * @param id - Primary key value. */ async deleteById(id: T[keyof T]): Promise<DatabaseResult> { return this.delete(this.primaryKeyName, id); } /** * Increment a numeric column by primary key. * @param column - Column to increment. * @param step - Amount to add. * @param id - Primary key value. */ async incrementById(column: keyof T, step: number, id: T[keyof T]): Promise<DatabaseResult> { return this.increment(column, step, this.primaryKeyName, id); } /** * Check existence by primary key. * @param id - Primary key value. */ async existsById(id: T[keyof T]): Promise<boolean> { return this.exists(this.primaryKeyName, id); } }