Skip to content

Commit

Permalink
feat: adapted CRUD store from enterprise into OSS (#6474)
Browse files Browse the repository at this point in the history
## 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<string, unknown>; 
     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
  • Loading branch information
gastonfournier committed Mar 8, 2024
1 parent 9148820 commit 82f4093
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 0 deletions.
132 changes: 132 additions & 0 deletions 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<IUnleashConfig, 'eventBus'>;

/**
* 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<ReadModel>,
RowWriteModel = Row<WriteModel>,
IdType = number,
> implements Store<ReadModel, IdType>
{
protected db: Db;

protected tableName: string;

protected readonly timer: (action: string) => Function;

protected toRow: (item: Partial<WriteModel>) => 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<WriteModel, RowWriteModel>;
this.fromRow =
options?.fromRow ?? defaultFromRow<ReadModel, RowReadModel>;
}

async getAll(query?: Partial<WriteModel>): Promise<ReadModel[]> {
let allQuery = this.db(this.tableName);
if (query) {
allQuery = allQuery.where(this.toRow(query) as Record<string, any>);
}
const items = await allQuery;
return items.map(this.fromRow);
}

async insert(item: WriteModel): Promise<ReadModel> {
const rows = await this.db(this.tableName)
.insert(this.toRow(item))
.returning('*');
return this.fromRow(rows[0]);
}

async bulkInsert(items: WriteModel[]): Promise<ReadModel[]> {
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<WriteModel>): Promise<ReadModel> {
const rows = await this.db(this.tableName)
.where({ id })
.update(this.toRow(item))
.returning('*');
return this.fromRow(rows[0]);
}

async delete(id: IdType): Promise<void> {
return this.db(this.tableName).where({ id }).delete();
}

async deleteAll(): Promise<void> {
return this.db(this.tableName).delete();
}

destroy(): void {}

async exists(id: IdType): Promise<boolean> {
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<WriteModel>): Promise<number> {
let countQuery = this.db(this.tableName).count('*');
if (query) {
countQuery = countQuery.where(
this.toRow(query) as Record<string, any>,
);
}
const { count } = (await countQuery.first()) ?? { count: 0 };
return Number(count);
}

async get(id: IdType): Promise<ReadModel> {
const row = await this.db(this.tableName).where({ id }).first();
if (!row) {
throw new NotFoundError(`No item with id ${id}`);
}
return this.fromRow(row);
}
}
34 changes: 34 additions & 0 deletions 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 = <WriteModel, WriteRow>(
item: WriteModel,
): WriteRow => {
const row: Partial<WriteRow> = {};
Object.entries(item as Record<string, any>).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 = <ReadModel, ReadRow>(row: ReadRow): ReadModel => {
const model: Partial<ReadModel> = {};
Object.entries(row as Record<string, any>).forEach(([key, value]) => {
model[snakeToCamelCase(key)] = value;
});
return model as ReadModel;
};
13 changes: 13 additions & 0 deletions src/lib/db/crud/row-type.ts
@@ -0,0 +1,13 @@
// This defines dynamic name for the generated types
type CamelCaseToSnakeCase<S extends string> = S extends `${infer P1}${infer P2}`
? P2 extends Uncapitalize<P2>
? `${P1}${CamelCaseToSnakeCase<P2>}`
: `${P1}_${CamelCaseToSnakeCase<Uncapitalize<P2>>}`
: S;

/**
* This helper type turns all fields in the type from camelCase to snake_case
*/
export type Row<T> = {
[K in keyof T as CamelCaseToSnakeCase<K & string>]: T[K];
};

0 comments on commit 82f4093

Please sign in to comment.