/* eslint-disable ghost/filenames/match-exported-class */ import {Knex} from 'knex'; import {mapKeys, chainTransformers} from '@tryghost/mongo-utils'; import errors from '@tryghost/errors'; type Entity = { id: T; deleted: boolean } type Order = { field: keyof T; direction: 'asc' | 'desc'; } export type ModelClass = { destroy: (data: {id: T}) => Promise; findOne: (data: {id: T}, options?: {require?: boolean}) => Promise | null>; add: (data: object) => Promise>; getFilteredCollection: (options: {filter?: string, mongoTransformer?: unknown}) => { count(): Promise, query: (f?: (q: Knex.QueryBuilder) => void) => Knex.QueryBuilder, fetchAll: () => Promise[]> }; } export type ModelInstance = { id: T; get(field: string): unknown; set(data: object|string, value?: unknown): void; save(properties: object, options?: {autoRefresh?: boolean; method?: 'update' | 'insert'}): Promise>; } type OptionalPropertyOf = Exclude<{ [K in keyof T]: T extends Record> ? never : K }[keyof T], undefined> // eslint-disable-next-line @typescript-eslint/no-explicit-any export type OrderOption = any> = Order[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type IncludeOption = any> = OptionalPropertyOf[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AllOptions = any> = { filter?: string; order?: OrderOption; page?: number; limit?: number, include?: IncludeOption } export abstract class BookshelfRepository> { protected Model: ModelClass; constructor(Model: ModelClass) { this.Model = Model; } protected abstract toPrimitive(entity: T): object; protected abstract modelToEntity (model: ModelInstance): Promise | T | null protected abstract getFieldToColumnMap(): Record; /** * override this method to add custom query logic to knex queries */ // eslint-disable-next-line @typescript-eslint/no-unused-vars applyCustomQuery(query: Knex.QueryBuilder, options: AllOptions) { return; } #entityFieldToColumn(field: keyof T): string { const mapping = this.getFieldToColumnMap(); return mapping[field]; } #orderToString(order?: OrderOption) { if (!order || order.length === 0) { return; } return order.map(({field, direction}) => `${this.#entityFieldToColumn(field)} ${direction}`).join(','); } /** * Map all the fields in an NQL filter to the names of the model */ #getNQLKeyTransformer() { return chainTransformers(...mapKeys(this.getFieldToColumnMap())); } async save(entity: T): Promise { if (entity.deleted) { await this.Model.destroy({id: entity.id}); return; } const existing = await this.Model.findOne({id: entity.id}, {require: false}); if (existing) { existing.set(this.toPrimitive(entity)); await existing.save({}, {autoRefresh: false, method: 'update'}); } else { await this.Model.add(this.toPrimitive(entity)); } } async getById(id: IDType): Promise { const models = await this.#fetchAll({ filter: `id:'${id}'`, limit: 1 }); if (models.length === 1) { return models[0]; } return null; } async #fetchAll(options: AllOptions = {}): Promise { const {filter, order, page, limit} = options; if (page !== undefined) { if (page < 1) { throw new errors.BadRequestError({message: 'page must be greater or equal to 1'}); } if (limit !== undefined && limit < 1) { throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'}); } } const collection = this.Model.getFilteredCollection({ filter, mongoTransformer: this.#getNQLKeyTransformer() }); const orderString = this.#orderToString(order); collection .query((q) => { this.applyCustomQuery(q, options); if (limit) { q.limit(limit); } if (limit && page) { q.limit(limit); q.offset(limit * (page - 1)); } if (orderString) { q.orderByRaw( orderString ); } }); const models = await collection.fetchAll(); return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[]; } async getAll({filter, order, include}: Omit, 'page'|'limit'> = {}): Promise { return this.#fetchAll({ filter, order, include }); } async getPage({filter, order, page, limit, include}: AllOptions & Required, 'page'|'limit'>>): Promise { return this.#fetchAll({ filter, order, page, limit, include }); } async getCount({filter}: { filter?: string } = {}): Promise { const collection = this.Model.getFilteredCollection({ filter, mongoTransformer: this.#getNQLKeyTransformer() }); return await collection.count(); } async getGroupedCount({filter, groupBy}: { filter?: string, groupBy: K }): Promise<({count: number} & Record)[]> { const columnName = this.#entityFieldToColumn(groupBy); const data = (await this.Model.getFilteredCollection({ filter, mongoTransformer: this.#getNQLKeyTransformer() }).query() .select(columnName) .count('* as count') .groupBy(columnName)) as ({count: number} & Record)[]; return data.map((row) => { return { count: row.count, [groupBy]: row[columnName] }; }) as ({count: number} & Record)[]; } }