diff --git a/packages/core/src/lib/core.ts b/packages/core/src/lib/core.ts index 041d55f19..8a74e156c 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 + true, // 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 + true, // 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,22 +471,22 @@ 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 + ); }; } 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, @@ -403,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( @@ -420,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 + true // isAsync + ); + + await strategy.postMap( + sourceObject, + destinationObject, + mapping + ); + + return undefined; + }) as Result; + } + sourceObject = strategy.preMap(sourceObject, mapping); mapMutate( @@ -434,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') { @@ -449,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[], @@ -474,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( @@ -497,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, // isMapArray + true, // isAsync + ); + + await strategy.postMap( + sourceObject, + destinationArray[i], + mapping + ); + } + + if (afterMap) { + await afterMap(sourceArray, destinationArray); + } + + return undefined; + }) as Result; + } + if (beforeMap) { beforeMap(sourceArray, destinationArray); } @@ -536,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; }; } @@ -552,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-member.ts b/packages/core/src/lib/mappings/map-member.ts index 63e14abb1..8f053ddbb 100644 --- a/packages/core/src/lib/mappings/map-member.ts +++ b/packages/core/src/lib/mappings/map-member.ts @@ -1,9 +1,10 @@ -import type { +import { ConditionReturn, ConvertUsingReturn, Dictionary, FromValueReturn, MapDeferReturn, + MapFnClassId, MapFromReturn, Mapper, MapWithArgumentsReturn, @@ -11,14 +12,17 @@ import type { MemberMapReturn, MetadataIdentifier, 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'; export function mapMember< TSource extends Dictionary, - TDestination extends Dictionary + TDestination extends Dictionary, + IsAsync extends boolean = false, >( transformationMapFn: MemberMapReturn, sourceObject: TSource, @@ -27,7 +31,8 @@ export function mapMember< 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 = @@ -57,7 +62,8 @@ export function mapMember< )( sourceObject, mapper, - extraArgs ? { extraArgs: () => extraArgs } : undefined + extraArgs ? { extraArgs: () => extraArgs } : undefined, + isAsync ); break; case TransformationType.ConvertUsing: @@ -66,28 +72,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: @@ -98,23 +109,29 @@ export function mapMember< >[MapFnClassId.fn] )(sourceObject, extraArgs || {}); break; - case TransformationType.MapDefer: - value = mapMember( - ( - mapFn as MapDeferReturn< - TSource, - TDestination - >[MapFnClassId.fn] - )(sourceObject) as MemberMapReturn, - sourceObject, - destinationObject, - destinationMemberPath, - extraArgs, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier + case TransformationType.MapDefer: { + value = asyncAware(() => ( + mapFn as MapDeferReturn< + TSource, + TDestination, + SelectorReturn + >[MapFnClassId.fn] + )(sourceObject), (deferFunction) => { + return mapMember( + deferFunction, + sourceObject, + destinationObject, + destinationMemberPath, + extraArgs, + mapper, + sourceMemberIdentifier, + destinationMemberIdentifier, + isAsync ); - break; + }, isAsync); + + break; + } } return value; } diff --git a/packages/core/src/lib/mappings/map.ts b/packages/core/src/lib/mappings/map.ts index be55049cd..9c8c36766 100644 --- a/packages/core/src/lib/mappings/map.ts +++ b/packages/core/src/lib/mappings/map.ts @@ -4,8 +4,9 @@ import type { Dictionary, MapInitializeReturn, MapOptions, + Mapper, Mapping, - MetadataIdentifier, + MetadataIdentifier } from '../types'; import { MapFnClassId, MetadataClassId, TransformationType } from '../types'; import { assertUnmappedProperties } from '../utils/assert-unmapped-properties'; @@ -17,6 +18,8 @@ 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'; +import { asyncAware } from '../utils/async-aware'; function setMemberReturnFn = any>( destinationMemberPath: string[], @@ -31,20 +34,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) { @@ -62,22 +68,31 @@ 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 { - map({ - sourceObject, - mapping, - setMemberFn: setMemberMutateFn(destinationObj), - getMemberFn: getMemberMutateFn(destinationObj), - options, - isMapArray, - }); + isMapArray = false, + isAsync?: IsAsync +): Result { + return asyncAware( + () => { + return map({ + sourceObject, + mapping, + setMemberFn: setMemberMutateFn(destinationObj), + getMemberFn: getMemberMutateFn(destinationObj), + options, + isMapArray, + }, isAsync) + }, + () => isAsync ? Promise.resolve() : undefined, + isAsync + ); } interface MapParameter< @@ -99,7 +114,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 +124,7 @@ export function map< setMemberFn, getMemberFn, isMapArray = false, -}: MapParameter): TDestination { +}: MapParameter, isAsync?: IsAsync): Result { // destructure mapping const [ [sourceIdentifier, destinationIdentifier], @@ -132,243 +149,354 @@ 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.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); + } + } + + await Promise.all(_mapInternalLogic({ + propsToMap, + destination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, + isAsync + })); + + 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; + } + + 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 - for (let i = 0, length = propsToMap.length; i < length; i++) { - // destructure mapping property - const [ - destinationMemberPath, + _mapInternalLogic({ + propsToMap, + destination: destination as TDestination, + mapper, + setMemberFn, + getMemberFn, + sourceObject, + destinationIdentifier, + extraArguments, + configuredKeys, + errorHandler, + extraArgs, + metadataMap, + }); + + if (!isMapArray) { + const afterMap = mapAfterCallback ?? mappingAfterCallback; + if (afterMap) { + afterMap(sourceObject, destination as TDestination, 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, + IsAsync extends boolean = false +>({ + 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 +}): (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); + return resolvable; + } + + 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): any => { + try { + pushResolvable(asyncAware(() => valFn(), (value) => { + return setMemberFn(destinationMemberPath, destination)(value) + }, isAsync)); + } 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, + }, isAsync) + ); continue; } - setMember(() => - mapMember( - transformationMapFn, - sourceObject, - destination, - destinationMemberPath, - extraArguments, - mapper, - sourceMemberIdentifier, - destinationMemberIdentifier - ) - ); + // if is primitive + setMember(() => mapInitializedValue); + continue; } - if (!isMapArray) { - const afterMap = mapAfterCallback ?? mappingAfterCallback; - if (afterMap) { - afterMap(sourceObject, destination, extraArguments); - } - } - - // Check unmapped properties - assertUnmappedProperties( - destination, - destinationWithMetadata, - configuredKeys, - sourceIdentifier, - destinationIdentifier, - errorHandler + setMember(() => + mapMember( + transformationMapFn, + sourceObject, + destination, + destinationMemberPath, + extraArguments, + mapper, + sourceMemberIdentifier, + destinationMemberIdentifier, + isAsync, + ) ); + } - return destination; + 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-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..5d54c5d91 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,13 @@ -import type { +import { Dictionary, MapOptions, MapWithReturn, ModelIdentifier, SelectorReturn, + TransformationType, + ValueSelector } from '../types'; -import { TransformationType, ValueSelector } from '../types'; +import { asyncAware } from '../utils/async-aware'; type Constructor = new (...args: unknown[]) => TModel; @@ -27,24 +29,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/types.ts b/packages/core/src/lib/types.ts index 8fcbe8ebe..f29c9a589 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -69,7 +69,14 @@ export type Selector< TReturnType = unknown > = (obj: TObject) => TReturnType; -export type SelectorReturn> = ReturnType< +export type SelectorAsyncAware< + TObject extends Dictionary = any, + TReturnType = unknown +> = (obj: TObject, isAsync: boolean) => TReturnType; + +export type SelectorReturn< + TObject extends Dictionary, +> = ReturnType< Selector >; @@ -91,7 +98,7 @@ export interface Converter< TSource extends Dictionary = any, TConvertDestination = any > { - convert(source: TSource): TConvertDestination; + convert(source: TSource): TConvertDestination | Promise; } export type MapCallback< @@ -102,7 +109,7 @@ export type MapCallback< source: TSource, destination: TDestination, extraArguments?: TExtraArgs -) => void; +) => void | Promise; export interface MapOptions< TSource extends Dictionary, @@ -339,23 +346,24 @@ export type PreConditionReturn< TSelectorReturn = SelectorReturn > = [ preConditionPredicate: ConditionPredicate, - defaultValue?: TSelectorReturn + defaultValue?: TSelectorReturn | Promise ]; export interface DeferFunction< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn -> { - (source: TSource): + TSelectorReturn = SelectorReturn, + TReturn = | MemberMapReturnNoDefer - | MapWithReturn; + | MapWithReturn +> { + (source: TSource): TReturn | Promise; } export type MapDeferReturn< TSource extends Dictionary, TDestination extends Dictionary, - TSelectorReturn = SelectorReturn + TSelectorReturn = SelectorReturn, > = [ TransformationType.MapDefer, DeferFunction @@ -376,12 +384,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< @@ -390,20 +401,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, @@ -411,7 +422,7 @@ export type NullSubstitutionReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.NullSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + (source: TSource, sourceMemberPath: string[]) => TSelectorReturn | Promise ]; export type UndefinedSubstitutionReturn< @@ -420,7 +431,7 @@ export type UndefinedSubstitutionReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.UndefinedSubstitution, - (source: TSource, sourceMemberPath: string[]) => TSelectorReturn + (source: TSource, sourceMemberPath: string[]) => TSelectorReturn | Promise ]; export type IgnoreReturn< @@ -434,7 +445,7 @@ export type MapWithArgumentsReturn< TSelectorReturn = SelectorReturn > = [ TransformationType.MapWithArguments, - (source: TSource, extraArguments: Record) => TSelectorReturn + (source: TSource, extraArguments: Record) => TSelectorReturn | Promise ]; export type MapInitializeReturn< @@ -598,7 +609,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..65c329980 --- /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/core/src/lib/utils/is-promise.ts b/packages/core/src/lib/utils/is-promise.ts new file mode 100644 index 000000000..e7f113ce4 --- /dev/null +++ b/packages/core/src/lib/utils/is-promise.ts @@ -0,0 +1,10 @@ +/** + * Check if value is a Promise + * + * @param value + */ +export function isPromise(value: unknown): value is Promise { + return ( + typeof (value === null || value === void 0 ? void 0 : (value as any).then) === 'function' + ); +} 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 07490bbcd..17075e897 100644 --- a/packages/documentations/docs/mapping-configuration/after-map.mdx +++ b/packages/documentations/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/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/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/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 584ec6ba5..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', ], }, { @@ -85,7 +86,6 @@ const sidebars = { 'misc/transformer-plugin', 'misc/mapped-types', 'misc/self-mapping', - 'misc/fake-async', ], }, 'nestjs', diff --git a/packages/integration-test/src/classes/map-async.spec.ts b/packages/integration-test/src/classes/map-async.spec.ts index 46fbf946a..50c71ab65 100644 --- a/packages/integration-test/src/classes/map-async.spec.ts +++ b/packages/integration-test/src/classes/map-async.spec.ts @@ -1,36 +1,228 @@ import { classes } from '@automapper/classes'; import { + 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'; +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 () => { + beforeEach(() => mapper.dispose()) + + 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 + }); + + class Source { + value?: string | null; + another?: Source; + } + class Destination { + value!: string; + another?: Destination; + } + + it('should resolve defer if Promise returned from the mapping', async () => { + const mockValue = 'mockValue'; + addProfile(mapper, function profile(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); + }); + + 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)); + }); }); diff --git a/packages/integration-test/src/classes/map.spec.ts b/packages/integration-test/src/classes/map.spec.ts index 9088fbea2..062d1c018 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/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/integration-test/src/transformer-plugin/transformer-plugin.spec.ts b/packages/integration-test/src/transformer-plugin/transformer-plugin.spec.ts index 1e17163cc..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,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 { + CompilerOptions, createProgram, ModuleKind, + NewLineKind, ScriptTarget, - transpileModule, + transpileModule } from 'typescript/lib/tsserverlibrary'; import { compiledCreateSkillRequestDto, compiledSkillEntity, createSkillRequestDtoText, - skillEntityText, + 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'; 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 }; }