From 82f4093c04ffd62ea1561f8ab26aeba7cdc009ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 8 Mar 2024 10:39:29 +0100 Subject: [PATCH] feat: adapted CRUD store from enterprise into OSS (#6474) ## About the changes This ports the CRUD store into OSS which is an abstraction to reduce the amount of boilerplate code we have to write in stores. By extending CRUDStore, the store becomes simply the type definition: ```typescript type ActionModel = { actionSetId: number; action: string; executionParams: Record; createdByUserId: number; sortOrder: number; }; export class ActionStore extends CRUDStore< ActionModel & { id: number; createdAt: Date }, ActionModel > { } ``` And eventually specific mappings between those types can be provided (if the mapping is more complex than camelCase -> snake_case): ```typescript toRow: ({ project, name, actor, match, createdByUserId }) => ({ created_by_user_id: createdByUserId, project, name, actor_id: actor, source: match.source, source_id: match.sourceId, payload: match.payload, }), fromRow: ({ id, created_at, created_by_user_id, project, name, actor_id, source, source_id, payload, }) => ({ id, createdAt: created_at, createdByUserId: created_by_user_id, project, name, actor: actor_id, match: { source, sourceId: source_id, payload, }, }), ``` Stores can also be extended to include additional functionality in case you need to join with another table or do an aggregation, but it significantly reduces the amount of boilerplate code needed to create a basic store --- src/lib/db/crud/crud-store.ts | 132 ++++++++++++++++++++++++++++ src/lib/db/crud/default-mappings.ts | 34 +++++++ src/lib/db/crud/row-type.ts | 13 +++ 3 files changed, 179 insertions(+) create mode 100644 src/lib/db/crud/crud-store.ts create mode 100644 src/lib/db/crud/default-mappings.ts create mode 100644 src/lib/db/crud/row-type.ts diff --git a/src/lib/db/crud/crud-store.ts b/src/lib/db/crud/crud-store.ts new file mode 100644 index 00000000000..ed6f88eb1f6 --- /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 00000000000..5bd7bc8aabf --- /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 00000000000..ad42cfcc849 --- /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]; +};