322 lines
9.1 KiB
TypeScript
322 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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|