/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any */ import { TableName } from "pg-promise"; import decamelcaseKeys from "decamelcase-keys"; import isObject from "lodash/isObject"; import isArray from "lodash/isArray"; import zipObject from "lodash/zipObject"; import isEmpty from "lodash/isEmpty"; import omit from "lodash/omit"; import { IDatabase, IMain, IResult } from "../db/types"; import { PgRecordInfo, idKeysOf } from "./record-info"; export interface ICrudRepository< TUnsavedR, TSavedR extends TUnsavedR & IdKeyT, IdKeyT extends object > { findById(id: IdKeyT): Promise; findBy(example: Partial): Promise; findAll(): Promise; findAllBy(example: Partial): Promise; existsById(id: IdKeyT): Promise; countBy(example: Partial): Promise; count(): Promise; insert(record: TUnsavedR): Promise; insertAll(toInsert: TUnsavedR[]): Promise; updateById(id: IdKeyT, attrs: Partial): Promise; update(record: TSavedR): Promise; updateAll(toUpdate: TSavedR[]): Promise; remove(record: TSavedR): Promise; removeAll(toRemove: TSavedR[]): Promise; removeBy(example: Partial): Promise; removeById(id: IdKeyT): Promise; } // The snake cased object going into the db type DatabaseRow = Record; /** * Base class for generic CRUD operations on a repository for a specific type. * * Several assumptions are made about your environment for this generic CRUD repository to work: * * - the underlying column names are snake_cased (this behavior can be changed, see [[columnize]]) * - the rows have only a single primary key (composite keys are not supported) * * @typeParam ID The type of the id column * @typeParam T The type of the record */ export abstract class CrudRepository< TUnsavedR, TSavedR extends TUnsavedR & IdKeyT, IdKeyT extends object > implements ICrudRepository { /** * the fully qualified table name */ abstract schemaTable: TableName; abstract recordType: PgRecordInfo; abstract db: IDatabase; abstract pgp: IMain; /** * Converts the record's columns into snake_case * * @param record the record of type T to convert */ columnize(record: TSavedR | Partial): DatabaseRow { return decamelcaseKeys(record); } /* * Creates a simple where clause with each key-value in `example` is * formatted as KEY=VALUE and all kv-pairs are ANDed together. * * @param example key value pair of column names and values */ where(example: Partial): string { const snaked = this.columnize(example); const clauses = Object.keys(snaked).reduce((acc, cur) => { const colName = this.pgp.as.format("$1:name", cur); return `${acc} and ${colName} = $<${cur}>`; }, ""); const where = this.pgp.as.format(`WHERE 1=1 ${clauses}`, { ...snaked }); // Pre-format WHERE condition return where; } /** * Converts a value containing the id of the record (which could be a primitive type, a composite object, or an array of values) * into an object which can be safely passed to [[where]]. */ idsObj(idValues: IdKeyT): IdKeyT { if (isEmpty(idValues)) { throw new Error(`idsObj(${this.schemaTable}): passed empty id(s)`); } let ids = {}; const idKeys = idKeysOf(this.recordType as any); if (isArray(idValues)) { ids = zipObject(idKeys, idValues); } else if (isObject(idValues)) { ids = idValues; } else { if (idKeys.length !== 1) { throw new Error( `idsObj(${this.schemaTable}): passed record has multiple primary keys. the ids must be passed as an object or array. ${idValues}` ); } // @ts-ignore ids[idKeys[0]] = idValues; } // this is a sanity check so we don't do something like // deleting all the data if a WHERE slips in with no ids if (isEmpty(ids)) { throw new Error(`idsObj(${this.schemaTable}): passed empty ids`); } return ids as IdKeyT; } /** * Returns all rows in the table */ async findAll(): Promise { return this.db.any("SELECT * FROM $1", [this.schemaTable]); } /** * Returns the number of rows in the table */ async count(): Promise { return this.db.one( "SELECT count(*) FROM $1", [this.schemaTable], (a: { count: string }) => Number(a.count) ); } /** * Returns the number of rows in the table matching the example */ async countBy(example: Partial): Promise { return this.db.one( "SELECT count(*) FROM $1 $2:raw ", [this.schemaTable, this.where(example)], (a: { count: string }) => Number(a.count) ); } /** * Find a single row where the example are true. * @param example key-value pairs of column names and values */ async findBy(example: Partial): Promise { return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [ this.schemaTable, this.where(example), ]); } /** * Retrieves a row by ID * @param id */ async findById(id: IdKeyT): Promise { const where = this.idsObj(id); return this.db.oneOrNone("SELECT * FROM $1 $2:raw", [ this.schemaTable, this.where(where), ]); } /** * Returns whether a given row with id exists * @param id */ async existsById(id: IdKeyT): Promise { return this.db.one( "SELECT EXISTS(SELECT 1 FROM $1 $2:raw)", [this.schemaTable, this.where(this.idsObj(id))], (a: { exists: boolean }) => a.exists ); } /** * Find all rows where the example are true. * @param example key-value pairs of column names and values */ async findAllBy(example: Partial): Promise { return this.db.any("SELECT * FROM $1 $2:raw", [ this.schemaTable, this.where(example), ]); } /** * Creates a new row * @param record * @return the new row */ async insert(record: TUnsavedR): Promise { return this.db.one("INSERT INTO $1 ($2:name) VALUES ($2:csv) RETURNING *", [ this.schemaTable, this.columnize(record as any), ]); } /** * Like `insert` but will insert/update a batch of rows at once */ async insertAll(toInsert: TUnsavedR[]): Promise { return this.db.tx((t) => { const insertCommands: any[] = []; toInsert.forEach((record) => { insertCommands.push(this.insert(record)); }); return t.batch(insertCommands); }); } /** * Deletes a row by id * @param id * @return the number of rows affected */ async removeById(id: IdKeyT): Promise { return this.db.result( "DELETE FROM $1 $2:raw", [this.schemaTable, this.where(this.idsObj(id))], (r: IResult) => r.rowCount ); } /** * Delete records matching the query * @param example key-value pairs of column names and values */ async removeBy(example: Partial): Promise { if (isEmpty(example)) throw new Error( `removeBy(${this.schemaTable}): passed empty constraint!` ); return this.db.result("DELETE FROM $1 $2:raw", [ this.schemaTable, this.where(example), ]); } /** * Deletes the given row * * @param record to remove * @return the number of rows affected */ async remove(record: TSavedR): Promise { return this.removeById(this.recordType.idOf(record)); } /** * Deletes all rows * @param toRemove a list of rows to remove, if empty, DELETES ALL ROWS * @return the number of rows affected */ async removeAll(toRemove: TSavedR[] = []): Promise { if (toRemove.length === 0) { return this.db.result( "DELETE FROM $1 WHERE 1=1;", [this.schemaTable], (r: IResult) => r.rowCount ); } const results = await this.db.tx((t) => { const delCommands: any[] = []; toRemove.forEach((record) => { delCommands.push(this.remove(record)); }); return t.batch(delCommands); }); return results.length; } /** * Updates an existing row * @param id * @param attrs * @return the updated row */ async updateById(id: IdKeyT, attrs: Partial): Promise { const idKeys = idKeysOf(this.recordType as any); const attrsSafe = omit(attrs, idKeys); return this.db.one( "UPDATE $1 SET ($2:name) = ROW($2:csv) $3:raw RETURNING *", [this.schemaTable, this.columnize(attrsSafe), this.where(this.idsObj(id))] ); } async update(record: TSavedR): Promise { return this.updateById(this.recordType.idOf(record), record); } /** * Update a batch of records at once */ async updateAll(toUpdate: TSavedR[]): Promise { return this.db.tx((t) => { const updateCommands: any[] = []; toUpdate.forEach((record) => { updateCommands.push(this.update(record)); }); return t.batch(updateCommands); }); } }