diff --git a/docs/source/Construction.md b/docs/source/Construction.md index 945181e64e..f70a5efe2e 100644 --- a/docs/source/Construction.md +++ b/docs/source/Construction.md @@ -53,4 +53,77 @@ You can configure which constructors are considered for the destination object: // use only public constructors var configuration = new MapperConfiguration(cfg => cfg.ShouldUseConstructor = constructor => constructor.IsPublic, loggerFactory); ``` -When mapping to records, consider using only public constructors. \ No newline at end of file +When mapping to records, consider using only public constructors. + +## Class-Based Destination Factories + +Instead of automatic constructor matching or inline `ConstructUsing` lambdas, you can implement custom constructor logic as a class. This enables reuse across multiple mappings and supports dependency injection. + +This is different than `ConvertUsing` which replaces the entire mapping operation. + +### Interface + +`IDestinationFactory` is used to implement custom object construction: + +```csharp +public interface IDestinationFactory +{ + TDestination Construct(TSource source, ResolutionContext context); +} +``` + +### Usage + +```csharp +public class CustomConstructor : IDestinationFactory +{ + public Destination Construct(Source source, ResolutionContext context) + { + // Custom instantiation logic + return new Destination { InitialValue = source.Value * 2 }; + } +} + +cfg.CreateMap() + .ConstructUsing(); +``` + +### Dependency Injection + +Destination factories are resolved from the DI container, enabling constructor injection of services: + +```csharp +public class DIAwareConstructor : IDestinationFactory +{ + private readonly IMyService _service; + + public DIAwareConstructor(IMyService service) + { + _service = service; + } + + public Destination Construct(Source source, ResolutionContext context) + { + return new Destination + { + InitialValue = _service.CalculateValue(source.Value) + }; + } +} + +// Registration +services.AddScoped(); +services.AddAutoMapper(cfg => +{ + cfg.CreateMap() + .ConstructUsing(); +}, typeof(IMyService).Assembly); +``` + +For runtime type resolution, use the non-generic overload: + +```csharp +cfg.CreateMap(typeof(Source), typeof(Destination)) + .ConstructUsing(typeof(CustomConstructor)); +``` + diff --git a/docs/source/Dependency-injection.md b/docs/source/Dependency-injection.md index c7ac7d96dd..65a8c7d356 100644 --- a/docs/source/Dependency-injection.md +++ b/docs/source/Dependency-injection.md @@ -55,55 +55,11 @@ When using `AddAutoMapper`, AutoMapper will automatically register implementatio - `IMemberValueResolver` - `ITypeConverter` - `IValueConverter` +- `IDestinationFactory` - `ICondition` - `IPreCondition` - `IMappingAction` -This allows you to use class-based conditions with dependency injection: - -```c# -public class MyCondition : ICondition -{ - private readonly IMyService _myService; - - public MyCondition(IMyService myService) - { - _myService = myService; - } - - public bool Evaluate(Source source, Destination destination, int sourceMember, - int destMember, ResolutionContext context) - { - return _myService.ShouldMap(sourceMember); - } -} - -public class ConditionProfile : Profile -{ - public ConditionProfile() - { - CreateMap() - .ForMember(d => d.Value, o => - { - o.Condition(); - o.MapFrom(s => s.Value); - }); - } -} - -// In Startup.cs / Program.cs: -services.AddTransient(); -services.AddAutoMapper(cfg => { }, typeof(ConditionProfile).Assembly); -``` - -Or dynamic service location, to be used in the case of instance-based containers (including child/nested containers): - -```c# -var mapper = new Mapper(configuration, childContainer.GetInstance); - -var dest = mapper.Map(new Source { Value = 15 }); -``` - ## Queryable Extensions Starting with 8.0 you can use `IMapper.ProjectTo`. For older versions you need to pass the configuration to the extension method ``` IQueryable.ProjectTo(IConfigurationProvider) ```. diff --git a/src/AutoMapper.DI.Tests/AppDomainResolutionTests.cs b/src/AutoMapper.DI.Tests/AppDomainResolutionTests.cs index 3a0dee5ac2..48742dbd66 100644 --- a/src/AutoMapper.DI.Tests/AppDomainResolutionTests.cs +++ b/src/AutoMapper.DI.Tests/AppDomainResolutionTests.cs @@ -30,7 +30,7 @@ public void ShouldResolveConfiguration() [Fact] public void ShouldConfigureProfiles() { - _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(5); + _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(6); } [Fact] diff --git a/src/AutoMapper.DI.Tests/AssemblyResolutionTests.cs b/src/AutoMapper.DI.Tests/AssemblyResolutionTests.cs index 97f516b0ce..496853e646 100644 --- a/src/AutoMapper.DI.Tests/AssemblyResolutionTests.cs +++ b/src/AutoMapper.DI.Tests/AssemblyResolutionTests.cs @@ -37,7 +37,7 @@ public void ShouldResolveConfiguration() [Fact] public void ShouldConfigureProfiles() { - _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(5); + _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(6); } [Fact] diff --git a/src/AutoMapper.DI.Tests/DependencyTests.cs b/src/AutoMapper.DI.Tests/DependencyTests.cs index 9756f6378b..ac69c6bb47 100644 --- a/src/AutoMapper.DI.Tests/DependencyTests.cs +++ b/src/AutoMapper.DI.Tests/DependencyTests.cs @@ -77,4 +77,42 @@ public void ShouldSkipMappingWithDependency_WhenConditionFails() dest.Value.ShouldBe(0); } } + + public class DestinationFactoryDependencyTests + { + private readonly IServiceProvider _provider; + + public DestinationFactoryDependencyTests() + { + IServiceCollection services = new ServiceCollection(); + services.AddTransient(sp => new FooService(5)); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddAutoMapper(_ => { }, typeof(FactorySource)); + _provider = services.BuildServiceProvider(); + + _provider.GetService().AssertConfigurationIsValid(); + } + + [Fact] + public void ShouldConstructWithDependency() + { + var mapper = _provider.GetService(); + var dest = mapper.Map(new FactorySource { Value = 10 }); + + // FooService.Modify(10) = 10 + 5 = 15 + dest.InitialValue.ShouldBe(15); + dest.Value.ShouldBe(10); + } + + [Fact] + public void ShouldConstructWithDependency_DifferentValue() + { + var mapper = _provider.GetService(); + var dest = mapper.Map(new FactorySource { Value = 20 }); + + // FooService.Modify(20) = 20 + 5 = 25 + dest.InitialValue.ShouldBe(25); + dest.Value.ShouldBe(20); + } + } } diff --git a/src/AutoMapper.DI.Tests/Profiles.cs b/src/AutoMapper.DI.Tests/Profiles.cs index d61cbdebab..bd99494daf 100644 --- a/src/AutoMapper.DI.Tests/Profiles.cs +++ b/src/AutoMapper.DI.Tests/Profiles.cs @@ -183,4 +183,35 @@ public ConditionProfile() }); } } + + public class FactorySource + { + public int Value { get; set; } + } + + public class FactoryDest + { + public int Value { get; set; } + public int InitialValue { get; set; } + } + + public class DependencyFactory : IDestinationFactory + { + private readonly ISomeService _service; + + public DependencyFactory(ISomeService service) => _service = service; + + public FactoryDest Construct(FactorySource source, ResolutionContext context) + => new FactoryDest { InitialValue = _service.Modify(source.Value) }; + } + + internal class FactoryProfile : Profile + { + public FactoryProfile() + { + CreateMap() + .ConstructUsing() + .ForMember(dest => dest.InitialValue, opt => opt.Ignore()); + } + } } \ No newline at end of file diff --git a/src/AutoMapper.DI.Tests/TypeResolutionTests.cs b/src/AutoMapper.DI.Tests/TypeResolutionTests.cs index 7e3a6c4df0..bae1f186ca 100644 --- a/src/AutoMapper.DI.Tests/TypeResolutionTests.cs +++ b/src/AutoMapper.DI.Tests/TypeResolutionTests.cs @@ -33,7 +33,7 @@ public void ShouldResolveConfiguration() [Fact] public void ShouldConfigureProfiles() { - _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(5); + _provider.GetService().Internal().GetAllTypeMaps().Count.ShouldBe(6); } [Fact] diff --git a/src/AutoMapper/Configuration/IMappingExpressionBase.cs b/src/AutoMapper/Configuration/IMappingExpressionBase.cs index 5be54f1a0d..6763cb6a40 100644 --- a/src/AutoMapper/Configuration/IMappingExpressionBase.cs +++ b/src/AutoMapper/Configuration/IMappingExpressionBase.cs @@ -157,6 +157,20 @@ public interface IMappingExpressionBaseItself TMappingExpression ConstructUsing(Func ctor); /// + /// Supply a custom object constructor type for instantiating the destination type with dependency injection support + /// + /// Not used for LINQ projection (ProjectTo). + /// Constructor type implementing IDestinationFactory<TSource, TDestination> + /// Itself + TMappingExpression ConstructUsing() where TConstructor : IDestinationFactory; + /// + /// Supply a custom object constructor type for instantiating the destination type with dependency injection support. + /// Used when the constructor type is not known at compile-time. + /// + /// Not used for LINQ projection (ProjectTo). + /// Constructor type implementing IDestinationFactory + void ConstructUsing(Type objectConstructorType); + /// /// Override the destination type mapping for looking up configuration and instantiation /// /// @@ -197,6 +211,22 @@ public interface IMappingExpressionBaseType converter type void ConvertUsing() where TTypeConverter : ITypeConverter; } + +/// +/// Custom destination factory for instantiating destination objects with dependency injection support. +/// +/// Source type +/// Destination type +public interface IDestinationFactory +{ + /// + /// Construct the destination object from the source object + /// + /// Source object + /// Resolution context + /// New destination instance + TDestination Construct(TSource source, ResolutionContext context); +} /// /// Custom mapping action /// diff --git a/src/AutoMapper/Configuration/TypeMapConfiguration.cs b/src/AutoMapper/Configuration/TypeMapConfiguration.cs index 05bb82e2e9..5d69432e63 100644 --- a/src/AutoMapper/Configuration/TypeMapConfiguration.cs +++ b/src/AutoMapper/Configuration/TypeMapConfiguration.cs @@ -325,6 +325,16 @@ public TMappingExpression ConstructUsing(Func> expr = (src, ctxt) => ctor(src, ctxt); return ConstructUsingCore(expr); } + public TMappingExpression ConstructUsing() where TConstructor : IDestinationFactory + { + TypeMapActions.Add(tm => tm.ConstructUsingObjectConstructor(typeof(TConstructor))); + return this as TMappingExpression; + } + + public void ConstructUsing(Type objectConstructorType) + { + TypeMapActions.Add(tm => tm.ConstructUsingObjectConstructor(objectConstructorType)); + } public void ConvertUsing(Type typeConverterType) { HasTypeConverter = true; diff --git a/src/AutoMapper/ServiceCollectionExtensions.cs b/src/AutoMapper/ServiceCollectionExtensions.cs index 19278ca6c1..d4d4064a3c 100644 --- a/src/AutoMapper/ServiceCollectionExtensions.cs +++ b/src/AutoMapper/ServiceCollectionExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.DependencyInjection; /// Extensions to scan for AutoMapper classes and register the configuration, mapping, and extensions with the service collection: /// /// Finds classes and initializes a new , -/// Scans for , , , , , and implementations and registers them as , +/// Scans for , , , , , , and implementations and registers them as , /// Registers as , and /// Registers as a configurable (default is ) /// @@ -24,7 +24,7 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class ServiceCollectionExtensions { - static readonly Type[] AmTypes = [typeof(IValueResolver<,,>), typeof(IMemberValueResolver<,,,>), typeof(ITypeConverter<,>), typeof(IValueConverter<,>), typeof(ICondition<,,>), typeof(IPreCondition<,>), typeof(IMappingAction<,>)]; + static readonly Type[] AmTypes = [typeof(IValueResolver<,,>), typeof(IMemberValueResolver<,,,>), typeof(ITypeConverter<,>), typeof(IValueConverter<,>), typeof(IDestinationFactory<,>), typeof(ICondition<,,>), typeof(IPreCondition<,>), typeof(IMappingAction<,>)]; public static IServiceCollection AddAutoMapper(this IServiceCollection services, Action configAction) => AddAutoMapperClasses(services, (sp, cfg) => configAction?.Invoke(cfg), null); diff --git a/src/AutoMapper/TypeMap.cs b/src/AutoMapper/TypeMap.cs index 2d4fddd8ef..bf890ffc5b 100644 --- a/src/AutoMapper/TypeMap.cs +++ b/src/AutoMapper/TypeMap.cs @@ -2,6 +2,8 @@ namespace AutoMapper; using Features; using System.Runtime.CompilerServices; +using static Expression; +using static ExpressionBuilder; /// /// Main configuration object holding all mapping configuration for a source and destination type @@ -254,6 +256,28 @@ public void IncludeBaseTypes(TypePair baseTypes) public void AddAfterMapAction(LambdaExpression afterMap) => Details.AddAfterMapAction(afterMap); public void AddValueTransformation(ValueTransformerConfiguration config) => Details.AddValueTransformation(config); public void ConstructUsingServiceLocator() => CustomCtorFunction = Lambda(ServiceLocator(DestinationType)); + public void ConstructUsingObjectConstructor(Type objectConstructorType) + { + var srcParam = Parameter(SourceType); + var ctxParam = Parameter(typeof(ResolutionContext)); + + var constructorInstance = ServiceLocator(objectConstructorType); + var expectedInterface = typeof(IDestinationFactory<,>).MakeGenericType(SourceType, DestinationType); + if (!expectedInterface.IsAssignableFrom(objectConstructorType)) + { + throw new InvalidOperationException($"Type '{objectConstructorType.Name}' does not implement IDestinationFactory<{SourceType.Name}, {DestinationType.Name}>"); + } + var constructMethod = expectedInterface.GetMethod("Construct") ?? + throw new InvalidOperationException($"IDestinationFactory<{SourceType.Name}, {DestinationType.Name}> does not define a 'Construct' method."); + + var callExpression = Call( + Convert(constructorInstance, expectedInterface), + constructMethod, + srcParam, ctxParam + ); + + CustomCtorFunction = Lambda(callExpression, srcParam, ctxParam); + } internal LambdaExpression CreateMapperLambda(IGlobalConfiguration configuration) => Types.ContainsGenericParameters ? null : new TypeMapPlanBuilder(configuration, this).CreateMapperLambda(); private PropertyMap GetPropertyMap(string name) diff --git a/src/UnitTests/Construction/ClassBasedObjectConstructorTests.cs b/src/UnitTests/Construction/ClassBasedObjectConstructorTests.cs new file mode 100644 index 0000000000..9d87282752 --- /dev/null +++ b/src/UnitTests/Construction/ClassBasedObjectConstructorTests.cs @@ -0,0 +1,112 @@ +namespace AutoMapper.UnitTests.Construction; + +public class ClassBasedObjectConstructorTests +{ + // Test classes + public class Source + { + public int Value { get; set; } + public string Name { get; set; } + } + + public class Destination + { + public int Value { get; set; } + public string Name { get; set; } + public int InitialValue { get; set; } + } + + // Destination factory implementations + public class CustomConstructor : IDestinationFactory + { + public Destination Construct(Source source, ResolutionContext context) + { + return new Destination { InitialValue = 100 }; + } + } + + public class SourceAwareConstructor : IDestinationFactory + { + public Destination Construct(Source source, ResolutionContext context) + { + return new Destination { InitialValue = source.Value * 2 }; + } + } + + [Fact] + public void When_using_class_based_object_constructor() + { + var config = new MapperConfiguration(cfg => + { + cfg.CreateMap() + .ConstructUsing(); + }); + + var mapper = config.CreateMapper(); + + var source = new Source { Value = 10, Name = "Test" }; + var result = mapper.Map(source); + + result.InitialValue.ShouldBe(100); + result.Value.ShouldBe(10); + result.Name.ShouldBe("Test"); + } + + [Fact] + public void When_using_source_aware_constructor() + { + var config = new MapperConfiguration(cfg => + { + cfg.CreateMap() + .ConstructUsing(); + }); + + var mapper = config.CreateMapper(); + + var source = new Source { Value = 25, Name = "Test" }; + var result = mapper.Map(source); + + result.InitialValue.ShouldBe(50); // 25 * 2 + result.Value.ShouldBe(25); + result.Name.ShouldBe("Test"); + } + + [Fact] + public void When_using_type_based_constructor_non_generic() + { + var config = new MapperConfiguration(cfg => + { + cfg.CreateMap(typeof(Source), typeof(Destination)) + .ConstructUsing(typeof(CustomConstructor)); + }); + + var mapper = config.CreateMapper(); + + var source = new Source { Value = 10, Name = "Test" }; + var result = (Destination)mapper.Map(source, typeof(Source), typeof(Destination)); + + result.InitialValue.ShouldBe(100); + result.Value.ShouldBe(10); + result.Name.ShouldBe("Test"); + } + + [Fact] + public void When_mixing_constructor_and_member_mapping() + { + var config = new MapperConfiguration(cfg => + { + cfg.CreateMap() + .ConstructUsing() + .ForMember(d => d.Name, o => o.MapFrom(s => s.Name.ToUpper())); + }); + + var mapper = config.CreateMapper(); + + var source = new Source { Value = 30, Name = "test" }; + var result = mapper.Map(source); + + result.InitialValue.ShouldBe(60); // 30 * 2 + result.Value.ShouldBe(30); + result.Name.ShouldBe("TEST"); + } +}