diff --git a/README.md b/README.md index 5da5cd8be4..7e57d72092 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The attributes defined in `Riok.Mapperly.Abstractions` can be used to customize The `MapperAttribute` provides options to customize the generated mapper class. The generated class name, the instance field name and the default enum mapping strategy is adjustable. -### Copy behaviour +#### Copy behaviour By default, Mapperly does not create deep copies of objects to improve performance. If an object can be directly assigned to the target, it will do so @@ -56,9 +56,16 @@ To create deep copies, set the `UseDeepCloning` property on the `MapperAttribute On each mapping method declaration property mappings can be customized. If a property on the target has a different name than on the source, the `MapPropertyAttribute` can be applied. -Flattening is not yet supported. If a property should be ignored, the `MapperIgnoreAttribute` can be used. +#### Flattening and unflattening + +It is pretty common to flatten objects during mapping, e.g. `Car.Make.Id => Car.MakeId`. +Mapperly tries to figure out flattenings automatically by making use of the pascal case c# notation. +If Mapperly can't resolve the target or source property correctly, it is possible to manually configure it by applying the `MapPropertyAttribute` +by either using the source and target property path names as arrays or using a dot separated property access path string (e.g. `[MapProperty(Source = new[] { nameof(Car), nameof(Car.Make), nameof(Car.Make.Id) }, Target = new[] { nameof(Car), nameof(Car.MakeId) })]` or `[MapProperty(Source = "Car.Make.Id", Target = "Car.MakeId")]`). +Note: unflattening is not yet automatically configured by Mapperly and needs to be configured manually via `MapPropertyAttribute`. + #### Enum The enum mapping can be customized by setting the strategy to use. diff --git a/Riok.Mapperly.sln b/Riok.Mapperly.sln index ce824928fa..f52f25c37b 100644 --- a/Riok.Mapperly.sln +++ b/Riok.Mapperly.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Riok.Mapperly.Abstractions" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Riok.Mapperly.Tests", "test\Riok.Mapperly.Tests\Riok.Mapperly.Tests.csproj", "{284E2122-CE48-4A5A-A045-3A3F941DA5C3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Riok.Mapperly.Abstractions.Test", "test\Riok.Mapperly.Abstractions.Test\Riok.Mapperly.Abstractions.Test.csproj", "{C3C40A0A-168F-4A66-B9F9-FC80D2F26306}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -40,11 +42,16 @@ Global {284E2122-CE48-4A5A-A045-3A3F941DA5C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {284E2122-CE48-4A5A-A045-3A3F941DA5C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {284E2122-CE48-4A5A-A045-3A3F941DA5C3}.Release|Any CPU.Build.0 = Release|Any CPU + {C3C40A0A-168F-4A66-B9F9-FC80D2F26306}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3C40A0A-168F-4A66-B9F9-FC80D2F26306}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3C40A0A-168F-4A66-B9F9-FC80D2F26306}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3C40A0A-168F-4A66-B9F9-FC80D2F26306}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CB991FD7-B9B7-47C0-A060-66EBE136DBDA} = {B65AF89A-4A3B-473C-83C8-5F0CB0EED30E} {FDA97A46-DB21-4B72-9958-6D61C508B1CD} = {3598BE50-28D5-4BF4-BEA7-09E5FEA2910C} {E45D5E6D-8CC9-4DAD-8E1C-723625475744} = {B65AF89A-4A3B-473C-83C8-5F0CB0EED30E} {284E2122-CE48-4A5A-A045-3A3F941DA5C3} = {3598BE50-28D5-4BF4-BEA7-09E5FEA2910C} + {C3C40A0A-168F-4A66-B9F9-FC80D2F26306} = {3598BE50-28D5-4BF4-BEA7-09E5FEA2910C} EndGlobalSection EndGlobal diff --git a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs index 61b82788c0..e0aa7bc970 100644 --- a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs @@ -6,12 +6,25 @@ namespace Riok.Mapperly.Abstractions; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class MapPropertyAttribute : Attribute { + private const string PropertyAccessSeparatorStr = "."; + private const char PropertyAccessSeparator = '.'; + /// /// Maps a specified source property to the specified target property. /// - /// The name of the source property. The use of `nameof()` is encouraged. - /// The name of the target property. The use of `nameof()` is encouraged. + /// The name of the source property. The use of `nameof()` is encouraged. A path can be specified by joining property names with a '.'. + /// The name of the target property. The use of `nameof()` is encouraged. A path can be specified by joining property names with a '.'. public MapPropertyAttribute(string source, string target) + : this(source.Split(PropertyAccessSeparator), target.Split(PropertyAccessSeparator)) + { + } + + /// + /// Maps a specified source property to the specified target property. + /// + /// The path of the source property. The use of `nameof()` is encouraged. + /// The path of the target property. The use of `nameof()` is encouraged. + public MapPropertyAttribute(string[] source, string[] target) { Source = source; Target = target; @@ -20,10 +33,20 @@ public MapPropertyAttribute(string source, string target) /// /// Gets the name of the source property. /// - public string Source { get; } + public IReadOnlyCollection Source { get; } + + /// + /// Gets the full name of the source property path. + /// + public string SourceFullName => string.Join(PropertyAccessSeparatorStr, Source); /// /// Gets the name of the target property. /// - public string Target { get; } + public IReadOnlyCollection Target { get; } + + /// + /// Gets the full name of the target property path. + /// + public string TargetFullName => string.Join(PropertyAccessSeparatorStr, Target); } diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index ffb804db8f..82203545a1 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -12,3 +12,7 @@ RMG005 | Mapper | Error | Mapping target property not found. RMG006 | Mapper | Error | Mapping source property not found. RMG007 | Mapper | Error | Could not map property. RMG008 | Mapper | Error | Could not create mapping. +RMG009 | Mapper | Info | Can not map to read only property. +RMG010 | Mapper | Info | Can not map from write only property. +RMG011 | Mapper | Info | Can not map to write only property path. +RMG012 | Mapper | Info | Mapping source property not found. diff --git a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs index 1696c8f7f8..c6eeafb606 100644 --- a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs +++ b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs @@ -31,7 +31,7 @@ public static IEnumerable Access(Compilation compilation, ISymbol symbol) { var attr = (T)Activator.CreateInstance( attrType, - BuildConstructorArguments(attrData)); + BuildArgumentValues(attrData.ConstructorArguments).ToArray()); foreach (var namedArgument in attrData.NamedArguments) { @@ -46,24 +46,50 @@ public static IEnumerable Access(Compilation compilation, ISymbol symbol) } } - private static object?[] BuildConstructorArguments(AttributeData attrData) + private static IEnumerable BuildArgumentValues(IEnumerable values) { - return attrData.ConstructorArguments - .Select(arg => - { - if (arg.Value == null) - return null; - - // box enum values to resolve correct ctor - if (arg.Type is not INamedTypeSymbol namedType || namedType.EnumUnderlyingType == null) - return arg.Value; - - var assemblyName = arg.Type!.ContainingAssembly.Name; - var qualifiedTypeName = Assembly.CreateQualifiedName(assemblyName, arg.Type.ToDisplayString()); - return Type.GetType(qualifiedTypeName) is { } type - ? Enum.ToObject(type, arg.Value) - : arg.Value; - }) - .ToArray(); + return values.Select(arg => arg.Kind switch + { + _ when arg.IsNull => null, + TypedConstantKind.Enum => GetEnumValue(arg), + TypedConstantKind.Array => BuildArrayValue(arg), + TypedConstantKind.Primitive => arg.Value, + _ => throw new ArgumentOutOfRangeException( + $"{nameof(AttributeDataAccessor)} does not support constructor arguments of kind {arg.Kind.ToString()}"), + }); + } + + private static object?[] BuildArrayValue(TypedConstant arg) + { + var arrayTypeSymbol = arg.Type as IArrayTypeSymbol + ?? throw new InvalidOperationException("Array typed constant is not of type " + nameof(IArrayTypeSymbol)); + + var elementType = GetReflectionType(arrayTypeSymbol.ElementType); + + var values = BuildArgumentValues(arg.Values).ToArray(); + var typedValues = Array.CreateInstance(elementType, values.Length); + Array.Copy(values, typedValues, values.Length); + return (object?[])typedValues; } + + private static object? GetEnumValue(TypedConstant arg) + { + var enumType = GetReflectionType(arg.Type ?? throw new InvalidOperationException("Type is null")); + return arg.Value == null + ? null + : Enum.ToObject(enumType, arg.Value); + } + + private static Type GetReflectionType(ITypeSymbol type) + { + // other special types not yet supported since they are not used yet. + if (type.SpecialType == SpecialType.System_String) + return typeof(string); + + var assemblyName = type.ContainingAssembly.Name; + var qualifiedTypeName = Assembly.CreateQualifiedName(assemblyName, type.ToDisplayString()); + return Type.GetType(qualifiedTypeName) + ?? throw new InvalidOperationException($"Type {qualifiedTypeName} not found"); + } + } diff --git a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs index 9543f784c6..63a2c3263b 100644 --- a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs @@ -2,7 +2,7 @@ using Riok.Mapperly.Abstractions; using Riok.Mapperly.Configuration; using Riok.Mapperly.Descriptors.MappingBuilder; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors; diff --git a/src/Riok.Mapperly/Descriptors/MapperDescriptor.cs b/src/Riok.Mapperly/Descriptors/MapperDescriptor.cs index 94a364182d..9dcb618255 100644 --- a/src/Riok.Mapperly/Descriptors/MapperDescriptor.cs +++ b/src/Riok.Mapperly/Descriptors/MapperDescriptor.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; namespace Riok.Mapperly.Descriptors; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs index 819f1e109d..98b7944341 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/CtorMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/DictionaryMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/DictionaryMappingBuilder.cs index f2cb57f391..b82470b1d6 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/DictionaryMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/DictionaryMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/DirectAssignmentMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/DirectAssignmentMappingBuilder.cs index 6f52f04266..fe0bfe106f 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/DirectAssignmentMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/DirectAssignmentMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs index 0a174fbbe8..43f492753a 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumMappingBuilder.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumToStringMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumToStringMappingBuilder.cs index f46d834c35..125ca04ae8 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumToStringMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumToStringMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumerableMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumerableMappingBuilder.cs index 715af04ad3..4ab2505e83 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumerableMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/EnumerableMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ExplicitCastMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ExplicitCastMappingBuilder.cs index dc366d6366..cfba194c83 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ExplicitCastMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ExplicitCastMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis.CSharp; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ImplicitCastMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ImplicitCastMappingBuilder.cs index e8541a92de..c80547ac09 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ImplicitCastMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ImplicitCastMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis.CSharp; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs index 22f62509d5..c0831a0dd3 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/NullableMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs index da0a13d335..f7a5c94aa3 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilder.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; +using Riok.Mapperly.Descriptors.Mappings.PropertyMappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; @@ -21,12 +22,15 @@ public static class ObjectPropertyMappingBuilder public static void BuildMappingBody(MappingBuilderContext ctx, ObjectPropertyMapping mapping) { + var mappingCtx = new ObjectPropertyMappingBuilderContext(ctx, mapping); + var ignoredTargetProperties = ctx.ListConfiguration() .Select(x => x.Target) .ToHashSet(); - var nameMappings = ctx.ListConfiguration() - .ToDictionary(x => x.Target, x => x.Source); + var propertyConfigsByRootTargetName = ctx.ListConfiguration() + .GroupBy(x => x.Target.First()) + .ToDictionary(x => x.Key, x => x.ToList()); var targetProperties = mapping.TargetType .GetAllMembers() @@ -38,93 +42,246 @@ public static void BuildMappingBody(MappingBuilderContext ctx, ObjectPropertyMap if (ignoredTargetProperties.Remove(targetProperty.Name)) continue; - var mappingNameWasManuallyConfigured = nameMappings.Remove(targetProperty.Name, out var sourcePropertyName); - sourcePropertyName ??= targetProperty.Name; - - var sourceProperty = FindSourceProperty(mapping.SourceType, sourcePropertyName); - if (sourceProperty != null) + if (propertyConfigsByRootTargetName.Remove(targetProperty.Name, out var propertyConfigs)) { - if (BuildPropertyMapping(ctx, mapping, sourceProperty, targetProperty) is { } propertyMapping) + // add all configured mappings + // order by target path count to map less nested items first (otherwise they would overwrite all others) + // eg. target.A = source.B should be mapped before target.A.Id = source.B.Id + foreach (var config in propertyConfigs.OrderBy(x => x.Target.Count)) { - mapping.AddPropertyMapping(propertyMapping); + BuildPropertyMapping(mappingCtx, config.Source, config.Target, true); } + continue; } - if (mappingNameWasManuallyConfigured) + // only try other namings if the property was not found, + // ignore all other results + var targetPropertyPath = new[] { targetProperty.Name }; + var targetPropFound = false; + foreach (var sourcePropertyCandidate in MemberPathCandidateBuilder.BuildMemberPathCandidates(targetProperty.Name)) + { + if (BuildPropertyMapping(mappingCtx, sourcePropertyCandidate.ToList(), targetPropertyPath) is not ValidationResult.PropertyNotFound) + { + targetPropFound = true; + break; + } + } + + // target property couldn't be found + // add a diagnostic. + if (!targetPropFound) { ctx.ReportDiagnostic( - DiagnosticDescriptors.ConfiguredMappingSourcePropertyNotFound, - sourcePropertyName, - mapping.TargetType); + DiagnosticDescriptors.MappingSourcePropertyNotFound, + targetProperty.Name, + mapping.SourceType); } } - AddUnmatchedIgnoredPropertiesDiagnostics(ctx, ignoredTargetProperties, mapping); - AddUnmatchedTargetPropertiesDiagnostics(ctx, nameMappings.Keys, mapping); + AddUnmatchedIgnoredPropertiesDiagnostics(mappingCtx, ignoredTargetProperties); + AddUnmatchedTargetPropertiesDiagnostics(mappingCtx, propertyConfigsByRootTargetName.Values.SelectMany(x => x)); } - private static void AddUnmatchedTargetPropertiesDiagnostics( - MappingBuilderContext ctx, - IEnumerable propertyNames, - ObjectPropertyMapping mapping) + private static ValidationResult BuildPropertyMapping( + ObjectPropertyMappingBuilderContext ctx, + IReadOnlyCollection sourcePath, + IReadOnlyCollection targetPath, + bool configuredTargetPropertyPath = false) { - foreach (var propertyName in propertyNames) + var targetPropertyPath = new PropertyPath(FindPropertyPath(ctx.Mapping.TargetType, targetPath).ToList()); + var sourcePropertyPath = new PropertyPath(FindPropertyPath(ctx.Mapping.SourceType, sourcePath).ToList()); + + var validationResult = ValidateMapping( + ctx, + sourcePropertyPath, + targetPropertyPath, + sourcePath, + targetPath, + configuredTargetPropertyPath); + if (validationResult != ValidationResult.Ok) + return validationResult; + + // nullability is handled inside the property mapping + var delegateMapping = ctx.BuilderContext.FindMapping(sourcePropertyPath.Member.Type.UpgradeNullable(), targetPropertyPath.Member.Type.UpgradeNullable()) + ?? ctx.BuilderContext.FindOrBuildMapping(sourcePropertyPath.Member.Type.NonNullable(), targetPropertyPath.Member.Type.NonNullable()); + + // couldn't build the mapping + if (delegateMapping == null) { - ctx.ReportDiagnostic( - DiagnosticDescriptors.ConfiguredMappingTargetPropertyNotFound, - propertyName, - mapping.TargetType); + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.CouldNotMapProperty, + ctx.Mapping.SourceType, + sourcePropertyPath.FullName, + sourcePropertyPath.Member.Type, + ctx.Mapping.TargetType, + targetPropertyPath.FullName, + targetPropertyPath.Member.Type); + return ValidationResult.CannotMapTypes; + } + + // no member of the source path is nullable, no null handling needed + if (!sourcePropertyPath.IsAnyNullable()) + { + ctx.AddPropertyMapping(new PropertyMapping( + sourcePropertyPath, + targetPropertyPath, + delegateMapping, + false)); + return ValidationResult.Ok; } + + // the source is nullable, or the mapping is a direct assignment and the target allows nulls + // access the source in a null save matter (via ?.) but no other special handling required. + if (delegateMapping.SourceType.IsNullable() || delegateMapping is DirectAssignmentMapping && targetPropertyPath.Member.IsNullable()) + { + ctx.AddPropertyMapping(new PropertyMapping( + sourcePropertyPath, + targetPropertyPath, + delegateMapping, + true)); + return ValidationResult.Ok; + } + + // additional null condition check + // (only map if source is not null, else may throw depending on settings) + ctx.AddNullDelegatePropertyMapping(new PropertyMapping( + sourcePropertyPath, + targetPropertyPath, + delegateMapping, + false)); + return ValidationResult.Ok; } - private static void AddUnmatchedIgnoredPropertiesDiagnostics( - MappingBuilderContext ctx, - HashSet ignoredTargetProperties, - ObjectPropertyMapping mapping) + private static ValidationResult ValidateMapping( + ObjectPropertyMappingBuilderContext ctx, + PropertyPath sourcePropertyPath, + PropertyPath targetPropertyPath, + IReadOnlyCollection configuredSourcePropertyPath, + IReadOnlyCollection configuredTargetPropertyPath, + bool reportDiagnosticIfPropertyNotFound) { - foreach (var notFoundIgnoredProperty in ignoredTargetProperties) + // the path parts don't match, not all target properties could be found + if (configuredTargetPropertyPath.Count != targetPropertyPath.Path.Count) { - ctx.ReportDiagnostic( - DiagnosticDescriptors.IgnoredPropertyNotFound, - notFoundIgnoredProperty, - mapping.TargetType); + if (reportDiagnosticIfPropertyNotFound) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.ConfiguredMappingTargetPropertyNotFound, + string.Join(PropertyPath.PropertyAccessSeparator, configuredTargetPropertyPath), + ctx.Mapping.TargetType); + } + return ValidationResult.PropertyNotFound; + } + + // the path parts don't match, not all source properties could be found + if (configuredSourcePropertyPath.Count != sourcePropertyPath.Path.Count) + { + if (reportDiagnosticIfPropertyNotFound) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.ConfiguredMappingSourcePropertyNotFound, + string.Join(PropertyPath.PropertyAccessSeparator, configuredSourcePropertyPath), + ctx.Mapping.SourceType); + } + return ValidationResult.PropertyNotFound; + } + + // the target property path is readonly + if (targetPropertyPath.Member.IsReadOnly) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.CanNotMapToReadOnlyProperty, + ctx.Mapping.SourceType, + sourcePropertyPath.FullName, + sourcePropertyPath.Member.Type, + ctx.Mapping.TargetType, + targetPropertyPath.FullName, + targetPropertyPath.Member.Type); + return ValidationResult.PropertyHasUnexpectedSpecification; + } + + // a target property path part is write only + if (targetPropertyPath.ObjectPath.Any(p => p.IsWriteOnly)) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.CanNotMapToWriteOnlyPropertyPath, + ctx.Mapping.SourceType, + sourcePropertyPath.FullName, + sourcePropertyPath.Member.Type, + ctx.Mapping.TargetType, + targetPropertyPath.FullName, + targetPropertyPath.Member.Type); + return ValidationResult.PropertyHasUnexpectedSpecification; + } + + // a source property path is write only + if (sourcePropertyPath.Path.Any(p => p.IsWriteOnly)) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.CanNotMapFromWriteOnlyProperty, + ctx.Mapping.SourceType, + sourcePropertyPath.FullName, + sourcePropertyPath.Member.Type, + ctx.Mapping.TargetType, + targetPropertyPath.FullName, + targetPropertyPath.Member.Type); + return ValidationResult.PropertyHasUnexpectedSpecification; + } + + return ValidationResult.Ok; + } + + private static IEnumerable FindPropertyPath(ITypeSymbol type, IEnumerable path) + { + foreach (var name in path) + { + if (FindProperty(type, name) is not { } property) + break; + + type = property.Type; + yield return property; } } - private static IPropertySymbol? FindSourceProperty(ITypeSymbol source, string name) + private static IPropertySymbol? FindProperty(ITypeSymbol type, string name) { - return source.GetAllMembers(name) + return type.GetAllMembers(name) .OfType() .FirstOrDefault(p => !p.IsStatic); } - private static PropertyMapping? BuildPropertyMapping( - MappingBuilderContext ctx, - ObjectPropertyMapping mapping, - IPropertySymbol sourceProperty, - IPropertySymbol targetProperty) + private static void AddUnmatchedTargetPropertiesDiagnostics( + ObjectPropertyMappingBuilderContext ctx, + IEnumerable unmatchedConfiguredProperties) { - if (targetProperty.IsReadOnly) - return null; + foreach (var propertyConfig in unmatchedConfiguredProperties) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.ConfiguredMappingTargetPropertyNotFound, + propertyConfig.TargetFullName, + ctx.Mapping.TargetType); + } + } - if (sourceProperty.IsWriteOnly) - return null; + private static void AddUnmatchedIgnoredPropertiesDiagnostics( + ObjectPropertyMappingBuilderContext ctx, + HashSet ignoredTargetProperties) + { + foreach (var notFoundIgnoredProperty in ignoredTargetProperties) + { + ctx.BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.IgnoredPropertyNotFound, + notFoundIgnoredProperty, + ctx.Mapping.TargetType); + } + } - // 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); - - ctx.ReportDiagnostic( - DiagnosticDescriptors.CouldNotMapProperty, - mapping.SourceType, - sourceProperty.Name, - sourceProperty.Type, - mapping.TargetType, - targetProperty.Name, - targetProperty.Type); - return null; + private enum ValidationResult + { + Ok, + PropertyNotFound, + PropertyHasUnexpectedSpecification, + CannotMapTypes, } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilderContext.cs new file mode 100644 index 0000000000..c4187deaf7 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ObjectPropertyMappingBuilderContext.cs @@ -0,0 +1,81 @@ +using Riok.Mapperly.Descriptors.Mappings; +using Riok.Mapperly.Descriptors.Mappings.PropertyMappings; +using Riok.Mapperly.Diagnostics; +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Descriptors.MappingBuilder; + +public class ObjectPropertyMappingBuilderContext +{ + private readonly Dictionary _nullDelegateMappings = new(); + + public ObjectPropertyMappingBuilderContext(MappingBuilderContext builderContext, ObjectPropertyMapping mapping) + { + BuilderContext = builderContext; + Mapping = mapping; + } + + public MappingBuilderContext BuilderContext { get; } + + public ObjectPropertyMapping Mapping { get; } + + public void AddPropertyMapping(PropertyMapping propertyMapping) + => AddPropertyMapping(Mapping, propertyMapping); + + public void AddNullDelegatePropertyMapping(PropertyMapping propertyMapping) + { + var nullConditionSourcePath = new PropertyPath(propertyMapping.SourcePath.PathWithoutTrailingNonNullable().ToList()); + var container = GetOrCreateNullDelegateMappingForPath(nullConditionSourcePath); + AddPropertyMapping(container, propertyMapping); + } + + private void AddPropertyMapping(IPropertyMappingContainer container, PropertyMapping mapping) + { + container.AddPropertyMappings(BuildNullPropertyInitializers(mapping.TargetPath)); + container.AddPropertyMapping(mapping); + } + + private IEnumerable BuildNullPropertyInitializers(PropertyPath path) + { + foreach (var nullableTrailPath in path.ObjectPathNullableSubPaths()) + { + var nullablePath = new PropertyPath(nullableTrailPath); + var type = nullablePath.Member.Type; + if (!type.HasAccessibleParameterlessConstructor()) + { + BuilderContext.ReportDiagnostic( + DiagnosticDescriptors.NoParameterlessConstructorFound, + type); + continue; + } + + yield return new PropertyNullInitializerDelegateMapping(nullablePath); + } + } + + private PropertyNullDelegateMapping GetOrCreateNullDelegateMappingForPath(PropertyPath nullConditionSourcePath) + { + // if there is already an exact match return that + if (_nullDelegateMappings.TryGetValue(nullConditionSourcePath, out var mapping)) + return mapping; + + IPropertyMappingContainer parentMapping = Mapping; + + // try to reuse parent path mappings and wrap inside them + foreach (var nullablePath in nullConditionSourcePath.ObjectPathNullableSubPaths().Reverse()) + { + if (_nullDelegateMappings.TryGetValue(new PropertyPath(nullablePath), out var parentMappingHolder)) + { + parentMapping = parentMappingHolder; + } + } + + mapping = new PropertyNullDelegateMapping( + nullConditionSourcePath, + parentMapping, + BuilderContext.MapperConfiguration.ThrowOnPropertyMappingNullMismatch); + _nullDelegateMappings[nullConditionSourcePath] = mapping; + parentMapping.AddPropertyMapping(mapping); + return mapping; + } +} diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ParseMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ParseMappingBuilder.cs index bd8add9def..a64cfe38f5 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ParseMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ParseMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/SpecialTypeMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/SpecialTypeMappingBuilder.cs index 11132cc053..89faa38c9c 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/SpecialTypeMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/SpecialTypeMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/StringToEnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/StringToEnumMappingBuilder.cs index f22cd68c83..9c0769fb43 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/StringToEnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/StringToEnumMappingBuilder.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/ToStringMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/ToStringMappingBuilder.cs index 7cc1005345..ef6f421dcd 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/ToStringMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/ToStringMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; namespace Riok.Mapperly.Descriptors.MappingBuilder; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs index 80ef43a143..4e40a222d0 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilder/UserMethodMappingBuilder.cs @@ -1,5 +1,5 @@ using Microsoft.CodeAnalysis; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 24c06b6803..d4c1fbf82e 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Configuration; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; namespace Riok.Mapperly.Descriptors; @@ -44,19 +44,6 @@ public class MappingBuilderContext : SimpleMappingBuilderContext 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 (should only be used as a delegate to another mapping) diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/ArrayCloneMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ArrayCloneMapping.cs similarity index 94% rename from src/Riok.Mapperly/Descriptors/TypeMappings/ArrayCloneMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/ArrayCloneMapping.cs index 18228cb9db..2da9d023e7 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/ArrayCloneMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/ArrayCloneMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping from an array to an array of the same type by using Array.Clone. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/CastMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/CastMapping.cs similarity index 93% rename from src/Riok.Mapperly/Descriptors/TypeMappings/CastMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/CastMapping.cs index f8fa24391c..35e4aa97d3 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/CastMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/CastMapping.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a cast mapping. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/CtorMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/CtorMapping.cs similarity index 93% rename from src/Riok.Mapperly/Descriptors/TypeMappings/CtorMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/CtorMapping.cs index 7213f35708..11b547fe71 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/CtorMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/CtorMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping where the target type has the source as single ctor argument. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/DirectAssignmentMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/DirectAssignmentMapping.cs similarity index 89% rename from src/Riok.Mapperly/Descriptors/TypeMappings/DirectAssignmentMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/DirectAssignmentMapping.cs index eb19e1d084..d91ea5fa42 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/DirectAssignmentMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/DirectAssignmentMapping.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a direct assignment mapping. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumFromStringMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/EnumFromStringMapping.cs similarity index 98% rename from src/Riok.Mapperly/Descriptors/TypeMappings/EnumFromStringMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/EnumFromStringMapping.cs index 581ebd30f2..a63d0df949 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumFromStringMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/EnumFromStringMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping from a string to an enum. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumNameMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/EnumNameMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/EnumNameMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/EnumNameMapping.cs index db5c376ae8..921db83500 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumNameMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/EnumNameMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping from an enum to another enum by using their names. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumToStringMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/EnumToStringMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/EnumToStringMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/EnumToStringMapping.cs index 41d183e38c..b1a463887b 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/EnumToStringMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/EnumToStringMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping from an enum to a string. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddDictionaryMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ForEachAddDictionaryMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddDictionaryMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/ForEachAddDictionaryMapping.cs index 34d4346f58..1f46edca44 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddDictionaryMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/ForEachAddDictionaryMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a foreach dictionary mapping which works by looping through the source, diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddEnumerableMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ForEachAddEnumerableMapping.cs similarity index 96% rename from src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddEnumerableMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/ForEachAddEnumerableMapping.cs index 60c131a79d..2195cb1674 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/ForEachAddEnumerableMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/ForEachAddEnumerableMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a foreach enumerable mapping which works by looping through the source, diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/IUserMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/IUserMapping.cs similarity index 77% rename from src/Riok.Mapperly/Descriptors/TypeMappings/IUserMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/IUserMapping.cs index 4fc43bafd7..53b696806b 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/IUserMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/IUserMapping.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// A user defined / implemented mapping. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/LinqEnumerableMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/LinqEnumerableMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/LinqEnumerableMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/LinqEnumerableMapping.cs index 64a6cef1e7..a401317521 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/LinqEnumerableMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/LinqEnumerableMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents an enumerable mapping which works by using linq (select + collect). diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs similarity index 98% rename from src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs index 4d930f1daa..b549f8df89 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/MethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MethodMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping which is not a single expression but an entire method. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/NewInstanceObjectPropertyMapping.cs similarity index 94% rename from src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/NewInstanceObjectPropertyMapping.cs index 748d292bc6..043b11a911 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NewInstanceObjectPropertyMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/NewInstanceObjectPropertyMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; public class NewInstanceObjectPropertyMapping : ObjectPropertyMapping { diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMapping.cs index 1eed32bda3..acc9c97cdb 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Null aware delegate mapping. Abstracts handling null values of the delegated mapping. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMethodMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMethodMapping.cs index c81390da28..dac5c589a9 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NullDelegateMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/NullDelegateMethodMapping.cs @@ -3,7 +3,7 @@ using Riok.Mapperly.Helpers; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Null aware delegate mapping for s. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/NullFallbackValue.cs similarity index 70% rename from src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs rename to src/Riok.Mapperly/Descriptors/Mappings/NullFallbackValue.cs index 4e3e6e24b1..3ed7b5259f 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/NullFallbackValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/NullFallbackValue.cs @@ -1,4 +1,4 @@ -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; public enum NullFallbackValue { diff --git a/src/Riok.Mapperly/Descriptors/Mappings/ObjectPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ObjectPropertyMapping.cs new file mode 100644 index 0000000000..db798d5269 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/ObjectPropertyMapping.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +namespace Riok.Mapperly.Descriptors.Mappings; + +/// +/// Represents a complex object mapping implemented in its own method. +/// Maps each property from the source to the target. +/// +public abstract class ObjectPropertyMapping : MethodMapping, IPropertyMappingContainer +{ + private readonly HashSet _mappings = new(); + + protected ObjectPropertyMapping(ITypeSymbol sourceType, ITypeSymbol targetType) : base(sourceType, targetType) + { + } + + public void AddPropertyMapping(IPropertyMapping mapping) + => _mappings.Add(mapping); + + public void AddPropertyMappings(IEnumerable mappings) + { + foreach (var mapping in mappings) + { + _mappings.Add(mapping); + } + } + + public bool HasPropertyMapping(IPropertyMapping mapping) + => _mappings.Contains(mapping); + + internal IEnumerable BuildBody(ExpressionSyntax source, ExpressionSyntax target) + => _mappings.Select(x => x.Build(source, target)); +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMapping.cs new file mode 100644 index 0000000000..1d2a1c6e71 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMapping.cs @@ -0,0 +1,13 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// Represents a property mapping or a container of property mappings. +/// +public interface IPropertyMapping +{ + StatementSyntax Build( + ExpressionSyntax sourceAccess, + ExpressionSyntax targetAccess); +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMappingContainer.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMappingContainer.cs new file mode 100644 index 0000000000..6acfeec5bc --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/IPropertyMappingContainer.cs @@ -0,0 +1,13 @@ +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// Represents a container of several property mappings. +/// +public interface IPropertyMappingContainer +{ + bool HasPropertyMapping(IPropertyMapping mapping); + + void AddPropertyMapping(IPropertyMapping mapping); + + void AddPropertyMappings(IEnumerable mappings); +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyMapping.cs new file mode 100644 index 0000000000..0ca429a997 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyMapping.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// Represents a simple property mapping (eg. target.A = source.B) +/// +[DebuggerDisplay("PropertyMapping({SourcePath.FullName} => {TargetPath.FullName})")] +public class PropertyMapping : IPropertyMapping +{ + private readonly TypeMapping _mapping; + private readonly bool _nullConditionalSourceAccess; + + public PropertyMapping( + PropertyPath sourcePath, + PropertyPath targetPath, + TypeMapping mapping, + bool nullConditionalSourceAccess) + { + SourcePath = sourcePath; + TargetPath = targetPath; + _mapping = mapping; + _nullConditionalSourceAccess = nullConditionalSourceAccess; + } + + public PropertyPath SourcePath { get; } + + public PropertyPath TargetPath { get; } + + public StatementSyntax Build( + ExpressionSyntax sourceAccess, + ExpressionSyntax targetAccess) + { + var sourcePropertyAccess = SourcePath.BuildAccess(sourceAccess, true, _nullConditionalSourceAccess); + var targetPropertyAccess = TargetPath.BuildAccess(targetAccess); + var mappedValue = _mapping.Build(sourcePropertyAccess); + + // target.Property = mappedValue; + var assignment = AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + targetPropertyAccess, + mappedValue); + return ExpressionStatement(assignment); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + if (obj.GetType() != GetType()) + return false; + + return Equals((PropertyMapping)obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = _mapping.GetHashCode(); + hashCode = (hashCode * 397) ^ SourcePath.GetHashCode(); + hashCode = (hashCode * 397) ^ TargetPath.GetHashCode(); + hashCode = (hashCode * 397) ^ _nullConditionalSourceAccess.GetHashCode(); + return hashCode; + } + } + + public static bool operator ==(PropertyMapping? left, PropertyMapping? right) + => Equals(left, right); + + public static bool operator !=(PropertyMapping? left, PropertyMapping? right) + => !Equals(left, right); + + protected bool Equals(PropertyMapping other) + { + return _mapping.Equals(other._mapping) + && SourcePath.Equals(other.SourcePath) + && TargetPath.Equals(other.TargetPath) + && _nullConditionalSourceAccess == other._nullConditionalSourceAccess; + } +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullDelegateMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullDelegateMapping.cs new file mode 100644 index 0000000000..66375011ed --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullDelegateMapping.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// a property mapping container, which performs a null check before the mappings. +/// +[DebuggerDisplay("PropertyNullDelegateMapping({_nullConditionalSourcePath} != null)")] +public class PropertyNullDelegateMapping : IPropertyMapping, IPropertyMappingContainer +{ + private readonly PropertyPath _nullConditionalSourcePath; + private readonly bool _throwInsteadOfConditionalNullMapping; + private readonly HashSet _delegateMappings = new(); + private readonly IPropertyMappingContainer _parent; + + public PropertyNullDelegateMapping( + PropertyPath nullConditionalSourcePath, + IPropertyMappingContainer parent, + bool throwInsteadOfConditionalNullMapping) + { + _nullConditionalSourcePath = nullConditionalSourcePath; + _throwInsteadOfConditionalNullMapping = throwInsteadOfConditionalNullMapping; + _parent = parent; + } + + public StatementSyntax Build( + ExpressionSyntax sourceAccess, + ExpressionSyntax targetAccess) + { + // if (source.Value != null) + // target.Value = Map(Source.Name); + // else + // throw ... + var sourceNullConditionalAccess = _nullConditionalSourcePath.BuildAccess(sourceAccess, true, true, true); + var condition = IsNotNull(sourceNullConditionalAccess); + var elseClause = _throwInsteadOfConditionalNullMapping + ? ElseClause(Block(ExpressionStatement(ThrowNewArgumentNullException(sourceNullConditionalAccess)))) + : null; + + var mappings = _delegateMappings.Select(m => m.Build(sourceAccess, targetAccess)).ToList(); + return IfStatement(condition, Block(mappings), elseClause); + } + + public void AddPropertyMappings(IEnumerable mappings) + { + foreach (var mapping in mappings) + { + AddPropertyMapping(mapping); + } + } + + public void AddPropertyMapping(IPropertyMapping mapping) + { + if (!HasPropertyMapping(mapping)) + { + _delegateMappings.Add(mapping); + } + } + + public bool HasPropertyMapping(IPropertyMapping mapping) + => _delegateMappings.Contains(mapping) || _parent.HasPropertyMapping(mapping); + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + if (obj.GetType() != GetType()) + return false; + + return Equals((PropertyNullDelegateMapping)obj); + } + + public override int GetHashCode() + { + unchecked + { + return (_nullConditionalSourcePath.GetHashCode() * 397) ^ _throwInsteadOfConditionalNullMapping.GetHashCode(); + } + } + + public static bool operator ==(PropertyNullDelegateMapping? left, PropertyNullDelegateMapping? right) + => Equals(left, right); + + public static bool operator !=(PropertyNullDelegateMapping? left, PropertyNullDelegateMapping? right) + => !Equals(left, right); + + protected bool Equals(PropertyNullDelegateMapping other) + { + return _nullConditionalSourcePath.Equals(other._nullConditionalSourcePath) + && _throwInsteadOfConditionalNullMapping == other._throwInsteadOfConditionalNullMapping; + } +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullInitializerDelegateMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullInitializerDelegateMapping.cs new file mode 100644 index 0000000000..fe645a96c0 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyNullInitializerDelegateMapping.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// A property initializer which initializes null properties to a new objects. +/// +[DebuggerDisplay("PropertyNullInitializerDelegateMapping({_pathToInitialize} ??= new())")] +public class PropertyNullInitializerDelegateMapping : IPropertyMapping +{ + private readonly PropertyPath _pathToInitialize; + + public PropertyNullInitializerDelegateMapping(PropertyPath pathToInitialize) + { + _pathToInitialize = pathToInitialize; + } + + public StatementSyntax Build(ExpressionSyntax sourceAccess, ExpressionSyntax targetAccess) + { + // source.Value ??= new(); + return ExpressionStatement( + AssignmentExpression( + SyntaxKind.CoalesceAssignmentExpression, + _pathToInitialize.BuildAccess(targetAccess), + ImplicitObjectCreationExpression())); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + if (obj.GetType() != GetType()) + return false; + + return Equals((PropertyNullInitializerDelegateMapping)obj); + } + + public override int GetHashCode() + => _pathToInitialize.GetHashCode(); + + public static bool operator ==(PropertyNullInitializerDelegateMapping? left, PropertyNullInitializerDelegateMapping? right) + => Equals(left, right); + + public static bool operator !=(PropertyNullInitializerDelegateMapping? left, PropertyNullInitializerDelegateMapping? right) + => !Equals(left, right); + + protected bool Equals(PropertyNullInitializerDelegateMapping other) + => _pathToInitialize.Equals(other._pathToInitialize); +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyPath.cs b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyPath.cs new file mode 100644 index 0000000000..fc3de05191 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/PropertyMappings/PropertyPath.cs @@ -0,0 +1,137 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Riok.Mapperly.Helpers; +using static Riok.Mapperly.Emit.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings; + +/// +/// Represents a set of properties to access a certain property. +/// Eg. A.B.C +/// +[DebuggerDisplay("{FullName}")] +public class PropertyPath +{ + internal const string PropertyAccessSeparator = "."; + private const string NullableValueProperty = "Value"; + + private IPropertySymbol? _member; + + public PropertyPath(IReadOnlyCollection path) + { + Path = path; + FullName = string.Join(PropertyAccessSeparator, Path.Select(x => x.Name)); + } + + public IReadOnlyCollection Path { get; } + + /// + /// Gets the path without the very last element (the path of the object containing the ). + /// + public IEnumerable ObjectPath => Path.SkipLast(); + + /// + /// Gets the last part of the path or throws if there is none. + /// + public IPropertySymbol Member + { + get => _member ??= Path.Last(); + } + + /// + /// Gets the full name of the path (eg. A.B.C). + /// + public string FullName { get; } + + /// + /// Builds a property path skipping trailing path items which are non nullable. + /// + /// The built path. + public IEnumerable PathWithoutTrailingNonNullable() + => Path.Reverse().SkipWhile(x => !x.IsNullable()).Reverse(); + + /// + /// Returns an element for each nullable sub-path of the . + /// If the is nullable, the entire is not returned. + /// + /// All nullable sub-paths of the . + public IEnumerable> ObjectPathNullableSubPaths() + { + var pathParts = new List(Path.Count); + foreach (var pathPart in ObjectPath) + { + pathParts.Add(pathPart); + if (!pathPart.IsNullable()) + continue; + + yield return pathParts; + } + } + + public bool IsAnyNullable() + => Path.Any(p => p.IsNullable()); + + public ExpressionSyntax BuildAccess( + ExpressionSyntax baseAccess, + bool addValuePropertyOnNullable = false, + bool nullConditional = false, + bool skipTrailingNonNullable = false) + { + if (!nullConditional) + { + if (addValuePropertyOnNullable) + { + return Path.Aggregate(baseAccess, (a, b) => b.Type.IsNullableValueType() + ? MemberAccess(MemberAccess(a, b.Name), NullableValueProperty) + : MemberAccess(a, b.Name)); + } + + return Path.Aggregate(baseAccess, (a, b) => MemberAccess(a, b.Name)); + } + + var path = skipTrailingNonNullable + ? PathWithoutTrailingNonNullable() + : Path; + + return path.AggregateWithPrevious( + baseAccess, + (expr, prevProp, prop) => prevProp?.IsNullable() == true + ? ConditionalAccess(expr, prop.Name) + : MemberAccess(expr, prop.Name)); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + if (obj.GetType() != GetType()) + return false; + + return Equals((PropertyPath)obj); + } + + public override int GetHashCode() + { + var hc = 0; + foreach (var item in Path) + { + hc ^= SymbolEqualityComparer.Default.GetHashCode(item); + } + + return hc; + } + + public static bool operator ==(PropertyPath? left, PropertyPath? right) + => Equals(left, right); + + public static bool operator !=(PropertyPath? left, PropertyPath? right) + => !Equals(left, right); + + private bool Equals(PropertyPath other) + => Path.SequenceEqual(other.Path, SymbolEqualityComparer.IncludeNullability); +} diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/SourceObjectMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs similarity index 93% rename from src/Riok.Mapperly/Descriptors/TypeMappings/SourceObjectMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs index 82cff89cbc..0af7209a87 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/SourceObjectMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/SourceObjectMethodMapping.cs @@ -3,7 +3,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping which works by invoking an instance method on the source object. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/StaticMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/StaticMethodMapping.cs similarity index 92% rename from src/Riok.Mapperly/Descriptors/TypeMappings/StaticMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/StaticMethodMapping.cs index 54675ce630..4173922e68 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/StaticMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/StaticMethodMapping.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping which works by invoking a static method with the source as only argument. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/TypeMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs similarity index 94% rename from src/Riok.Mapperly/Descriptors/TypeMappings/TypeMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs index 640608bc57..52fb09e8b2 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/TypeMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/TypeMapping.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping to map from one type to another. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedExistingInstanceMethodMapping.cs similarity index 97% rename from src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/UserDefinedExistingInstanceMethodMapping.cs index d5a3fcf0d3..803ed05a2f 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedExistingInstanceMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedExistingInstanceMethodMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping method declared but not implemented by the user which reuses an existing target object instance. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs similarity index 96% rename from src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs index a35c5c7a93..13720e180a 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserDefinedNewInstanceMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserDefinedNewInstanceMethodMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping method declared but not implemented by the user which results in a new target object instance. diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/UserImplementedMethodMapping.cs similarity index 96% rename from src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs rename to src/Riok.Mapperly/Descriptors/Mappings/UserImplementedMethodMapping.cs index ed3b974487..237ba4cc58 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/UserImplementedMethodMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/UserImplementedMethodMapping.cs @@ -4,7 +4,7 @@ using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.SyntaxFactoryHelper; -namespace Riok.Mapperly.Descriptors.TypeMappings; +namespace Riok.Mapperly.Descriptors.Mappings; /// /// Represents a mapping method on the mapper which is implemented by the user. diff --git a/src/Riok.Mapperly/Descriptors/MemberPathCandidateBuilder.cs b/src/Riok.Mapperly/Descriptors/MemberPathCandidateBuilder.cs new file mode 100644 index 0000000000..39d864ffee --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/MemberPathCandidateBuilder.cs @@ -0,0 +1,22 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Descriptors; + +public static class MemberPathCandidateBuilder +{ + internal static IEnumerable> BuildMemberPathCandidates(string name) + { + var chunks = StringChunker.ChunkPascalCase(name).ToList(); + for (var i = 1 << chunks.Count - 1; i > 0; i--) + { + yield return BuildName(chunks, i); + } + } + + private static IEnumerable BuildName(IEnumerable chunks, int splitPositions) + { + return chunks + .Chunk((_, i) => (splitPositions & (1 << i)) == 0) + .Select(x => string.Concat(x)); + } +} diff --git a/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs b/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs index 393abf2cd2..53bf05d5ce 100644 --- a/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MethodNameBuilder.cs @@ -1,4 +1,4 @@ -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; namespace Riok.Mapperly.Descriptors; diff --git a/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs b/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs deleted file mode 100644 index 07d88663a0..0000000000 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/ObjectPropertyMapping.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Riok.Mapperly.Descriptors.TypeMappings; - -/// -/// Represents a complex object mapping implemented in its own method. -/// Maps each property from the source to the target. -/// -public abstract class ObjectPropertyMapping : MethodMapping -{ - private readonly List _propertyMappings = new(); - - protected ObjectPropertyMapping(ITypeSymbol sourceType, ITypeSymbol targetType) : base(sourceType, targetType) - { - } - - public void AddPropertyMapping(PropertyMapping propertyMapping) - => _propertyMappings.Add(propertyMapping); - - internal IEnumerable BuildBody(ExpressionSyntax source, ExpressionSyntax target) - => _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 deleted file mode 100644 index f7c5965024..0000000000 --- a/src/Riok.Mapperly/Descriptors/TypeMappings/PropertyMapping.cs +++ /dev/null @@ -1,90 +0,0 @@ -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() || (_mapping is DirectAssignmentMapping && _target.IsNullable())) - return (null, sourceAccess); - - // if source is nullable but the mapping does not accept nulls - // and is also not a direct assignment where the target is also nullable - // 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/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 601cdcff17..1ec05e550c 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -67,4 +67,36 @@ internal static class DiagnosticDescriptors DiagnosticCategories.Mapper, DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor CanNotMapToReadOnlyProperty = new( + "RMG009", + "Can not map to read only property", + "Can not map property {0}.{1} of type {2} to read only property {3}.{4} of type {5}", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Info, + true); + + public static readonly DiagnosticDescriptor CanNotMapFromWriteOnlyProperty = new( + "RMG010", + "Can not map from write only property", + "Can not map from write only property {0}.{1} of type {2} to property {3}.{4} of type {5}", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Info, + true); + + public static readonly DiagnosticDescriptor CanNotMapToWriteOnlyPropertyPath = new( + "RMG011", + "Can not map to write only property path", + "Can not map from property {0}.{1} of type {2} to write only property path {3}.{4} of type {5}", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Info, + true); + + public static readonly DiagnosticDescriptor MappingSourcePropertyNotFound = new( + "RMG012", + "Mapping source property not found", + "Property {0} on source type {1} was not found", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Info, + true); } diff --git a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs index 97d7928451..ce21b2c002 100644 --- a/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs +++ b/src/Riok.Mapperly/Emit/SyntaxFactoryHelper.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; @@ -17,6 +17,8 @@ public static class SyntaxFactoryHelper public static readonly IdentifierNameSyntax VarIdentifier = IdentifierName("var"); + private static readonly IdentifierNameSyntax _nameofIdentifier = IdentifierName("nameof"); + public static SyntaxToken Accessibility(Accessibility accessibility) { return accessibility switch @@ -93,8 +95,11 @@ public static MemberAccessExpressionSyntax MemberAccess(string identifierName, s public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax idExpression, string propertyIdentifierName) => MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, idExpression, IdentifierName(propertyIdentifierName)); + public static ConditionalAccessExpressionSyntax ConditionalAccess(ExpressionSyntax idExpression, string propertyIdentifierName) + => ConditionalAccessExpression(idExpression, MemberBindingExpression(IdentifierName(propertyIdentifierName))); + public static InvocationExpressionSyntax NameOf(ExpressionSyntax expression) - => Invocation(IdentifierName("nameof"), expression); + => Invocation(_nameofIdentifier, expression); public static ThrowExpressionSyntax ThrowArgumentOutOfRangeException(ExpressionSyntax arg) { diff --git a/src/Riok.Mapperly/Helpers/EnumerableExtensions.cs b/src/Riok.Mapperly/Helpers/EnumerableExtensions.cs index 399391c9c4..23276911b4 100644 --- a/src/Riok.Mapperly/Helpers/EnumerableExtensions.cs +++ b/src/Riok.Mapperly/Helpers/EnumerableExtensions.cs @@ -28,9 +28,54 @@ public static HashSet ToHashSet(this IEnumerable enumerable) } } - public static IEnumerable WhereNotNull(this IEnumerable enumerable) - where T : class -#nullable disable - => enumerable.Where(x => x != null); -#nullable enable + public static IEnumerable> Chunk(this IEnumerable enumerable, Func shouldChunk) + { + var l = new List(); + var i = 0; + foreach (var item in enumerable) + { + l.Add(item); + if (!shouldChunk(item, i++)) + continue; + + if (l.Count == 0) + continue; + + yield return l; + l = new(); + } + + if (l.Count != 0) + yield return l; + } + + public static IEnumerable SkipLast(this IEnumerable enumerable) + { + using var enumerator = enumerable.GetEnumerator(); + if (!enumerator.MoveNext()) + yield break; + + var previousItem = enumerator.Current; + while (enumerator.MoveNext()) + { + yield return previousItem; + previousItem = enumerator.Current; + } + } + + public static TAccumulate AggregateWithPrevious( + this IEnumerable source, + TAccumulate seed, + Func func) + { + var result = seed; + T? prev = default; + foreach (var element in source) + { + result = func(result, prev, element); + prev = element; + } + + return result; + } } diff --git a/src/Riok.Mapperly/Helpers/StringChunker.cs b/src/Riok.Mapperly/Helpers/StringChunker.cs new file mode 100644 index 0000000000..5457f8103e --- /dev/null +++ b/src/Riok.Mapperly/Helpers/StringChunker.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace Riok.Mapperly.Helpers; + +public static class StringChunker +{ + internal static IEnumerable ChunkPascalCase(string str) + { + var sb = new StringBuilder(); + foreach (var c in str) + { + if (!char.IsUpper(c)) + { + sb.Append(c); + continue; + } + + if (sb.Length != 0) + { + yield return sb.ToString(); + sb.Clear(); + } + + sb.Append(c); + } + + if (sb.Length != 0) + yield return sb.ToString(); + } +} diff --git a/src/Riok.Mapperly/Riok.Mapperly.csproj b/src/Riok.Mapperly/Riok.Mapperly.csproj index e58fd4704b..76820963bd 100644 --- a/src/Riok.Mapperly/Riok.Mapperly.csproj +++ b/src/Riok.Mapperly/Riok.Mapperly.csproj @@ -22,7 +22,7 @@ - + diff --git a/test/Riok.Mapperly.Abstractions.Test/MapPropertyAttributeTest.cs b/test/Riok.Mapperly.Abstractions.Test/MapPropertyAttributeTest.cs new file mode 100644 index 0000000000..c67a723508 --- /dev/null +++ b/test/Riok.Mapperly.Abstractions.Test/MapPropertyAttributeTest.cs @@ -0,0 +1,14 @@ +namespace Riok.Mapperly.Abstractions.Test; + +public class MapPropertyAttributeTest +{ + [Fact] + public void ShouldSplitMemberAccess() + { + var attr = new MapPropertyAttribute("a.b.c", "d.e.f"); + attr.Source.Should().BeEquivalentTo("a", "b", "c"); + attr.SourceFullName.Should().BeEquivalentTo("a.b.c"); + attr.Target.Should().BeEquivalentTo("d", "e", "f"); + attr.TargetFullName.Should().BeEquivalentTo("d.e.f"); + } +} diff --git a/test/Riok.Mapperly.Abstractions.Test/Riok.Mapperly.Abstractions.Test.csproj b/test/Riok.Mapperly.Abstractions.Test/Riok.Mapperly.Abstractions.Test.csproj new file mode 100644 index 0000000000..80ceab9d51 --- /dev/null +++ b/test/Riok.Mapperly.Abstractions.Test/Riok.Mapperly.Abstractions.Test.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/test/Riok.Mapperly.IntegrationTests/Dto/IdObjectDto.cs b/test/Riok.Mapperly.IntegrationTests/Dto/IdObjectDto.cs new file mode 100644 index 0000000000..2f98ccc381 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/Dto/IdObjectDto.cs @@ -0,0 +1,6 @@ +namespace Riok.Mapperly.IntegrationTests.Dto; + +public class IdObjectDto +{ + public int IdValue { get; set; } +} diff --git a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs index 603d8eebeb..5ae9789a06 100644 --- a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs +++ b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs @@ -10,6 +10,16 @@ public class TestObjectDto public string RenamedStringValue2 { get; set; } = string.Empty; + public int FlatteningIdValue { get; set; } + + public int? NullableFlatteningIdValue { get; set; } + + public IdObjectDto Unflattening { get; set; } = new(); + + public IdObjectDto? NullableUnflattening { get; set; } + + public int NestedNullableIntValue { get; set; } + public TestObjectNestedDto? NestedNullable { get; set; } public TestObjectNestedDto NestedNullableTargetNotNullable { get; set; } = new(); diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs index 1bf99f6da1..f2a776fb93 100644 --- a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs +++ b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs @@ -32,6 +32,12 @@ public TestObjectDto MapToDto(TestObject src) [MapperIgnore(nameof(TestObjectDto.IgnoredStringValue))] [MapProperty(nameof(TestObject.RenamedStringValue), nameof(TestObjectDto.RenamedStringValue2))] + [MapProperty( + new[] { nameof(TestObject.UnflatteningIdValue) }, + new[] { nameof(TestObjectDto.Unflattening), nameof(TestObjectDto.Unflattening.IdValue) })] + [MapProperty( + nameof(TestObject.NullableUnflatteningIdValue), + $"{nameof(TestObjectDto.NullableUnflattening)}.{nameof(TestObjectDto.NullableUnflattening.IdValue)}")] private partial TestObjectDto MapToDtoInternal(TestObject testObject); [MapperIgnore(nameof(TestObject.IgnoredStringValue))] diff --git a/test/Riok.Mapperly.IntegrationTests/MapperTest.cs b/test/Riok.Mapperly.IntegrationTests/MapperTest.cs index f0b43849ef..d06f6988f7 100644 --- a/test/Riok.Mapperly.IntegrationTests/MapperTest.cs +++ b/test/Riok.Mapperly.IntegrationTests/MapperTest.cs @@ -51,6 +51,10 @@ private TestObject NewTestObj() StringNullableTargetNotNullable = "fooBar3", EnumReverseStringValue = nameof(TestEnumDtoByValue.DtoValue3), NestedNullableTargetNotNullable = new(), + Flattening = new() { IdValue = 10 }, + NullableFlattening = new() { IdValue = 100 }, + UnflatteningIdValue = 20, + NullableUnflatteningIdValue = 200, RecursiveObject = new() { diff --git a/test/Riok.Mapperly.IntegrationTests/Models/IdObject.cs b/test/Riok.Mapperly.IntegrationTests/Models/IdObject.cs new file mode 100644 index 0000000000..01e17354f7 --- /dev/null +++ b/test/Riok.Mapperly.IntegrationTests/Models/IdObject.cs @@ -0,0 +1,6 @@ +namespace Riok.Mapperly.IntegrationTests.Models; + +public class IdObject +{ + public int IdValue { get; set; } +} diff --git a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs index 777266f8a4..a233f94460 100644 --- a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs +++ b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs @@ -8,6 +8,14 @@ public class TestObject public string RenamedStringValue { get; set; } = string.Empty; + public IdObject Flattening { get; set; } = new(); + + public IdObject? NullableFlattening { get; set; } + + public int UnflatteningIdValue { get; set; } + + public int? NullableUnflatteningIdValue { get; set; } + public TestObjectNested? NestedNullable { get; set; } public TestObjectNested? NestedNullableTargetNotNullable { get; set; } diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork.verified.txt index b4901c516a..cda9c7c0a1 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork.verified.txt +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork.verified.txt @@ -2,6 +2,15 @@ IntValue: 10, StringValue: fooBar+after-map, RenamedStringValue2: fooBar2, + FlatteningIdValue: 10, + NullableFlatteningIdValue: 100, + Unflattening: { + IdValue: 20 + }, + NullableUnflattening: { + IdValue: 200 + }, + NestedNullableIntValue: 100, NestedNullable: { IntValue: 100 }, @@ -10,6 +19,7 @@ RecursiveObject: { StringValue: +after-map, RenamedStringValue2: , + Unflattening: {}, NestedNullableTargetNotNullable: {}, StringNullableTargetNotNullable: , EnumValue: DtoValue1, @@ -21,6 +31,7 @@ IntValue: 99, StringValue: , RenamedStringValue: , + Flattening: {}, EnumReverseStringValue: }, NullableReadOnlyObjectCollection: [ diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource.verified.cs index 0afe1cbdd7..173ea7ea63 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource.verified.cs @@ -48,22 +48,40 @@ public partial class TestMapper var target = new Riok.Mapperly.IntegrationTests.Models.TestObject(); target.IntValue = DirectInt(dto.IntValue); target.StringValue = dto.StringValue; + target.UnflatteningIdValue = DirectInt(dto.Unflattening.IdValue); + if (dto.NullableUnflattening != null) + { + target.NullableUnflatteningIdValue = CastIntNullable(dto.NullableUnflattening.IdValue); + } + if (dto.NestedNullable != null) + { target.NestedNullable = MapToTestObjectNested(dto.NestedNullable); + } + target.NestedNullableTargetNotNullable = MapToTestObjectNested(dto.NestedNullableTargetNotNullable); target.StringNullableTargetNotNullable = dto.StringNullableTargetNotNullable; if (dto.RecursiveObject != null) + { target.RecursiveObject = MapFromDto(dto.RecursiveObject); + } + target.SourceTargetSameObjectType = dto.SourceTargetSameObjectType; if (dto.NullableReadOnlyObjectCollection != null) + { target.NullableReadOnlyObjectCollection = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(dto.NullableReadOnlyObjectCollection, x => MapToTestObjectNested(x))); + } + target.EnumValue = (Riok.Mapperly.IntegrationTests.Models.TestEnum)dto.EnumValue; target.EnumName = (Riok.Mapperly.IntegrationTests.Models.TestEnum)dto.EnumName; target.EnumRawValue = (Riok.Mapperly.IntegrationTests.Models.TestEnum)dto.EnumRawValue; target.EnumStringValue = MapToTestEnum(dto.EnumStringValue); target.EnumReverseStringValue = MapToString1(dto.EnumReverseStringValue); if (dto.SubObject != null) + { target.SubObject = MapToInheritanceSubObject(dto.SubObject); + } + return target; } @@ -158,24 +176,56 @@ private Riok.Mapperly.IntegrationTests.Models.InheritanceSubObject MapToInherita target.IntValue = DirectInt(testObject.IntValue); target.StringValue = testObject.StringValue; target.RenamedStringValue2 = testObject.RenamedStringValue; + target.FlatteningIdValue = DirectInt(testObject.Flattening.IdValue); + if (testObject.NullableFlattening != null) + { + target.NullableFlatteningIdValue = CastIntNullable(testObject.NullableFlattening.IdValue); + } + + target.Unflattening.IdValue = DirectInt(testObject.UnflatteningIdValue); + if (testObject.NullableUnflatteningIdValue != null) + { + target.NullableUnflattening ??= new(); + target.NullableUnflattening.IdValue = DirectInt(testObject.NullableUnflatteningIdValue.Value); + } + if (testObject.NestedNullable != null) + { + target.NestedNullableIntValue = DirectInt(testObject.NestedNullable.IntValue); target.NestedNullable = MapToTestObjectNestedDto(testObject.NestedNullable); + } + if (testObject.NestedNullableTargetNotNullable != null) + { target.NestedNullableTargetNotNullable = MapToTestObjectNestedDto(testObject.NestedNullableTargetNotNullable); + } + if (testObject.StringNullableTargetNotNullable != null) + { target.StringNullableTargetNotNullable = testObject.StringNullableTargetNotNullable; + } + if (testObject.RecursiveObject != null) + { target.RecursiveObject = MapToDto(testObject.RecursiveObject); + } + target.SourceTargetSameObjectType = testObject.SourceTargetSameObjectType; if (testObject.NullableReadOnlyObjectCollection != null) + { target.NullableReadOnlyObjectCollection = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(testObject.NullableReadOnlyObjectCollection, x => MapToTestObjectNestedDto(x))); + } + target.EnumValue = (Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByValue)testObject.EnumValue; target.EnumName = MapToEnumDtoByName(testObject.EnumName); target.EnumRawValue = (byte)testObject.EnumRawValue; target.EnumStringValue = MapToString(testObject.EnumStringValue); target.EnumReverseStringValue = MapToTestEnumDtoByValue(testObject.EnumReverseStringValue); if (testObject.SubObject != null) + { target.SubObject = MapToInheritanceSubObjectDto(testObject.SubObject); + } + return target; } @@ -183,24 +233,49 @@ private Riok.Mapperly.IntegrationTests.Models.InheritanceSubObject MapToInherita { target.IntValue = DirectInt(source.IntValue); target.StringValue = source.StringValue; + target.FlatteningIdValue = DirectInt(source.Flattening.IdValue); + if (source.NullableFlattening != null) + { + target.NullableFlatteningIdValue = CastIntNullable(source.NullableFlattening.IdValue); + } + if (source.NestedNullable != null) + { + target.NestedNullableIntValue = DirectInt(source.NestedNullable.IntValue); target.NestedNullable = MapToTestObjectNestedDto(source.NestedNullable); + } + if (source.NestedNullableTargetNotNullable != null) + { target.NestedNullableTargetNotNullable = MapToTestObjectNestedDto(source.NestedNullableTargetNotNullable); + } + if (source.StringNullableTargetNotNullable != null) + { target.StringNullableTargetNotNullable = source.StringNullableTargetNotNullable; + } + if (source.RecursiveObject != null) + { target.RecursiveObject = MapToDto(source.RecursiveObject); + } + target.SourceTargetSameObjectType = source.SourceTargetSameObjectType; if (source.NullableReadOnlyObjectCollection != null) + { target.NullableReadOnlyObjectCollection = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(source.NullableReadOnlyObjectCollection, x => MapToTestObjectNestedDto(x))); + } + target.EnumValue = (Riok.Mapperly.IntegrationTests.Dto.TestEnumDtoByValue)source.EnumValue; target.EnumName = MapToEnumDtoByName(source.EnumName); target.EnumRawValue = (byte)source.EnumRawValue; target.EnumStringValue = MapToString(source.EnumStringValue); target.EnumReverseStringValue = MapToTestEnumDtoByValue(source.EnumReverseStringValue); if (source.SubObject != null) + { target.SubObject = MapToInheritanceSubObjectDto(source.SubObject); + } + target.IgnoredStringValue = source.IgnoredStringValue; } diff --git a/test/Riok.Mapperly.Tests/Descriptors/MemberPathCandidateBuilderTest.cs b/test/Riok.Mapperly.Tests/Descriptors/MemberPathCandidateBuilderTest.cs new file mode 100644 index 0000000000..8fa9fb827d --- /dev/null +++ b/test/Riok.Mapperly.Tests/Descriptors/MemberPathCandidateBuilderTest.cs @@ -0,0 +1,18 @@ +using Riok.Mapperly.Descriptors; + +namespace Riok.Mapperly.Tests.Descriptors; + +public class MemberPathCandidateBuilderTest +{ + [Theory] + [InlineData("Value", new[] { "Value" })] + [InlineData("MyValue", new[] { "MyValue", "My.Value" })] + [InlineData("MyValueId", new[] { "MyValueId", "My.ValueId", "MyValue.Id", "My.Value.Id" })] + public void BuildMemberPathCandidatesShouldWork(string name, string[] chunks) + { + MemberPathCandidateBuilder.BuildMemberPathCandidates(name) + .Select(x => string.Join(".", x)) + .Should() + .BeEquivalentTo(chunks); + } +} diff --git a/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs b/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs index 60b04a642f..f4a837eaa5 100644 --- a/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs +++ b/test/Riok.Mapperly.Tests/Descriptors/MethodNameBuilderTest.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Moq; using Riok.Mapperly.Descriptors; -using Riok.Mapperly.Descriptors.TypeMappings; +using Riok.Mapperly.Descriptors.Mappings; namespace Riok.Mapperly.Tests.Descriptors; diff --git a/test/Riok.Mapperly.Tests/Helpers/DictionaryExtensionsTest.cs b/test/Riok.Mapperly.Tests/Helpers/DictionaryExtensionsTest.cs new file mode 100644 index 0000000000..d9812682c5 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/DictionaryExtensionsTest.cs @@ -0,0 +1,45 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +// can't use extension methods due to ambiguous method reference. +// (no support for this method in netstandard2.0) +public class DictionaryExtensionsTest +{ + [Fact] + public void RemoveShouldReturnTrueWhenKeyWasRemoved() + { + var d = new Dictionary { ["a"] = 10, ["b"] = 20, }; + DictionaryExtensions.Remove(d, "a", out var value).Should().BeTrue(); + value.Should().Be(10); + } + + [Fact] + public void RemoveShouldReturnFalseWhenKeyWasNotRemoved() + { + var d = new Dictionary { ["a"] = 10, ["b"] = 20, }; + DictionaryExtensions.Remove(d, "c", out var value).Should().BeFalse(); + value.Should().Be(0); + } + + [Fact] + public void GetValueOrDefaultShouldReturnValueIfFound() + { + var d = new Dictionary { ["a"] = 10, ["b"] = 20, }; + DictionaryExtensions.GetValueOrDefault(d, "a").Should().Be(10); + } + + [Fact] + public void GetValueOrDefaultShouldReturnDefaultForPrimitiveIfNotFound() + { + var d = new Dictionary { ["a"] = 10, ["b"] = 20, }; + DictionaryExtensions.GetValueOrDefault(d, "c").Should().Be(0); + } + + [Fact] + public void GetValueOrDefaultShouldReturnDefaultForReferenceTypeIfNotFound() + { + var d = new Dictionary { ["a"] = new(), ["b"] = new(), }; + DictionaryExtensions.GetValueOrDefault(d, "c").Should().BeNull(); + } +} diff --git a/test/Riok.Mapperly.Tests/Helpers/EnumerableExtensionsTest.cs b/test/Riok.Mapperly.Tests/Helpers/EnumerableExtensionsTest.cs new file mode 100644 index 0000000000..b8c5766405 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/EnumerableExtensionsTest.cs @@ -0,0 +1,71 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +public class EnumerableExtensionsTest +{ + [Fact] + public void ToHashSetShouldWork() + { + var items = new[] { 1, 1, 2, 3, 4, 5, 5 }; + + // can't use extension method due to ambiguous method reference. + // (no support for this method in netstandard2.0) + var hashSet = EnumerableExtensions.ToHashSet(items); + hashSet.Should().BeEquivalentTo(new[] { 1, 2, 3, 4, 5 }); + } + + [Fact] + public void DistinctByShouldWork() + { + var items = new[] + { + ("item10", 10), + ("item11", 10), + ("item12", 10), + ("item20", 20), + ("item30", 30), + ("item31", 30), + }; + + items + .DistinctBy(x => x.Item2) + .Select(x => x.Item1) + .Should() + .BeEquivalentTo(new[] { "item10", "item20", "item30" }); + } + + [Fact] + public void ChunkShouldWork() + { + Enumerable + .Range(0, 5) + .Chunk((_, index) => index % 2 == 1) + .Should() + .BeEquivalentTo(new[] + { + new[] { 0, 1 }, + new[] { 2, 3 }, + new[] { 4 } + }); + } + + [Fact] + public void SkipLastShouldWork() + { + var items = new[] { 1, 2, 5, 6, 7 }; + items + .SkipLast() + .Should() + .BeEquivalentTo(items.Take(items.Length - 1)); + } + + [Fact] + public void AggregateWithPrevious() + { + var items = new[] { 1, 2, 5, 6, 7 }; + items.AggregateWithPrevious(100, (agg, prev, item) => agg - prev + item) + .Should() + .Be(107); + } +} diff --git a/test/Riok.Mapperly.Tests/Helpers/QueueExtensionsTest.cs b/test/Riok.Mapperly.Tests/Helpers/QueueExtensionsTest.cs new file mode 100644 index 0000000000..ae14f1a6e7 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/QueueExtensionsTest.cs @@ -0,0 +1,31 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +public class QueueExtensionsTest +{ + [Fact] + public void DequeueAllShouldDequeueAll() + { + var q = new Queue(); + q.Enqueue(0); + q.Enqueue(1); + + var index = 0; + foreach (var item in q.DequeueAll()) + { + item.Should().Be(index++); + + // enqueue during dequeue + if (item == 0) + { + for (var i = 2; i < 10; i++) + { + q.Enqueue(i); + } + } + } + + index.Should().Be(10); + } +} diff --git a/test/Riok.Mapperly.Tests/Helpers/StringChunkerTest.cs b/test/Riok.Mapperly.Tests/Helpers/StringChunkerTest.cs new file mode 100644 index 0000000000..9716d30c91 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/StringChunkerTest.cs @@ -0,0 +1,18 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +public class StringChunkerTest +{ + [Theory] + [InlineData("camelCase", new[] { "camel", "Case" })] + [InlineData("PascalCase", new[] { "Pascal", "Case" })] + [InlineData("ABC", new[] { "A", "B", "C" })] + [InlineData("abcABC", new[] { "abc", "A", "B", "C" })] + public void ChunkPascalCaseShouldWork(string str, string[] expected) + { + StringChunker.ChunkPascalCase(str) + .Should() + .BeEquivalentTo(expected); + } +} diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs index 22a8780c3f..85e063893a 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyTest.cs @@ -105,7 +105,10 @@ public void NullableIntToNonNullableIntProperty() .Should() .Be(@"var target = new B(); if (source.Value != null) + { target.Value = source.Value.Value; + } + return target;".ReplaceLineEndings()); } @@ -122,7 +125,10 @@ public void NullableStringToNonNullableStringProperty() .Should() .Be(@"var target = new B(); if (source.Value != null) + { target.Value = source.Value; + } + return target;".ReplaceLineEndings()); } @@ -141,7 +147,10 @@ public void NullableClassToNonNullableClassProperty() .Should() .Be(@"var target = new B(); if (source.Value != null) + { target.Value = MapToD(source.Value); + } + return target;".ReplaceLineEndings()); } @@ -194,7 +203,10 @@ public void DisabledNullableClassPropertyToNonNullableProperty() .Should() .Be(@"var target = new B(); if (source.Value != null) + { target.Value = MapToD(source.Value); + } + return target;".ReplaceLineEndings()); } @@ -213,7 +225,10 @@ public void NullableClassPropertyToDisabledNullableProperty() .Should() .Be(@"var target = new B(); if (source.Value != null) + { target.Value = MapToD(source.Value); + } + return target;".ReplaceLineEndings()); } @@ -233,14 +248,19 @@ public void NullableClassToNonNullableClassPropertyThrow() .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] - public void ShouldIgnoreWriteOnlyPropertyOnSource() + public Task ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics() { var source = TestSourceBuilder.Mapping( "A", @@ -248,15 +268,11 @@ public void ShouldIgnoreWriteOnlyPropertyOnSource() "class A { public string StringValue { get; set; } public string StringValue2 { set; } }", "class B { public string StringValue { get; set; } public string StringValue2 { get; set; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"var target = new B(); - target.StringValue = source.StringValue; - return target;".ReplaceLineEndings()); + return TestHelper.VerifyGenerator(source); } [Fact] - public void ShouldIgnoreReadOnlyPropertyOnTarget() + public Task ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic() { var source = TestSourceBuilder.Mapping( "A", @@ -264,15 +280,11 @@ public void ShouldIgnoreReadOnlyPropertyOnTarget() "class A { public string StringValue { get; set; } public string StringValue2 { get; set; } }", "class B { public string StringValue { get; set; } public string StringValue2 { get; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"var target = new B(); - target.StringValue = source.StringValue; - return target;".ReplaceLineEndings()); + return TestHelper.VerifyGenerator(source); } [Fact] - public void WithUnmatchedProperty() + public Task WithUnmatchedPropertyShouldDiagnostic() { var source = TestSourceBuilder.Mapping( "A", @@ -280,11 +292,7 @@ public void WithUnmatchedProperty() "class A { public string StringValue { get; set; } public string StringValueA { get; set; } }", "class B { public string StringValue { get; set; } public string StringValueB { get; set; } }"); - TestHelper.GenerateSingleMapperMethodBody(source) - .Should() - .Be(@"var target = new B(); - target.StringValue = source.StringValue; - return target;".ReplaceLineEndings()); + return TestHelper.VerifyGenerator(source); } [Fact] @@ -317,6 +325,28 @@ public void WithManualMappedProperty() return target;".ReplaceLineEndings()); } + [Fact] + public Task WithManualMappedNotFoundTargetPropertyShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(A.StringValue), nameof(B.StringValue9)] partial B Map(A source);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue2 { get; set; } }"); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public Task WithManualMappedNotFoundSourcePropertyShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(nameof(A.StringValue9), nameof(B.StringValue2)] partial B Map(A source);", + "class A { public string StringValue { get; set; } }", + "class B { public string StringValue2 { get; set; } }"); + + return TestHelper.VerifyGenerator(source); + } + [Fact] public void ShouldUseUserImplementedMapping() { @@ -411,9 +441,178 @@ public Task WithNotFoundIgnoredPropertyShouldDiagnostic() { var source = TestSourceBuilder.MapperWithBodyAndTypes( "[MapperIgnore(\"not_found\")] partial B Map(A source);", - "class A { public string StringValue { get; set; } }", - "class B { public string StringValue2 { get; set; } }"); + "class A { }", + "class B { }"); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public void ManualFlattenedProperty() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"Value.Id\", \"MyValueId\")] partial B Map(A source);", + "class A { public C Value { get; set; } }", + "class B { public string MyValueId { get; set; } }", + "class C { public string Id { get; set; }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + target.MyValueId = source.Value.Id; + return target;".ReplaceLineEndings()); + } + + [Fact] + public void AutoFlattenedProperty() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public C Value { get; set; } }", + "class B { public string ValueId { get; set; } }", + "class C { public string Id { get; set; }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + target.ValueId = source.Value.Id; + return target;".ReplaceLineEndings()); + } + + [Fact] + public void AutoFlattenedPropertyNullablePath() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public C? Value { get; set; } }", + "class B { public string ValueId { get; set; } }", + "class C { public string Id { get; set; }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + if (source.Value != null) + { + target.ValueId = source.Value.Id; + } + + return target;".ReplaceLineEndings()); + } + + [Fact] + public void ManualUnflattenedProperty() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueId\", \"Value.Id\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } }", + "class B { public C Value { get; set; } }", + "class C { public string Id { get; set; }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + target.Value.Id = source.MyValueId; + return target;".ReplaceLineEndings()); + } + + [Fact] + public void ManualUnflattenedPropertyNullablePath() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueId\", \"Value.Id\"), MapProperty($\"MyValueId2\", \"Value.Id2\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } public string MyValueId2 { get; set; } }", + "class B { public C? Value { get; set; } }", + "class C { public string Id { get; set; } public string Id2 { get; set; } }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + target.Value ??= new(); + target.Value.Id = source.MyValueId; + target.Value.Id2 = source.MyValueId2; + return target;".ReplaceLineEndings()); + } + + [Fact] + public Task ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueId\", \"Value.Id\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } }", + "class B { public C? Value { get; set; } }", + "class C { public C(string arg) {} public string Id { get; set; } }"); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public Task ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueIdXXX\", \"Value.Id\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } }", + "class B { public C? Value { get; set; } }", + "class C { public C(string arg) {} public string Id { get; set; } }"); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public Task ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueId\", \"Value.Id\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } }", + "class B { public C? Value { set; } }", + "class C { public C(string arg) {} public string Id { get; set; } }"); return TestHelper.VerifyGenerator(source); } + + [Fact] + public Task ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty($\"MyValueId\", \"Value.IdXXX\")] partial B Map(A source);", + "class A { public string MyValueId { get; set; } }", + "class B { public C? Value { get; set; } }", + "class C { public C(string arg) {} public string Id { get; set; } }"); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public void ManualNestedPropertyNullablePath() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapProperty(\"Value1.Value1.Id1\", \"Value2.Value2.Id2\")]" + + "[MapProperty(\"Value1.Value1.Id10\", \"Value2.Value2.Id20\")]" + + "[MapProperty(new[] { \"Value1\", \"Id100\" }, new[] { \"Value2\", \"Id200\" })]" + + "partial B Map(A source);", + "class A { public C? Value1 { get; set; } }", + "class B { public E? Value2 { get; set; } }", + "class C { public D? Value1 { get; set; } public string Id100 { get; set; } }", + "class D { public string Id1 { get; set; } public string Id10 { get; set; } }", + "class E { public F? Value2 { get; set; } public string Id200 { get; set; } }", + "class F { public string Id2 { get; set; } public string Id20 { get; set; } }"); + + TestHelper.GenerateSingleMapperMethodBody(source) + .Should() + .Be(@"var target = new B(); + if (source.Value1 != null) + { + target.Value2 ??= new(); + target.Value2.Id200 = source.Value1.Id100; + if (source.Value1?.Value1 != null) + { + target.Value2.Value2 ??= new(); + target.Value2.Value2.Id2 = source.Value1.Value1.Id1; + target.Value2.Value2.Id20 = source.Value1.Value1.Id10; + } + } + + return target;".ReplaceLineEndings()); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..f101ba9364 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG002, + Title: No accessible parameterless constructor found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,68), + Description: , + HelpLink: , + MessageFormat: {0} has no accessible parameterless constructor, + Message: C? has no accessible parameterless constructor, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..555a081839 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyNullablePathNoParameterlessCtorShouldDiagnostic.01.verified.cs @@ -0,0 +1,11 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + target.Value.Id = source.MyValueId; + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..d20d9c4861 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG006, + Title: Mapping source property not found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,71), + Description: , + HelpLink: , + MessageFormat: Specified property {0} on source type {1} was not found, + Message: Specified property MyValueIdXXX on source type A was not found, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..3e34a4c677 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertySourcePropertyNotFoundShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..73ad2f6cf6 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG005, + Title: Mapping target property not found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,71), + Description: , + HelpLink: , + MessageFormat: Specified property {0} on mapping target type {1} was not found, + Message: Specified property Value.IdXXX on mapping target type B was not found, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..3e34a4c677 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..a37b3e7602 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG011, + Title: Can not map to write only property path, + Severity: Info, + WarningLevel: 1, + Location: : (10,4)-(10,68), + Description: , + HelpLink: , + MessageFormat: Can not map from property {0}.{1} of type {2} to write only property path {3}.{4} of type {5}, + Message: Can not map from property A.MyValueId of type string to write only property path B.Value.Id of type string, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..3e34a4c677 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ManualUnflattenedPropertyTargetPropertyPathWriteOnlyShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.00.verified.txt new file mode 100644 index 0000000000..55f9b7b518 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG009, + Title: Can not map to read only property, + Severity: Info, + WarningLevel: 1, + Location: : (10,4)-(10,28), + Description: , + HelpLink: , + MessageFormat: Can not map property {0}.{1} of type {2} to read only property {3}.{4} of type {5}, + Message: Can not map property A.StringValue2 of type string to read only property B.StringValue2 of type string, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.01.verified.cs new file mode 100644 index 0000000000..83d6844209 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreReadOnlyPropertyOnTargetWithDiagnostic.01.verified.cs @@ -0,0 +1,11 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + target.StringValue = source.StringValue; + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.00.verified.txt new file mode 100644 index 0000000000..acb840306a --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG010, + Title: Can not map from write only property, + Severity: Info, + WarningLevel: 1, + Location: : (10,4)-(10,28), + Description: , + HelpLink: , + MessageFormat: Can not map from write only property {0}.{1} of type {2} to property {3}.{4} of type {5}, + Message: Can not map from write only property A.StringValue2 of type string to property B.StringValue2 of type string, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.01.verified.cs new file mode 100644 index 0000000000..83d6844209 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.ShouldIgnoreWriteOnlyPropertyOnSourceWithDiagnostics.01.verified.cs @@ -0,0 +1,11 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + target.StringValue = source.StringValue; + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..1464392f67 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG006, + Title: Mapping source property not found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,89), + Description: , + HelpLink: , + MessageFormat: Specified property {0} on source type {1} was not found, + Message: Specified property StringValue9 on source type A was not found, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..3e34a4c677 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundSourcePropertyShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..6ee8d34de0 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.00.verified.txt @@ -0,0 +1,30 @@ +{ + Diagnostics: [ + { + Id: RMG012, + Title: Mapping source property not found, + Severity: Info, + WarningLevel: 1, + Location: : (10,4)-(10,88), + Description: , + HelpLink: , + MessageFormat: Property {0} on source type {1} was not found, + Message: Property StringValue2 on source type A was not found, + Category: Mapper, + CustomTags: [] + }, + { + Id: RMG005, + Title: Mapping target property not found, + Severity: Error, + WarningLevel: 0, + Location: : (10,4)-(10,88), + Description: , + HelpLink: , + MessageFormat: Specified property {0} on mapping target type {1} was not found, + Message: Specified property StringValue9 on mapping target type B was not found, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..3e34a4c677 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualMappedNotFoundTargetPropertyShouldDiagnostic.01.verified.cs @@ -0,0 +1,10 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + return target; + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualNotFoundSourcePropertyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualNotFoundSourcePropertyShouldDiagnostic.00.verified.txt index 003243441c..f51e1498f1 100644 --- a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualNotFoundSourcePropertyShouldDiagnostic.00.verified.txt +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithManualNotFoundSourcePropertyShouldDiagnostic.00.verified.txt @@ -9,7 +9,7 @@ Description: , HelpLink: , MessageFormat: Specified property {0} on source type {1} was not found, - Message: Specified property not_found on source type B was not found, + Message: Specified property not_found on source type A was not found, Category: Mapper, CustomTags: [] } diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.00.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.00.verified.txt new file mode 100644 index 0000000000..081f024cef --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.00.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Id: RMG012, + Title: Mapping source property not found, + Severity: Info, + WarningLevel: 1, + Location: : (10,4)-(10,28), + Description: , + HelpLink: , + MessageFormat: Property {0} on source type {1} was not found, + Message: Property StringValueB on source type A was not found, + Category: Mapper, + CustomTags: [] + } + ] +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.01.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.01.verified.cs new file mode 100644 index 0000000000..83d6844209 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/ObjectPropertyTest.WithUnmatchedPropertyShouldDiagnostic.01.verified.cs @@ -0,0 +1,11 @@ +//HintName: Mapper.g.cs +#nullable enable +public partial class Mapper +{ + private partial B Map(A source) + { + var target = new B(); + target.StringValue = source.StringValue; + return target; + } +} \ No newline at end of file