link-stack/packages/metamigo-common/records/crud-repository.ts
2023-02-13 13:10:48 +00:00

321 lines
9.1 KiB
TypeScript

/* 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<TSavedR | null>;
findBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
findAll(): Promise<TSavedR[]>;
findAllBy(example: Partial<TSavedR>): Promise<TSavedR[]>;
existsById(id: IdKeyT): Promise<boolean>;
countBy(example: Partial<TSavedR>): Promise<number>;
count(): Promise<number>;
insert(record: TUnsavedR): Promise<TSavedR>;
insertAll(toInsert: TUnsavedR[]): Promise<TSavedR[]>;
updateById(id: IdKeyT, attrs: Partial<TSavedR>): Promise<TSavedR>;
update(record: TSavedR): Promise<TSavedR>;
updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]>;
remove(record: TSavedR): Promise<number>;
removeAll(toRemove: TSavedR[]): Promise<number>;
removeBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
removeById(id: IdKeyT): Promise<number>;
}
// The snake cased object going into the db
type DatabaseRow = Record<string, unknown>;
/**
* 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<TUnsavedR, TSavedR, IdKeyT>
{
/**
* the fully qualified table name
*/
abstract schemaTable: TableName;
abstract recordType: PgRecordInfo<TUnsavedR, TSavedR, IdKeyT>;
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<TSavedR>): 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<TSavedR>): 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<TSavedR[]> {
return this.db.any("SELECT * FROM $1", [this.schemaTable]);
}
/**
* Returns the number of rows in the table
*/
async count(): Promise<number> {
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<TSavedR>): Promise<number> {
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<TSavedR>): Promise<TSavedR | null> {
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<TSavedR | null> {
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<boolean> {
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<TSavedR>): Promise<TSavedR[]> {
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<TSavedR> {
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<TSavedR[]> {
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<number> {
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<TSavedR>): Promise<TSavedR | null> {
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<number> {
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<number> {
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<TSavedR>): Promise<TSavedR> {
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<TSavedR> {
return this.updateById(this.recordType.idOf(record), record);
}
/**
* Update a batch of records at once
*/
async updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]> {
return this.db.tx((t) => {
const updateCommands: any[] = [];
toUpdate.forEach((record) => {
updateCommands.push(this.update(record));
});
return t.batch(updateCommands);
});
}
}