UNPKG

@serafin/pipeline

Version:

CRUD data access library with a functional approach

434 lines (399 loc) 17.4 kB
import * as _ from "lodash" import * as util from "util" import { SchemaBuilder } from "@serafin/schema-builder" import { notImplementedError, error } from "./error" import { IdentityInterface } from "./IdentityInterface" import { SchemaBuildersInterface, schemaBuildersInterfaceKeys } from "./SchemaBuildersInterface" import { Pipe, PipeActionsInterface } from "./PipeInterface" import { Relation } from "./Relation" import { ResultsInterface } from "./ResultsInterface" import { PipelineCreateFunction, PipelineDeleteFunction, PipelineInterface, PipelineMethods, PipelinePatchFunction, PipelineReadFunction, ReadOnlyPipelineInterface, pipelineMethods, } from "./PipelineInterface" import { RelationType } from "./RelationType" export interface PipelineAbstractOptions { validationEnabled?: boolean name?: string } export const defaultPipelineAbstractOptions: PipelineAbstractOptions = { validationEnabled: true } export abstract class PipelineAbstract< M extends IdentityInterface = IdentityInterface, CV = any, CO = any, RQ = any, PQ = any, PV = any, DQ = any, CM = any, RM = any, PM = any, DM = any, CTX = any, R extends Record<string, Relation<IdentityInterface, string, IdentityInterface, any, any, RelationType>> = {}, > implements PipelineInterface<M, CV, CO, RQ, PQ, PV, DQ, CM, RM, PM, DM, CTX> { public relations: R = {} as R private pipes: PipeActionsInterface[] = [] private options: PipelineAbstractOptions constructor(public schemaBuilders: SchemaBuildersInterface<M, CV, CO, RQ, PQ, PV, DQ, CM, RM, PM, DM, CTX>, options?: PipelineAbstractOptions) { this.options = { ...defaultPipelineAbstractOptions, ...options } } public get modelSchemaBuilder() { return this.schemaBuilders.model as SchemaBuilder<M> } public pipe< M2 extends IdentityInterface = M, CV2 = CV, CO2 = CO, RQ2 = RQ, PQ2 = PQ, PV2 = PV, DQ2 = DQ, CM2 = CM, RM2 = RM, PM2 = PM, DM2 = DM, CTX2 = CTX, >(pipe: Pipe<M, CV, CO, RQ, PQ, PV, DQ, CM, RM, PM, DM, CTX, M2, CV2, CO2, RQ2, PQ2, PV2, DQ2, CM2, RM2, PM2, DM2, CTX2>) { // run the pipe const result = typeof pipe === "function" ? pipe({ ...this.schemaBuilders, }) : pipe.transform({ ...this.schemaBuilders, }) // combine schema modifications with the current schemas const newPipeline = this.clone() as any as PipelineAbstract<M2, CV2, CO2, RQ2, PQ2, PV2, DQ2, CM2, RM2, PM2, DM2, CTX2, R> const modifiedSchemas = _.pick(result, schemaBuildersInterfaceKeys) if (Object.keys(modifiedSchemas).length > 0) { newPipeline.schemaBuilders = { ...this.schemaBuilders, ...modifiedSchemas, } as any } // add pipe methods modifications to the pipeline if it implements at least one of the CRUD methods const modifiedMethods = _.pickBy(result, (v, k) => !!v && pipelineMethods.includes(k as any)) if (Object.keys(modifiedMethods).length > 0) { newPipeline.pipes = [ _.mapValues( modifiedMethods, (value: any, key) => (...props: any[]) => value(...props, this), // the current pipeline is provided as the last argument ), ...this.pipes, ] } return newPipeline } /** * Build a recursive function that will call all the pipes for a CRUD method */ private pipeChain(method: PipelineMethods) { const callChain = async (i: number, ...args: any[]) => { while (i < this.pipes.length && !this.pipes[i][method]) { ++i } if (i >= this.pipes.length) { return (this[`_${method}`] as (...args: any[]) => any)(...args) } else { return (this.pipes[i++] as any)[method]((...args: any[]) => callChain(i, ...args), ...args) } } return async (...args: any[]) => callChain(0, ...args) } /** * Add a many relation to the pipeline. * A query parameter is added to the all actions to conditionally fetch the related entities. */ public addRelationWithMany<NameKey extends string, RelationModel extends IdentityInterface, ReadQuery, ReadMeta>( name: NameKey, pipeline: ReadOnlyPipelineInterface<RelationModel, ReadQuery, ReadMeta>, query: Partial<ReadQuery>, ) { const relationSchema = SchemaBuilder.arraySchema(pipeline.schemaBuilders.model) const relationOption = `with${_.upperFirst(name)}` as `with${Capitalize<NameKey>}` const relationOptionSchema = SchemaBuilder.booleanSchema({ description: `If set to 'true', the result will include the property '${name}'` }) const relation: Relation<M, NameKey, RelationModel, ReadQuery, ReadMeta, RelationType.many> = new Relation( this, name, pipeline, query, RelationType.many, ) const newPipeline = ( this as any as PipelineAbstract< M, CV, CO, RQ, PQ, PV, DQ, CM, RM, PM, DM, CTX, R & { [key in NameKey]: Relation<M, NameKey, RelationModel, ReadQuery, ReadMeta, RelationType.many> } > ).pipe((p) => { const model = p.model.addProperty(name, relationSchema, false) const readQuery = p.readQuery.addProperty(relationOption, relationOptionSchema, false) const createOptions = p.createOptions.addProperty(relationOption, relationOptionSchema, false) const patchQuery = p.patchQuery.addProperty(relationOption, relationOptionSchema, false) const deleteQuery = p.deleteQuery.addProperty(relationOption, relationOptionSchema, false) return { model, readQuery, createOptions, patchQuery, deleteQuery, create: async (next, resources, options, context) => { const result = await next(resources, options, context) return { ...result, data: await relation.assignToResources(result.data) } }, read: async (next, query: typeof readQuery.T, context) => { const result = await next(query, context) return { ...result, data: await relation.assignToResources(result.data) } }, patch: async (next, query, values, context) => { const result = await next(query, values, context) return { ...result, data: await relation.assignToResources(result.data) } }, delete: async (next, query, context) => { const result = await next(query, context) return { ...result, data: await relation.assignToResources(result.data) } }, } }) newPipeline.relations[name] = relation as any return newPipeline } /** * Add a one relation to the pipeline. * A query parameter is added to the all actions to conditionally fetch the related entity. */ public addRelationWithOne<NameKey extends string, RelationModel extends IdentityInterface, ReadQuery, ReadMeta>( name: NameKey, pipeline: ReadOnlyPipelineInterface<RelationModel, ReadQuery, ReadMeta>, query: Partial<ReadQuery>, ) { const relationSchema = pipeline.schemaBuilders.model const relationOption = `with${_.upperFirst(name)}` as `with${Capitalize<NameKey>}` const relationOptionSchema = SchemaBuilder.booleanSchema({ description: `If set to 'true', the result will include the property '${name}'` }) const relation: Relation<M, NameKey, RelationModel, ReadQuery, ReadMeta, RelationType.one> = new Relation(this, name, pipeline, query, RelationType.one) const newPipeline = ( this as any as PipelineAbstract< M, CV, CO, RQ, PQ, PV, DQ, CM, RM, PM, DM, CTX, R & { [key in NameKey]: Relation<M, NameKey, RelationModel, ReadQuery, ReadMeta, RelationType.one> } > ).pipe((p) => { const model = p.model.addProperty(name, relationSchema, false) const readQuery = p.readQuery.addProperty(relationOption, relationOptionSchema, false) const createOptions = p.createOptions.addProperty(relationOption, relationOptionSchema, false) const patchQuery = p.patchQuery.addProperty(relationOption, relationOptionSchema, false) const deleteQuery = p.deleteQuery.addProperty(relationOption, relationOptionSchema, false) return { model, readQuery, createOptions, patchQuery, deleteQuery, create: async (next, resources, options, context) => { const result = await next(resources, options, context) return { ...result, data: await relation.assignToResources(result.data) } }, read: async (next, query: typeof readQuery.T, context) => { const result = await next(query, context) return { ...result, data: await relation.assignToResources(result.data) } }, patch: async (next, query, values, context) => { const result = await next(query, values, context) return { ...result, data: await relation.assignToResources(result.data) } }, delete: async (next, query, context) => { const result = await next(query, context) return { ...result, data: await relation.assignToResources(result.data) } }, } }) newPipeline.relations[name] = relation as any return newPipeline } /** * Get a readable description of what this pipeline does */ toString(): string { return util.inspect( _.mapValues(this.schemaBuilders, (schema: SchemaBuilder<any>) => schema.schema), false, null, ) } /** * Create new resources based on `resources` input array. * * @param resources An array of partial resources to be created * @param options Options that can alter the action behavior * @param context Context object */ async create(resources: CV[], options?: CO, context?: CTX): Promise<ResultsInterface<M, CM>> { resources = _.cloneDeep(resources) options = _.cloneDeep(options ?? ({} as CO)) context = _.cloneDeep(context ?? ({} as CTX)) this.handleValidationOfData("create", "resources", this.schemaBuilders.createValues, resources) this.handleValidationOfData("create", "options", this.schemaBuilders.createOptions, options) this.handleValidationOfData("create", "context", this.schemaBuilders.context, context) return this.pipeChain("create")(resources, options, context) } /** * Create action placeholder * It should be overridden by child pipeline class */ protected _create(resources: CV[], options: CO, context: CTX): Promise<ResultsInterface<M, CM>> { throw notImplementedError("create", Object.getPrototypeOf(this).constructor.name) } /** * Extract a standalone create function. * It can be used as a parameter for pipes to isolate dependencies and ease constraints definition */ getCreateFunction() { return this.create.bind(this) as PipelineCreateFunction<M, CV, CO, CM, CTX> } /** * Read resources from the underlying source according to the given `query`. * * @param query The query filter to be used for fetching the data * @param context Context object */ async read(query: RQ, context?: CTX): Promise<ResultsInterface<M, RM>> { query = _.cloneDeep(query) context = _.cloneDeep(context ?? ({} as CTX)) this.handleValidationOfData("read", "query", this.schemaBuilders.readQuery, query) this.handleValidationOfData("read", "context", this.schemaBuilders.context, context) return this.pipeChain("read")(query, context) } /** * Read action placeholder * It should be overridden by child pipeline class */ protected _read(query: RQ, context: CTX): Promise<ResultsInterface<M, RM>> { throw notImplementedError("read", Object.getPrototypeOf(this).constructor.name) } /** * Extract a standalone read function. * It can be used as a parameter for pipes to isolate dependencies and ease constraints definition */ getReadFunction() { return this.read.bind(this) as PipelineReadFunction<M, RQ, RM, CTX> } /** * Patch resources according to the given query and values. * The `query` will select a subset of the underlying data source and `values` are updated on it. * This method should follow the JSON merge patch standard if possible. @see https://tools.ietf.org/html/rfc7396 * * @param query * @param values * @param context Context object */ async patch(query: PQ, values: PV, context?: CTX): Promise<ResultsInterface<M, PM>> { query = _.cloneDeep(query) values = _.cloneDeep(values) context = _.cloneDeep(context ?? ({} as CTX)) this.handleValidationOfData("patch", "query", this.schemaBuilders.patchQuery, query) this.handleValidationOfData("patch", "values", this.schemaBuilders.patchValues, values) this.handleValidationOfData("patch", "context", this.schemaBuilders.context, context) return this.pipeChain("patch")(query, values, context) } /** * Patch action placeholder * It should be overridden by child pipeline class */ protected _patch(query: PQ, values: PV, context: CTX): Promise<ResultsInterface<M, PM>> { throw notImplementedError("patch", Object.getPrototypeOf(this).constructor.name) } /** * Extract a standalone patch function. * It can be used as a parameter for pipes to isolate dependencies and ease constraints definition */ getPatchFunction() { return this.patch.bind(this) as PipelinePatchFunction<M, PQ, PV, PM, CTX> } /** * Delete resources that match th given Query. * @param query The query filter to be used for selecting resources to delete * @param context Context object */ async delete(query: DQ, context?: CTX): Promise<ResultsInterface<M, DM>> { query = _.cloneDeep(query) context = _.cloneDeep(context ?? ({} as CTX)) this.handleValidationOfData("delete", "query", this.schemaBuilders.deleteQuery, query) this.handleValidationOfData("delete", "context", this.schemaBuilders.context, context) return this.pipeChain("delete")(query, context) } /** * Delete action placeholder * It should be overridden by child pipeline class */ protected _delete(query: DQ, context: CTX): Promise<ResultsInterface<M, DM>> { throw notImplementedError("delete", Object.getPrototypeOf(this).constructor.name) } /** * Extract a standalone delete function. * It can be used as a parameter for pipes to isolate dependencies and ease constraints definition */ getDeleteFunction() { return this.delete.bind(this) as PipelineDeleteFunction<M, DQ, DM, CTX> } private handleValidationOfData(method: string, valueName: string, schema: SchemaBuilder<any>, data: any) { if (!this.options.validationEnabled) { return } try { if (Array.isArray(data)) { schema.validateList(data) } else { schema.validate(data) } } catch (e) { throw error( "SerafinValidationError", `Validation failed in ${Object.getPrototypeOf(this).constructor.name}${ this.options.name ? `::${this.options.name}` : "" }::${method}(${valueName})`, { constructor: Object.getPrototypeOf(this).constructor.name, method: method, schema: schema, data: data }, e, ) } } clone(): this { let clonedPipeline = _.cloneDeepWith(this, (value: any, key: number | string | undefined) => { if (key === "relations" || key === "schemaBuilders" || key === "pipes") { // shallow clone return _.clone(value) } }) return clonedPipeline } }