1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

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<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
This commit is contained in:
Gastón Fournier 2024-03-08 10:39:29 +01:00 committed by GitHub
parent 9148820a8f
commit 82f4093c04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 179 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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;
};

View File

@ -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];
};