diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index e350c7cd9f..f12bf77a1b 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -28,6 +28,23 @@ public sealed class MapperAttribute : Attribute /// public bool EnumMappingIgnoreCase { get; set; } + /// + /// Specifies the behaviour in the case when the mapper tries to return null in a mapping method with a non-nullable return type. + /// If set to true an is thrown. + /// If set to false the mapper tries to return a default value. + /// For a this is , + /// for value types default + /// and for reference types new() if a parameterless constructor exists or else an is thrown. + /// + public bool ThrowOnMappingNullMismatch { get; set; } = true; + + /// + /// Specifies the behaviour in the case when the mapper tries to set a non-nullable property to a null value. + /// If set to true an is thrown. + /// If set to false the property assignment is ignored. + /// + public bool ThrowOnPropertyMappingNullMismatch { get; set; } + /// /// Whether to always deep copy objects. /// Eg. when the type Person[] should be mapped to the same type Person[], diff --git a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs index cae41ddb98..639586cc68 100644 --- a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs @@ -14,6 +14,7 @@ public class DescriptorBuilder private static readonly IReadOnlyCollection _mappingBuilders = new MappingBuilder[] { + NullableMappingBuilder.TryBuildMapping, SpecialTypeMappingBuilder.TryBuildMapping, DirectAssignmentMappingBuilder.TryBuildMapping, DictionaryMappingBuilder.TryBuildMapping, @@ -40,7 +41,7 @@ public class DescriptorBuilder private readonly Dictionary _defaultConfigurations = new(); // this includes mappings to build and already built mappings - private readonly Dictionary<(ITypeSymbol SourceType, ITypeSymbol TargetType), TypeMapping> _mappings = new(); + private readonly Dictionary<(ITypeSymbol SourceType, ITypeSymbol TargetType), TypeMapping> _mappings = new(new MappingTupleEqualityComparer()); // additional user defined mappings // (with same signature as already defined mappings but with different names) @@ -131,24 +132,47 @@ public MapperDescriptor Build() _mapperDescriptor.AddTypeMapping(extraMapping); } + // set method names + foreach (var methodMapping in _mapperDescriptor.MethodTypeMappings) + { + methodMapping.SetMethodNameIfNeeded(_methodNameBuilder.Build); + } + return _mapperDescriptor; } - internal TypeMapping? FindOrBuildMapping( + public TypeMapping? FindOrBuildMapping( ITypeSymbol sourceType, ITypeSymbol targetType) { if (FindMapping(sourceType, targetType) is { } foundMapping) return foundMapping; - if (TryBuildNewMapping(null, sourceType, targetType) is not { } mapping) + if (BuildDelegateMapping(null, sourceType, targetType) is not { } mapping) return null; AddMapping(mapping); return mapping; } - public TypeMapping? TryBuildNewMapping( + public TypeMapping? FindMapping(ITypeSymbol sourceType, ITypeSymbol targetType) + { + _mappings.TryGetValue((sourceType, targetType), out var mapping); + return mapping; + } + + public TypeMapping? FindOrBuildDelegateMapping( + ISymbol? userSymbol, + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + if (FindMapping(sourceType, targetType) is { } foundMapping) + return foundMapping; + + return BuildDelegateMapping(userSymbol, sourceType, targetType); + } + + public TypeMapping? BuildDelegateMapping( ISymbol? userSymbol, ITypeSymbol sourceType, ITypeSymbol targetType) @@ -171,11 +195,6 @@ internal void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? locati private void BuildMappingBody(MappingBuilderContext ctx, TypeMapping typeMapping) { - if (typeMapping is not MethodMapping methodMapping) - return; - - methodMapping.SetMethodNameIfNeeded(_methodNameBuilder.Build); - switch (typeMapping) { case ObjectPropertyMapping mapping: @@ -192,7 +211,7 @@ private void AddMapping(TypeMapping mapping) private void AddUserMapping(TypeMapping mapping) { - _methodNameBuilder.Add(((IUserMapping)mapping).Method.Name); + _methodNameBuilder.Reserve(((IUserMapping)mapping).Method.Name); if (mapping.CallableByOtherMappings && FindMapping(mapping.SourceType, mapping.TargetType) is null) { AddMapping(mapping); @@ -202,9 +221,23 @@ private void AddUserMapping(TypeMapping mapping) _extraMappings.Add(mapping); } - private TypeMapping? FindMapping(ITypeSymbol sourceType, ITypeSymbol targetType) + private class MappingTupleEqualityComparer : IEqualityComparer<(ITypeSymbol Source, ITypeSymbol Target)> { - _mappings.TryGetValue((sourceType, targetType), out var mapping); - return mapping; + public bool Equals((ITypeSymbol Source, ITypeSymbol Target) x, (ITypeSymbol Source, ITypeSymbol Target) y) + { + return Equals(x.Source, y.Source) + && Equals(x.Target, y.Target); + } + + public int GetHashCode((ITypeSymbol Source, ITypeSymbol Target) obj) + { + unchecked + { + return (SymbolEqualityComparer.Default.GetHashCode(obj.Source) * 397) ^ SymbolEqualityComparer.Default.GetHashCode(obj.Target); + } + } + + private bool Equals(ITypeSymbol x, ITypeSymbol y) + => SymbolEqualityComparer.IncludeNullability.Equals(x, y); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs index c7481a9e1b..819f1e109d 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; @@ -14,7 +15,8 @@ public static class CtorMappingBuilder var ctorMethod = namedTarget.InstanceConstructors .FirstOrDefault(m => m.Parameters.Length == 1 - && SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, ctx.Source)); + && SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, ctx.Source) + && ctx.Source.HasSameOrStricterNullability(m.Parameters[0].Type)); return ctorMethod == null ? null diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs index 42d659c1dd..0a174fbbe8 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs @@ -26,15 +26,15 @@ public static class EnumMappingBuilder } // since enums are immutable they can be directly assigned if they are of the same type - if (SymbolEqualityComparer.Default.Equals(ctx.Source, ctx.Target)) - return new NullDelegateMapping(ctx.Source, ctx.Target, new DirectAssignmentMapping(ctx.Source.NonNullable())); + if (SymbolEqualityComparer.IncludeNullability.Equals(ctx.Source, ctx.Target)) + return new DirectAssignmentMapping(ctx.Source); // map enums by strategy var config = ctx.GetConfigurationOrDefault(); return config.Strategy switch { EnumMappingStrategy.ByName => BuildNameMapping(ctx, config.IgnoreCase), - _ => new NullDelegateMapping(ctx.Source, ctx.Target, new CastMapping(ctx.Source.NonNullable(), ctx.Target.NonNullable())), + _ => new CastMapping(ctx.Source, ctx.Target), }; } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs new file mode 100644 index 0000000000..22f62509d5 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Diagnostics; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Descriptors.MappingBuilder; + +public static class NullableMappingBuilder +{ + public static TypeMapping? TryBuildMapping(MappingBuilderContext ctx) + { + var sourceIsNullable = ctx.Source.TryGetNonNullable(out var sourceNonNullable); + var targetIsNullable = ctx.Target.TryGetNonNullable(out var targetNonNullable); + if (!sourceIsNullable && !targetIsNullable) + return null; + + var delegateMapping = ctx.BuildDelegateMapping(sourceNonNullable ?? ctx.Source, targetNonNullable ?? ctx.Target); + return delegateMapping == null + ? null + : BuildNullDelegateMapping(ctx, delegateMapping); + } + + private static TypeMapping BuildNullDelegateMapping(MappingBuilderContext ctx, TypeMapping mapping) + { + var nullFallback = GetNullFallbackValue(ctx); + return mapping switch + { + MethodMapping methodMapping => new NullDelegateMethodMapping( + ctx.Source, + ctx.Target, + methodMapping, + nullFallback), + _ => new NullDelegateMapping(ctx.Source, ctx.Target, mapping, nullFallback), + }; + } + + private static NullFallbackValue GetNullFallbackValue(MappingBuilderContext ctx) + { + if (ctx.Target.IsNullable()) + return NullFallbackValue.Default; + + if (ctx.MapperConfiguration.ThrowOnMappingNullMismatch) + return NullFallbackValue.ThrowArgumentNullException; + + if (!ctx.Target.IsReferenceType || ctx.Target.IsNullable()) + return NullFallbackValue.Default; + + if (ctx.Target.SpecialType == SpecialType.System_String) + return NullFallbackValue.EmptyString; + + if (ctx.Target.HasAccessibleParameterlessConstructor()) + return NullFallbackValue.CreateInstance; + + ctx.ReportDiagnostic(DiagnosticDescriptors.NoParameterlessConstructorFound, ctx.Target); + return NullFallbackValue.ThrowArgumentNullException; + } +} diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs index 92719e0c8f..da0a13d335 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs @@ -16,7 +16,7 @@ public static class ObjectPropertyMappingBuilder if (ctx.Target.SpecialType != SpecialType.None || ctx.Source.SpecialType != SpecialType.None) return null; - return new NewInstanceObjectPropertyMapping(ctx.Source, ctx.Target); + return new NewInstanceObjectPropertyMapping(ctx.Source, ctx.Target.NonNullable()); } public static void BuildMappingBody(MappingBuilderContext ctx, ObjectPropertyMapping mapping) @@ -99,7 +99,7 @@ private static void AddUnmatchedIgnoredPropertiesDiagnostics( .FirstOrDefault(p => !p.IsStatic); } - private static PropertyMappingDescriptor? BuildPropertyMapping( + private static PropertyMapping? BuildPropertyMapping( MappingBuilderContext ctx, ObjectPropertyMapping mapping, IPropertySymbol sourceProperty, @@ -111,26 +111,20 @@ private static void AddUnmatchedIgnoredPropertiesDiagnostics( if (sourceProperty.IsWriteOnly) return null; - var propertyMapping = ctx.FindOrBuildMapping(sourceProperty.Type.NonNullable(), targetProperty.Type.NonNullable()); - if (propertyMapping == null) - { - ctx.ReportDiagnostic( - DiagnosticDescriptors.CouldNotMapProperty, - mapping.SourceType, - sourceProperty.Name, - sourceProperty.Type, - mapping.TargetType, - targetProperty.Name, - targetProperty.Type); - return null; - } + // nullability is handled inside the property mapping + var delegateMapping = ctx.FindMapping(sourceProperty.Type.UpgradeNullable(), targetProperty.Type.UpgradeNullable()) + ?? ctx.FindOrBuildMapping(sourceProperty.Type.NonNullable(), targetProperty.Type.NonNullable()); + if (delegateMapping != null) + return new PropertyMapping(sourceProperty, targetProperty, delegateMapping, ctx.MapperConfiguration.ThrowOnPropertyMappingNullMismatch); - var nullDelegateMapping = new NullDelegateMapping( - sourceProperty.IsNullable(), - targetProperty.IsNullable(), + ctx.ReportDiagnostic( + DiagnosticDescriptors.CouldNotMapProperty, + mapping.SourceType, + sourceProperty.Name, sourceProperty.Type, - targetProperty.Type, - propertyMapping); - return new PropertyMappingDescriptor(sourceProperty, targetProperty, nullDelegateMapping); + mapping.TargetType, + targetProperty.Name, + targetProperty.Type); + return null; } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs index 398a9aab55..d62343bce8 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs @@ -24,7 +24,7 @@ public static IEnumerable ExtractUserMappings( public static void BuildMappingBody(MappingBuilderContext ctx, UserDefinedNewInstanceMethodMapping mapping) { - mapping.DelegateMapping = ctx.TryBuildNewMapping(mapping.SourceType, mapping.TargetType); + mapping.DelegateMapping = ctx.BuildDelegateMapping(mapping.SourceType, mapping.TargetType); if (mapping.DelegateMapping == null) { ctx.ReportDiagnostic( @@ -80,7 +80,7 @@ private static IEnumerable ExtractMethods(INamedTypeSymbol object // and is accessible it is a user implemented method mapping if (!methodSymbol.IsAbstract) { - return methodSymbol.IsAccessible() + return methodSymbol.IsAccessible(true) ? new UserImplementedMethodMapping(methodSymbol) : null; } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index fa419f78d4..24c06b6803 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -26,6 +26,9 @@ public MappingBuilderContext( public ITypeSymbol Target { get; } + public TypeMapping? FindMapping(ITypeSymbol sourceType, ITypeSymbol targetType) + => _builder.FindMapping(sourceType, targetType); + /// /// Tries to find an existing mapping for the provided types. /// If none is found, a new one is created. @@ -41,16 +44,30 @@ public MappingBuilderContext( public TypeMapping? FindOrBuildMapping(ITypeSymbol sourceType, ITypeSymbol targetType) => _builder.FindOrBuildMapping(sourceType, targetType); + /// + /// Tries to find an existing mapping for the provided types. + /// If none is found, a new one is created. + /// If a new mapping is created, it is not added to the mapping descriptor (should only be used as a delegate to another mapping) + /// and is therefore not accessible by other mappings. + /// Configuration / the user symbol is passed from the caller. + /// + /// The source type. + /// The target type. + /// The created mapping or null if none could be created. + public TypeMapping? FindOrBuildDelegateMapping(ITypeSymbol source, ITypeSymbol target) + => _builder.FindOrBuildDelegateMapping(_userSymbol, source, target); + /// /// Tries to build a new mapping for the given types. - /// The built mapping is not added to the mapping descriptor. + /// The built mapping is not added to the mapping descriptor (should only be used as a delegate to another mapping) + /// and is therefore not accessible by other mappings. /// Configuration / the user symbol is passed from the caller. /// /// The source type. /// The target type. /// The created mapping or null if none could be created. - public TypeMapping? TryBuildNewMapping(ITypeSymbol source, ITypeSymbol target) - => _builder.TryBuildNewMapping(_userSymbol, source, target); + public TypeMapping? BuildDelegateMapping(ITypeSymbol source, ITypeSymbol target) + => _builder.BuildDelegateMapping(_userSymbol, source, target); public T GetConfigurationOrDefault() where T : Attribute { diff --git a/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs b/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs index fa44689fa9..393abf2cd2 100644 --- a/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs @@ -8,7 +8,7 @@ internal class MethodNameBuilder private const string MethodNamePrefix = "MapTo"; private readonly HashSet _usedNames = new(); - internal void Add(string name) + internal void Reserve(string name) => _usedNames.Add(name); internal string Build(MethodMapping mapping) diff --git a/src/Riok.Mapperly/Descriptors/PropertyMappingDescriptor.cs b/src/Riok.Mapperly/Descriptors/PropertyMappingDescriptor.cs deleted file mode 100644 index 0b1fabbab8..0000000000 --- a/src/Riok.Mapperly/Descriptors/PropertyMappingDescriptor.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Diagnostics; -using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; - -namespace Riok.Mapperly.Descriptors; - -[DebuggerDisplay("PropertyMapping({Source.Name} => {Target.Name})")] -public class PropertyMappingDescriptor -{ - public PropertyMappingDescriptor( - IPropertySymbol source, - IPropertySymbol target, - TypeMapping typeMapping) - { - Source = source; - Target = target; - TypeMapping = typeMapping; - } - - public IPropertySymbol Source { get; } - - public IPropertySymbol Target { get; } - - public TypeMapping TypeMapping { get; } -} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs index 56284586eb..9ad55105a0 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs @@ -40,8 +40,7 @@ public MethodDeclarationSyntax BuildMethod() return MethodDeclaration(returnType, Identifier(MethodName)) .WithModifiers(TokenList(BuildModifiers())) .WithParameterList(BuildParameterList()) - .WithBody(Block(BuildBody(IdentifierName(SourceParamName)))) - .WithAttributeLists(List(BuildAttributes(SourceParamName))); + .WithBody(Block(BuildBody(IdentifierName(SourceParamName)))); } public abstract IEnumerable BuildBody(ExpressionSyntax source); @@ -73,14 +72,4 @@ private ParameterListSyntax BuildParameterList() { return ParameterList(CommaSeparatedList(BuildParameters())); } - - private IEnumerable BuildAttributes(string sourceParamName) - { - // if target and source types are nullable we add a [return: NotNullIfNotNull("source")] annotation - if (TargetType.NullableAnnotation == NullableAnnotation.Annotated - && SourceType.NullableAnnotation == NullableAnnotation.Annotated) - { - yield return ReturnNotNullIfNotNullAttribute(sourceParamName); - } - } } diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs index b4d246b0fb..748d292bc6 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs @@ -1,6 +1,5 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; @@ -19,17 +18,11 @@ public NewInstanceObjectPropertyMapping( public override IEnumerable BuildBody(ExpressionSyntax source) { - // if the source type is nullable, add a null guard. - if (SourceType.IsNullable()) - { - yield return IfNullReturn(source, NullSubstitute(TargetType, source)); - } - // var target = new T(); yield return CreateInstance(TargetVariableName, TargetType); // map properties - foreach (var expression in base.BuildBody(source, IdentifierName(TargetVariableName))) + foreach (var expression in BuildBody(source, IdentifierName(TargetVariableName))) { yield return expression; } diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs index bbfcef7d79..1eed32bda3 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs @@ -13,71 +13,37 @@ public class NullDelegateMapping : TypeMapping { private const string NullableValueProperty = "Value"; - private readonly bool _targetIsNullable; - private readonly bool _sourceIsNullable; private readonly TypeMapping _delegateMapping; - - public NullDelegateMapping(ITypeSymbol nullableSourceType, ITypeSymbol nullableTargetType, TypeMapping delegateMapping) - : base(nullableSourceType, nullableTargetType) - { - _sourceIsNullable = nullableSourceType.IsNullable(); - _targetIsNullable = nullableTargetType.IsNullable(); - _delegateMapping = delegateMapping; - } + private readonly NullFallbackValue _nullFallbackValue; public NullDelegateMapping( - bool sourceIsNullable, - bool targetIsNullable, ITypeSymbol nullableSourceType, ITypeSymbol nullableTargetType, - TypeMapping delegateMapping) + TypeMapping delegateMapping, + NullFallbackValue nullFallbackValue) : base(nullableSourceType, nullableTargetType) { - _sourceIsNullable = sourceIsNullable; - _targetIsNullable = targetIsNullable; _delegateMapping = delegateMapping; + _nullFallbackValue = nullFallbackValue; } public override ExpressionSyntax Build(ExpressionSyntax source) { + if (!SourceType.IsNullable() || _delegateMapping.SourceType.IsNullable()) + return _delegateMapping.Build(source); + // source is nullable and the mapping method cannot handle nulls, // call mapping only if source is not null. - if (_sourceIsNullable && !_delegateMapping.SourceType.IsNullable()) - { - // for direct assignments - // source ?? ; - if (_delegateMapping is DirectAssignmentMapping) - { - return _targetIsNullable - ? _delegateMapping.Build(source) - : Coalesce( - _delegateMapping.Build(source), - NullSubstitute(TargetType.NonNullable(), source)); - } - - // for non direct assignments - // source == null ? : Map(source) - // or for nullable value types: - // source == null ? : Map(source.Value) - var sourceValue = SourceType.IsNullableValueType() - ? MemberAccess(source, NullableValueProperty) - : source; - - return ConditionalExpression( - IsNull(source), - _targetIsNullable ? DefaultLiteral() : NullSubstitute(TargetType.NonNullable(), source), - _delegateMapping.Build(sourceValue)); - } - - // target can not be nullable and the map method may return null values - // therefore we replace Map(source) with Map(source) ?? ; - if (!_targetIsNullable && _delegateMapping.TargetType.IsNullable()) - { - return Coalesce( - _delegateMapping.Build(source), - NullSubstitute(TargetType.NonNullable(), source)); - } - - return _delegateMapping.Build(source); + // source == null ? : Map(source) + // or for nullable value types: + // source == null ? : Map(source.Value) + var sourceValue = SourceType.IsNullableValueType() + ? MemberAccess(source, NullableValueProperty) + : source; + + return ConditionalExpression( + IsNull(source), + NullSubstitute(TargetType.NonNullable(), source, _nullFallbackValue), + _delegateMapping.Build(sourceValue)); } } diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs new file mode 100644 index 0000000000..c81390da28 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; +using static Riok.Mapperly.Emit.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.TypeMappings; + +/// +/// Null aware delegate mapping for s. +/// Abstracts handling null values of the delegated mapping. +/// +public class NullDelegateMethodMapping : MethodMapping +{ + private readonly MethodMapping _delegateMapping; + private readonly NullFallbackValue _nullFallbackValue; + + public NullDelegateMethodMapping( + ITypeSymbol nullableSourceType, + ITypeSymbol nullableTargetType, + MethodMapping delegateMapping, + NullFallbackValue nullFallbackValue) + : base(nullableSourceType, nullableTargetType) + { + _delegateMapping = delegateMapping; + _nullFallbackValue = nullFallbackValue; + } + + public override IEnumerable BuildBody(ExpressionSyntax source) + { + var body = _delegateMapping.BuildBody(source); + return AddPreNullHandling(source, body); + } + + private IEnumerable AddPreNullHandling(ExpressionSyntax source, IEnumerable body) + { + if (!SourceType.IsNullable() || _delegateMapping.SourceType.IsNullable()) + return body; + + // source is nullable and the mapping method cannot handle nulls, + // call mapping only if source is not null. + // if (source == null) + // return ; + return body.Prepend(IfNullReturnOrThrow( + source, + NullSubstitute(TargetType.NonNullable(), source, _nullFallbackValue))); + } +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs new file mode 100644 index 0000000000..4e3e6e24b1 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs @@ -0,0 +1,9 @@ +namespace Riok.Mapperly.Descriptors.TypeMappings; + +public enum NullFallbackValue +{ + Default, + EmptyString, + CreateInstance, + ThrowArgumentNullException, +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs index 747a4869fd..07d88663a0 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs @@ -1,8 +1,5 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using static Riok.Mapperly.Emit.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.TypeMappings; @@ -12,38 +9,15 @@ namespace Riok.Mapperly.Descriptors.TypeMappings; /// public abstract class ObjectPropertyMapping : MethodMapping { - private readonly List _propertyMappings = new(); + private readonly List _propertyMappings = new(); protected ObjectPropertyMapping(ITypeSymbol sourceType, ITypeSymbol targetType) : base(sourceType, targetType) { } - public void AddPropertyMapping(PropertyMappingDescriptor propertyMapping) + public void AddPropertyMapping(PropertyMapping propertyMapping) => _propertyMappings.Add(propertyMapping); internal IEnumerable BuildBody(ExpressionSyntax source, ExpressionSyntax target) - { - foreach (var propertyMapping in _propertyMappings) - { - yield return PropertyMapping(propertyMapping, source, target); - } - } - - private static ExpressionStatementSyntax PropertyMapping( - PropertyMappingDescriptor mapping, - ExpressionSyntax sourceAccess, - ExpressionSyntax targetAccess) - { - // Map(source.Property) - var sourcePropertyAccess = MemberAccess(sourceAccess, mapping.Source.Name); - var sourceMappedExpression = mapping.TypeMapping.Build(sourcePropertyAccess); - - // target.Property = Map(source.Property) - var assignment = AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - MemberAccess(targetAccess, mapping.Target.Name), - sourceMappedExpression); - - return ExpressionStatement(assignment); - } + => _propertyMappings.Select(x => x.Build(source, target)); } diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/PropertyMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/PropertyMapping.cs new file mode 100644 index 0000000000..97b986a851 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/PropertyMapping.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.TypeMappings; + +[DebuggerDisplay("PropertyMapping({_source.Name} => {_target.Name})")] +public class PropertyMapping +{ + private const string NullableValueProperty = "Value"; + + private readonly TypeMapping _mapping; + private readonly IPropertySymbol _source; + private readonly IPropertySymbol _target; + private readonly bool _throwInsteadOfConditionalNullMapping; + + public PropertyMapping( + IPropertySymbol source, + IPropertySymbol target, + TypeMapping mapping, + bool throwInsteadOfConditionalNullMapping) + { + _source = source; + _target = target; + _mapping = mapping; + _throwInsteadOfConditionalNullMapping = throwInsteadOfConditionalNullMapping; + } + + public StatementSyntax Build( + ExpressionSyntax sourceAccess, + ExpressionSyntax targetAccess) + { + var targetPropertyAccess = MemberAccess(targetAccess, _target.Name); + ExpressionSyntax sourcePropertyAccess = MemberAccess(sourceAccess, _source.Name); + + // if source is nullable, but mapping doesn't accept nulls + // condition: source != null + (var condition, sourcePropertyAccess) = BuildPreMappingCondition(sourcePropertyAccess); + var mappedValue = _mapping.Build(sourcePropertyAccess); + + // target.Property = mappedValue; + var assignment = AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + targetPropertyAccess, + mappedValue); + var assignmentExpression = ExpressionStatement(assignment); + + // if (source.Value != null) + // target.Value = Map(Source.Name); + // else + // throw ... + return BuildIf(condition, assignmentExpression, sourcePropertyAccess); + } + + private StatementSyntax BuildIf(ExpressionSyntax? condition, StatementSyntax assignment, ExpressionSyntax sourcePropertyAccess) + { + if (condition == null) + return assignment; + + var elseClause = _throwInsteadOfConditionalNullMapping + ? ElseClause(ExpressionStatement(ThrowNewArgumentNullException(sourcePropertyAccess))) + : null; + return IfStatement(condition, assignment, elseClause); + } + + private (ExpressionSyntax? Condition, ExpressionSyntax SourceAccess) BuildPreMappingCondition(ExpressionSyntax sourceAccess) + { + if (!_source.IsNullable() || _mapping.SourceType.IsNullable()) + return (null, sourceAccess); + + // if source is nullable but the mapping does not accept nulls + // add not null condition + var condition = IsNotNull(sourceAccess); + + // source != null + // if the source is a nullable value type + // replace source by source.Value for the mapping + if (_source.Type.IsNullableValueType()) + { + sourceAccess = MemberAccess(sourceAccess, NullableValueProperty); + } + + return (condition, sourceAccess); + } +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs index 59dcd78c61..1cccb405e4 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs @@ -15,7 +15,7 @@ public class UserDefinedExistingInstanceMethodMapping : ObjectPropertyMapping, I public UserDefinedExistingInstanceMethodMapping( IMethodSymbol method, bool isAbstractMapperDefinition) - : base(method.Parameters[0].Type, method.Parameters[1].Type) + : base(method.Parameters[0].Type.UpgradeNullable(), method.Parameters[1].Type.UpgradeNullable()) { Override = isAbstractMapperDefinition; Accessibility = Accessibility.Public; diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs index 44cd015735..f0693a112d 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; @@ -13,7 +14,7 @@ public class UserDefinedNewInstanceMethodMapping : MethodMapping, IUserMapping private const string NoMappingComment = "// Could not generate mapping"; public UserDefinedNewInstanceMethodMapping(IMethodSymbol method, bool isAbstractMapperDefinition) - : base(method.Parameters.Single().Type, method.ReturnType) + : base(method.Parameters.Single().Type.UpgradeNullable(), method.ReturnType.UpgradeNullable()) { Override = isAbstractMapperDefinition; Accessibility = Accessibility.Public; diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs index c237ee7039..ed3b974487 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; @@ -11,7 +12,7 @@ namespace Riok.Mapperly.Descriptors.TypeMappings; public class UserImplementedMethodMapping : TypeMapping, IUserMapping { public UserImplementedMethodMapping(IMethodSymbol method) - : base(method.Parameters.Single().Type, method.ReturnType) + : base(method.Parameters.Single().Type.UpgradeNullable(), method.ReturnType.UpgradeNullable()) { Method = method; } diff --git a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs index b49e8b454c..bb5a0c63ea 100644 --- a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs +++ b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Descriptors.TypeMappings; using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; @@ -13,7 +14,6 @@ public static class SyntaxFactoryHelper private const string ArgumentNullExceptionClassName = "System.ArgumentNullException"; private const string NotImplementedExceptionClassName = "System.NotImplementedException"; - private const string NotNullIfNotNullAttributeName = "System.Diagnostics.CodeAnalysis.NotNullIfNotNull"; public static readonly IdentifierNameSyntax VarIdentifier = IdentifierName("var"); @@ -42,24 +42,34 @@ public static BinaryExpressionSyntax Coalesce( public static BinaryExpressionSyntax IsNull(ExpressionSyntax expression) => BinaryExpression(SyntaxKind.EqualsExpression, expression, NullLiteral()); - public static ExpressionSyntax NullSubstitute(ITypeSymbol t, ExpressionSyntax argument) - { - if (!t.IsReferenceType || t.IsNullable()) - return DefaultLiteral(); - - if (t.SpecialType == SpecialType.System_String) - return StringLiteral(string.Empty); + public static BinaryExpressionSyntax IsNotNull(ExpressionSyntax expression) + => BinaryExpression(SyntaxKind.NotEqualsExpression, expression, NullLiteral()); - return t.HasAccessibleParameterlessConstructor() - ? CreateInstance(t) - : ThrowNewArgumentNullException(argument); + public static ExpressionSyntax NullSubstitute(ITypeSymbol t, ExpressionSyntax argument, NullFallbackValue nullFallbackValue) + { + return nullFallbackValue switch + { + NullFallbackValue.Default => DefaultLiteral(), + NullFallbackValue.EmptyString => StringLiteral(string.Empty), + NullFallbackValue.CreateInstance => CreateInstance(t), + _ => ThrowNewArgumentNullException(argument), + }; } - public static StatementSyntax IfNullReturn(ExpressionSyntax expression, ExpressionSyntax? returnExpression = null) + public static StatementSyntax IfNullReturn(ExpressionSyntax expression) + => IfStatement(IsNull(expression), ReturnStatement()); + + public static StatementSyntax IfNullReturnOrThrow(ExpressionSyntax expression, ExpressionSyntax? returnOrThrowExpression = null) { + StatementSyntax ifExpression = returnOrThrowExpression switch + { + ThrowExpressionSyntax throwSyntax => ThrowStatement(throwSyntax.Expression), + _ => ReturnStatement(returnOrThrowExpression), + }; + return IfStatement( IsNull(expression), - ReturnStatement(returnExpression)); + ifExpression); } public static LiteralExpressionSyntax DefaultLiteral() @@ -74,15 +84,6 @@ public static LiteralExpressionSyntax StringLiteral(string content) => public static LiteralExpressionSyntax BooleanLiteral(bool b) => LiteralExpression(b ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); - public static AttributeListSyntax ReturnNotNullIfNotNullAttribute(string paramName) - { - var attribute = Attribute(IdentifierName(NotNullIfNotNullAttributeName)) - .WithArgumentList(AttributeArgumentList(SingletonSeparatedList(AttributeArgument(StringLiteral(paramName))))); - - return AttributeList(SingletonSeparatedList(attribute)) - .WithTarget(AttributeTargetSpecifier(Token(SyntaxKind.ReturnKeyword))); - } - public static StatementSyntax ReturnVariable(string identifierName) => ReturnStatement(IdentifierName(identifierName)); @@ -95,12 +96,6 @@ public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax idExpre public static InvocationExpressionSyntax NameOf(ExpressionSyntax expression) => Invocation(IdentifierName("nameof"), expression); - public static ElementAccessExpressionSyntax ArrayElementAccess(ExpressionSyntax array, ExpressionSyntax index) - { - return ElementAccessExpression(array) - .WithArgumentList(BracketedArgumentList(SingletonSeparatedList(Argument(index)))); - } - public static ThrowExpressionSyntax ThrowArgumentOutOfRangeException(ExpressionSyntax arg) { return ThrowExpression(ObjectCreationExpression(IdentifierName(ArgumentOutOfRangeExceptionClassName)) diff --git a/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs b/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs index 714d7a1d65..a8c764541a 100644 --- a/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs +++ b/src/Riok.Mapperly/Helpers/NullableSymbolExtensions.cs @@ -7,6 +7,41 @@ public static class NullableSymbolExtensions { private const string NullableGenericTypeName = "System.Nullable"; + internal static bool HasSameOrStricterNullability(this ITypeSymbol symbol, ITypeSymbol other) + { + return symbol.NullableAnnotation == NullableAnnotation.NotAnnotated + || symbol.UpgradeNullable().NullableAnnotation == other.UpgradeNullable().NullableAnnotation; + } + + /// + /// Upgrade the nullability of a symbol from to . + /// + /// The symbol to upgrade. + /// The upgraded symbol + internal static ITypeSymbol UpgradeNullable(this ITypeSymbol symbol) + { + TryUpgradeNullable(symbol, out var upgradedSymbol); + return upgradedSymbol ?? symbol; + } + + /// + /// Tries to upgrade the nullability of a symbol from to . + /// + /// The symbol. + /// The upgraded symbol, if an upgrade has taken place, null otherwise. + /// Whether an upgrade has taken place. + internal static bool TryUpgradeNullable(this ITypeSymbol symbol, [NotNullWhen(true)] out ITypeSymbol? upgradedSymbol) + { + if (symbol.NullableAnnotation != NullableAnnotation.None) + { + upgradedSymbol = default; + return false; + } + + upgradedSymbol = symbol.WithNullableAnnotation(NullableAnnotation.Annotated); + return true; + } + internal static bool TryGetNonNullable(this ITypeSymbol symbol, [NotNullWhen(true)] out ITypeSymbol? nonNullable) { if (symbol.NonNullableValueType() is { } t) diff --git a/src/Riok.Mapperly/Helpers/PropertyEqualityComparer.cs b/src/Riok.Mapperly/Helpers/PropertyEqualityComparer.cs deleted file mode 100644 index 815c2bf25d..0000000000 --- a/src/Riok.Mapperly/Helpers/PropertyEqualityComparer.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Riok.Mapperly.Helpers; - -public class PropertyEqualityComparer : IEqualityComparer -{ - private readonly Func _valueSelector; - private readonly IEqualityComparer _valueComparer; - - public PropertyEqualityComparer(Func valueSelector, IEqualityComparer valueComparer) - { - _valueSelector = valueSelector; - _valueComparer = valueComparer; - } - - public bool Equals(T x, T y) - => _valueComparer.Equals(Value(x), Value(y)); - - public int GetHashCode(T obj) - => _valueComparer.GetHashCode(Value(obj)); - - private TValue Value(T obj) - => _valueSelector(obj); -} diff --git a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs index dceee09088..85bce8ff74 100644 --- a/src/Riok.Mapperly/Helpers/SymbolExtensions.cs +++ b/src/Riok.Mapperly/Helpers/SymbolExtensions.cs @@ -11,14 +11,14 @@ internal static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol attribut internal static bool IsImmutable(this ISymbol symbol) => symbol is INamedTypeSymbol namedSymbol && (namedSymbol.IsReadOnly || namedSymbol.SpecialType == SpecialType.System_String); - internal static bool IsAccessible(this ISymbol symbol) - => symbol.DeclaredAccessibility.HasFlag(Accessibility.Protected) - || symbol.DeclaredAccessibility.HasFlag(Accessibility.Internal) - || symbol.DeclaredAccessibility.HasFlag(Accessibility.Public); + internal static bool IsAccessible(this ISymbol symbol, bool allowProtected = false) + => symbol.DeclaredAccessibility.HasFlag(Accessibility.Internal) + || symbol.DeclaredAccessibility.HasFlag(Accessibility.Public) + || (symbol.DeclaredAccessibility.HasFlag(Accessibility.Protected) && allowProtected); - internal static bool HasAccessibleParameterlessConstructor(this ITypeSymbol symbol) + internal static bool HasAccessibleParameterlessConstructor(this ITypeSymbol symbol, bool allowProtected = false) => symbol is INamedTypeSymbol { IsAbstract: false } namedTypeSymbol - && namedTypeSymbol.Constructors.Any(c => c.Parameters.IsDefaultOrEmpty && c.IsAccessible()); + && namedTypeSymbol.Constructors.Any(c => c.Parameters.IsDefaultOrEmpty && c.IsAccessible(allowProtected)); internal static bool IsArrayType(this ITypeSymbol symbol) => symbol is IArrayTypeSymbol; diff --git a/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs b/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs index e4c5d973bd..60b04a642f 100644 --- a/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs +++ b/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs @@ -12,7 +12,7 @@ public class MethodNameBuilderTest public void ShouldGenerateUniqueMethodNames() { var builder = new MethodNameBuilder(); - builder.Add("MapToA"); + builder.Reserve("MapToA"); builder.Build(NewMethodMappingMock("A")) .Should() .BeEquivalentTo("MapToA1"); diff --git a/test/Riok.Mapperly.Tests/Mapping/EnumTest.cs b/test/Riok.Mapperly.Tests/Mapping/EnumTest.cs index c2ba2c0c2f..497bb76c54 100644 --- a/test/Riok.Mapperly.Tests/Mapping/EnumTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/EnumTest.cs @@ -163,7 +163,7 @@ public void NullableEnumToOtherEnumShouldCastWithNullHandling() TestHelper.GenerateSingleMapperMethodBody(source) .Should() - .Be("return source == null ? default : (E2)source.Value;"); + .Be("return source == null ? throw new System.ArgumentNullException(nameof(source)) : (E2)source.Value;"); } [Fact] diff --git a/test/Riok.Mapperly.Tests/Mapping/NullableTest.cs b/test/Riok.Mapperly.Tests/Mapping/NullableTest.cs new file mode 100644 index 0000000000..3cf5cef9e6 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/NullableTest.cs @@ -0,0 +1,126 @@ +namespace Riok.Mapperly.Tests.Mapping; + +[UsesVerify] +public class NullableTest +{ + [Fact] + public void NullableToNonNullableShouldThrow() + { + var source = TestSourceBuilder.Mapping( + "A?", + "B", + "class A { }", + "class B { }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"if (source == null) + throw new System.ArgumentNullException(nameof(source)); + var target = new B(); + return target;".ReplaceLineEndings()); + } + + [Fact] + public void NullableToNullableShouldWork() + { + var source = TestSourceBuilder.Mapping( + "A?", + "B?", + "class A { }", + "class B { }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"if (source == null) + return default; + var target = new B(); + return target;".ReplaceLineEndings()); + } + + [Fact] + public void NonNullableToNullableShouldWork() + { + var source = TestSourceBuilder.Mapping( + "A", + "B?", + "class A { }", + "class B { }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + return target;".ReplaceLineEndings()); + } + + [Fact] + public void NullableToNonNullableWithNoThrowShouldReturnNewInstance() + { + var source = TestSourceBuilder.Mapping( + "A?", + "B", + TestSourceBuilderOptions.Default with { ThrowOnMappingNullMismatch = false }, + "class A { }", + "class B { }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"if (source == null) + return new B(); + var target = new B(); + return target;".ReplaceLineEndings()); + } + + [Fact] + public Task NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic() + { + var source = TestSourceBuilder.Mapping( + "string?", + "B", + TestSourceBuilderOptions.Default with { ThrowOnMappingNullMismatch = false }, + "class A { }", + "class B { protected B(){} public static B Parse(string v) => new B(); }"); + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public void NullableToNonNullableStringWithNoThrowShouldReturnEmptyString() + { + var source = TestSourceBuilder.Mapping( + "A?", + "string", + TestSourceBuilderOptions.Default with { ThrowOnMappingNullMismatch = false }, + "class A { }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be("return source == null ? \"\" : source.ToString();".ReplaceLineEndings()); + } + + [Fact] + public void NullableToNonNullableValueTypeWithNoThrowShouldReturnDefault() + { + var source = TestSourceBuilder.Mapping( + "DateTime?", + "DateTime", + TestSourceBuilderOptions.Default with { ThrowOnMappingNullMismatch = false }); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be("return source == null ? default : source.Value;".ReplaceLineEndings()); + } + + [Fact] + public void WithExistingInstanceNullableSource() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "void Map(A? source, B target)", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue { get; set; } }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"if (source == null) + return; + target.StringValue = source.StringValue;".ReplaceLineEndings()); + } +} diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs index 548deb70ce..cb4a0cb5f0 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis; + namespace Riok.Mapperly.Tests.Mapping; [UsesVerify] @@ -102,7 +104,8 @@ public void NullableIntToNonNullableIntProperty() TestHelper.GenerateSingleMapperMethodBody(source) .Should() .Be(@"var target = new B(); - target.Value = source.Value ?? default; + if (source.Value != null) + target.Value = source.Value.Value; return target;".ReplaceLineEndings()); } @@ -118,77 +121,106 @@ public void NullableStringToNonNullableStringProperty() TestHelper.GenerateSingleMapperMethodBody(source) .Should() .Be(@"var target = new B(); - target.Value = source.Value ?? """"; + if (source.Value != null) + target.Value = source.Value; return target;".ReplaceLineEndings()); } [Fact] - public void NullableToNonNullableWithUserImplementedMethodAsAbstractClass() + public void NullableClassToNonNullableClassProperty() { - var source = @" -using System; -using System.Collections.Generic; -using Riok.Mapperly.Abstractions; - -[Mapper] -public abstract class MyMapper -{ - public abstract B Map(A source); + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public C? Value { get; set; } }", + "class B { public D Value { get; set; } }", + "class C { public string V {get; set; } }", + "class D { public string V {get; set; } }"); - public string? MapString(string s) - => s; -} + TestHelper.GenerateMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + if (source.Value != null) + target.Value = MapToD(source.Value); + return target;".ReplaceLineEndings()); + } -class A { public string Value { get; set; } } -class B { public string Value { get; set; } } -"; + [Fact] + public void NonNullableClassToNullableClassProperty() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public C Value { get; set; } }", + "class B { public D? Value { get; set; } }", + "class C { public string V {get; set; } }", + "class D { public string V {get; set; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) + TestHelper.GenerateMapperMethodBody(source) .Should() .Be(@"var target = new B(); - target.Value = MapString(source.Value) ?? """"; + target.Value = MapToD(source.Value); return target;".ReplaceLineEndings()); } [Fact] - public void NullableToNonNullableWithUserImplementedMethodAsInterface() + public void DisabledNullableClassPropertyToNonNullableProperty() { - var source = @" -using System; -using System.Collections.Generic; -using Riok.Mapperly.Abstractions; - -[Mapper] -public interface IMyMapper -{ - B Map(A source); + var source = TestSourceBuilder.Mapping( + "A", + "B", + "#nullable disable\n class A { public C Value { get; set; } }\n#nullable enable", + "class B { public D Value { get; set; } }", + "class C { public string V {get; set; } }", + "class D { public string V {get; set; } }"); - string? MapString(string s) => s; -} + TestHelper.GenerateMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + if (source.Value != null) + target.Value = MapToD(source.Value); + return target;".ReplaceLineEndings()); + } -class A { public string Value { get; set; } } -class B { public string Value { get; set; } } -"; + [Fact] + public void NullableClassPropertyToDisabledNullableProperty() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public C? Value { get; set; } }", + "#nullable disable\n class B { public D Value { get; set; } }\n#nullable enable", + "class C { public string V {get; set; } }", + "class D { public string V {get; set; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) + TestHelper.GenerateMapperMethodBody(source) .Should() .Be(@"var target = new B(); - target.Value = ((IMyMapper)this).MapString(source.Value) ?? """"; + if (source.Value != null) + target.Value = MapToD(source.Value); return target;".ReplaceLineEndings()); } [Fact] - public Task NullableClassToNonNullableClassProperty() + public void NullableClassToNonNullableClassPropertyThrow() { var source = TestSourceBuilder.Mapping( "A", "B", + TestSourceBuilderOptions.Default with { ThrowOnPropertyMappingNullMismatch = true }, "class A { public C? Value { get; set; } }", "class B { public D Value { get; set; } }", "class C { public string V {get; set; } }", "class D { public string V {get; set; } }"); - return TestHelper.VerifyGenerator(source); + TestHelper.GenerateMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + if (source.Value != null) + target.Value = MapToD(source.Value); + else + throw new System.ArgumentNullException(nameof(source.Value)); + return target;".ReplaceLineEndings()); } [Fact] @@ -292,78 +324,65 @@ public void ShouldUseUserProvidedMappingAsInterface() } [Fact] - public void ShouldUseUserProvidedMappingAsAbstractClass() + public void ShouldUseUserProvidedMappingWithDisabledNullability() { var mapperBody = @" -public abstract B Map(A source); -public D UserImplementedMap(C source) => new D();"; +B Map(A source); +D UserImplementedMap(C source) => new D();"; var source = TestSourceBuilder.MapperWithBodyAndTypes( mapperBody, - TestSourceBuilderOptions.Default with { AsInterface = false }, "class A { public string StringValue { get; set; } public C NestedValue { get; set; } }", "class B { public string StringValue { get; set; } public D NestedValue { get; set; } }", "class C {}", "class D {}"); - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"var target = new B(); - target.StringValue = source.StringValue; - target.NestedValue = UserImplementedMap(source.NestedValue); - return target;".ReplaceLineEndings()); - } - - [Fact] - public void NullableToNullable() - { - var source = TestSourceBuilder.Mapping( - "A?", - "B?", - "class A { public string StringValue { get; set; } }", - "class B { public string StringValue { get; set; } }"); - - TestHelper.GenerateSingleMapperMethodBody(source) + TestHelper.GenerateSingleMapperMethodBody( + source, + TestHelperOptions.Default with { NullableOption = NullableContextOptions.Disable }) .Should() .Be(@"if (source == null) return default; var target = new B(); - target.StringValue = source.StringValue; + if (source.StringValue != null) + target.StringValue = source.StringValue; + target.NestedValue = ((IMapper)this).UserImplementedMap(source.NestedValue); return target;".ReplaceLineEndings()); } [Fact] - public void NullableToNonNullable() + public void ShouldUseUserProvidedMappingAsAbstractClass() { - var source = TestSourceBuilder.Mapping( - "A?", - "B", - "class A { public string StringValue { get; set; } }", - "class B { public string StringValue { get; set; } }"); + var mapperBody = @" +public abstract B Map(A source); +public D UserImplementedMap(C source) => new D();"; + + var source = TestSourceBuilder.MapperWithBodyAndTypes( + mapperBody, + TestSourceBuilderOptions.Default with { AsInterface = false }, + "class A { public string StringValue { get; set; } public C NestedValue { get; set; } }", + "class B { public string StringValue { get; set; } public D NestedValue { get; set; } }", + "class C {}", + "class D {}"); TestHelper.GenerateSingleMapperMethodBody(source) .Should() - .Be(@"if (source == null) - return new B(); - var target = new B(); + .Be(@"var target = new B(); target.StringValue = source.StringValue; + target.NestedValue = UserImplementedMap(source.NestedValue); return target;".ReplaceLineEndings()); } [Fact] - public void CustomClassToNullableCustomClass() + public Task WithUnmappablePropertyShouldDiagnostic() { var source = TestSourceBuilder.Mapping( "A", - "B?", - "class A { public string StringValue { get; set; } }", - "class B { public string StringValue { get; set; } }"); + "B", + "class A { public DateTime Value { get; set; } }", + "class B { public Version Value { get; set; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"var target = new B(); - target.StringValue = source.StringValue; - return target;".ReplaceLineEndings()); + return TestHelper.VerifyGenerator(source); } [Fact] diff --git a/test/Riok.Mapperly.Tests/Mapping/ParseTest.cs b/test/Riok.Mapperly.Tests/Mapping/ParseTest.cs index 4bdb1278d2..7cc873e83f 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ParseTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ParseTest.cs @@ -26,7 +26,7 @@ public void ParseableBuiltNullableInClass() var source = TestSourceBuilder.Mapping("string?", "int?"); TestHelper.GenerateSingleMapperMethodBody(source) .Should() - .Be("return int.Parse(source);"); + .Be("return source == null ? default : int.Parse(source);"); } [Fact] diff --git a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs index 16af677256..e24157d59e 100644 --- a/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/UserMethodTest.cs @@ -72,21 +72,6 @@ public void WithExistingInstance() .Be("target.StringValue = source.StringValue;"); } - [Fact] - public void WithExistingInstanceNullableSource() - { - var source = TestSourceBuilder.MapperWithBodyAndTypes( - "void Map(A? source, B target)", - "class A { public string StringValue { get; set; } }", - "class B { public string StringValue { get; set; } }"); - - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"if (source == null) - return; - target.StringValue = source.StringValue;".ReplaceLineEndings()); - } - [Fact] public void WithMultipleUserDefinedMethodShouldWork() { @@ -133,7 +118,7 @@ public void WithSameNamesShouldGenerateUniqueMethodNames() TestHelper.GenerateMapperMethodBodies(source) .Select(x => x.Name) .Should() - .BeEquivalentTo("MapToB", "MapToB2"); + .BeEquivalentTo("MapToB", "MapToB1"); } [Fact] diff --git a/test/Riok.Mapperly.Tests/TestHelper.cs b/test/Riok.Mapperly.Tests/TestHelper.cs index 1bdc05ba0e..0573ca9145 100644 --- a/test/Riok.Mapperly.Tests/TestHelper.cs +++ b/test/Riok.Mapperly.Tests/TestHelper.cs @@ -9,32 +9,38 @@ public static class TestHelper { public static Task VerifyGenerator( string source, - NullableContextOptions nullableOption = NullableContextOptions.Enable, - LanguageVersion languageVersion = LanguageVersion.Default) + TestHelperOptions? options = null) { - var driver = Generate(source, nullableOption, languageVersion); + var driver = Generate(source, options); return Verify(driver).ToTask(); } - public static string GenerateSingleMapperMethodBody(string source, bool allowDiagnostics = false) + public static string GenerateSingleMapperMethodBody(string source, TestHelperOptions? options = null) { - return GenerateMapperMethodBodies(source, allowDiagnostics) + return GenerateMapperMethodBodies(source, options) .Single() .Body; } - public static string GenerateMapperMethodBody(string source, string methodName = TestSourceBuilder.DefaultMapMethodName, bool allowDiagnostics = false) + public static string GenerateMapperMethodBody( + string source, + string methodName = TestSourceBuilder.DefaultMapMethodName, + TestHelperOptions? options = null) { - return GenerateMapperMethodBodies(source, allowDiagnostics) + return GenerateMapperMethodBodies(source, options) .Single(x => x.Name == methodName) .Body; } - public static IEnumerable<(string Name, string Body)> GenerateMapperMethodBodies(string source, bool allowDiagnostics = false) + public static IEnumerable<(string Name, string Body)> GenerateMapperMethodBodies( + string source, + TestHelperOptions? options = null) { - var result = Generate(source).GetRunResult(); + options ??= TestHelperOptions.Default; - if (!allowDiagnostics) + var result = Generate(source, options).GetRunResult(); + + if (!options.AllowDiagnostics) { result.Diagnostics.Should().HaveCount(0); } @@ -62,11 +68,12 @@ private static string ExtractBody(MethodDeclarationSyntax methodImpl) private static GeneratorDriver Generate( string source, - NullableContextOptions nullableOption = NullableContextOptions.Enable, - LanguageVersion languageVersion = LanguageVersion.Default) + TestHelperOptions? options) { - var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(languageVersion)); - var compilation = BuildCompilation(nullableOption, syntaxTree); + options ??= TestHelperOptions.Default; + + var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(options.LanguageVersion)); + var compilation = BuildCompilation(options.NullableOption, syntaxTree); var generator = new MapperGenerator(); GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); diff --git a/test/Riok.Mapperly.Tests/TestHelperOptions.cs b/test/Riok.Mapperly.Tests/TestHelperOptions.cs new file mode 100644 index 0000000000..f3b716d07c --- /dev/null +++ b/test/Riok.Mapperly.Tests/TestHelperOptions.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Riok.Mapperly.Tests; + +public record TestHelperOptions( + NullableContextOptions NullableOption = NullableContextOptions.Enable, + LanguageVersion LanguageVersion = LanguageVersion.Default, + bool AllowDiagnostics = false) +{ + public static readonly TestHelperOptions Default = new(); +} diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs index 81e800918e..6f188c0266 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Riok.Mapperly.Tests; public static class TestSourceBuilder @@ -36,9 +38,22 @@ public static string MapperWithBody(string body, TestSourceBuilderOptions? optio private static string BuildAttribute(TestSourceBuilderOptions options) { - return options.UseDeepCloning - ? "[Mapper(UseDeepCloning = true)]" - : "[Mapper]"; + var attrs = new[] + { + Attribute(options.UseDeepCloning), + Attribute(options.ThrowOnMappingNullMismatch), + Attribute(options.ThrowOnPropertyMappingNullMismatch), + }; + + return $"[Mapper({string.Join(", ", attrs)})]"; + } + + private static string Attribute(bool value, [CallerArgumentExpression("value")] string? expression = null) + { + if (expression == null) + throw new ArgumentNullException(nameof(expression)); + + return $"{expression.Split(".").Last()} = {(value ? "true" : "false")}"; } public static string MapperWithBodyAndTypes(string body, params string[] types) diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs index e92a6d6990..ee8c362328 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs @@ -3,7 +3,9 @@ namespace Riok.Mapperly.Tests; public record TestSourceBuilderOptions( bool AsInterface = true, string? Namespace = null, - bool UseDeepCloning = false) + bool UseDeepCloning = false, + bool ThrowOnMappingNullMismatch = true, + bool ThrowOnPropertyMappingNullMismatch = false) { public static readonly TestSourceBuilderOptions Default = new(); public static readonly TestSourceBuilderOptions WithDeepCloning = new(UseDeepCloning: true); diff --git a/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..3edbb81745 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG002, + Title: No accessible parameterless constructor found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,26), + Description: , + HelpLink: , + MessageFormat: {0} has no accessible parameterless constructor, + Message: B has no accessible parameterless constructor, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..1f826ec69f --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/NullableTest.NullableToNonNullableWithNoThrowNoAccessibleCtorShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public sealed class Mapper : IMapper +{ + public static readonly IMapper Instance = new Mapper(); + public B Map(string? source) + { + return source == null ? throw new System.ArgumentNullException(nameof(source)) : B.Parse(source); + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..f2a2032e64 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG007, + Title: Could not map property, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,20), + Description: , + HelpLink: , + MessageFormat: Could not map property {0}.{1} of type {2} to {3}.{4} of type {5}, + Message: Could not map property A.Value of type System.DateTime to B.Value of type System.Version, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.NullableClassToNonNullableClassProperty.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.01.verified.cs similarity index 53% rename from test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.NullableClassToNonNullableClassProperty.verified.cs rename to test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.01.verified.cs index 3843746994..77f1e00e63 100644 --- a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.NullableClassToNonNullableClassProperty.verified.cs +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmappablePropertyShouldDiagnostic.01.verified.cs @@ -6,14 +6,6 @@ public sealed class Mapper : IMapper public B Map(A source) { var target = new B(); - target.Value = source.Value == null ? new D() : MapToD(source.Value); - return target; - } - - private D MapToD(C source) - { - var target = new D(); - target.V = source.V; return target; } } \ No newline at end of file