From 369e753005c1ab916f17f6c9bb3779818f8b7162 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Tue, 13 Feb 2024 00:21:50 +0400 Subject: [PATCH] feat: Morph interface, unified mapping for nested arrays, generic types --- src/MorphEach.ts | 22 ++++++--- src/MorphOne.ts | 110 ++++++++++++++++++++++------------------- src/extract.ts | 14 ++++-- src/inject.ts | 8 +-- tests/index.test.ts | 73 ++++++++++++++------------- types/index.d.ts | 105 ++++++++++++++++++++++++++++++++++++--- types/scaffolding.d.ts | 10 +++- 7 files changed, 238 insertions(+), 104 deletions(-) diff --git a/src/MorphEach.ts b/src/MorphEach.ts index b98bad0..c916c2c 100644 --- a/src/MorphEach.ts +++ b/src/MorphEach.ts @@ -1,13 +1,23 @@ -import MorphOne from '@/MorphOne' +import type { ArrayElement } from '../types/scaffolding' +import type { Morph } from '../types' -export default class MorphEach { - private readonly _morph: MorphOne +export default class MorphEach< + Source extends unknown[] = unknown[], + Target extends unknown[] = unknown[] +> implements Morph { + private readonly _morph: Morph< + ArrayElement, + ArrayElement + > - constructor (morph: MorphOne) { + constructor (morph: Morph< + ArrayElement, + ArrayElement + >) { this._morph = morph } - apply (source: unknown[]): unknown[] { - return source.map(v => this._morph.apply(v)) + convert (source: Source): Target { + return source.map(v => this._morph.convert(v as ArrayElement)) as Target } } \ No newline at end of file diff --git a/src/MorphOne.ts b/src/MorphOne.ts index 0e0ac84..746ed43 100644 --- a/src/MorphOne.ts +++ b/src/MorphOne.ts @@ -1,118 +1,116 @@ import type { Extractor, Injector, + Morph, Processor, } from '../types' -import type { Recursive } from '../types/scaffolding' +import type { + Key, + Maybe, + Recursive, + Returns, +} from '../types/scaffolding' import extract from '@/extract' import flatten from '@/flatten' import inject from '@/inject' -export default class MorphOne { - private _destination: () => unknown - private readonly _extractors: Map - private readonly _injectors: Map - private readonly _processors: Map - - /** - * @param {() => unknown} destination Defaults to () => ({}) - */ - constructor (destination: () => unknown = () => ({})) { - this._destination = destination - this._extractors = new Map() - this._injectors = new Map() - this._processors = new Map() - } +export default class MorphOne< + Source = unknown, + Target = unknown +> implements Morph { + private readonly _target: Returns + private readonly _extractors: Map> + private readonly _injectors: Map + private readonly _processors: Map /** - * Sets callback function will be used to create destination if it not set in mapper ::map method - * - * @param {() => unknown} destination + * @param {() => unknown} target Defaults to () => ({}) */ - destination (destination: () => unknown): MorphOne { - this._destination = destination - return this + constructor (target: Returns = () => ({} as Target)) { + this._target = target + this._extractors = new Map>() + this._injectors = new Map() + this._processors = new Map() } - apply (source: unknown, destination?: unknown) { - const dst = destination !== undefined ? destination : this._destination() + convert (source: Source, target?: Target): Target { + const t = target !== undefined ? target : this._target() this._extractors.forEach((extract, path) => { const _inject = this._injectors.get(path) ?? inject const processors = this._processors.get(path) ?? [] - _inject(dst, path, processors.reduce((raw, process) => { - return process instanceof MorphOne ? process.apply(raw) : process(raw) + _inject(t, path, processors.reduce((raw, process) => { + return 'convert' in process ? process.convert(raw) : process(raw) }, extract(source) as unknown)) }) - return dst + return t } /** * Associate a member to another member given their property paths. * - * @param {string} dstPath - * @param {string | string[]} srcPath + * @param {Key | Key[]} srcPath + * @param {Key} dstPath * @param fallback * * @return {this} Current instance */ move ( - srcPath: string | string[], - dstPath: string, + srcPath: Key | Key[], + dstPath: Key, fallback: unknown = undefined - ): MorphOne { - return this.extract(dstPath, ((source: unknown) => extract(source, srcPath, fallback)) as Extractor) + ): MorphOne { + return this.extract(dstPath, (source: Source) => extract(source, srcPath, fallback)) } /** * Applies a field extractor policy to a member. * - * @param {string} dstPath - * @param {Extractor} extractor + * @param {Key} path + * @param {Extractor} by * * @return {this} Current instance */ - extract (dstPath: string, extractor: Extractor): MorphOne { - this._extractors.set(dstPath, extractor) - + extract (path: Key, by: Extractor): MorphOne { + this._extractors.set(path, by) return this } - inject (dstPath: string, injector: Injector): MorphOne { - this._injectors.set(dstPath, injector) + inject (path: Key, by: Injector): MorphOne { + this._injectors.set(path, by) return this } /** * Applies a processor to the field. * - * @param {string} dstPath - * @param {Recursive} processor Map name or callback or processor instance + * @param {Key} path + * @param {Recursive} by Morph or callback * * @return {this} Current instance */ - process (dstPath: string, processor: Recursive): MorphOne { - if (Array.isArray(processor)) { - this._processors.set(dstPath, flatten(processor)) + process (path: Key, by: Recursive): MorphOne { + if (Array.isArray(by)) { + this._processors.set(path, flatten(by)) } else { - this._processors.set(dstPath, [processor]) + this._processors.set(path, [by]) } return this } /** - * Removes destination member + * Excludes destination member * - * @param {string} dstPath + * @param {Key} dstPath Member to exclude * * @return {this} Current instance */ - exclude (dstPath: string): MorphOne { + exclude (dstPath: Key): MorphOne { [this._extractors, this._processors, this._injectors].forEach(map => { if (map.has(dstPath)) { map.delete(dstPath) @@ -122,13 +120,23 @@ export default class MorphOne { return this } - clone (): MorphOne { - const morph = new MorphOne(this._destination) + override < + NewTarget = unknown, + Factory extends Maybe> = undefined + > (destination: Factory = undefined): MorphOne> { + const morph = new MorphOne< + Source, + Factory extends undefined ? Target : ReturnType + >((destination ?? this._target) as Returns>) this._extractors.forEach((extractor, path) => { morph._extractors.set(path, extractor) }) + this._injectors.forEach((injector, key) => { + morph._injectors.set(key, injector) + }) + this._processors.forEach((processors, path) => { morph._processors.set(path, processors) }) diff --git a/src/extract.ts b/src/extract.ts index 96696cb..0422c58 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -1,3 +1,5 @@ +import type { Key } from '../types/scaffolding' + import fail from '@/fail' const _guard = (source: unknown, message: string): void|never => { @@ -6,13 +8,13 @@ const _guard = (source: unknown, message: string): void|never => { } } -const _has = (object: Record, property: string, message?: string): void => { +const _has = (object: Record, property: Key, message?: string): void => { if (!Object.prototype.hasOwnProperty.call(object, property)) { fail(message || 'Object has no property ' + property) } } -const _extract = (source: Record, path: string[], prev: string[]): unknown => { +const _extract = (source: Record, path: Key[], prev: Key[]): unknown => { if (path.length === 0) { return source } @@ -35,10 +37,14 @@ const _extract = (source: Record, path: string[], prev: string[ export default ( source: Source, - path: string | string[], + path: Key | Key[], fallback: unknown = undefined ): unknown => { - const _path = typeof path === 'string' ? path.split('.') : path + const _path = typeof path === 'string' + ? path.split('.') + : typeof path === 'number' + ? [path] + : path try { _guard(source, 'Path extracting not available for scalar types') diff --git a/src/inject.ts b/src/inject.ts index 1355759..75eb5e9 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,6 +1,8 @@ +import type { Key } from '../types/scaffolding' + import fail from '@/fail' -const _inject = (destination: Record, path: string, value: unknown): void => { +const _inject = (destination: Record, path: Key, value: unknown): void => { if (typeof destination[path] === 'function') { const fn = destination[path] as (value: unknown) => void @@ -10,10 +12,10 @@ const _inject = (destination: Record, path: string, value: unkn } } -export default (destination: unknown, path: string, value: unknown): void => { +export default (destination: unknown, path: Key, value: unknown): void => { if (typeof destination !== 'object' || destination === null) { return fail('Scalar destinations not supported by default injector') } - _inject(destination as Record, path, value) + _inject(destination as Record, path, value) } \ No newline at end of file diff --git a/tests/index.test.ts b/tests/index.test.ts index cf97950..91f0091 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,8 +1,3 @@ -import type { - Extractor, - Processor, -} from '../types' - import { describe, expect, @@ -26,7 +21,7 @@ describe('Morph', () => { .move('_id', 'id') .move('_name', 'name') - expect(morph.apply({ + expect(morph.convert({ _id: 1, _name: 'Star Wars. Episode IV: A New Hope', })).toEqual({ @@ -40,7 +35,7 @@ describe('Morph', () => { .move('_id', 'id') .move('_name', 'name') - expect(() => morph.apply({ + expect(() => morph.convert({ _id: 1, })).toThrow('[@modulify/morph] Path _name is not reachable in the source') }) @@ -50,7 +45,7 @@ describe('Morph', () => { .move('_id', 'id') .move('_name', 'name', '%not present%') - expect(morph.apply({ + expect(morph.convert({ _id: 1, })).toEqual({ id: 1, @@ -63,7 +58,7 @@ describe('Morph', () => { .move('_id', 'id') .move('_name', 'name', '%not present%') - expect(morph.apply({ + expect(morph.convert({ _id: 1, _name: 'Star Wars. Episode IV: A New Hope', })).toEqual({ @@ -81,7 +76,7 @@ describe('Morph', () => { .move('_id', 'id') .move('_name', 'name', fallback) - expect(morph.apply({ + expect(morph.convert({ _id: 1, })).toEqual({ id: 1, @@ -93,7 +88,7 @@ describe('Morph', () => { const morph = new MorphOne() .move('_studio.name', 'studio') - expect(morph.apply({ + expect(morph.convert({ _studio: { name: 'Lucasfilm Ltd. LLC' }, })).toEqual({ studio: 'Lucasfilm Ltd. LLC', @@ -108,9 +103,9 @@ describe('Morph', () => { } const morph = new MorphOne() - .extract('studio', ((source: FilmPayload) => source._studio.name) as Extractor) + .extract('studio', (source: FilmPayload) => source._studio.name) - expect(morph.apply({ + expect(morph.convert({ _studio: { name: 'Lucasfilm Ltd. LLC' }, })).toEqual({ studio: 'Lucasfilm Ltd. LLC', @@ -120,9 +115,9 @@ describe('Morph', () => { test('uses callback processor', () => { const morph = new MorphOne() .move('_name', 'name') - .process('name', toUpperCase as Processor) + .process('name', toUpperCase) - expect(morph.apply({ + expect(morph.convert({ _name: 'Star Wars. Episode IV: A New Hope', })).toEqual({ name: 'STAR WARS. EPISODE IV: A NEW HOPE', @@ -135,9 +130,9 @@ describe('Morph', () => { .process('name', [ toUpperCase, (value: unknown) => '<<' + value + '>>', - ] as Processor[]) + ]) - expect(morph.apply({ + expect(morph.convert({ _name: 'Star Wars. Episode IV: A New Hope', })).toEqual({ name: '<>', @@ -148,14 +143,14 @@ describe('Morph', () => { const morph = new MorphOne() .move('_name', 'name') .process('name', [ - toUpperCase as Processor, + toUpperCase, [ - ((value: unknown) => '<' + value + '>') as Processor, - ((value: unknown) => '<' + value + '>') as Processor, + (value: unknown) => '<' + value + '>', + (value: unknown) => '<' + value + '>', ], ]) - expect(morph.apply({ + expect(morph.convert({ _name: 'Star Wars. Episode IV: A New Hope', })).toEqual({ name: '<>', @@ -168,7 +163,7 @@ describe('Morph', () => { .process('studio', new MorphOne() .move('_name', 'name')) - expect(morph.apply({ + expect(morph.convert({ _id: 1, _name: 'Star Wars. Episode IV: A New Hope', _studio: { _name: 'Lucasfilm Ltd. LLC' }, @@ -184,7 +179,7 @@ describe('Morph', () => { .move('_name', 'name') ) - expect(morph.apply([{ + expect(morph.convert([{ _id: 1, _name: 'Star Wars. Episode IV: A New Hope', }, { @@ -205,13 +200,11 @@ describe('Morph', () => { .process('studio', new MorphOne() .move('_name', 'name')) .move('_films', 'films') - .process('films', ((value: unknown[]) => new MorphEach(new MorphOne() + .process('films', new MorphEach(new MorphOne() .move('_id', 'id') - .move('_name', 'name')) - .apply(value) - ) as Processor) + .move('_name', 'name'))) - expect(morph.apply({ + expect(morph.convert({ _studio: { _name: 'Lucasfilm Ltd. LLC' }, _films: [{ _id: 1, @@ -240,7 +233,7 @@ describe('Morph', () => { date.setHours(0, 0) - morph.apply({ + morph.convert({ hours: 10, minutes: 30, }, date) @@ -249,20 +242,34 @@ describe('Morph', () => { expect(date.getMinutes()).toEqual(30) }) + test('custom destination', () => { + const morph = new MorphOne(() => new Date()) + .move('hours', 'setHours') + .move('minutes', 'setMinutes') + + const date = morph.convert({ + hours: 10, + minutes: 30, + }) + + expect(date.getHours()).toEqual(10) + expect(date.getMinutes()).toEqual(30) + }) + test('mapping by custom injector', () => { const date = new Date() - const morph = new MorphOne() + const morph = new MorphOne() .move('hours', 'setHours') .move('minutes', 'minutes') - .inject('minutes', (destination: unknown, _: string, value: unknown) => { - if (destination instanceof Date && typeof value === 'number') { + .inject('minutes', (destination, _, value) => { + if (typeof value === 'number') { destination.setMinutes(value) } }) date.setHours(0, 0) - morph.apply({ + morph.convert({ hours: 10, minutes: 30, }, date) diff --git a/types/index.d.ts b/types/index.d.ts index 042ee55..5b70baf 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,15 +1,108 @@ -export type Extractor = < +import type { + ArrayElement, + Key, + Maybe, + Recursive, + Returns, +} from './scaffolding' + +export type Extractor< Source = unknown, Value = unknown ->(source: Source) => Value +> = (source: Source) => Value -export type Injector = ( +export type Injector = ( destination: Destination, - path: string, + path: Key, value: unknown ) => void -export type Processor = < +export type Processor< Raw = unknown, Processed = unknown ->(value: Raw) => Processed \ No newline at end of file +> = (value: Raw) => Processed + +export interface Morph { + convert (source: Source): Target; +} + +export declare class MorphOne< + Source = unknown, + Target = unknown +> implements Morph { + /** + * @param {() => unknown} target Defaults to () => ({}) + */ + constructor (target?: () => Target); + + convert (source: Source): Target; + convert (source: Source, target: Target): Target; + + /** + * Associate a member to another member given their property paths. + * + * @param {Key | Key[]} srcPath + * @param {Key} dstPath + * @param fallback undefined by default + * + * @return {this} Current instance + */ + move ( + srcPath: Key | Key[], + dstPath: Key, + fallback?: unknown + ): MorphOne; + + /** + * Applies a field extractor policy to a member. + * + * @param {Key} path + * @param {Extractor} by + * + * @return {this} Current instance + */ + extract (path: Key, by: Extractor): MorphOne; + + inject (path: Key, by: Injector): MorphOne; + + /** + * Applies a processor to the field. + * + * @param {Key} path + * @param {Recursive} by Morph or callback or array of Morph & callbacks + * + * @return {this} Current instance + */ + process (path: Key, by: Recursive): MorphOne; + + /** + * Excludes destination member + * + * @param {Key} dstPath Member to exclude + * + * @return {this} Current instance + */ + exclude (dstPath: Key): MorphOne; + + override < + NewTarget = unknown, + Factory extends Maybe> = undefined + > (destination?: Factory): MorphOne< + Source, + Factory extends undefined + ? Target + : ReturnType + >; +} + +export default class MorphEach< + Source extends unknown[] = unknown[], + Target extends unknown[] = unknown[] +> implements Morph { + constructor (morph: Morph< + ArrayElement, + ArrayElement + >); + + convert (source: Source): Target; +} \ No newline at end of file diff --git a/types/scaffolding.d.ts b/types/scaffolding.d.ts index b2f1fd1..f63e97b 100644 --- a/types/scaffolding.d.ts +++ b/types/scaffolding.d.ts @@ -1,3 +1,11 @@ +export type ArrayElement = T extends Array ? D : never + +export type Key = number | string + +export type Maybe = T extends undefined + ? undefined + : T | undefined + export type Recursive = T | Recursive[] -export type Injector = (destination: D, path: string, value: unknown) => void \ No newline at end of file +export type Returns = () => T