From e3790616ba917ed8545e4b638b0196dfdd455ae6 Mon Sep 17 00:00:00 2001 From: koenigstag <50256886+koenigstag@users.noreply.github.com> Date: Fri, 26 Apr 2024 00:23:56 +0300 Subject: [PATCH 01/13] feat(core): Add async mapping support Add promises to map method Add async handlers to mapAsync and mapArrayAsync --- packages/core/src/lib/core.ts | 161 +++++++-- packages/core/src/lib/mappings/map.ts | 488 ++++++++++++++++---------- packages/core/src/lib/types.ts | 2 +- 3 files changed, 423 insertions(+), 228 deletions(-) diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 041d55f19..6798b3f04 100644 --- a/packages/core/src/lib/core.ts +++ b/packages/core/src/lib/core.ts @@ -218,17 +218,20 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to if (p === 'map') { return < TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TDestination >( sourceObject: TSource, sourceIdentifier: ModelIdentifier, destinationIdentifierOrOptions?: | ModelIdentifier | MapOptions, - options?: MapOptions - ): TDestination => { + options?: MapOptions, + isAsync?: IsAsync + ): Result => { if (sourceObject == null) - return sourceObject as TDestination; + return (isAsync ? Promise.resolve(sourceObject) : sourceObject) as Result; const { destinationIdentifier, mapOptions } = getOptions( @@ -243,15 +246,41 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationIdentifier ); + // ASYNCRONOUS + if (isAsync) { + return Promise.resolve(sourceObject) + .then(async (sourceObject) => { + sourceObject = await strategy.preMap(sourceObject, mapping); + + let destination = await mapReturn( + mapping, + sourceObject, + > mapOptions || {}, + false, // isMapArray + isAsync, + ); + + destination = await strategy.postMap( + sourceObject, + destination, + mapping, + ); + + return destination; + }) as Result; + } + + // SYNCRONOUS + sourceObject = strategy.preMap(sourceObject, mapping); - const destination = mapReturn( + let destination = mapReturn( mapping, sourceObject, - mapOptions || {} + > mapOptions || {} ); - return strategy.postMap( + destination = strategy.postMap( sourceObject, // seal destination so that consumers cannot add properties to it // or change the property descriptors. but they can still modify it @@ -259,6 +288,8 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destination, mapping ); + + return destination; }; } @@ -274,31 +305,32 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to | MapOptions, options?: MapOptions ): Promise => { - const result = receiver['map']( + return receiver['map']( sourceObject, sourceIdentifier, destinationIdentifierOrOptions, - options + options, + true ); - return new Promise((res) => { - setTimeout(res, 0, result); - }); }; } if (p === 'mapArray') { return < TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TDestination[] >( sourceArray: TSource[], sourceIdentifier: ModelIdentifier, destinationIdentifierOrOptions?: | ModelIdentifier | MapOptions, - options?: MapOptions - ): TDestination[] => { - if (!sourceArray.length) return []; + options?: MapOptions, + isAsync?: IsAsync + ): Result => { + if (!sourceArray.length) return (isAsync ? Promise.resolve([]) : []) as Result; const { destinationIdentifier, mapOptions } = getOptions( @@ -319,6 +351,65 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to TDestination[] >; + // ASYNCRONOUS + if (isAsync) { + return Promise.resolve<{ + sourceArray: TSource[]; + destinationArray: TDestination[]; + }>({ + sourceArray, + destinationArray: [], + }).then(async ({ sourceArray, destinationArray }) => { + if (beforeMap) { + await beforeMap(sourceArray, []); + } + + for ( + let i = 0, length = sourceArray.length; + i < length; + i++ + ) { + let sourceObject = sourceArray[i]; + sourceObject = await strategy.preMap( + sourceObject, + mapping + ); + + let destination = await mapReturn( + mapping, + sourceObject, + { + extraArgs: extraArgs as MapOptions< + TSource, + TDestination + >['extraArgs'], + }, + true, // isMapArray + isAsync, + ); + + destination = await strategy.postMap( + sourceObject, + // seal destination so that consumers cannot add properties to it + // or change the property descriptors. but they can still modify it + // the ideal behavior is seal but the consumers might need to add/modify the object after map finishes + destination, + mapping + ); + + destinationArray.push(destination); + } + + if (afterMap) { + await afterMap(sourceArray, destinationArray); + } + + return destinationArray; + }) as Result; + } + + // SYNCRONOUS + if (beforeMap) { beforeMap(sourceArray, []); } @@ -336,7 +427,7 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to mapping ); - const destination = mapReturn( + let destination = mapReturn( mapping, sourceObject, { @@ -348,23 +439,23 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to true ); - destinationArray.push( - strategy.postMap( - sourceObject, - // seal destination so that consumers cannot add properties to it - // or change the property descriptors. but they can still modify it - // the ideal behavior is seal but the consumers might need to add/modify the object after map finishes - destination, - mapping - ) as TDestination + destination = strategy.postMap( + sourceObject, + // seal destination so that consumers cannot add properties to it + // or change the property descriptors. but they can still modify it + // the ideal behavior is seal but the consumers might need to add/modify the object after map finishes + destination, + mapping ); + + destinationArray.push(destination); } if (afterMap) { afterMap(sourceArray, destinationArray); } - return destinationArray; + return destinationArray as Result; }; } @@ -380,15 +471,13 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to | MapOptions, options?: MapOptions ) => { - const result = receiver['mapArray']( - sourceArray, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - return new Promise((res) => { - setTimeout(res, 0, result); - }); + return receiver['mapArray']( + sourceArray, + sourceIdentifier, + destinationIdentifierOrOptions, + options, + true + ); }; } diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index be55049cd..ae99d1bbb 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -4,6 +4,7 @@ import type { Dictionary, MapInitializeReturn, MapOptions, + Mapper, Mapping, MetadataIdentifier, } from '../types'; @@ -31,20 +32,23 @@ function setMemberReturnFn = any>( export function mapReturn< TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TDestination >( mapping: Mapping, sourceObject: TSource, options: MapOptions, - isMapArray = false -): TDestination { - return map({ + isMapArray = false, + isAsync?: IsAsync +): Result { + return map({ mapping, sourceObject, options, setMemberFn: setMemberReturnFn, isMapArray, - }); + }, isAsync); } function setMemberMutateFn(destinationObj: Record) { @@ -99,7 +103,9 @@ interface MapParameter< export function map< TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TDestination >({ mapping, sourceObject, @@ -107,7 +113,7 @@ export function map< setMemberFn, getMemberFn, isMapArray = false, -}: MapParameter): TDestination { +}: MapParameter, isAsync?: IsAsync): Result { // destructure mapping const [ [sourceIdentifier, destinationIdentifier], @@ -143,6 +149,60 @@ export function map< // initialize an array of keys that have already been configured const configuredKeys: string[] = []; + if (isAsync) { + return Promise.resolve<{ + sourceObject: TSource; + destination: TDestination; + }>({ + sourceObject, + destination, + }).then(async ({ + sourceObject, + destination, + }) => { + if (!isMapArray) { + const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; + if (beforeMap) { + await beforeMap(sourceObject, destination, extraArguments); + } + } + + _mapInternalLogic({ + propsToMap, + destination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, + }); + + if (!isMapArray) { + const afterMap = mapAfterCallback ?? mappingAfterCallback; + if (afterMap) { + await afterMap(sourceObject, destination, extraArguments); + } + } + + // Check unmapped properties + assertUnmappedProperties( + destination, + destinationWithMetadata, + configuredKeys, + sourceIdentifier, + destinationIdentifier, + errorHandler + ); + + return destination; + }) as Result; + } + if (!isMapArray) { const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; if (beforeMap) { @@ -151,224 +211,270 @@ export function map< } // map - for (let i = 0, length = propsToMap.length; i < length; i++) { - // destructure mapping property - const [ - destinationMemberPath, + _mapInternalLogic({ + propsToMap, + destination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, + }); + + if (!isMapArray) { + const afterMap = mapAfterCallback ?? mappingAfterCallback; + if (afterMap) { + afterMap(sourceObject, destination, extraArguments); + } + } + + // Check unmapped properties + assertUnmappedProperties( + destination, + destinationWithMetadata, + configuredKeys, + sourceIdentifier, + destinationIdentifier, + errorHandler + ); + + return destination as unknown as Result; +} + +function _mapInternalLogic< +TSource extends Dictionary, +TDestination extends Dictionary +>({ + propsToMap, + destination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, +}: { + propsToMap: Mapping[2], + destination: TDestination, + mapper: Mapper, + setMemberFn: MapParameter['setMemberFn'], + getMemberFn: MapParameter['getMemberFn'], + sourceObject: TSource, + destinationIdentifier: MetadataIdentifier, + extraArguments?: Record, + configuredKeys: string[], + errorHandler: ReturnType, + extraArgs: MapOptions['extraArgs'], + metadataMap: ReturnType, +}) { + for (let i = 0, length = propsToMap.length; i < length; i++) { + // destructure mapping property + const [ + destinationMemberPath, + [ + , [ - , + transformationMapFn, [ - transformationMapFn, - [ - transformationPreConditionPredicate, - transformationPreConditionDefaultValue = undefined, - ] = [], - ], + transformationPreConditionPredicate, + transformationPreConditionDefaultValue = undefined, + ] = [], ], - [destinationMemberIdentifier, sourceMemberIdentifier] = [], - ] = propsToMap[i]; - - let hasSameIdentifier = - !isPrimitiveConstructor(destinationMemberIdentifier) && - !isDateConstructor(destinationMemberIdentifier) && - !isPrimitiveConstructor(sourceMemberIdentifier) && - !isDateConstructor(sourceMemberIdentifier) && - sourceMemberIdentifier === destinationMemberIdentifier; - - if (hasSameIdentifier) { - // at this point, we have a same identifier that aren't primitive or date - // we then check if there is a mapping created for this identifier - hasSameIdentifier = !getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier, - true - ); - } + ], + [destinationMemberIdentifier, sourceMemberIdentifier] = [], + ] = propsToMap[i]; + + let hasSameIdentifier = + !isPrimitiveConstructor(destinationMemberIdentifier) && + !isDateConstructor(destinationMemberIdentifier) && + !isPrimitiveConstructor(sourceMemberIdentifier) && + !isDateConstructor(sourceMemberIdentifier) && + sourceMemberIdentifier === destinationMemberIdentifier; + + if (hasSameIdentifier) { + // at this point, we have a same identifier that aren't primitive or date + // we then check if there is a mapping created for this identifier + hasSameIdentifier = !getMapping( + mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier, + true + ); + } - // Set up a shortcut function to set destinationMemberPath on destination with value as argument - const setMember = (valFn: () => unknown) => { - try { - return setMemberFn(destinationMemberPath, destination)(valFn()); - } catch (originalError) { - const errorMessage = ` + // Set up a shortcut function to set destinationMemberPath on destination with value as argument + const setMember = (valFn: () => unknown) => { + try { + return setMemberFn(destinationMemberPath, destination)(valFn()); + } catch (originalError) { + const errorMessage = ` Error at "${destinationMemberPath}" on ${ - (destinationIdentifier as Constructor)['prototype'] - ?.constructor?.name || destinationIdentifier.toString() - } (${JSON.stringify(destination)}) + (destinationIdentifier as Constructor)['prototype'] + ?.constructor?.name || destinationIdentifier.toString() + } (${JSON.stringify(destination)}) --------------------------------------------------------------------- Original error: ${originalError}`; - errorHandler.handle(errorMessage); - throw new Error(errorMessage); - } - }; + errorHandler.handle(errorMessage); + throw new Error(errorMessage); + } + }; - // This destination key is being configured. Push to configuredKeys array - configuredKeys.push(destinationMemberPath[0]); + // This destination key is being configured. Push to configuredKeys array + configuredKeys.push(destinationMemberPath[0]); - // Pre Condition check + // Pre Condition check + if ( + transformationPreConditionPredicate && + !transformationPreConditionPredicate(sourceObject) + ) { + setMember(() => transformationPreConditionDefaultValue); + continue; + } + + // Start with all the mapInitialize + if ( + transformationMapFn[MapFnClassId.type] === + TransformationType.MapInitialize + ) { + // check if metadata as destinationMemberPath is null + const destinationMetadata = metadataMap.get(destinationIdentifier); + const hasNullMetadata = + destinationMetadata && + destinationMetadata.find((metadata) => + isPrimitiveArrayEqual( + metadata[MetadataClassId.propertyKeys], + destinationMemberPath + ) + ) === null; + + const mapInitializedValue = ( + transformationMapFn[MapFnClassId.fn] as MapInitializeReturn< + TSource, + TDestination + >[MapFnClassId.fn] + )(sourceObject); + const isTypedConverted = + transformationMapFn[MapFnClassId.isConverted]; + + // if null/undefined + // if isDate, isFile + // if metadata is null, treat as-is + // if it has same identifier that are not primitives or Date + // if the initialized value was converted with typeConverter if ( - transformationPreConditionPredicate && - !transformationPreConditionPredicate(sourceObject) + mapInitializedValue == null || + mapInitializedValue instanceof Date || + Object.prototype.toString + .call(mapInitializedValue) + .slice(8, -1) === 'File' || + hasNullMetadata || + hasSameIdentifier || + isTypedConverted ) { - setMember(() => transformationPreConditionDefaultValue); + setMember(() => mapInitializedValue); continue; } - // Start with all the mapInitialize - if ( - transformationMapFn[MapFnClassId.type] === - TransformationType.MapInitialize - ) { - // check if metadata as destinationMemberPath is null - const destinationMetadata = metadataMap.get(destinationIdentifier); - const hasNullMetadata = - destinationMetadata && - destinationMetadata.find((metadata) => - isPrimitiveArrayEqual( - metadata[MetadataClassId.propertyKeys], - destinationMemberPath - ) - ) === null; - - const mapInitializedValue = ( - transformationMapFn[MapFnClassId.fn] as MapInitializeReturn< - TSource, - TDestination - >[MapFnClassId.fn] - )(sourceObject); - const isTypedConverted = - transformationMapFn[MapFnClassId.isConverted]; - - // if null/undefined - // if isDate, isFile - // if metadata is null, treat as-is - // if it has same identifier that are not primitives or Date - // if the initialized value was converted with typeConverter + // if isArray + if (Array.isArray(mapInitializedValue)) { + const [first] = mapInitializedValue; + // if first item is a primitive if ( - mapInitializedValue == null || - mapInitializedValue instanceof Date || - Object.prototype.toString - .call(mapInitializedValue) - .slice(8, -1) === 'File' || - hasNullMetadata || - hasSameIdentifier || - isTypedConverted + typeof first !== 'object' || + first instanceof Date || + Object.prototype.toString.call(first).slice(8, -1) === + 'File' ) { - setMember(() => mapInitializedValue); + setMember(() => mapInitializedValue.slice()); continue; } - // if isArray - if (Array.isArray(mapInitializedValue)) { - const [first] = mapInitializedValue; - // if first item is a primitive - if ( - typeof first !== 'object' || - first instanceof Date || - Object.prototype.toString.call(first).slice(8, -1) === - 'File' - ) { - setMember(() => mapInitializedValue.slice()); - continue; - } - - // if first is empty - if (isEmpty(first)) { - setMember(() => []); - continue; - } - - // if first is object but the destination identifier is a primitive - // then skip completely - if (isPrimitiveConstructor(destinationMemberIdentifier)) { - continue; - } + // if first is empty + if (isEmpty(first)) { + setMember(() => []); + continue; + } - setMember(() => - mapInitializedValue.map((each) => - mapReturn( - getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ), - each, - { extraArgs } - ) - ) - ); + // if first is object but the destination identifier is a primitive + // then skip completely + if (isPrimitiveConstructor(destinationMemberIdentifier)) { continue; } - if (typeof mapInitializedValue === 'object') { - const nestedMapping = getMapping( - mapper, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ); - - // nested mutate - if (getMemberFn) { - const memberValue = getMemberFn(destinationMemberPath); - if (memberValue !== undefined) { - map({ - sourceObject: mapInitializedValue as TSource, - mapping: nestedMapping, - options: { extraArgs }, - setMemberFn: setMemberMutateFn(memberValue), - getMemberFn: getMemberMutateFn(memberValue), - }); - } - continue; - } + setMember(() => + mapInitializedValue.map((each) => + mapReturn( + getMapping( + mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ), + each, + { extraArgs } + ) + ) + ); + continue; + } + + if (typeof mapInitializedValue === 'object') { + const nestedMapping = getMapping( + mapper, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ); - setMember(() => + // nested mutate + if (getMemberFn) { + const memberValue = getMemberFn(destinationMemberPath); + if (memberValue !== undefined) { map({ - mapping: nestedMapping, sourceObject: mapInitializedValue as TSource, + mapping: nestedMapping, options: { extraArgs }, - setMemberFn: setMemberReturnFn, - }) - ); + setMemberFn: setMemberMutateFn(memberValue), + getMemberFn: getMemberMutateFn(memberValue), + }); + } continue; } - // if is primitive - setMember(() => mapInitializedValue); + setMember(() => + map({ + mapping: nestedMapping, + sourceObject: mapInitializedValue as TSource, + options: { extraArgs }, + setMemberFn: setMemberReturnFn, + }) + ); continue; } - setMember(() => - mapMember( - transformationMapFn, - sourceObject, - destination, - destinationMemberPath, - extraArguments, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier - ) - ); - } - - if (!isMapArray) { - const afterMap = mapAfterCallback ?? mappingAfterCallback; - if (afterMap) { - afterMap(sourceObject, destination, extraArguments); - } + // if is primitive + setMember(() => mapInitializedValue); + continue; } - // Check unmapped properties - assertUnmappedProperties( - destination, - destinationWithMetadata, - configuredKeys, - sourceIdentifier, - destinationIdentifier, - errorHandler + setMember(() => + mapMember( + transformationMapFn, + sourceObject, + destination, + destinationMemberPath, + extraArguments, + mapper, + sourceMemberIdentifier, + destinationMemberIdentifier + ) ); - - return destination; + } } diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 8fcbe8ebe..b8596fa7f 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -102,7 +102,7 @@ export type MapCallback< source: TSource, destination: TDestination, extraArguments?: TExtraArgs -) => void; +) => void | Promise; export interface MapOptions< TSource extends Dictionary, From 756204d28404b5e8ef53b58fafb239a913fb02bf Mon Sep 17 00:00:00 2001 From: koenigstag Date: Sun, 28 Apr 2024 12:59:33 +0300 Subject: [PATCH 02/13] feat(core) Add async mutate support --- packages/core/src/lib/core.ts | 140 ++++++++++++++++++++------ packages/core/src/lib/mappings/map.ts | 24 ++++- 2 files changed, 131 insertions(+), 33 deletions(-) diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 6798b3f04..0256fed1f 100644 --- a/packages/core/src/lib/core.ts +++ b/packages/core/src/lib/core.ts @@ -484,7 +484,9 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to if (p === 'mutate') { return < TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean | undefined = undefined, + Result = IsAsync extends true ? Promise : void >( sourceObject: TSource, destinationObject: TDestination, @@ -492,9 +494,10 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationIdentifierOrOptions?: | ModelIdentifier | MapOptions, - options?: MapOptions - ) => { - if (sourceObject == null) return; + options?: MapOptions, + isAsync?: IsAsync + ): Result => { + if (sourceObject == null) return (isAsync ? Promise.resolve() : undefined) as Result; const { destinationIdentifier, mapOptions } = getOptions( @@ -509,6 +512,29 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationIdentifier ); + if (isAsync) { + return Promise.resolve(sourceObject).then(async (sourceObject) => { + sourceObject = await strategy.preMap(sourceObject, mapping); + + await mapMutate( + mapping, + sourceObject, + destinationObject, + > mapOptions || {}, + false, // isMapArray + isAsync + ); + + await strategy.postMap( + sourceObject, + destinationObject, + mapping + ); + + return undefined; + }) as Result; + } + sourceObject = strategy.preMap(sourceObject, mapping); mapMutate( @@ -523,6 +549,8 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationObject, mapping ); + + return undefined as Result; }; } if (p === 'mutateAsync') { @@ -538,24 +566,23 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to | MapOptions, options?: MapOptions ) => { - return new Promise((res) => { - receiver['mutate']( - sourceObject, - destinationObject, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - setTimeout(res, 0); - }); + return receiver['mutate']( + sourceObject, + destinationObject, + sourceIdentifier, + destinationIdentifierOrOptions, + options, + true + ); }; } if (p === 'mutateArray') { return < TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean | undefined = undefined, + Result = IsAsync extends true ? Promise : void >( sourceArray: TSource[], destinationArray: TDestination[], @@ -563,9 +590,10 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationIdentifierOrOptions?: | ModelIdentifier | MapOptions, - options?: MapOptions - ) => { - if (!sourceArray.length) return; + options?: MapOptions, + isAsync?: IsAsync + ): Result => { + if (!sourceArray.length) return (isAsync ? Promise.resolve : undefined) as Result; const { destinationIdentifier, mapOptions } = getOptions( @@ -586,6 +614,59 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to TDestination[] >; + if (isAsync) { + return Promise.resolve<{ + sourceArray: TSource[]; + destinationArray: TDestination[]; + }>({ + sourceArray, + destinationArray, + }).then(async ({ sourceArray, destinationArray }) => { + if (beforeMap) { + await beforeMap(sourceArray, destinationArray); + } + + for ( + let i = 0, length = sourceArray.length; + i < length; + i++ + ) { + let sourceObject = sourceArray[i]; + + sourceObject = await strategy.preMap( + sourceObject, + mapping + ); + + await mapMutate( + mapping, + sourceObject, + destinationArray[i] || {}, + { + extraArgs: extraArgs as MapOptions< + TSource, + TDestination + >['extraArgs'], + }, + true, + isAsync + ); + + await strategy.postMap( + sourceObject, + destinationArray[i], + mapping + ); + } + + if (afterMap) { + await afterMap(sourceArray, destinationArray); + } + + return undefined; + }) as Result; + } + if (beforeMap) { beforeMap(sourceArray, destinationArray); } @@ -625,6 +706,8 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to if (afterMap) { afterMap(sourceArray, destinationArray); } + + return undefined as Result; }; } @@ -641,17 +724,14 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to | MapOptions, options?: MapOptions ) => { - return new Promise((res) => { - receiver['mutateArray']( - sourceArray, - destinationArray, - sourceIdentifier, - destinationIdentifierOrOptions, - options - ); - - setTimeout(res, 0); - }); + return receiver['mutateArray']( + sourceArray, + destinationArray, + sourceIdentifier, + destinationIdentifierOrOptions, + options, + true + ); }; } diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index ae99d1bbb..450a4dfa9 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -66,14 +66,30 @@ function getMemberMutateFn(destinationObj: Record) { export function mapMutate< TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean | undefined = undefined, + Result = IsAsync extends true ? Promise : void >( mapping: Mapping, sourceObject: TSource, destinationObj: TDestination, options: MapOptions, - isMapArray = false -): void { + isMapArray = false, + isAsync?: IsAsync +): Result { + if (isAsync) { + return Promise.resolve().then(async () => { + await map({ + sourceObject, + mapping, + setMemberFn: setMemberMutateFn(destinationObj), + getMemberFn: getMemberMutateFn(destinationObj), + options, + isMapArray, + }, isAsync); + }) as Result; + } + map({ sourceObject, mapping, @@ -82,6 +98,8 @@ export function mapMutate< options, isMapArray, }); + + return undefined as unknown as Result; } interface MapParameter< From e2f4937415af962c8fdef1bcb7eecb91cdfadc30 Mon Sep 17 00:00:00 2001 From: koenigstag <50256886+koenigstag@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:17:45 +0300 Subject: [PATCH 03/13] fix(core) Fix unnecessary await --- packages/core/src/lib/core.ts | 10 +++++----- packages/core/src/lib/mappings/map.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 0256fed1f..8a74e156c 100644 --- a/packages/core/src/lib/core.ts +++ b/packages/core/src/lib/core.ts @@ -257,7 +257,7 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to sourceObject, > mapOptions || {}, false, // isMapArray - isAsync, + true, // isAsync ); destination = await strategy.postMap( @@ -385,7 +385,7 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to >['extraArgs'], }, true, // isMapArray - isAsync, + true, // isAsync ); destination = await strategy.postMap( @@ -522,7 +522,7 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to destinationObject, > mapOptions || {}, false, // isMapArray - isAsync + true // isAsync ); await strategy.postMap( @@ -648,8 +648,8 @@ Mapper {} is an empty Object as a Proxy. The following methods are available to TDestination >['extraArgs'], }, - true, - isAsync + true, // isMapArray + true, // isAsync ); await strategy.postMap( diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index 450a4dfa9..cbf88e39e 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -86,7 +86,7 @@ export function mapMutate< getMemberFn: getMemberMutateFn(destinationObj), options, isMapArray, - }, isAsync); + }, true); }) as Result; } From 1446a8e8f9579aba2d80db635f2ff0ef8f6fb129 Mon Sep 17 00:00:00 2001 From: koenigstag Date: Sat, 4 May 2024 00:01:17 +0300 Subject: [PATCH 04/13] add(tests) add few tests for mapAsync and mapArrayAsync --- .../src/classes/map-async.spec.ts | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/integration-test/src/classes/map-async.spec.ts b/packages/integration-test/src/classes/map-async.spec.ts index 46fbf946a..9455bebe1 100644 --- a/packages/integration-test/src/classes/map-async.spec.ts +++ b/packages/integration-test/src/classes/map-async.spec.ts @@ -1,6 +1,7 @@ import { classes } from '@automapper/classes'; import { afterMap, + beforeMap, createMap, createMapper, forMember, @@ -9,28 +10,69 @@ import { import { SimpleUserDto } from './dtos/simple-user.dto'; import { SimpleUser } from './models/simple-user'; +async function asyncResolve(value: T, delayMs = 1000): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(value); + }, delayMs); + }); +} + +const getFullname = (user: SimpleUser) => user.firstName + ' ' + user.lastName; + describe('Map Async Classes', () => { const mapper = createMapper({ strategyInitializer: classes() }); - it('should map', async () => { + it('should map async', async () => { createMap( mapper, SimpleUser, SimpleUserDto, forMember((d) => d.fullName, ignore()), - afterMap(async (_, destination) => { - const fullName = await Promise.resolve().then( - () => 'Tran Chau' - ); + beforeMap(async (source) => { + source.firstName = await asyncResolve(source.firstName); + }), + afterMap(async (source, destination) => { + const fullName = await asyncResolve(getFullname(source)); + Object.assign(destination, { fullName }); }) ); + const user = new SimpleUser('Chau', 'Tran'); + const dto = await mapper.mapAsync( - new SimpleUser('Chau', 'Tran'), + user, SimpleUser, SimpleUserDto ); - expect(dto.fullName).toEqual('Tran Chau'); + expect(dto.fullName).toEqual(user.firstName + ' ' + user.lastName); }); + + it('should map array async without calling afterMap', async () => { + createMap( + mapper, + SimpleUser, + SimpleUserDto, + forMember((d) => d.fullName, ignore()), + afterMap(async (source, destination) => { + const fullName = await asyncResolve(getFullname(source)); + + Object.assign(destination, { fullName }); + }) + ); + + const user = new SimpleUser('Chau', 'Tran'); + + const dtos = await mapper.mapArrayAsync( + [user], + SimpleUser, + SimpleUserDto + ); + + expect(dtos).toHaveLength(1); + expect(dtos[0].firstName).toEqual(user.firstName); + expect(dtos[0].lastName).toEqual(user.lastName); + expect(dtos[0].fullName).toEqual(undefined); // afterMap is not called + }); }); From 143a10d355b2b308a302ef6dfef5d66f14f9377a Mon Sep 17 00:00:00 2001 From: koenigstag Date: Sat, 4 May 2024 09:21:25 +0300 Subject: [PATCH 05/13] remove(doc) remove fake-async mentions --- .../docs/mapping-configuration/after-map.mdx | 6 ---- .../documentations/docs/misc/fake-async.mdx | 32 ------------------- packages/documentations/sidebars.js | 1 - 3 files changed, 39 deletions(-) delete mode 100644 packages/documentations/docs/misc/fake-async.mdx diff --git a/packages/documentations/docs/mapping-configuration/after-map.mdx b/packages/documentations/docs/mapping-configuration/after-map.mdx index 07490bbcd..87019399f 100644 --- a/packages/documentations/docs/mapping-configuration/after-map.mdx +++ b/packages/documentations/docs/mapping-configuration/after-map.mdx @@ -59,12 +59,6 @@ createMap( const dto = await mapper.mapAsync(user, User, UserDto); ``` -:::caution - -Simple asynchronous operations should be fine with this approach. However due to [Fake Async](../misc/fake-async), we should **NOT** use AutoMapper for a particular pair of models if those models require some heavy and complex asynchronous operations. - -::: - ## What about `postMap`? When create the `Mapper`, we can customize the `postMap` function on the `MappingStrategy`. The differences between `postMap` and `afterMap` are: diff --git a/packages/documentations/docs/misc/fake-async.mdx b/packages/documentations/docs/misc/fake-async.mdx deleted file mode 100644 index d81a05062..000000000 --- a/packages/documentations/docs/misc/fake-async.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: fake-async -title: Fake Async -sidebar_label: Fake Async -sidebar_position: 4 ---- - -## "Fake" Async - -Currently, AutoMapper is manipulating the [Event Loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop) to provide a "fake" async support for the `mapAsync()` and `mutateAsync()` variants. - -```ts -// 👇 simplified for brevity -function mapAsync(...args) { - const result = map(...args); - return new Promise((res) => { - setTimeout(res, 0, result); - }); -} - -// 👇 simplified for brevity -function mutateAsync(...args) { - return new Promise((res) => { - mutate(...args); - setTimeout(res); - }); -} -``` - -## Help wanted - -Real async support can be achieved by some Isomorphic Worker that would execute the map operations on the Worker thread. However, AutoMapper implementation is full of `Function` which cannot be serialized (easily) to transfer to the Worker thread. If anyone wants to contribute Asynchronous support, I'm happy to walk you through the repository. diff --git a/packages/documentations/sidebars.js b/packages/documentations/sidebars.js index 584ec6ba5..78bff8006 100644 --- a/packages/documentations/sidebars.js +++ b/packages/documentations/sidebars.js @@ -85,7 +85,6 @@ const sidebars = { 'misc/transformer-plugin', 'misc/mapped-types', 'misc/self-mapping', - 'misc/fake-async', ], }, 'nestjs', From b1324ea4279363270a6da87674f67d2cd9d5c16a Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Mon, 2 Sep 2024 16:32:00 +0200 Subject: [PATCH 06/13] feat(core): Continue @koenigstag `async-core-rewrite` initiative: Make `mapDefer` usable for async approach --- .../lib/mapping-configurations/for-member.ts | 12 +-- packages/core/src/lib/mappings/map-member.ts | 77 ++++++++++++------ packages/core/src/lib/mappings/map.ts | 78 ++++++++++++------- .../src/lib/member-map-functions/map-defer.ts | 7 +- .../specs/map-defer.spec.ts | 66 +++++++++++++++- packages/core/src/lib/types.ts | 45 +++++++---- packages/core/src/lib/utils/is-promise.ts | 12 +++ .../src/classes/map-async.spec.ts | 33 ++++++-- .../integration-test/src/classes/map.spec.ts | 32 +++++++- .../transformer-plugin.spec.ts | 36 ++++----- 10 files changed, 293 insertions(+), 105 deletions(-) create mode 100644 packages/core/src/lib/utils/is-promise.ts diff --git a/packages/core/src/lib/mapping-configurations/for-member.ts b/packages/core/src/lib/mapping-configurations/for-member.ts index 3d273c804..ad644dd4d 100644 --- a/packages/core/src/lib/mapping-configurations/for-member.ts +++ b/packages/core/src/lib/mapping-configurations/for-member.ts @@ -18,15 +18,16 @@ import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; export function forMember< TSource extends Dictionary, TDestination extends Dictionary, - TMemberType = SelectorReturn + TMemberType = SelectorReturn, + IsAsync extends boolean = false, >( selector: Selector, ...fns: [ preCondOrMapMemberFn: | PreConditionReturn - | MemberMapReturn + | MemberMapReturn | undefined, - mapMemberFn?: MemberMapReturn + mapMemberFn?: MemberMapReturn ] ): MappingConfiguration { let [preCondOrMapMemberFn, mapMemberFn] = fns; @@ -37,7 +38,8 @@ export function forMember< mapMemberFn = preCondOrMapMemberFn as MemberMapReturn< TSource, TDestination, - TMemberType + TMemberType, + IsAsync >; preCondOrMapMemberFn = undefined; } @@ -45,7 +47,7 @@ export function forMember< const mappingProperty: MappingProperty = [ memberPath, [ - mapMemberFn, + mapMemberFn as MemberMapReturn, preCondOrMapMemberFn as PreConditionReturn< TSource, TDestination, diff --git a/packages/core/src/lib/mappings/map-member.ts b/packages/core/src/lib/mappings/map-member.ts index 63e14abb1..ef297c9d8 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -1,33 +1,36 @@ -import type { - ConditionReturn, - ConvertUsingReturn, - Dictionary, - FromValueReturn, - MapDeferReturn, - MapFromReturn, - Mapper, - MapWithArgumentsReturn, - MapWithReturn, - MemberMapReturn, - MetadataIdentifier, - Primitive, +import { + ConditionReturn, + ConvertUsingReturn, + Dictionary, + FromValueReturn, + MapDeferReturn, + MapFromReturn, + Mapper, + MapWithArgumentsReturn, + MapWithReturn, + MemberMapReturn, + MetadataIdentifier, + Primitive, SelectorReturn } from '../types'; import { MapFnClassId, TransformationType } from '../types'; import { isDateConstructor } from '../utils/is-date-constructor'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; +import { isPromise } from '../utils/is-promise'; export function mapMember< TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, >( - transformationMapFn: MemberMapReturn, + transformationMapFn: MemberMapReturn, sourceObject: TSource, destinationObject: TDestination, destinationMemberPath: string[], extraArgs: Record | undefined, mapper: Mapper, sourceMemberIdentifier?: MetadataIdentifier | Primitive | Date, - destinationMemberIdentifier?: MetadataIdentifier | Primitive | Date + destinationMemberIdentifier?: MetadataIdentifier | Primitive | Date, + isAsync?: IsAsync, ) { let value: unknown; const transformationType: TransformationType = @@ -98,14 +101,23 @@ export function mapMember< >[MapFnClassId.fn] )(sourceObject, extraArgs || {}); break; - case TransformationType.MapDefer: - value = mapMember( - ( - mapFn as MapDeferReturn< - TSource, - TDestination - >[MapFnClassId.fn] - )(sourceObject) as MemberMapReturn, + case TransformationType.MapDefer: { + const deferFunctionResult = ( + mapFn as MapDeferReturn< + TSource, + TDestination, + SelectorReturn, + IsAsync + >[MapFnClassId.fn] + )(sourceObject, isAsync) as + | MemberMapReturn + | Promise>; + + if (isPromise(deferFunctionResult)) { + if (isAsync !== true) throw new Error('Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations'); + value = (deferFunctionResult as Promise>).then((deferFunctionResult) => { + return mapMember( + deferFunctionResult, sourceObject, destinationObject, destinationMemberPath, @@ -113,8 +125,23 @@ export function mapMember< mapper, sourceMemberIdentifier, destinationMemberIdentifier - ); + ); + }); break; + } + + value = mapMember( + deferFunctionResult as MemberMapReturn, + sourceObject, + destinationObject, + destinationMemberPath, + extraArgs, + mapper, + sourceMemberIdentifier, + destinationMemberIdentifier + ); + break; + } } return value; } diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index cbf88e39e..f8f4506e3 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -1,12 +1,12 @@ import { getErrorHandler, getMetadataMap } from '../symbols'; import type { - Constructor, - Dictionary, - MapInitializeReturn, - MapOptions, - Mapper, - Mapping, - MetadataIdentifier, + Constructor, + Dictionary, + MapInitializeReturn, + MapOptions, + Mapper, + Mapping, MemberMapReturn, + MetadataIdentifier } from '../types'; import { MapFnClassId, MetadataClassId, TransformationType } from '../types'; import { assertUnmappedProperties } from '../utils/assert-unmapped-properties'; @@ -18,6 +18,7 @@ import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; import { set, setMutate } from '../utils/set'; import { mapMember } from './map-member'; +import { isPromise } from '../utils/is-promise'; function setMemberReturnFn = any>( destinationMemberPath: string[], @@ -185,8 +186,8 @@ export function map< } } - _mapInternalLogic({ - propsToMap, + await Promise.all(_mapInternalLogic({ + propsToMap: (propsToMap as Mapping[2]), destination, mapper, setMemberFn, @@ -198,7 +199,8 @@ export function map< errorHandler, extraArgs, metadataMap, - }); + isAsync + })); if (!isMapArray) { const afterMap = mapAfterCallback ?? mappingAfterCallback; @@ -265,8 +267,9 @@ export function map< } function _mapInternalLogic< -TSource extends Dictionary, -TDestination extends Dictionary + TSource extends Dictionary, + TDestination extends Dictionary, + IsAsync extends boolean = false >({ propsToMap, destination, @@ -280,8 +283,9 @@ TDestination extends Dictionary errorHandler, extraArgs, metadataMap, + isAsync, }: { - propsToMap: Mapping[2], + propsToMap: Mapping[2], destination: TDestination, mapper: Mapper, setMemberFn: MapParameter['setMemberFn'], @@ -293,7 +297,14 @@ TDestination extends Dictionary errorHandler: ReturnType, extraArgs: MapOptions['extraArgs'], metadataMap: ReturnType, -}) { + isAsync?: IsAsync +}): (IsAsync extends true ? any[] : undefined) { + const resolvables: any[] = []; + const pushResolvable = (resolvable: any) => { + if (isAsync !== true && isPromise(resolvable)) throw new Error('TODO'); + if (isAsync) resolvables.push(resolvable); + } + for (let i = 0, length = propsToMap.length; i < length; i++) { // destructure mapping property const [ @@ -330,9 +341,17 @@ TDestination extends Dictionary } // Set up a shortcut function to set destinationMemberPath on destination with value as argument - const setMember = (valFn: () => unknown) => { + const setMember = (valFn: () => unknown): any => { try { - return setMemberFn(destinationMemberPath, destination)(valFn()); + const value = valFn(); + if (isAsync) { + return Promise.resolve(value).then((value) => { + return setMemberFn(destinationMemberPath, destination)(value) + }); + } else { + if (isPromise(value)) throw new Error('TODO'); + return setMemberFn(destinationMemberPath, destination)(value); + } } catch (originalError) { const errorMessage = ` Error at "${destinationMemberPath}" on ${ @@ -354,7 +373,7 @@ Original error: ${originalError}`; transformationPreConditionPredicate && !transformationPreConditionPredicate(sourceObject) ) { - setMember(() => transformationPreConditionDefaultValue); + pushResolvable(setMember(() => transformationPreConditionDefaultValue)); continue; } @@ -398,7 +417,7 @@ Original error: ${originalError}`; hasSameIdentifier || isTypedConverted ) { - setMember(() => mapInitializedValue); + pushResolvable(setMember(() => mapInitializedValue)); continue; } @@ -412,7 +431,7 @@ Original error: ${originalError}`; Object.prototype.toString.call(first).slice(8, -1) === 'File' ) { - setMember(() => mapInitializedValue.slice()); + pushResolvable(setMember(() => mapInitializedValue.slice())); continue; } @@ -428,7 +447,7 @@ Original error: ${originalError}`; continue; } - setMember(() => + pushResolvable(setMember(() => mapInitializedValue.map((each) => mapReturn( getMapping( @@ -440,7 +459,7 @@ Original error: ${originalError}`; { extraArgs } ) ) - ); + )); continue; } @@ -466,23 +485,23 @@ Original error: ${originalError}`; continue; } - setMember(() => + pushResolvable(setMember(() => map({ mapping: nestedMapping, sourceObject: mapInitializedValue as TSource, options: { extraArgs }, setMemberFn: setMemberReturnFn, - }) - ); + }, isAsync) + )); continue; } // if is primitive - setMember(() => mapInitializedValue); + pushResolvable(setMember(() => mapInitializedValue)); continue; } - setMember(() => + pushResolvable(setMember(() => mapMember( transformationMapFn, sourceObject, @@ -491,8 +510,11 @@ Original error: ${originalError}`; extraArguments, mapper, sourceMemberIdentifier, - destinationMemberIdentifier + destinationMemberIdentifier, + isAsync, ) - ); + )); } + + return (isAsync === true ? resolvables : undefined) as IsAsync extends true ? any[] : undefined; } diff --git a/packages/core/src/lib/member-map-functions/map-defer.ts b/packages/core/src/lib/member-map-functions/map-defer.ts index e40437c14..81a2cdd37 100644 --- a/packages/core/src/lib/member-map-functions/map-defer.ts +++ b/packages/core/src/lib/member-map-functions/map-defer.ts @@ -4,9 +4,10 @@ import { SelectorReturn, TransformationType } from '../types'; export function mapDefer< TSource extends Dictionary = any, TDestination extends Dictionary = any, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = TSelectorReturn extends Promise ? true : false >( - defer: DeferFunction -): MapDeferReturn { + defer: DeferFunction +): MapDeferReturn { return [TransformationType.MapDefer, defer]; } diff --git a/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts b/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts index 1ffe137d9..075b4cb32 100644 --- a/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts @@ -1,4 +1,9 @@ -import { MapFnClassId, TransformationType } from '../../types'; +import { + type DeferFunction, + Dictionary, + MapFnClassId, + TransformationType +} from '../../types'; import { ignore } from '../ignore'; import { mapDefer } from '../map-defer'; @@ -14,4 +19,63 @@ describe(mapDefer.name, () => { ); expect(mapDeferFn[MapFnClassId.fn]).toBe(defer); }); + + // Static type checks + // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars + function expectType(actualType: TExpected) {} + // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars + function itCheckTypes() { + + // Type checks passing + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown> + ]>(mapDefer, Dictionary, unknown>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, false> + ]>(mapDefer, Dictionary, unknown>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown> + ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, false> + ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, true> + ]>(mapDefer, Dictionary, unknown, true>(() => Promise.resolve(ignore()))); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown> + ]>(mapDefer, Dictionary, unknown>(() => Promise.resolve(ignore()))); + + // Type checks failing + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, true> + // @ts-expect-error: TS2345 + ]>(mapDefer, Dictionary, unknown>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, true> + // @ts-expect-error: TS2345 + ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); + + expectType<[ + TransformationType.MapDefer, + DeferFunction, Dictionary, unknown, false> + // @ts-expect-error: TS2345 + ]>(mapDefer, Dictionary, unknown, true>(() => ignore())); + } }); diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index b8596fa7f..85d0d4f4f 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -69,7 +69,9 @@ export type Selector< TReturnType = unknown > = (obj: TObject) => TReturnType; -export type SelectorReturn> = ReturnType< +export type SelectorReturn< + TObject extends Dictionary, +> = ReturnType< Selector >; @@ -328,10 +330,11 @@ export type MemberMapReturnNoDefer< export type MemberMapReturn< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = false, > = | MemberMapReturnNoDefer - | MapDeferReturn; + | MapDeferReturn; export type PreConditionReturn< TSource extends Dictionary, @@ -345,20 +348,24 @@ export type PreConditionReturn< export interface DeferFunction< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn -> { - (source: TSource): + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = TSelectorReturn extends Promise ? true : false, + TReturn = | MemberMapReturnNoDefer - | MapWithReturn; + | MapWithReturn +> { + (source: TSource, isAsync?: IsAsync): + IsAsync extends true ? Promise : Promise | TReturn; } export type MapDeferReturn< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = TSelectorReturn extends Promise ? true : false > = [ TransformationType.MapDefer, - DeferFunction + DeferFunction ]; export type MapFromReturn< @@ -455,9 +462,10 @@ export const enum MappingTransformationClassId { export type MappingTransformation< TSource extends Dictionary = any, TDestination extends Dictionary = any, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = false > = [ - memberMapFn: MemberMapReturn, + memberMapFn: MemberMapReturn, preCond?: PreConditionReturn ]; @@ -468,13 +476,15 @@ export const enum MappingPropertyClassId { export type MappingProperty< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, + IsAsync extends boolean = false > = [ target: string[], transformation: MappingTransformation< TSource, TDestination, - TSelectorReturn + TSelectorReturn, + IsAsync > ]; export const enum MappingPropertiesClassId { @@ -512,7 +522,8 @@ export const enum MappingClassId { export type Mapping< TSource extends Dictionary = any, - TDestination extends Dictionary = any + TDestination extends Dictionary = any, + IsAsync extends boolean = false > = [ identifiers: [ source: MetadataIdentifier, @@ -525,7 +536,8 @@ export type Mapping< mappingProperty: MappingProperty< TSource, TDestination, - SelectorReturn + SelectorReturn, + IsAsync >, nestedMappingPair?: [ destination: MetadataIdentifier | Primitive | Date, @@ -539,7 +551,8 @@ export type Mapping< mappingProperty: MappingProperty< TSource, TDestination, - SelectorReturn + SelectorReturn, + IsAsync >, nestedMappingPair?: [ destination: MetadataIdentifier | Primitive | Date, diff --git a/packages/core/src/lib/utils/is-promise.ts b/packages/core/src/lib/utils/is-promise.ts new file mode 100644 index 000000000..11d13d21c --- /dev/null +++ b/packages/core/src/lib/utils/is-promise.ts @@ -0,0 +1,12 @@ +/** + * Check if value is a Promise + * + * @param value + */ +export function isPromise(value: unknown): value is Promise { + return ( + // @ts-ignore TS2339: Property then does not exist on type {} + typeof (value === null || value === void 0 ? void 0 : value.then) === + 'function' + ); +} diff --git a/packages/integration-test/src/classes/map-async.spec.ts b/packages/integration-test/src/classes/map-async.spec.ts index 9455bebe1..c0c81a23d 100644 --- a/packages/integration-test/src/classes/map-async.spec.ts +++ b/packages/integration-test/src/classes/map-async.spec.ts @@ -1,14 +1,16 @@ import { classes } from '@automapper/classes'; import { - afterMap, - beforeMap, - createMap, - createMapper, - forMember, - ignore, + addProfile, + afterMap, + beforeMap, + createMap, + createMapper, + forMember, fromValue, + ignore, mapDefer } from '@automapper/core'; import { SimpleUserDto } from './dtos/simple-user.dto'; import { SimpleUser } from './models/simple-user'; +import { getUser } from './utils/get-user'; async function asyncResolve(value: T, delayMs = 1000): Promise { return new Promise((resolve) => { @@ -75,4 +77,23 @@ describe('Map Async Classes', () => { expect(dtos[0].lastName).toEqual(user.lastName); expect(dtos[0].fullName).toEqual(undefined); // afterMap is not called }); + + + + it('should resolve defer if Promise returned from the mapping', async () => { + class Source {} + class Destination { + value!: string; + } + const mockValue = 'mockValue'; + addProfile(mapper, function failingProfile(mapper) { + createMap(mapper, Source, Destination, + forMember((d) => d.value, mapDefer(() => Promise.resolve(fromValue(mockValue)))), + ) + }); + + const destination = await mapper.mapAsync({}, Source, Destination); + + expect(destination.value).toEqual(mockValue); + }); }); diff --git a/packages/integration-test/src/classes/map.spec.ts b/packages/integration-test/src/classes/map.spec.ts index 9088fbea2..a1d80350f 100644 --- a/packages/integration-test/src/classes/map.spec.ts +++ b/packages/integration-test/src/classes/map.spec.ts @@ -1,5 +1,13 @@ import { classes } from '@automapper/classes'; -import { addProfile, CamelCaseNamingConvention, createMapper } from '@automapper/core'; +import { + addProfile, + CamelCaseNamingConvention, + createMap, + createMapper, + forMember, + ignore, + mapDefer, +} from '@automapper/core'; import { UserDto } from './dtos/user.dto'; import { User } from './models/user'; import { addressProfile } from './profiles/address.profile'; @@ -33,9 +41,27 @@ describe('Map Classes', () => { expect(dtos).toEqual([]); }); - it('should throw error if mapped without the mapping', () => { + it('should throw an error if mapped without the mapping', () => { const user = getUser(); - expect(() => mapper.map(user, User, UserDto)).toThrow(); + expect(() => mapper.map(user, User, UserDto)).toThrow('Mapping is not found for User and UserDto'); + }); + + it('should throw an error if Promise returned from the mapping', () => { + class Source {} + class Destination { + value!: string; + } + addProfile(mapper, function failingProfile(mapper) { + createMap(mapper, Source, Destination, + forMember((d) => d.value, mapDefer(() => Promise.resolve(ignore()))), + ) + }); + + const mapOperation = () => mapper.map({}, Source, Destination); + + expect(mapOperation).toThrow( + 'Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations' + ); }); it('should not freeze source', () => { diff --git a/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts b/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts index 1e17163cc..46a90c6d7 100644 --- a/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts +++ b/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts @@ -1,26 +1,20 @@ -import automapperTransformerPlugin, { - before, -} from '@automapper/classes/transformer-plugin'; -import type { CompilerOptions } from 'typescript/lib/tsserverlibrary'; +import automapperTransformerPlugin, { before } from '@automapper/classes/transformer-plugin'; import { - createProgram, - ModuleKind, - ScriptTarget, - transpileModule, + CompilerOptions, + createProgram, + ModuleKind, + NewLineKind, + ScriptTarget, + transpileModule } from 'typescript/lib/tsserverlibrary'; import { - compiledCreateSkillRequestDto, - compiledSkillEntity, - createSkillRequestDtoText, - skillEntityText, + compiledCreateSkillRequestDto, + compiledSkillEntity, + createSkillRequestDtoText, + skillEntityText } from './issues/486/models'; -import { - userModelText, - userModelTextStrict, - userModelTranspiledText, - userModelTranspiledTextESM, -} from './model'; +import { userModelText, userModelTextStrict, userModelTranspiledText, userModelTranspiledTextESM } from './model'; describe('Classes - Transformer Plugin', () => { describe('named before import', () => { @@ -29,6 +23,7 @@ describe('Classes - Transformer Plugin', () => { module: ModuleKind.CommonJS, target: ScriptTarget.ESNext, noEmitHelpers: true, + newLine: NewLineKind.LineFeed, }; const fileName = 'user.model.ts'; @@ -52,6 +47,7 @@ describe('Classes - Transformer Plugin', () => { module: ModuleKind.CommonJS, target: ScriptTarget.ESNext, noEmitHelpers: true, + newLine: NewLineKind.LineFeed, }; const fileName = 'user.model.ts'; @@ -76,6 +72,7 @@ describe('Classes - Transformer Plugin', () => { module: ModuleKind.ES2015, target: ScriptTarget.ESNext, noEmitHelpers: true, + newLine: NewLineKind.LineFeed, }; const fileName = 'user.model.ts'; @@ -101,6 +98,7 @@ describe('Classes - Transformer Plugin', () => { target: ScriptTarget.ESNext, noEmitHelpers: true, strict: true, + newLine: NewLineKind.LineFeed, }; const fileName = 'user.model.ts'; @@ -126,6 +124,7 @@ describe('Classes - Transformer Plugin', () => { target: ScriptTarget.ESNext, noEmitHelpers: true, strict: true, + newLine: NewLineKind.LineFeed, }; const fileName = 'user.model.ts'; @@ -151,6 +150,7 @@ describe('Classes - Transformer Plugin', () => { module: ModuleKind.CommonJS, target: ScriptTarget.ESNext, noEmitHelpers: true, + newLine: NewLineKind.LineFeed, }; const createSkillFileName = 'create-skill.dto.ts'; From 077004ea9d4c554a07a90c00e707c47af5d9ae70 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 15:30:00 +0200 Subject: [PATCH 07/13] feat(core): Continue @koenigstag `async-core-rewrite` initiative: Implement full async capability --- .../lib/mapping-configurations/for-member.ts | 7 +- packages/core/src/lib/mappings/map-member.ts | 96 +++++------- packages/core/src/lib/mappings/map.ts | 100 +++++------- .../src/lib/member-map-functions/condition.ts | 13 +- .../lib/member-map-functions/convert-using.ts | 11 +- .../lib/member-map-functions/from-value.ts | 2 +- .../src/lib/member-map-functions/map-defer.ts | 7 +- .../src/lib/member-map-functions/map-from.ts | 6 +- .../src/lib/member-map-functions/map-with.ts | 43 +++--- .../member-map-functions/null-substitution.ts | 2 +- .../lib/member-map-functions/pre-condition.ts | 2 +- .../specs/condition.spec.ts | 8 +- .../specs/convert-using.spec.ts | 2 +- .../specs/map-defer.spec.ts | 66 +------- packages/core/src/lib/types.ts | 72 +++++---- packages/core/src/lib/utils/async-aware.ts | 25 +++ .../src/classes/map-async.spec.ts | 146 ++++++++++++++++-- 17 files changed, 339 insertions(+), 269 deletions(-) create mode 100644 packages/core/src/lib/utils/async-aware.ts diff --git a/packages/core/src/lib/mapping-configurations/for-member.ts b/packages/core/src/lib/mapping-configurations/for-member.ts index ad644dd4d..b486e9707 100644 --- a/packages/core/src/lib/mapping-configurations/for-member.ts +++ b/packages/core/src/lib/mapping-configurations/for-member.ts @@ -25,9 +25,9 @@ export function forMember< ...fns: [ preCondOrMapMemberFn: | PreConditionReturn - | MemberMapReturn + | MemberMapReturn | undefined, - mapMemberFn?: MemberMapReturn + mapMemberFn?: MemberMapReturn ] ): MappingConfiguration { let [preCondOrMapMemberFn, mapMemberFn] = fns; @@ -38,8 +38,7 @@ export function forMember< mapMemberFn = preCondOrMapMemberFn as MemberMapReturn< TSource, TDestination, - TMemberType, - IsAsync + TMemberType >; preCondOrMapMemberFn = undefined; } diff --git a/packages/core/src/lib/mappings/map-member.ts b/packages/core/src/lib/mappings/map-member.ts index ef297c9d8..694843953 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -15,14 +15,14 @@ import { import { MapFnClassId, TransformationType } from '../types'; import { isDateConstructor } from '../utils/is-date-constructor'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; -import { isPromise } from '../utils/is-promise'; +import { asyncAware } from '../utils/async-aware'; export function mapMember< TSource extends Dictionary, TDestination extends Dictionary, IsAsync extends boolean = false, >( - transformationMapFn: MemberMapReturn, + transformationMapFn: MemberMapReturn, sourceObject: TSource, destinationObject: TDestination, destinationMemberPath: string[], @@ -60,7 +60,8 @@ export function mapMember< )( sourceObject, mapper, - extraArgs ? { extraArgs: () => extraArgs } : undefined + extraArgs ? { extraArgs: () => extraArgs } : undefined, + isAsync ); break; case TransformationType.ConvertUsing: @@ -69,28 +70,33 @@ export function mapMember< TSource, TDestination >[MapFnClassId.fn] - )(sourceObject); + )(sourceObject, isAsync as IsAsync); break; case TransformationType.Condition: case TransformationType.NullSubstitution: case TransformationType.UndefinedSubstitution: - value = ( - mapFn as ConditionReturn[MapFnClassId.fn] - )(sourceObject, destinationMemberPath); - - if (shouldRunImplicitMap && value != null) { - value = Array.isArray(value) - ? mapper.mapArray( - value, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ) - : mapper.map( - value, - sourceMemberIdentifier as MetadataIdentifier, - destinationMemberIdentifier as MetadataIdentifier - ); - } + value = asyncAware( + () => ( + mapFn as ConditionReturn[MapFnClassId.fn] + )(sourceObject, destinationMemberPath, isAsync as boolean), + (value) => { + if (shouldRunImplicitMap && value != null) { + return Array.isArray(value) + ? mapper.mapArray( + value, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ) + : mapper.map( + value, + sourceMemberIdentifier as MetadataIdentifier, + destinationMemberIdentifier as MetadataIdentifier + ); + } + return value; + }, + isAsync + ); break; case TransformationType.MapWithArguments: @@ -102,44 +108,26 @@ export function mapMember< )(sourceObject, extraArgs || {}); break; case TransformationType.MapDefer: { - const deferFunctionResult = ( + value = asyncAware(() => ( mapFn as MapDeferReturn< TSource, TDestination, - SelectorReturn, - IsAsync + SelectorReturn >[MapFnClassId.fn] - )(sourceObject, isAsync) as - | MemberMapReturn - | Promise>; - - if (isPromise(deferFunctionResult)) { - if (isAsync !== true) throw new Error('Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations'); - value = (deferFunctionResult as Promise>).then((deferFunctionResult) => { - return mapMember( - deferFunctionResult, - sourceObject, - destinationObject, - destinationMemberPath, - extraArgs, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier - ); - }); - break; - } + )(sourceObject), (deferFunction) => { + return mapMember( + deferFunction, + sourceObject, + destinationObject, + destinationMemberPath, + extraArgs, + mapper, + sourceMemberIdentifier, + destinationMemberIdentifier, + isAsync + ); + }, isAsync); - value = mapMember( - deferFunctionResult as MemberMapReturn, - sourceObject, - destinationObject, - destinationMemberPath, - extraArgs, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier - ); break; } } diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index f8f4506e3..cc4a74c19 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -19,6 +19,7 @@ import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; import { set, setMutate } from '../utils/set'; import { mapMember } from './map-member'; import { isPromise } from '../utils/is-promise'; +import { asyncAware } from '../utils/async-aware'; function setMemberReturnFn = any>( destinationMemberPath: string[], @@ -78,29 +79,20 @@ export function mapMutate< isMapArray = false, isAsync?: IsAsync ): Result { - if (isAsync) { - return Promise.resolve().then(async () => { - await map({ + return asyncAware( + () => { + return map({ sourceObject, mapping, setMemberFn: setMemberMutateFn(destinationObj), getMemberFn: getMemberMutateFn(destinationObj), options, isMapArray, - }, true); - }) as Result; - } - - map({ - sourceObject, - mapping, - setMemberFn: setMemberMutateFn(destinationObj), - getMemberFn: getMemberMutateFn(destinationObj), - options, - isMapArray, - }); - - return undefined as unknown as Result; + }, isAsync) + }, + () => isAsync ? Promise.resolve() : undefined, + isAsync + ); } interface MapParameter< @@ -157,37 +149,30 @@ export function map< const errorHandler = getErrorHandler(mapper); const metadataMap = getMetadataMap(mapper); - const destination: TDestination = mapDestinationConstructor( + const destination: TDestination | Promise = mapDestinationConstructor( sourceObject, destinationIdentifier ); - // get extraArguments - const extraArguments = extraArgs?.(mapping, destination); - // initialize an array of keys that have already been configured const configuredKeys: string[] = []; if (isAsync) { - return Promise.resolve<{ - sourceObject: TSource; - destination: TDestination; - }>({ - sourceObject, - destination, - }).then(async ({ - sourceObject, - destination, - }) => { + return Promise.all([ + sourceObject, + destination, + ]).then(async ([sourceObject, destination]) => { + const extraArguments = extraArgs?.(mapping, destination); + if (!isMapArray) { - const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; - if (beforeMap) { - await beforeMap(sourceObject, destination, extraArguments); - } + const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; + if (beforeMap) { + await beforeMap(sourceObject, destination, extraArguments); + } } await Promise.all(_mapInternalLogic({ - propsToMap: (propsToMap as Mapping[2]), + propsToMap, destination, mapper, setMemberFn, @@ -223,17 +208,19 @@ export function map< }) as Result; } + const extraArguments = extraArgs?.(mapping, destination as TDestination) + if (!isMapArray) { const beforeMap = mapBeforeCallback ?? mappingBeforeCallback; if (beforeMap) { - beforeMap(sourceObject, destination, extraArguments); + beforeMap(sourceObject, destination as TDestination, extraArguments); } } // map _mapInternalLogic({ propsToMap, - destination, + destination: destination as TDestination, mapper, setMemberFn, getMemberFn, @@ -249,7 +236,7 @@ export function map< if (!isMapArray) { const afterMap = mapAfterCallback ?? mappingAfterCallback; if (afterMap) { - afterMap(sourceObject, destination, extraArguments); + afterMap(sourceObject, destination as TDestination, extraArguments); } } @@ -285,7 +272,7 @@ function _mapInternalLogic< metadataMap, isAsync, }: { - propsToMap: Mapping[2], + propsToMap: Mapping[2], destination: TDestination, mapper: Mapper, setMemberFn: MapParameter['setMemberFn'], @@ -303,6 +290,7 @@ function _mapInternalLogic< const pushResolvable = (resolvable: any) => { if (isAsync !== true && isPromise(resolvable)) throw new Error('TODO'); if (isAsync) resolvables.push(resolvable); + return resolvable; } for (let i = 0, length = propsToMap.length; i < length; i++) { @@ -343,15 +331,9 @@ function _mapInternalLogic< // Set up a shortcut function to set destinationMemberPath on destination with value as argument const setMember = (valFn: () => unknown): any => { try { - const value = valFn(); - if (isAsync) { - return Promise.resolve(value).then((value) => { - return setMemberFn(destinationMemberPath, destination)(value) - }); - } else { - if (isPromise(value)) throw new Error('TODO'); - return setMemberFn(destinationMemberPath, destination)(value); - } + pushResolvable(asyncAware(() => valFn(), (value) => { + return setMemberFn(destinationMemberPath, destination)(value) + }, isAsync)); } catch (originalError) { const errorMessage = ` Error at "${destinationMemberPath}" on ${ @@ -373,7 +355,7 @@ Original error: ${originalError}`; transformationPreConditionPredicate && !transformationPreConditionPredicate(sourceObject) ) { - pushResolvable(setMember(() => transformationPreConditionDefaultValue)); + setMember(() => transformationPreConditionDefaultValue); continue; } @@ -417,7 +399,7 @@ Original error: ${originalError}`; hasSameIdentifier || isTypedConverted ) { - pushResolvable(setMember(() => mapInitializedValue)); + setMember(() => mapInitializedValue); continue; } @@ -431,7 +413,7 @@ Original error: ${originalError}`; Object.prototype.toString.call(first).slice(8, -1) === 'File' ) { - pushResolvable(setMember(() => mapInitializedValue.slice())); + setMember(() => mapInitializedValue.slice()); continue; } @@ -447,7 +429,7 @@ Original error: ${originalError}`; continue; } - pushResolvable(setMember(() => + setMember(() => mapInitializedValue.map((each) => mapReturn( getMapping( @@ -459,7 +441,7 @@ Original error: ${originalError}`; { extraArgs } ) ) - )); + ); continue; } @@ -485,23 +467,23 @@ Original error: ${originalError}`; continue; } - pushResolvable(setMember(() => + setMember(() => map({ mapping: nestedMapping, sourceObject: mapInitializedValue as TSource, options: { extraArgs }, setMemberFn: setMemberReturnFn, }, isAsync) - )); + ); continue; } // if is primitive - pushResolvable(setMember(() => mapInitializedValue)); + setMember(() => mapInitializedValue); continue; } - pushResolvable(setMember(() => + setMember(() => mapMember( transformationMapFn, sourceObject, @@ -513,7 +495,7 @@ Original error: ${originalError}`; destinationMemberIdentifier, isAsync, ) - )); + ); } return (isAsync === true ? resolvables : undefined) as IsAsync extends true ? any[] : undefined; diff --git a/packages/core/src/lib/member-map-functions/condition.ts b/packages/core/src/lib/member-map-functions/condition.ts index b831baf65..fecdad2cb 100644 --- a/packages/core/src/lib/member-map-functions/condition.ts +++ b/packages/core/src/lib/member-map-functions/condition.ts @@ -6,6 +6,7 @@ import type { } from '../types'; import { TransformationType } from '../types'; import { get } from '../utils/get'; +import { asyncAware } from '../utils/async-aware'; export function condition< TSource extends Dictionary, @@ -17,12 +18,14 @@ export function condition< ): ConditionReturn { return [ TransformationType.Condition, - (source, sourceMemberPaths) => { - if (predicate(source)) { - return get(source, sourceMemberPaths) as TSelectorReturn; - } + (source, sourceMemberPaths, isAsync) => { + return asyncAware(() => predicate(source), (predicateResult) => { + if (predicateResult) { + return get(source, sourceMemberPaths) as TSelectorReturn; + } - return defaultValue as TSelectorReturn; + return defaultValue as TSelectorReturn; + }, isAsync) }, ]; } diff --git a/packages/core/src/lib/member-map-functions/convert-using.ts b/packages/core/src/lib/member-map-functions/convert-using.ts index b9289bb7f..394679abe 100644 --- a/packages/core/src/lib/member-map-functions/convert-using.ts +++ b/packages/core/src/lib/member-map-functions/convert-using.ts @@ -6,18 +6,23 @@ import type { SelectorReturn, } from '../types'; import { TransformationType } from '../types'; +import { asyncAware } from '../utils/async-aware'; export function convertUsing< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn, - TConvertSourceReturn = SelectorReturn + TConvertSourceReturn = SelectorReturn, >( converter: Converter, - selector: Selector + selector: Selector, ): ConvertUsingReturn { return [ TransformationType.ConvertUsing, - (source) => converter.convert(selector(source)), + (source, isAsync) => { + return asyncAware(() => selector(source), (selected) => { + return converter.convert(selected); + }, isAsync) + }, ]; } diff --git a/packages/core/src/lib/member-map-functions/from-value.ts b/packages/core/src/lib/member-map-functions/from-value.ts index c640acf73..58def4baf 100644 --- a/packages/core/src/lib/member-map-functions/from-value.ts +++ b/packages/core/src/lib/member-map-functions/from-value.ts @@ -6,7 +6,7 @@ export function fromValue< TDestination extends Dictionary, TSelectorReturn = SelectorReturn >( - rawValue: TSelectorReturn + rawValue: TSelectorReturn | Promise ): FromValueReturn { return [TransformationType.FromValue, () => rawValue]; } diff --git a/packages/core/src/lib/member-map-functions/map-defer.ts b/packages/core/src/lib/member-map-functions/map-defer.ts index 81a2cdd37..e40437c14 100644 --- a/packages/core/src/lib/member-map-functions/map-defer.ts +++ b/packages/core/src/lib/member-map-functions/map-defer.ts @@ -4,10 +4,9 @@ import { SelectorReturn, TransformationType } from '../types'; export function mapDefer< TSource extends Dictionary = any, TDestination extends Dictionary = any, - TSelectorReturn = SelectorReturn, - IsAsync extends boolean = TSelectorReturn extends Promise ? true : false + TSelectorReturn = SelectorReturn >( - defer: DeferFunction -): MapDeferReturn { + defer: DeferFunction +): MapDeferReturn { return [TransformationType.MapDefer, defer]; } diff --git a/packages/core/src/lib/member-map-functions/map-from.ts b/packages/core/src/lib/member-map-functions/map-from.ts index 51eba135e..a5071b8f1 100644 --- a/packages/core/src/lib/member-map-functions/map-from.ts +++ b/packages/core/src/lib/member-map-functions/map-from.ts @@ -14,9 +14,9 @@ export function mapFrom< TSelectorReturn = SelectorReturn >( from: - | ValueSelector - | Resolver -): MapFromReturn { + | ValueSelector> + | Resolver> +): MapFromReturn> { if (isResolver(from)) { return [TransformationType.MapFrom, from.resolve.bind(from)]; } diff --git a/packages/core/src/lib/member-map-functions/map-with.ts b/packages/core/src/lib/member-map-functions/map-with.ts index 64d0bf67b..caa9d3863 100644 --- a/packages/core/src/lib/member-map-functions/map-with.ts +++ b/packages/core/src/lib/member-map-functions/map-with.ts @@ -1,11 +1,12 @@ -import type { - Dictionary, - MapOptions, - MapWithReturn, - ModelIdentifier, - SelectorReturn, +import { + Dictionary, + MapOptions, + MapWithReturn, + ModelIdentifier, + SelectorReturn, ValueSelectorAsyncable } from '../types'; import { TransformationType, ValueSelector } from '../types'; +import { asyncAware } from '../utils/async-aware'; type Constructor = new (...args: unknown[]) => TModel; @@ -27,24 +28,24 @@ export function mapWith< ): MapWithReturn { return [ TransformationType.MapWith, - (source, mapper, options) => { - const sourceValue = withSourceValue(source); + (source, mapper, options, isAsync) => { + return asyncAware(() => withSourceValue(source) as TWithSource, (nestedObject) => { + if (Array.isArray(nestedObject)) { + return (isAsync ? mapper.mapArrayAsync : mapper.mapArray)( + nestedObject, + withSource, + withDestination, + options as unknown as MapOptions + ) as TSelectorReturn; + } - if (Array.isArray(sourceValue)) { - return mapper.mapArray( - sourceValue, + return (isAsync ? mapper.mapAsync : mapper.map)( + nestedObject, withSource, withDestination, - options as unknown as MapOptions - ) as unknown as TSelectorReturn; - } - - return mapper.map( - sourceValue, - withSource, - withDestination, - options - ) as unknown as TSelectorReturn; + options as unknown as MapOptions + ) as TSelectorReturn; + }, isAsync); }, ]; } diff --git a/packages/core/src/lib/member-map-functions/null-substitution.ts b/packages/core/src/lib/member-map-functions/null-substitution.ts index 1c668c97b..16190ba13 100644 --- a/packages/core/src/lib/member-map-functions/null-substitution.ts +++ b/packages/core/src/lib/member-map-functions/null-substitution.ts @@ -11,7 +11,7 @@ export function nullSubstitution< TDestination extends Dictionary, TSelectorReturn = SelectorReturn >( - substitution: TSelectorReturn + substitution: TSelectorReturn | Promise ): NullSubstitutionReturn { return [ TransformationType.NullSubstitution, diff --git a/packages/core/src/lib/member-map-functions/pre-condition.ts b/packages/core/src/lib/member-map-functions/pre-condition.ts index cc0d82318..f8b12a2a2 100644 --- a/packages/core/src/lib/member-map-functions/pre-condition.ts +++ b/packages/core/src/lib/member-map-functions/pre-condition.ts @@ -11,7 +11,7 @@ export function preCondition< TSelectorReturn = SelectorReturn >( predicate: ConditionPredicate, - defaultValue?: TSelectorReturn + defaultValue?: TSelectorReturn | Promise ): PreConditionReturn { return [predicate, defaultValue]; } diff --git a/packages/core/src/lib/member-map-functions/specs/condition.spec.ts b/packages/core/src/lib/member-map-functions/specs/condition.spec.ts index 38d064f2f..9c40a22f9 100644 --- a/packages/core/src/lib/member-map-functions/specs/condition.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/condition.spec.ts @@ -17,25 +17,25 @@ describe(condition.name, () => { it('should map to source.truthy when evaluated to true', () => { const conditionFn = condition(() => true); - const result = conditionFn[MapFnClassId.fn](source, ['toMap']); + const result = conditionFn[MapFnClassId.fn](source, ['toMap'], false); expect(result).toEqual(source.toMap); }); it('should map to source.truthy when evaluated to true regardless of defaultValue', () => { const conditionFn = condition(() => true, 'defaultValue'); - const result = conditionFn[MapFnClassId.fn](source, ['toMap']); + const result = conditionFn[MapFnClassId.fn](source, ['toMap'], false); expect(result).toEqual(source.toMap); }); it('should map to undefined when evaluated to false', () => { const conditionFn = condition(() => false); - const result = conditionFn[MapFnClassId.fn](source, ['toMap']); + const result = conditionFn[MapFnClassId.fn](source, ['toMap'], false); expect(result).toEqual(undefined); }); it('should map to defaultValue when evaluated to false and defaultValue is provided', () => { const conditionFn = condition(() => false, 'defaultValue'); - const result = conditionFn[MapFnClassId.fn](source, ['toMap']); + const result = conditionFn[MapFnClassId.fn](source, ['toMap'], false); expect(result).toEqual('defaultValue'); }); }); diff --git a/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts b/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts index 4c83282aa..6525c24c3 100644 --- a/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/convert-using.spec.ts @@ -30,7 +30,7 @@ describe(convertUsing.name, () => { birthdayToStringConverter, (s) => s.birthday ); - const result = convertUsingFn[MapFnClassId.fn](source); + const result = convertUsingFn[MapFnClassId.fn](source, false); expect(result).toEqual(source.birthday.toDateString()); }); }); diff --git a/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts b/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts index 075b4cb32..1ffe137d9 100644 --- a/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts +++ b/packages/core/src/lib/member-map-functions/specs/map-defer.spec.ts @@ -1,9 +1,4 @@ -import { - type DeferFunction, - Dictionary, - MapFnClassId, - TransformationType -} from '../../types'; +import { MapFnClassId, TransformationType } from '../../types'; import { ignore } from '../ignore'; import { mapDefer } from '../map-defer'; @@ -19,63 +14,4 @@ describe(mapDefer.name, () => { ); expect(mapDeferFn[MapFnClassId.fn]).toBe(defer); }); - - // Static type checks - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - function expectType(actualType: TExpected) {} - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - function itCheckTypes() { - - // Type checks passing - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown> - ]>(mapDefer, Dictionary, unknown>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, false> - ]>(mapDefer, Dictionary, unknown>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown> - ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, false> - ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, true> - ]>(mapDefer, Dictionary, unknown, true>(() => Promise.resolve(ignore()))); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown> - ]>(mapDefer, Dictionary, unknown>(() => Promise.resolve(ignore()))); - - // Type checks failing - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, true> - // @ts-expect-error: TS2345 - ]>(mapDefer, Dictionary, unknown>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, true> - // @ts-expect-error: TS2345 - ]>(mapDefer, Dictionary, unknown, false>(() => ignore())); - - expectType<[ - TransformationType.MapDefer, - DeferFunction, Dictionary, unknown, false> - // @ts-expect-error: TS2345 - ]>(mapDefer, Dictionary, unknown, true>(() => ignore())); - } }); diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 85d0d4f4f..5d60c6dfe 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -69,6 +69,11 @@ export type Selector< TReturnType = unknown > = (obj: TObject) => TReturnType; +export type SelectorAsyncAware< + TObject extends Dictionary = any, + TReturnType = unknown +> = (obj: TObject, isAsync: boolean) => TReturnType; + export type SelectorReturn< TObject extends Dictionary, > = ReturnType< @@ -81,6 +86,12 @@ export type ValueSelector< TValueReturn = SelectorReturn > = (source: TSource) => TValueReturn; +export type ValueSelectorAsyncable< + TSource extends Dictionary = any, + TDestination extends Dictionary = any, + TValueReturn = SelectorReturn +> = (source: TSource) => TValueReturn | Promise; + export interface Resolver< TSource extends Dictionary = any, TDestination extends Dictionary = any, @@ -93,7 +104,7 @@ export interface Converter< TSource extends Dictionary = any, TConvertDestination = any > { - convert(source: TSource): TConvertDestination; + convert(source: TSource): TConvertDestination | Promise; } export type MapCallback< @@ -330,11 +341,10 @@ export type MemberMapReturnNoDefer< export type MemberMapReturn< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn, - IsAsync extends boolean = false, + TSelectorReturn = SelectorReturn > = | MemberMapReturnNoDefer - | MapDeferReturn; + | MapDeferReturn; export type PreConditionReturn< TSource extends Dictionary, @@ -342,30 +352,27 @@ export type PreConditionReturn< TSelectorReturn = SelectorReturn > = [ preConditionPredicate: ConditionPredicate, - defaultValue?: TSelectorReturn + defaultValue?: TSelectorReturn | Promise ]; export interface DeferFunction< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn, - IsAsync extends boolean = TSelectorReturn extends Promise ? true : false, TReturn = | MemberMapReturnNoDefer | MapWithReturn > { - (source: TSource, isAsync?: IsAsync): - IsAsync extends true ? Promise : Promise | TReturn; + (source: TSource): TReturn | Promise; } export type MapDeferReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn, - IsAsync extends boolean = TSelectorReturn extends Promise ? true : false > = [ TransformationType.MapDefer, - DeferFunction + DeferFunction ]; export type MapFromReturn< @@ -383,12 +390,15 @@ export type MapWithReturn< ( sourceObj: TSource, mapper: Mapper, - options?: MapOptions - ) => TSelectorReturn | undefined | null + options?: MapOptions, + isAsync?: boolean + ) => TSelectorReturn | Promise | undefined | null ]; -export interface ConditionPredicate> { - (source: TSource): boolean; +export interface ConditionPredicate< + TSource extends Dictionary +> { + (source: TSource): boolean | Promise; } export type ConditionReturn< @@ -397,20 +407,20 @@ export type ConditionReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.Condition, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + (source: TSource, sourceMemberPath: string[], isAsync: boolean) => TSelectorReturn | Promise, ]; export type FromValueReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn -> = [TransformationType.FromValue, () => TSelectorReturn]; +> = [TransformationType.FromValue, () => TSelectorReturn | Promise]; export type ConvertUsingReturn< TSource extends Dictionary, TDestination extends Dictionary, TSelectorReturn = SelectorReturn -> = [TransformationType.ConvertUsing, Selector]; +> = [TransformationType.ConvertUsing, SelectorAsyncAware>]; export type NullSubstitutionReturn< TSource extends Dictionary, @@ -418,7 +428,7 @@ export type NullSubstitutionReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.NullSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + (source: TSource, sourceMemberPath: string[]) => TSelectorReturn | Promise ]; export type UndefinedSubstitutionReturn< @@ -427,7 +437,7 @@ export type UndefinedSubstitutionReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.UndefinedSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + (source: TSource, sourceMemberPath: string[]) => TSelectorReturn | Promise ]; export type IgnoreReturn< @@ -441,7 +451,7 @@ export type MapWithArgumentsReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.MapWithArguments, - (source: TSource, extraArguments: Record) => TSelectorReturn + (source: TSource, extraArguments: Record) => TSelectorReturn | Promise ]; export type MapInitializeReturn< @@ -462,10 +472,9 @@ export const enum MappingTransformationClassId { export type MappingTransformation< TSource extends Dictionary = any, TDestination extends Dictionary = any, - TSelectorReturn = SelectorReturn, - IsAsync extends boolean = false + TSelectorReturn = SelectorReturn > = [ - memberMapFn: MemberMapReturn, + memberMapFn: MemberMapReturn, preCond?: PreConditionReturn ]; @@ -476,15 +485,13 @@ export const enum MappingPropertyClassId { export type MappingProperty< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn, - IsAsync extends boolean = false + TSelectorReturn = SelectorReturn > = [ target: string[], transformation: MappingTransformation< TSource, TDestination, - TSelectorReturn, - IsAsync + TSelectorReturn > ]; export const enum MappingPropertiesClassId { @@ -522,8 +529,7 @@ export const enum MappingClassId { export type Mapping< TSource extends Dictionary = any, - TDestination extends Dictionary = any, - IsAsync extends boolean = false + TDestination extends Dictionary = any > = [ identifiers: [ source: MetadataIdentifier, @@ -536,8 +542,7 @@ export type Mapping< mappingProperty: MappingProperty< TSource, TDestination, - SelectorReturn, - IsAsync + SelectorReturn >, nestedMappingPair?: [ destination: MetadataIdentifier | Primitive | Date, @@ -551,8 +556,7 @@ export type Mapping< mappingProperty: MappingProperty< TSource, TDestination, - SelectorReturn, - IsAsync + SelectorReturn >, nestedMappingPair?: [ destination: MetadataIdentifier | Primitive | Date, @@ -611,7 +615,7 @@ export type DestinationConstructor< > = ( sourceObject: TSource, destinationIdentifier: MetadataIdentifier -) => TDestination; +) => TDestination | Promise; export type MappingProfile = (mapper: Mapper) => void; diff --git a/packages/core/src/lib/utils/async-aware.ts b/packages/core/src/lib/utils/async-aware.ts new file mode 100644 index 000000000..363c2ae84 --- /dev/null +++ b/packages/core/src/lib/utils/async-aware.ts @@ -0,0 +1,25 @@ +import { isPromise } from './is-promise'; + +export function asyncAware< + TAwaited, + TOperationResult, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TOperationResult +>( + awaitable: (isAsync: IsAsync) => TAwaited | Promise, + operation: (awaited: TAwaited, isAsync: IsAsync) => TOperationResult | Promise, + isAsync?: IsAsync +): Result { + const awaited = awaitable(isAsync as IsAsync); + + if (isAsync) { + return Promise.resolve(awaited).then((awaited) => { + return operation(awaited, isAsync as true as IsAsync); + }) as Result; + } else { + if (isPromise(awaited)) throw new Error( + 'Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations' + ); + return operation(awaited as Awaited, isAsync as false as IsAsync) as Result; + } +} diff --git a/packages/integration-test/src/classes/map-async.spec.ts b/packages/integration-test/src/classes/map-async.spec.ts index c0c81a23d..18c64672a 100644 --- a/packages/integration-test/src/classes/map-async.spec.ts +++ b/packages/integration-test/src/classes/map-async.spec.ts @@ -3,14 +3,21 @@ import { addProfile, afterMap, beforeMap, + condition, + convertUsing, createMap, createMapper, - forMember, fromValue, - ignore, mapDefer + forMember, + fromValue, + ignore, + mapDefer, + mapFrom, Mapper, + mapWith, + nullSubstitution, + undefinedSubstitution } from '@automapper/core'; import { SimpleUserDto } from './dtos/simple-user.dto'; import { SimpleUser } from './models/simple-user'; -import { getUser } from './utils/get-user'; async function asyncResolve(value: T, delayMs = 1000): Promise { return new Promise((resolve) => { @@ -25,6 +32,8 @@ const getFullname = (user: SimpleUser) => user.firstName + ' ' + user.lastName; describe('Map Async Classes', () => { const mapper = createMapper({ strategyInitializer: classes() }); + beforeEach(() => mapper.dispose()) + it('should map async', async () => { createMap( mapper, @@ -78,15 +87,18 @@ describe('Map Async Classes', () => { expect(dtos[0].fullName).toEqual(undefined); // afterMap is not called }); - + class Source { + value?: string | null; + another?: Source; + } + class Destination { + value!: string; + another?: Destination; + } it('should resolve defer if Promise returned from the mapping', async () => { - class Source {} - class Destination { - value!: string; - } const mockValue = 'mockValue'; - addProfile(mapper, function failingProfile(mapper) { + addProfile(mapper, function profile(mapper) { createMap(mapper, Source, Destination, forMember((d) => d.value, mapDefer(() => Promise.resolve(fromValue(mockValue)))), ) @@ -96,4 +108,120 @@ describe('Map Async Classes', () => { expect(destination.value).toEqual(mockValue); }); + + function prepareProfileForMemberMapFunction< + MemberMapFunction extends (...arg: any[]) => any, + MemberMapFunctionArgs extends Parameters + >(memberMapFunction: MemberMapFunction, ...args: MemberMapFunctionArgs) { + addProfile(mapper, function profile(mapper: Mapper) { + createMap(mapper, Source, Destination, + forMember((d) => d.value, memberMapFunction(...args)), + ) + }); + } + + function asynced(value: any) { + return `asynced ${ value }`; + } + + it('should resolve async `condition` and prevent mapping', async () => { + prepareProfileForMemberMapFunction(condition, () => Promise.resolve(false)); + const source: Source = { value: condition.name }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(undefined); + }); + + it('should resolve async `condition` and perform mapping', async () => { + prepareProfileForMemberMapFunction(condition, () => Promise.resolve(true)); + const source: Source = { value: condition.name }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(condition.name); + }); + + it('should resolve async `convertUsing`', async () => { + prepareProfileForMemberMapFunction(convertUsing, { + convert: (value) => Promise.resolve(asynced(value)), + }, (source: Source) => source.value); + const source: Source = { value: convertUsing.name }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(convertUsing.name)); + }); + + it('should resolve async `fromValue`', async () => { + prepareProfileForMemberMapFunction(fromValue, Promise.resolve(asynced(fromValue.name))); + + const destination = await mapper.mapAsync({}, Source, Destination); + + expect(destination.value).toEqual(asynced(fromValue.name)); + }); + + it('should resolve async `mapDefer`', async () => { + prepareProfileForMemberMapFunction( + mapDefer, + (source: Source) => Promise.resolve(fromValue(asynced(source.value))) + ); + const source: Source = { value: mapDefer.name }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(mapDefer.name)); + }); + + it('should resolve async `mapFrom`', async () => { + prepareProfileForMemberMapFunction(mapFrom, (source: Source) => Promise.resolve(asynced(source.value))); + const source: Source = {value: mapFrom.name}; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(mapFrom.name)); + }); + + it('should resolve async `mapWith`', async () => { + addProfile(mapper, function failingProfile(mapper) { + createMap(mapper, Source, Destination, + forMember((d) => d.value, mapFrom((source) => asynced(source.value))), + forMember((d) => d.another, mapDefer(async (source) => { + return source.another + ? mapWith(Destination, Source, (source: Source) => Promise.resolve(source.another)) + : ignore(); + })) + ) + }); + const source: Source = { + value: mapWith.name, + another: { + value: mapWith.name + } + }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(mapWith.name)); + expect(destination.another!.value).toEqual(asynced(mapWith.name)); + expect(destination.another!.another).toEqual(undefined); + }); + + it('should resolve async `nullSubstitution`', async () => { + prepareProfileForMemberMapFunction(nullSubstitution, Promise.resolve(asynced(nullSubstitution.name))); + const source: Source = { value: null }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(nullSubstitution.name)); + }); + + it('should resolve async `undefinedSubstitution`', async () => { + prepareProfileForMemberMapFunction(undefinedSubstitution, Promise.resolve(asynced(undefinedSubstitution.name))); + const source: Source = { value: undefined }; + + const destination = await mapper.mapAsync(source, Source, Destination); + + expect(destination.value).toEqual(asynced(undefinedSubstitution.name)); + }); }); From be415c7e1fbdc5e5077ee3d93e6054cee809e221 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 16:12:00 +0200 Subject: [PATCH 08/13] feat(nestjs): Continue @koenigstag `async-core-rewrite` initiative: Make NestJS `MapPipe` and `MapInterceptor` to call `mapAsync` instead `map`\n\n- Add possibility to fall back to synchronous mapping --- .../src/nestjs/app.controller.spec.ts | 18 ++++++++++++++++++ .../src/nestjs/app.controller.ts | 11 +++++++++++ packages/nestjs/src/lib/map.interceptor.ts | 11 ++++++----- packages/nestjs/src/lib/map.pipe.ts | 11 ++++++----- packages/nestjs/src/lib/utils/transform.ts | 17 ++++++++++++----- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/integration-test/src/nestjs/app.controller.spec.ts b/packages/integration-test/src/nestjs/app.controller.spec.ts index 31f512f13..5b68e0376 100644 --- a/packages/integration-test/src/nestjs/app.controller.spec.ts +++ b/packages/integration-test/src/nestjs/app.controller.spec.ts @@ -59,6 +59,15 @@ describe(AppController.name, () => { .expect(JSON.parse(JSON.stringify(getUserDto()))); }); + it('GET /raw-sync', () => { + mockedAppService.getRawUser.mockReturnValueOnce(getUser()); + + return request(app.getHttpServer()) + .get('/raw-sync') + .expect(200) + .expect(JSON.parse(JSON.stringify(getUserDto()))); + }); + it('GET /raw-array', () => { mockedAppService.getRawUser.mockReturnValueOnce(getUser()); @@ -81,6 +90,15 @@ describe(AppController.name, () => { .expect(JSON.parse(JSON.stringify(getUserDto()))); }); + it('POST /from-body-sync', () => { + return request(app.getHttpServer()) + .post('/from-body-sync') + .set('Content-Type', 'application/json') + .send(getUser()) + .expect(201) + .expect(JSON.parse(JSON.stringify(getUserDto()))); + }); + it('POST /from-body-data', () => { return request(app.getHttpServer()) .post('/from-body-data') diff --git a/packages/integration-test/src/nestjs/app.controller.ts b/packages/integration-test/src/nestjs/app.controller.ts index 439134c70..d7d0ee09e 100644 --- a/packages/integration-test/src/nestjs/app.controller.ts +++ b/packages/integration-test/src/nestjs/app.controller.ts @@ -31,6 +31,12 @@ export class AppController { return this.appService.getRawUser(); } + @Get('raw-sync') + @UseInterceptors(MapInterceptor(User, UserDto, { sync: true })) + getRawUserSync() { + return this.appService.getRawUser(); + } + @Get('raw-array') @UseInterceptors(MapInterceptor(User, UserDto, { isArray: true })) getRawUserArray() { @@ -47,6 +53,11 @@ export class AppController { return dto; } + @Post('from-body-sync') + getUserFromBodySync(@Body(MapPipe(User, UserDto, { sync: true })) dto: UserDto) { + return dto; + } + @Post('from-body-data') getUserFromBodyData(@Body('data', MapPipe(User, UserDto)) dto: UserDto) { return { dto }; diff --git a/packages/nestjs/src/lib/map.interceptor.ts b/packages/nestjs/src/lib/map.interceptor.ts index 30f47ffac..a31e676f2 100644 --- a/packages/nestjs/src/lib/map.interceptor.ts +++ b/packages/nestjs/src/lib/map.interceptor.ts @@ -26,7 +26,7 @@ export const MapInterceptor: < >( from: ModelIdentifier, to: ModelIdentifier, - options?: { isArray?: boolean; mapperName?: string } & MapOptions< + options?: { isArray?: boolean; mapperName?: string; sync?: boolean } & MapOptions< TSource, TDestination > @@ -38,12 +38,12 @@ function createMapInterceptor< >( from: ModelIdentifier, to: ModelIdentifier, - options?: { isArray?: boolean; mapperName?: string } & MapOptions< + options?: { isArray?: boolean; mapperName?: string; sync?: boolean } & MapOptions< TSource, TDestination > ): new (...args: unknown[]) => NestInterceptor { - const { isArray, mapperName, transformedMapOptions } = + const { isArray, mapperName, isAsync, transformedMapOptions } = getTransformOptions(options); class MixinMapInterceptor implements NestInterceptor { @@ -73,11 +73,12 @@ function createMapInterceptor< transformedMapOptions as unknown as MapOptions< TSource[], TDestination[] - > + >, + isAsync ); } - return this.mapper?.map( + return (isAsync ? this.mapper?.mapAsync : this.mapper?.map)?.( response, from, to, diff --git a/packages/nestjs/src/lib/map.pipe.ts b/packages/nestjs/src/lib/map.pipe.ts index 697bde992..957032061 100644 --- a/packages/nestjs/src/lib/map.pipe.ts +++ b/packages/nestjs/src/lib/map.pipe.ts @@ -20,7 +20,7 @@ export const MapPipe: < >( from: ModelIdentifier, to: ModelIdentifier, - options?: { isArray?: boolean; mapperName?: string } & MapOptions< + options?: { isArray?: boolean; mapperName?: string, sync?: boolean } & MapOptions< TSource, TDestination > @@ -32,12 +32,12 @@ function createMapPipe< >( from: ModelIdentifier, to: ModelIdentifier, - options?: { isArray?: boolean; mapperName?: string } & MapOptions< + options?: { isArray?: boolean; mapperName?: string; sync: boolean } & MapOptions< TSource, TDestination > ): new (...args: unknown[]) => PipeTransform { - const { isArray, mapperName, transformedMapOptions } = + const { isArray, mapperName, isAsync, transformedMapOptions } = getTransformOptions(options); class MixinMapPipe implements PipeTransform { @@ -68,11 +68,12 @@ function createMapPipe< transformedMapOptions as unknown as MapOptions< TSource[], TDestination[] - > + >, + isAsync ) as TDestination[]; } - return this.mapper?.map( + return (isAsync ? this.mapper?.mapAsync : this.mapper?.map)?.( value as TSource, from, to, diff --git a/packages/nestjs/src/lib/utils/transform.ts b/packages/nestjs/src/lib/utils/transform.ts index fdb3fb281..ef8a499f8 100644 --- a/packages/nestjs/src/lib/utils/transform.ts +++ b/packages/nestjs/src/lib/utils/transform.ts @@ -25,26 +25,33 @@ export function transformArray< mapper: Mapper | undefined, from: ModelIdentifier, to: ModelIdentifier, - options?: MapOptions + options?: MapOptions, + isAsync?: boolean ) { if (!Array.isArray(value)) return value; - return mapper?.mapArray(value, from, to, options); + return (isAsync ? mapper?.mapArrayAsync : mapper?.mapArray)?.( + value, + from, + to, + options + ); } export function getTransformOptions< TSource extends Dictionary, TDestination extends Dictionary >( - options?: { isArray?: boolean; mapperName?: string } & MapOptions< + options?: { isArray?: boolean; mapperName?: string, sync?: boolean } & MapOptions< TSource, TDestination > ): { mapperName?: string; isArray: boolean; + isAsync: boolean; transformedMapOptions?: MapOptions; } { - const { isArray = false, mapperName, ...mapOptions } = options || {}; + const { isArray = false, mapperName, sync = false, ...mapOptions } = options || {}; const transformedMapOptions = isEmpty(mapOptions) ? undefined : mapOptions; - return { isArray, mapperName, transformedMapOptions }; + return { isArray, mapperName, isAsync: !sync, transformedMapOptions }; } From be95f5fce6d41e0695acdac1dc04b396a1f6eaa0 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 17:02:00 +0200 Subject: [PATCH 09/13] feat(doc): Continue @koenigstag `async-core-rewrite` initiative: Update documentation to cover the changes --- .../docs/fundamentals/mapping.mdx | 27 +++++----- .../docs/mapping-configuration/after-map.mdx | 28 ---------- .../mapping-configuration/async-mapping.mdx | 43 +++++++++++++++ .../documentation/docs/misc/fake-async.mdx | 32 ------------ packages/documentation/docs/nestjs.mdx | 12 +++++ packages/documentation/sidebars.js | 2 +- .../documentations/docs/api/nestjs/modules.md | 52 +++++++++---------- .../docs/fundamentals/mapping.mdx | 27 +++++----- .../docs/mapping-configuration/after-map.mdx | 22 -------- .../mapping-configuration/async-mapping.mdx | 46 ++++++++++++++++ packages/documentations/docs/nestjs.mdx | 12 +++++ packages/documentations/sidebars.js | 1 + 12 files changed, 169 insertions(+), 135 deletions(-) create mode 100644 packages/documentation/docs/mapping-configuration/async-mapping.mdx delete mode 100644 packages/documentation/docs/misc/fake-async.mdx create mode 100644 packages/documentations/docs/mapping-configuration/async-mapping.mdx diff --git a/packages/documentation/docs/fundamentals/mapping.mdx b/packages/documentation/docs/fundamentals/mapping.mdx index f4d2ff3f9..7c31bc213 100644 --- a/packages/documentation/docs/fundamentals/mapping.mdx +++ b/packages/documentation/docs/fundamentals/mapping.mdx @@ -34,16 +34,17 @@ We can also create a Mapping between the same model (identifier). Read more abou There are currently 10 `TransformationType` -| type | member map function | description | -| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Ignore | `ignore()` | Ignore a member on the `Destination` | -| MapFrom | `mapFrom()` | Customize instruction for a member with a `Selector` or a [Resolver](../mapping-configuration/for-member/map-from#value-resolver) | -| Condition | `condition()` | If the member on the `Destination` matches with another member on the `Source`, this will conditionally map the member on the `Source` to `Destination` if some predicate is evaluated to truthy | -| FromValue | `fromValue()` | Map a raw value to the member | -| MapWith | `mapWith()` | In some cases where nested models do not work automatically, this is to specify the nested `Destination` of the member as well as the nested `Source` | -| ConvertUsing | `convertUsing()` | Map a member using [Converters](../mapping-configuration/for-member/convert-using) | -| MapInitialize | `mapInitialize()` | This is used internally to initialize the `MappingProperty` with the `Destination` metadata | -| NullSubstitution | `nullSubstitution()` | If the member on `Source` is `null`, this will substitute the `null` value with a different value for that member on `Destination` | -| UndefinedSubstitution | `undefinedSubstitution()` | If the member on `Source` is `undefined`, this will substitute the `undefined` value with a different value for that member on `Destination` | -| MapWithArguments | `mapWithArguments()` | This can be used to map with extra arguments where the arguments come in at runtime when `map()` is invoked | -| MapDefer | `mapDefer()` | This can be used to defer a `TransformationType` with the `Source`. For example, if `Source` has data A, we want `MapFrom` but if `Source` has B, we want to `Ignore` | +| type | member map function | operation | description | +| --------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Ignore | `ignore()` | Sync | Ignore a member on the `Destination` | +| MapFrom | `mapFrom()` | Sync or Async | Customize instruction for a member with a `Selector` or a [Resolver](../mapping-configuration/for-member/map-from#value-resolver) | +| Condition | `condition()` | Sync or Async | If the member on the `Destination` matches with another member on the `Source`, this will conditionally map the member on the `Source` to `Destination` if some predicate is evaluated to truthy | +| FromValue | `fromValue()` | Sync or Async | Map a raw value to the member | +| MapWith | `mapWith()` | Sync or Async | In some cases where nested models do not work automatically, this is to specify the nested `Destination` of the member as well as the nested `Source` | +| ConvertUsing | `convertUsing()` | Sync or Async | Map a member using [Converters](../mapping-configuration/for-member/convert-using) | +| MapInitialize | `mapInitialize()` | Sync | This is used internally to initialize the `MappingProperty` with the `Destination` metadata | +| NullSubstitution | `nullSubstitution()` | Sync or Async | If the member on `Source` is `null`, this will substitute the `null` value with a different value for that member on `Destination` | +| UndefinedSubstitution | `undefinedSubstitution()` | Sync or Async | If the member on `Source` is `undefined`, this will substitute the `undefined` value with a different value for that member on `Destination` | +| MapWith | `mapWith()` | Sync or Async | This can be used to explicitly define the nested structure mapping `Source` and `Destination` types | +| MapWithArguments | `mapWithArguments()` | Sync or Async | This can be used to map with extra arguments where the arguments come in at runtime when `map()` is invoked | +| MapDefer | `mapDefer()` | Sync or Async | This can be used to defer a `TransformationType` with the `Source`. For example, if `Source` has data A, we want `MapFrom` but if `Source` has B, we want to `Ignore` | diff --git a/packages/documentation/docs/mapping-configuration/after-map.mdx b/packages/documentation/docs/mapping-configuration/after-map.mdx index 41d20248c..28a2af8c8 100644 --- a/packages/documentation/docs/mapping-configuration/after-map.mdx +++ b/packages/documentation/docs/mapping-configuration/after-map.mdx @@ -37,34 +37,6 @@ mapper.map(user, User, UserDto, { ::: -## Async Mapping - -One of the common use-cases of `afterMap` is to execute some asynchronous operation. Let's assume our `Destination` have some property whose value can only be computed from an asynchronous operation, we can leverage `mapAsync()` and `afterMap()` for it. - -```ts -createMap( - mapper, - User, - UserDto, - // 👇 We are fetching the "fullName" manually - // 👇 👇 so we need to ignore it - forMember((d) => d.fullName, ignore()), - afterMap(async (source, destination) => { - const fullName = await fetchFullName(source); - Object.assign(destination, { fullName }); - }) -); - -// 👇 mapAsync is needed if we use the above "trick" with afterMap -const dto = await mapper.mapAsync(user, User, UserDto); -``` - -:::caution - -Simple asynchronous operations should be fine with this approach. However due to [Fake Async](../misc/fake-async), we should **NOT** use AutoMapper for a particular pair of models if those models require some heavy and complex asynchronous operations. - -::: - ## What about `postMap`? When create the `Mapper`, we can customize the `postMap` function on the `MappingStrategy`. The differences between `postMap` and `afterMap` are: diff --git a/packages/documentation/docs/mapping-configuration/async-mapping.mdx b/packages/documentation/docs/mapping-configuration/async-mapping.mdx new file mode 100644 index 000000000..937c6ef52 --- /dev/null +++ b/packages/documentation/docs/mapping-configuration/async-mapping.mdx @@ -0,0 +1,43 @@ +--- +id: async-mapping +title: Async Mapping +sidebar_label: Async Mapping +sidebar_position: 11 +--- + +Automapper supports processing asynchronous mappings. Instead of calling `Mapper::map` it is required to call `Mapper::mapAsync`. + +:::caution + +If a mapping configuration includes asynchronous operations, but the mapping is executed by calling `Mapping::map` +instead of `Mapping::mapAsync`, it will result with an error which requests the use of `Mapping::mapAsync`. + +::: + +An example below will give a slight overview of asynchronous mapping. + +```ts +import { createMap, forMember, mapFrom } from '@automapper/core'; + +createMap( + mapper, + User, + UserDto, + // 👇 We are fetching the "fullName" manually + // 👇 👇 so we need to define its retrieval operation + forMember((d) => d.fullName, mapFrom(async (source) => { + return await fetchFullName(source); + } +); + +// 👇 mapAsync is needed if any async operations are configured for the mapping profile +const dto = await mapper.mapAsync(user, User, UserDto); +``` + +## Support + +The support for asynchronous processing among member mapping functions are listed in [NamingConvention](../fundamentals/mapping#mappingtransformation). + +The asynchronous processing is supported also by + - [`afterMap`](./after-map) and [`beforeMap`](./before-map) mapping hooks, and + - `destinationConstructor`, `postMap` and `preMap` options in mapping strategy. diff --git a/packages/documentation/docs/misc/fake-async.mdx b/packages/documentation/docs/misc/fake-async.mdx deleted file mode 100644 index d81a05062..000000000 --- a/packages/documentation/docs/misc/fake-async.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: fake-async -title: Fake Async -sidebar_label: Fake Async -sidebar_position: 4 ---- - -## "Fake" Async - -Currently, AutoMapper is manipulating the [Event Loop](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop) to provide a "fake" async support for the `mapAsync()` and `mutateAsync()` variants. - -```ts -// 👇 simplified for brevity -function mapAsync(...args) { - const result = map(...args); - return new Promise((res) => { - setTimeout(res, 0, result); - }); -} - -// 👇 simplified for brevity -function mutateAsync(...args) { - return new Promise((res) => { - mutate(...args); - setTimeout(res); - }); -} -``` - -## Help wanted - -Real async support can be achieved by some Isomorphic Worker that would execute the map operations on the Worker thread. However, AutoMapper implementation is full of `Function` which cannot be serialized (easily) to transfer to the Worker thread. If anyone wants to contribute Asynchronous support, I'm happy to walk you through the repository. diff --git a/packages/documentation/docs/nestjs.mdx b/packages/documentation/docs/nestjs.mdx index ecb3e4dcf..fa4c8952d 100644 --- a/packages/documentation/docs/nestjs.mdx +++ b/packages/documentation/docs/nestjs.mdx @@ -139,6 +139,11 @@ export class UserProfile extends AutomapperProfile { `@automapper/nestjs` provides `MapInterceptor`. In cases where you do not care about annotating the correct return type for a **Controller#method** and want your **Service** to be a little cleaner, you can utilize the `MapInterceptor` to execute the mapping. +:::tip +Automapper's NestJS integration executes mapping by default asynchronously. It is possible to force it to be synchronous +by passing `{ sync: true }` option to `MapInterceptor` decorator. +::: + ```ts import { UseInterceptors } from '@nestjs/common'; import { MapInterceptor } from '@automapper/nestjs'; @@ -159,6 +164,7 @@ export class UserController { MapInterceptor(sourceModelType, destinationModelType, { isArray?: boolean; mapperName?: string; + sync?: boolean; } & MapOptions) ``` @@ -166,6 +172,11 @@ MapInterceptor(sourceModelType, destinationModelType, { `@automapper/nestjs` provides `MapPipe`. When you want to transform the incoming request body before it gets to the route handler, you can utilize `MapPipe` to achieve this behavior +:::tip +Automapper's NestJS integration executes mapping by default asynchronously. It is possible to force it to be synchronous +by passing `{ sync: true }` option to `MapPipe` decorator. +::: + ```ts import { MapPipe } from '@automapper/nestjs'; @@ -196,5 +207,6 @@ getFromQuery(@Query(MapPipe(User, UserDto)) user: UserDto) { MapPipe(sourceModelType, destinationModelType, { isArray?: boolean; mapperName?: string; + sync?: boolean; } & MapOptions) ``` diff --git a/packages/documentation/sidebars.js b/packages/documentation/sidebars.js index 48bfa8228..cf036f738 100644 --- a/packages/documentation/sidebars.js +++ b/packages/documentation/sidebars.js @@ -66,6 +66,7 @@ const sidebars = { 'mapping-configuration/for-self', 'mapping-configuration/naming-conventions', 'mapping-configuration/type-converters', + 'mapping-configuration/async-mapping', ], }, { @@ -85,7 +86,6 @@ const sidebars = { 'misc/transformer-plugin', 'misc/mapped-types', 'misc/self-mapping', - 'misc/fake-async', ], }, 'nestjs', diff --git a/packages/documentations/docs/api/nestjs/modules.md b/packages/documentations/docs/api/nestjs/modules.md index f3393916c..4b83ead81 100644 --- a/packages/documentations/docs/api/nestjs/modules.md +++ b/packages/documentations/docs/api/nestjs/modules.md @@ -45,9 +45,9 @@ custom_edit_url: null #### Parameters -| Name | Type | -| :------ | :------ | -| `name?` | `string` | +| Name | Type | +|:---------|:---------| +| `name?` | `string` | #### Returns @@ -84,11 +84,11 @@ or symbols as the injection token. ##### Parameters -| Name | Type | -| :------ | :------ | -| `target` | `object` | -| `key` | `string` \| `symbol` | -| `index?` | `number` | +| Name | Type | +|:---------|:---------------------| +| `target` | `object` | +| `key` | `string` \| `symbol` | +| `index?` | `number` | ##### Returns @@ -106,18 +106,18 @@ ___ #### Type parameters -| Name | Type | -| :------ | :------ | -| `TSource` | extends `Dictionary`<`TSource`\> | +| Name | Type | +|:---------------|:--------------------------------------| +| `TSource` | extends `Dictionary`<`TSource`\> | | `TDestination` | extends `Dictionary`<`TDestination`\> | #### Parameters -| Name | Type | -| :------ | :------ | -| `from` | `ModelIdentifier`<`TSource`\> | -| `to` | `ModelIdentifier`<`TDestination`\> | -| `options?` | { `isArray?`: `boolean` ; `mapperName?`: `string` } & `MapOptions`<`TSource`, `TDestination`, `Record`<`string`, `any`\>\> | +| Name | Type | +|:-----------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `from` | `ModelIdentifier`<`TSource`\> | +| `to` | `ModelIdentifier`<`TDestination`\> | +| `options?` | { `isArray`: `boolean` = `false` ; `mapperName?`: `string`; `sync`: `boolean` = `false` } & `MapOptions`<`TSource`, `TDestination`, `Record`<`string`, `any`\>\> | #### Returns @@ -135,18 +135,18 @@ ___ #### Type parameters -| Name | Type | -| :------ | :------ | -| `TSource` | extends `Dictionary`<`TSource`\> | +| Name | Type | +|:---------------|:--------------------------------------| +| `TSource` | extends `Dictionary`<`TSource`\> | | `TDestination` | extends `Dictionary`<`TDestination`\> | #### Parameters -| Name | Type | -| :------ | :------ | -| `from` | `ModelIdentifier`<`TSource`\> | -| `to` | `ModelIdentifier`<`TDestination`\> | -| `options?` | { `isArray?`: `boolean` ; `mapperName?`: `string` } & `MapOptions`<`TSource`, `TDestination`, `Record`<`string`, `any`\>\> | +| Name | Type | +|:-----------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `from` | `ModelIdentifier`<`TSource`\> | +| `to` | `ModelIdentifier`<`TDestination`\> | +| `options?` | { `isArray`: `boolean` = `false` ; `mapperName?`: `string`; `sync`: `boolean` = `false` } & `MapOptions`<`TSource`, `TDestination`, `Record`<`string`, `any`\>\> | #### Returns @@ -164,8 +164,8 @@ ___ #### Parameters -| Name | Type | -| :------ | :------ | +| Name | Type | +|:--------|:---------| | `name?` | `string` | #### Returns diff --git a/packages/documentations/docs/fundamentals/mapping.mdx b/packages/documentations/docs/fundamentals/mapping.mdx index f4d2ff3f9..7c31bc213 100644 --- a/packages/documentations/docs/fundamentals/mapping.mdx +++ b/packages/documentations/docs/fundamentals/mapping.mdx @@ -34,16 +34,17 @@ We can also create a Mapping between the same model (identifier). Read more abou There are currently 10 `TransformationType` -| type | member map function | description | -| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Ignore | `ignore()` | Ignore a member on the `Destination` | -| MapFrom | `mapFrom()` | Customize instruction for a member with a `Selector` or a [Resolver](../mapping-configuration/for-member/map-from#value-resolver) | -| Condition | `condition()` | If the member on the `Destination` matches with another member on the `Source`, this will conditionally map the member on the `Source` to `Destination` if some predicate is evaluated to truthy | -| FromValue | `fromValue()` | Map a raw value to the member | -| MapWith | `mapWith()` | In some cases where nested models do not work automatically, this is to specify the nested `Destination` of the member as well as the nested `Source` | -| ConvertUsing | `convertUsing()` | Map a member using [Converters](../mapping-configuration/for-member/convert-using) | -| MapInitialize | `mapInitialize()` | This is used internally to initialize the `MappingProperty` with the `Destination` metadata | -| NullSubstitution | `nullSubstitution()` | If the member on `Source` is `null`, this will substitute the `null` value with a different value for that member on `Destination` | -| UndefinedSubstitution | `undefinedSubstitution()` | If the member on `Source` is `undefined`, this will substitute the `undefined` value with a different value for that member on `Destination` | -| MapWithArguments | `mapWithArguments()` | This can be used to map with extra arguments where the arguments come in at runtime when `map()` is invoked | -| MapDefer | `mapDefer()` | This can be used to defer a `TransformationType` with the `Source`. For example, if `Source` has data A, we want `MapFrom` but if `Source` has B, we want to `Ignore` | +| type | member map function | operation | description | +| --------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Ignore | `ignore()` | Sync | Ignore a member on the `Destination` | +| MapFrom | `mapFrom()` | Sync or Async | Customize instruction for a member with a `Selector` or a [Resolver](../mapping-configuration/for-member/map-from#value-resolver) | +| Condition | `condition()` | Sync or Async | If the member on the `Destination` matches with another member on the `Source`, this will conditionally map the member on the `Source` to `Destination` if some predicate is evaluated to truthy | +| FromValue | `fromValue()` | Sync or Async | Map a raw value to the member | +| MapWith | `mapWith()` | Sync or Async | In some cases where nested models do not work automatically, this is to specify the nested `Destination` of the member as well as the nested `Source` | +| ConvertUsing | `convertUsing()` | Sync or Async | Map a member using [Converters](../mapping-configuration/for-member/convert-using) | +| MapInitialize | `mapInitialize()` | Sync | This is used internally to initialize the `MappingProperty` with the `Destination` metadata | +| NullSubstitution | `nullSubstitution()` | Sync or Async | If the member on `Source` is `null`, this will substitute the `null` value with a different value for that member on `Destination` | +| UndefinedSubstitution | `undefinedSubstitution()` | Sync or Async | If the member on `Source` is `undefined`, this will substitute the `undefined` value with a different value for that member on `Destination` | +| MapWith | `mapWith()` | Sync or Async | This can be used to explicitly define the nested structure mapping `Source` and `Destination` types | +| MapWithArguments | `mapWithArguments()` | Sync or Async | This can be used to map with extra arguments where the arguments come in at runtime when `map()` is invoked | +| MapDefer | `mapDefer()` | Sync or Async | This can be used to defer a `TransformationType` with the `Source`. For example, if `Source` has data A, we want `MapFrom` but if `Source` has B, we want to `Ignore` | diff --git a/packages/documentations/docs/mapping-configuration/after-map.mdx b/packages/documentations/docs/mapping-configuration/after-map.mdx index 87019399f..17075e897 100644 --- a/packages/documentations/docs/mapping-configuration/after-map.mdx +++ b/packages/documentations/docs/mapping-configuration/after-map.mdx @@ -37,28 +37,6 @@ mapper.map(user, User, UserDto, { ::: -## Async Mapping - -One of the common use-cases of `afterMap` is to execute some asynchronous operation. Let's assume our `Destination` have some property whose value can only be computed from an asynchronous operation, we can leverage `mapAsync()` and `afterMap()` for it. - -```ts -createMap( - mapper, - User, - UserDto, - // 👇 We are fetching the "fullName" manually - // 👇 👇 so we need to ignore it - forMember((d) => d.fullName, ignore()), - afterMap(async (source, destination) => { - const fullName = await fetchFullName(source); - Object.assign(destination, { fullName }); - }) -); - -// 👇 mapAsync is needed if we use the above "trick" with afterMap -const dto = await mapper.mapAsync(user, User, UserDto); -``` - ## What about `postMap`? When create the `Mapper`, we can customize the `postMap` function on the `MappingStrategy`. The differences between `postMap` and `afterMap` are: diff --git a/packages/documentations/docs/mapping-configuration/async-mapping.mdx b/packages/documentations/docs/mapping-configuration/async-mapping.mdx new file mode 100644 index 000000000..b9cf5e20a --- /dev/null +++ b/packages/documentations/docs/mapping-configuration/async-mapping.mdx @@ -0,0 +1,46 @@ +--- +id: async-mapping +title: Async Mapping +sidebar_label: Async Mapping +sidebar_position: 11 +--- + +Automapper supports processing asynchronous mappings. Instead of calling [`Mapper::map`](../api/core/interfaces/Mapper#map) +it is required to call [`Mapper::mapAsync`](../api/core/interfaces/Mapper#mapasync). + +:::caution + +If a mapping configuration includes asynchronous operations, but the mapping is executed by calling `Mapping::map` +instead of `Mapping::mapAsync`, it will result with an error which requests the use of `Mapping::mapAsync`. + +::: + +An example below will give a slight overview of asynchronous mapping. + +```ts +import { createMap, forMember, mapFrom } from '@automapper/core'; + +createMap( + mapper, + User, + UserDto, + // 👇 We are fetching the "fullName" manually + // 👇 👇 so we need to define its retrieval operation + forMember((d) => d.fullName, mapFrom(async (source) => { + return await fetchFullName(source); + } +); + +// 👇 mapAsync is needed if any async operations are configured for the mapping profile +const dto = await mapper.mapAsync(user, User, UserDto); +``` + +## Support + +The support for asynchronous processing among member mapping functions are listed in [MappingTransformation](../fundamentals/mapping#mappingtransformation). + +The asynchronous processing is supported also by + - [`afterMap`](./after-map) and [`beforeMap`](./before-map) mapping hooks, and + - [`destinationConstructor`](../api/core/interfaces/MappingStrategy#destinationConstructor), + [`postMap`](../api/core/interfaces/MappingStrategy#postmap) and + [`preMap`](../api/core/interfaces/MappingStrategy#premap) options in mapping strategy. diff --git a/packages/documentations/docs/nestjs.mdx b/packages/documentations/docs/nestjs.mdx index ecb3e4dcf..fa4c8952d 100644 --- a/packages/documentations/docs/nestjs.mdx +++ b/packages/documentations/docs/nestjs.mdx @@ -139,6 +139,11 @@ export class UserProfile extends AutomapperProfile { `@automapper/nestjs` provides `MapInterceptor`. In cases where you do not care about annotating the correct return type for a **Controller#method** and want your **Service** to be a little cleaner, you can utilize the `MapInterceptor` to execute the mapping. +:::tip +Automapper's NestJS integration executes mapping by default asynchronously. It is possible to force it to be synchronous +by passing `{ sync: true }` option to `MapInterceptor` decorator. +::: + ```ts import { UseInterceptors } from '@nestjs/common'; import { MapInterceptor } from '@automapper/nestjs'; @@ -159,6 +164,7 @@ export class UserController { MapInterceptor(sourceModelType, destinationModelType, { isArray?: boolean; mapperName?: string; + sync?: boolean; } & MapOptions) ``` @@ -166,6 +172,11 @@ MapInterceptor(sourceModelType, destinationModelType, { `@automapper/nestjs` provides `MapPipe`. When you want to transform the incoming request body before it gets to the route handler, you can utilize `MapPipe` to achieve this behavior +:::tip +Automapper's NestJS integration executes mapping by default asynchronously. It is possible to force it to be synchronous +by passing `{ sync: true }` option to `MapPipe` decorator. +::: + ```ts import { MapPipe } from '@automapper/nestjs'; @@ -196,5 +207,6 @@ getFromQuery(@Query(MapPipe(User, UserDto)) user: UserDto) { MapPipe(sourceModelType, destinationModelType, { isArray?: boolean; mapperName?: string; + sync?: boolean; } & MapOptions) ``` diff --git a/packages/documentations/sidebars.js b/packages/documentations/sidebars.js index 78bff8006..919161d03 100644 --- a/packages/documentations/sidebars.js +++ b/packages/documentations/sidebars.js @@ -66,6 +66,7 @@ const sidebars = { 'mapping-configuration/for-self', 'mapping-configuration/naming-conventions', 'mapping-configuration/type-converters', + 'mapping-configuration/async-mapping', ], }, { From d59ffe52305922202c85c3d965a394ace745f517 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 17:28:00 +0200 Subject: [PATCH 10/13] feat(core): Continue @koenigstag `async-core-rewrite` initiative: revert redundant changes\n\n- fix formatting --- .../lib/mapping-configurations/for-member.ts | 5 +- packages/core/src/lib/mappings/map.ts | 66 +++++++++---------- .../src/lib/member-map-functions/map-with.ts | 10 +-- packages/core/src/lib/types.ts | 12 +--- packages/core/src/lib/utils/async-aware.ts | 16 ++--- packages/core/src/lib/utils/is-promise.ts | 10 +-- .../src/classes/map-async.spec.ts | 31 ++++----- .../integration-test/src/classes/map.spec.ts | 14 ++-- .../transformer-plugin.spec.ts | 20 +++--- 9 files changed, 89 insertions(+), 95 deletions(-) diff --git a/packages/core/src/lib/mapping-configurations/for-member.ts b/packages/core/src/lib/mapping-configurations/for-member.ts index b486e9707..3d273c804 100644 --- a/packages/core/src/lib/mapping-configurations/for-member.ts +++ b/packages/core/src/lib/mapping-configurations/for-member.ts @@ -18,8 +18,7 @@ import { isPrimitiveArrayEqual } from '../utils/is-primitive-array-equal'; export function forMember< TSource extends Dictionary, TDestination extends Dictionary, - TMemberType = SelectorReturn, - IsAsync extends boolean = false, + TMemberType = SelectorReturn >( selector: Selector, ...fns: [ @@ -46,7 +45,7 @@ export function forMember< const mappingProperty: MappingProperty = [ memberPath, [ - mapMemberFn as MemberMapReturn, + mapMemberFn, preCondOrMapMemberFn as PreConditionReturn< TSource, TDestination, diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index cc4a74c19..9c8c36766 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -1,12 +1,12 @@ import { getErrorHandler, getMetadataMap } from '../symbols'; import type { - Constructor, - Dictionary, - MapInitializeReturn, - MapOptions, - Mapper, - Mapping, MemberMapReturn, - MetadataIdentifier + Constructor, + Dictionary, + MapInitializeReturn, + MapOptions, + Mapper, + Mapping, + MetadataIdentifier } from '../types'; import { MapFnClassId, MetadataClassId, TransformationType } from '../types'; import { assertUnmappedProperties } from '../utils/assert-unmapped-properties'; @@ -258,33 +258,33 @@ function _mapInternalLogic< TDestination extends Dictionary, IsAsync extends boolean = false >({ - propsToMap, - destination, - mapper, - setMemberFn, - getMemberFn, - sourceObject, - destinationIdentifier, - extraArguments, - configuredKeys, - errorHandler, - extraArgs, - metadataMap, - isAsync, + propsToMap, + destination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, + isAsync, }: { - propsToMap: Mapping[2], - destination: TDestination, - mapper: Mapper, - setMemberFn: MapParameter['setMemberFn'], - getMemberFn: MapParameter['getMemberFn'], - sourceObject: TSource, - destinationIdentifier: MetadataIdentifier, - extraArguments?: Record, - configuredKeys: string[], - errorHandler: ReturnType, - extraArgs: MapOptions['extraArgs'], - metadataMap: ReturnType, - isAsync?: IsAsync + propsToMap: Mapping[2], + destination: TDestination, + mapper: Mapper, + setMemberFn: MapParameter['setMemberFn'], + getMemberFn: MapParameter['getMemberFn'], + sourceObject: TSource, + destinationIdentifier: MetadataIdentifier, + extraArguments?: Record, + configuredKeys: string[], + errorHandler: ReturnType, + extraArgs: MapOptions['extraArgs'], + metadataMap: ReturnType, + isAsync?: IsAsync }): (IsAsync extends true ? any[] : undefined) { const resolvables: any[] = []; const pushResolvable = (resolvable: any) => { diff --git a/packages/core/src/lib/member-map-functions/map-with.ts b/packages/core/src/lib/member-map-functions/map-with.ts index caa9d3863..f0e4ffd57 100644 --- a/packages/core/src/lib/member-map-functions/map-with.ts +++ b/packages/core/src/lib/member-map-functions/map-with.ts @@ -1,9 +1,9 @@ import { - Dictionary, - MapOptions, - MapWithReturn, - ModelIdentifier, - SelectorReturn, ValueSelectorAsyncable + Dictionary, + MapOptions, + MapWithReturn, + ModelIdentifier, + SelectorReturn, } from '../types'; import { TransformationType, ValueSelector } from '../types'; import { asyncAware } from '../utils/async-aware'; diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 5d60c6dfe..f29c9a589 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -70,8 +70,8 @@ export type Selector< > = (obj: TObject) => TReturnType; export type SelectorAsyncAware< - TObject extends Dictionary = any, - TReturnType = unknown + TObject extends Dictionary = any, + TReturnType = unknown > = (obj: TObject, isAsync: boolean) => TReturnType; export type SelectorReturn< @@ -86,12 +86,6 @@ export type ValueSelector< TValueReturn = SelectorReturn > = (source: TSource) => TValueReturn; -export type ValueSelectorAsyncable< - TSource extends Dictionary = any, - TDestination extends Dictionary = any, - TValueReturn = SelectorReturn -> = (source: TSource) => TValueReturn | Promise; - export interface Resolver< TSource extends Dictionary = any, TDestination extends Dictionary = any, @@ -396,7 +390,7 @@ export type MapWithReturn< ]; export interface ConditionPredicate< - TSource extends Dictionary + TSource extends Dictionary > { (source: TSource): boolean | Promise; } diff --git a/packages/core/src/lib/utils/async-aware.ts b/packages/core/src/lib/utils/async-aware.ts index 363c2ae84..65c329980 100644 --- a/packages/core/src/lib/utils/async-aware.ts +++ b/packages/core/src/lib/utils/async-aware.ts @@ -1,10 +1,10 @@ import { isPromise } from './is-promise'; export function asyncAware< - TAwaited, - TOperationResult, - IsAsync extends boolean = false, - Result = IsAsync extends true ? Promise : TOperationResult + TAwaited, + TOperationResult, + IsAsync extends boolean = false, + Result = IsAsync extends true ? Promise : TOperationResult >( awaitable: (isAsync: IsAsync) => TAwaited | Promise, operation: (awaited: TAwaited, isAsync: IsAsync) => TOperationResult | Promise, @@ -17,9 +17,9 @@ export function asyncAware< return operation(awaited, isAsync as true as IsAsync); }) as Result; } else { - if (isPromise(awaited)) throw new Error( - 'Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations' - ); - return operation(awaited as Awaited, isAsync as false as IsAsync) as Result; + if (isPromise(awaited)) throw new Error( + 'Use `Mapper::mapAsync` instead of `Mapper::map` as the mapping contains async operations' + ); + return operation(awaited as Awaited, isAsync as false as IsAsync) as Result; } } diff --git a/packages/core/src/lib/utils/is-promise.ts b/packages/core/src/lib/utils/is-promise.ts index 11d13d21c..778b6e558 100644 --- a/packages/core/src/lib/utils/is-promise.ts +++ b/packages/core/src/lib/utils/is-promise.ts @@ -4,9 +4,9 @@ * @param value */ export function isPromise(value: unknown): value is Promise { - return ( - // @ts-ignore TS2339: Property then does not exist on type {} - typeof (value === null || value === void 0 ? void 0 : value.then) === - 'function' - ); + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // TS2339: Property then does not exist on type {} + typeof (value === null || value === void 0 ? void 0 : value.then) === 'function' + ); } diff --git a/packages/integration-test/src/classes/map-async.spec.ts b/packages/integration-test/src/classes/map-async.spec.ts index 18c64672a..50c71ab65 100644 --- a/packages/integration-test/src/classes/map-async.spec.ts +++ b/packages/integration-test/src/classes/map-async.spec.ts @@ -1,20 +1,21 @@ import { classes } from '@automapper/classes'; import { - addProfile, - afterMap, - beforeMap, - condition, - convertUsing, - createMap, - createMapper, - forMember, - fromValue, - ignore, - mapDefer, - mapFrom, Mapper, - mapWith, - nullSubstitution, - undefinedSubstitution + addProfile, + afterMap, + beforeMap, + condition, + convertUsing, + createMap, + createMapper, + forMember, + fromValue, + ignore, + mapDefer, + mapFrom, + Mapper, + mapWith, + nullSubstitution, + undefinedSubstitution } from '@automapper/core'; import { SimpleUserDto } from './dtos/simple-user.dto'; import { SimpleUser } from './models/simple-user'; diff --git a/packages/integration-test/src/classes/map.spec.ts b/packages/integration-test/src/classes/map.spec.ts index a1d80350f..062d1c018 100644 --- a/packages/integration-test/src/classes/map.spec.ts +++ b/packages/integration-test/src/classes/map.spec.ts @@ -1,12 +1,12 @@ import { classes } from '@automapper/classes'; import { - addProfile, - CamelCaseNamingConvention, - createMap, - createMapper, - forMember, - ignore, - mapDefer, + addProfile, + CamelCaseNamingConvention, + createMap, + createMapper, + forMember, + ignore, + mapDefer, } from '@automapper/core'; import { UserDto } from './dtos/user.dto'; import { User } from './models/user'; diff --git a/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts b/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts index 46a90c6d7..9de1ebc23 100644 --- a/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts +++ b/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts @@ -1,17 +1,17 @@ import automapperTransformerPlugin, { before } from '@automapper/classes/transformer-plugin'; import { - CompilerOptions, - createProgram, - ModuleKind, - NewLineKind, - ScriptTarget, - transpileModule + CompilerOptions, + createProgram, + ModuleKind, + NewLineKind, + ScriptTarget, + transpileModule } from 'typescript/lib/tsserverlibrary'; import { - compiledCreateSkillRequestDto, - compiledSkillEntity, - createSkillRequestDtoText, - skillEntityText + compiledCreateSkillRequestDto, + compiledSkillEntity, + createSkillRequestDtoText, + skillEntityText } from './issues/486/models'; import { userModelText, userModelTextStrict, userModelTranspiledText, userModelTranspiledTextESM } from './model'; From b2bae3751c30ffe34a6748446d7b8da675c983ac Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 17:53:00 +0200 Subject: [PATCH 11/13] feat(core): Continue @koenigstag initiative: improve type safety --- packages/core/src/lib/utils/is-promise.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/lib/utils/is-promise.ts b/packages/core/src/lib/utils/is-promise.ts index 778b6e558..e7f113ce4 100644 --- a/packages/core/src/lib/utils/is-promise.ts +++ b/packages/core/src/lib/utils/is-promise.ts @@ -5,8 +5,6 @@ */ export function isPromise(value: unknown): value is Promise { return ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // TS2339: Property then does not exist on type {} - typeof (value === null || value === void 0 ? void 0 : value.then) === 'function' + typeof (value === null || value === void 0 ? void 0 : (value as any).then) === 'function' ); } From 4e980c11736f07079006bfa146c736022c2b5617 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Tue, 3 Sep 2024 18:30:49 +0300 Subject: [PATCH 12/13] feat(core): Continue @koenigstag initiative: fix few sonarcloud issues --- packages/core/src/lib/mappings/map-member.ts | 6 ++++-- packages/core/src/lib/member-map-functions/map-with.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/lib/mappings/map-member.ts b/packages/core/src/lib/mappings/map-member.ts index 694843953..f082bd384 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -4,15 +4,17 @@ import { Dictionary, FromValueReturn, MapDeferReturn, + MapFnClassId, MapFromReturn, Mapper, MapWithArgumentsReturn, MapWithReturn, MemberMapReturn, MetadataIdentifier, - Primitive, SelectorReturn + Primitive, + SelectorReturn, + TransformationType } from '../types'; -import { MapFnClassId, TransformationType } from '../types'; import { isDateConstructor } from '../utils/is-date-constructor'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor'; import { asyncAware } from '../utils/async-aware'; diff --git a/packages/core/src/lib/member-map-functions/map-with.ts b/packages/core/src/lib/member-map-functions/map-with.ts index f0e4ffd57..5d54c5d91 100644 --- a/packages/core/src/lib/member-map-functions/map-with.ts +++ b/packages/core/src/lib/member-map-functions/map-with.ts @@ -4,8 +4,9 @@ import { MapWithReturn, ModelIdentifier, SelectorReturn, + TransformationType, + ValueSelector } from '../types'; -import { TransformationType, ValueSelector } from '../types'; import { asyncAware } from '../utils/async-aware'; type Constructor = new (...args: unknown[]) => TModel; From 4148254c0bf24a9fd73409ddd6a2fcfcc6ae1bf7 Mon Sep 17 00:00:00 2001 From: Kaarel Raspel Date: Wed, 4 Sep 2024 02:25:48 +0300 Subject: [PATCH 13/13] feat(core): Continue @koenigstag initiative: revert formatting --- packages/core/src/lib/mappings/map-member.ts | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/core/src/lib/mappings/map-member.ts b/packages/core/src/lib/mappings/map-member.ts index f082bd384..8f053ddbb 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -1,19 +1,19 @@ import { - ConditionReturn, - ConvertUsingReturn, - Dictionary, - FromValueReturn, - MapDeferReturn, - MapFnClassId, - MapFromReturn, - Mapper, - MapWithArgumentsReturn, - MapWithReturn, - MemberMapReturn, - MetadataIdentifier, - Primitive, - SelectorReturn, - TransformationType + ConditionReturn, + ConvertUsingReturn, + Dictionary, + FromValueReturn, + MapDeferReturn, + MapFnClassId, + MapFromReturn, + Mapper, + MapWithArgumentsReturn, + MapWithReturn, + MemberMapReturn, + MetadataIdentifier, + Primitive, + SelectorReturn, + TransformationType } from '../types'; import { isDateConstructor } from '../utils/is-date-constructor'; import { isPrimitiveConstructor } from '../utils/is-primitive-constructor';