diff --git a/src/lib/db/crud/crud-store.ts b/src/lib/db/crud/crud-store.ts new file mode 100644 index 0000000000..ed6f88eb1f --- /dev/null +++ b/src/lib/db/crud/crud-store.ts @@ -0,0 +1,132 @@ +import { NotFoundError } from '../../error'; +import { DB_TIME } from '../../metric-events'; +import { Db, IUnleashConfig } from '../../server-impl'; +import { Store } from '../../types/stores/store'; +import metricsHelper from '../../util/metrics-helper'; +import { defaultFromRow, defaultToRow } from './default-mappings'; +import { Row } from './row-type'; + +export type CrudStoreConfig = Pick; + +/** + * This abstract class defines the basic operations for a CRUD store + * + * Provides default types for: + * - RowReadModel turning the properties of ReadModel from camelCase to snake_case + * - RowWriteModel turning the properties of WriteModel from camelCase to snake_case + * - IdType assumming it's a number + * + * These types can be overridden to suit different needs. + * + * Default implementations of toRow and fromRow are provided, but can be overridden. + */ +export abstract class CRUDStore< + ReadModel extends { id: IdType }, + WriteModel, + RowReadModel = Row, + RowWriteModel = Row, + IdType = number, +> implements Store +{ + protected db: Db; + + protected tableName: string; + + protected readonly timer: (action: string) => Function; + + protected toRow: (item: Partial) => RowWriteModel; + protected fromRow: (item: RowReadModel) => ReadModel; + + constructor( + tableName: string, + db: Db, + { eventBus }: CrudStoreConfig, + options?: Partial<{ + toRow: (item: WriteModel) => RowWriteModel; + fromRow: (item: RowReadModel) => ReadModel; + }>, + ) { + this.tableName = tableName; + this.db = db; + this.timer = (action: string) => + metricsHelper.wrapTimer(eventBus, DB_TIME, { + store: tableName, + action, + }); + this.toRow = options?.toRow ?? defaultToRow; + this.fromRow = + options?.fromRow ?? defaultFromRow; + } + + async getAll(query?: Partial): Promise { + let allQuery = this.db(this.tableName); + if (query) { + allQuery = allQuery.where(this.toRow(query) as Record); + } + const items = await allQuery; + return items.map(this.fromRow); + } + + async insert(item: WriteModel): Promise { + const rows = await this.db(this.tableName) + .insert(this.toRow(item)) + .returning('*'); + return this.fromRow(rows[0]); + } + + async bulkInsert(items: WriteModel[]): Promise { + if (!items || items.length === 0) { + return []; + } + const rows = await this.db(this.tableName) + .insert(items.map(this.toRow)) + .returning('*'); + return rows.map(this.fromRow); + } + + async update(id: IdType, item: Partial): Promise { + const rows = await this.db(this.tableName) + .where({ id }) + .update(this.toRow(item)) + .returning('*'); + return this.fromRow(rows[0]); + } + + async delete(id: IdType): Promise { + return this.db(this.tableName).where({ id }).delete(); + } + + async deleteAll(): Promise { + return this.db(this.tableName).delete(); + } + + destroy(): void {} + + async exists(id: IdType): Promise { + const result = await this.db.raw( + `SELECT EXISTS(SELECT 1 FROM ${this.tableName} WHERE id = ?) AS present`, + [id], + ); + const { present } = result.rows[0]; + return present; + } + + async count(query?: Partial): Promise { + let countQuery = this.db(this.tableName).count('*'); + if (query) { + countQuery = countQuery.where( + this.toRow(query) as Record, + ); + } + const { count } = (await countQuery.first()) ?? { count: 0 }; + return Number(count); + } + + async get(id: IdType): Promise { + const row = await this.db(this.tableName).where({ id }).first(); + if (!row) { + throw new NotFoundError(`No item with id ${id}`); + } + return this.fromRow(row); + } +} diff --git a/src/lib/db/crud/default-mappings.ts b/src/lib/db/crud/default-mappings.ts new file mode 100644 index 0000000000..5bd7bc8aab --- /dev/null +++ b/src/lib/db/crud/default-mappings.ts @@ -0,0 +1,34 @@ +const camelToSnakeCase = (str: string) => + str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + +const snakeToCamelCase = (str: string) => + str.replace(/(_\w)/g, (letter) => letter[1].toUpperCase()); + +/** + * This helper function turns all fields in the item object from camelCase to snake_case + * + * @param item is the input object + * @returns a modified version of item with all fields in snake_case + */ +export const defaultToRow = ( + item: WriteModel, +): WriteRow => { + const row: Partial = {}; + Object.entries(item as Record).forEach(([key, value]) => { + row[camelToSnakeCase(key)] = value; + }); + return row as WriteRow; +}; + +/** + * This helper function turns all fields in the row object from snake_case to camelCase + * @param row is the input object + * @returns a modified version of row with all fields in camelCase + */ +export const defaultFromRow = (row: ReadRow): ReadModel => { + const model: Partial = {}; + Object.entries(row as Record).forEach(([key, value]) => { + model[snakeToCamelCase(key)] = value; + }); + return model as ReadModel; +}; diff --git a/src/lib/db/crud/row-type.ts b/src/lib/db/crud/row-type.ts new file mode 100644 index 0000000000..ad42cfcc84 --- /dev/null +++ b/src/lib/db/crud/row-type.ts @@ -0,0 +1,13 @@ +// This defines dynamic name for the generated types +type CamelCaseToSnakeCase = S extends `${infer P1}${infer P2}` + ? P2 extends Uncapitalize + ? `${P1}${CamelCaseToSnakeCase}` + : `${P1}_${CamelCaseToSnakeCase>}` + : S; + +/** + * This helper type turns all fields in the type from camelCase to snake_case + */ +export type Row = { + [K in keyof T as CamelCaseToSnakeCase]: T[K]; +};