diff --git a/MapTo.sln b/MapTo.sln index 9b0a09b..0c203d5 100644 --- a/MapTo.sln +++ b/MapTo.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MapToTests", "test\MapTo.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestConsoleApp", "test\TestConsoleApp\TestConsoleApp.csproj", "{5BE2551A-9EF9-42FA-B6D1-5B5E6A90CC85}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MapTo.Integration.Tests", "test\MapTo.Integration.Tests\MapTo.Integration.Tests.csproj", "{23B46FDF-6A1E-4287-88C9-C8C5D7EECB8C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {5BE2551A-9EF9-42FA-B6D1-5B5E6A90CC85}.Debug|Any CPU.Build.0 = Debug|Any CPU {5BE2551A-9EF9-42FA-B6D1-5B5E6A90CC85}.Release|Any CPU.ActiveCfg = Release|Any CPU {5BE2551A-9EF9-42FA-B6D1-5B5E6A90CC85}.Release|Any CPU.Build.0 = Release|Any CPU + {23B46FDF-6A1E-4287-88C9-C8C5D7EECB8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23B46FDF-6A1E-4287-88C9-C8C5D7EECB8C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23B46FDF-6A1E-4287-88C9-C8C5D7EECB8C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23B46FDF-6A1E-4287-88C9-C8C5D7EECB8C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0f21dd8..8db4591 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A convention based object to object mapper using [Roslyn source generator](https://github.com/dotnet/roslyn/blob/master/docs/features/source-generators.md). -MapTo is a library to programmatically generate the necessary code to map one object to another during compile-time. It creates mappings during compile-time, eliminating the need to use reflection to map one object to another and make it simpler to use faster than other libraries at runtime. +MapTo is a library to programmatically generate the necessary code to map one object to another during compile-time, eliminating the need to use reflection to map objects and make it much faster in runtime. It provides compile-time safety checks and ease of use by leveraging extension methods. ## Installation diff --git a/src/MapTo/Extensions/RoslynExtensions.cs b/src/MapTo/Extensions/RoslynExtensions.cs index 6d35e1c..cc2b0d4 100644 --- a/src/MapTo/Extensions/RoslynExtensions.cs +++ b/src/MapTo/Extensions/RoslynExtensions.cs @@ -75,5 +75,21 @@ namespace MapTo.Extensions compilation.GetSpecialType(SpecialType.System_Collections_Generic_IEnumerable_T).Equals(typeSymbol.OriginalDefinition, SymbolEqualityComparer.Default); public static bool IsArray(this Compilation compilation, ITypeSymbol typeSymbol) => typeSymbol is IArrayTypeSymbol; + + public static bool IsPrimitiveType(this ITypeSymbol type) => type.SpecialType is + SpecialType.System_String or + SpecialType.System_Boolean or + SpecialType.System_SByte or + SpecialType.System_Int16 or + SpecialType.System_Int32 or + SpecialType.System_Int64 or + SpecialType.System_Byte or + SpecialType.System_UInt16 or + SpecialType.System_UInt32 or + SpecialType.System_UInt64 or + SpecialType.System_Single or + SpecialType.System_Double or + SpecialType.System_Char or + SpecialType.System_Object; } } \ No newline at end of file diff --git a/src/MapTo/MapToGenerator.cs b/src/MapTo/MapToGenerator.cs index 6ac4970..2c42e74 100644 --- a/src/MapTo/MapToGenerator.cs +++ b/src/MapTo/MapToGenerator.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using MapTo.Extensions; using MapTo.Sources; @@ -22,18 +23,27 @@ namespace MapTo /// public void Execute(GeneratorExecutionContext context) { - var options = SourceGenerationOptions.From(context); - - var compilation = context.Compilation - .AddSource(ref context, MapFromAttributeSource.Generate(options)) - .AddSource(ref context, IgnorePropertyAttributeSource.Generate(options)) - .AddSource(ref context, ITypeConverterSource.Generate(options)) - .AddSource(ref context, MapTypeConverterAttributeSource.Generate(options)) - .AddSource(ref context, MapPropertyAttributeSource.Generate(options)); - - if (context.SyntaxReceiver is MapToSyntaxReceiver receiver && receiver.CandidateClasses.Any()) + try { - AddGeneratedMappingsClasses(context, compilation, receiver.CandidateClasses, options); + var options = SourceGenerationOptions.From(context); + + var compilation = context.Compilation + .AddSource(ref context, MapFromAttributeSource.Generate(options)) + .AddSource(ref context, IgnorePropertyAttributeSource.Generate(options)) + .AddSource(ref context, ITypeConverterSource.Generate(options)) + .AddSource(ref context, MapTypeConverterAttributeSource.Generate(options)) + .AddSource(ref context, MapPropertyAttributeSource.Generate(options)) + .AddSource(ref context, MappingContextSource.Generate(options)); + + if (context.SyntaxReceiver is MapToSyntaxReceiver receiver && receiver.CandidateClasses.Any()) + { + AddGeneratedMappingsClasses(context, compilation, receiver.CandidateClasses, options); + } + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; } } diff --git a/src/MapTo/MappingContext.cs b/src/MapTo/MappingContext.cs index 445fa05..4ca5a24 100644 --- a/src/MapTo/MappingContext.cs +++ b/src/MapTo/MappingContext.cs @@ -24,7 +24,7 @@ namespace MapTo internal MappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, ClassDeclarationSyntax classSyntax) { _diagnostics = new List(); - _usings = new List { "System" }; + _usings = new List { "System", Constants.RootNamespace }; _sourceGenerationOptions = sourceGenerationOptions; _classSyntax = classSyntax; _compilation = compilation; @@ -35,7 +35,7 @@ namespace MapTo _mapPropertyAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapPropertyAttributeSource.FullyQualifiedName); _mapFromAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapFromAttributeSource.FullyQualifiedName); - AddUsingIfRequired(sourceGenerationOptions.SupportNullableReferenceTypes, "System.Diagnostics.CodeAnalysis"); + AddUsingIfRequired(sourceGenerationOptions.SupportNullableStaticAnalysis, "System.Diagnostics.CodeAnalysis"); Initialize(); } @@ -154,7 +154,10 @@ namespace MapTo } var mapFromAttribute = property.Type.GetAttribute(_mapFromAttributeTypeSymbol); - if (mapFromAttribute is null && property.Type is INamedTypeSymbol namedTypeSymbol && _compilation.IsGenericEnumerable(property.Type)) + if (mapFromAttribute is null && + property.Type is INamedTypeSymbol namedTypeSymbol && + !property.Type.IsPrimitiveType() && + (_compilation.IsGenericEnumerable(property.Type) || property.Type.AllInterfaces.Any(i => _compilation.IsGenericEnumerable(i)))) { enumerableTypeArgument = namedTypeSymbol.TypeArguments.First(); mapFromAttribute = enumerableTypeArgument.GetAttribute(_mapFromAttributeTypeSymbol); diff --git a/src/MapTo/Models.cs b/src/MapTo/Models.cs index 409724f..d23d71e 100644 --- a/src/MapTo/Models.cs +++ b/src/MapTo/Models.cs @@ -36,17 +36,30 @@ namespace MapTo AccessModifier ConstructorAccessModifier, AccessModifier GeneratedMethodsAccessModifier, bool GenerateXmlDocument, - bool SupportNullableReferenceTypes) + bool SupportNullableReferenceTypes, + bool SupportNullableStaticAnalysis, + LanguageVersion LanguageVersion) { internal static SourceGenerationOptions From(GeneratorExecutionContext context) { - var compilationOptions = (context.Compilation as CSharpCompilation)?.Options; + var compilation = context.Compilation as CSharpCompilation; + var supportNullableReferenceTypes = false; + var supportNullableStaticAnalysis = false; + if (compilation is not null) + { + supportNullableStaticAnalysis = compilation.LanguageVersion >= LanguageVersion.CSharp8; + supportNullableReferenceTypes = compilation.Options.NullableContextOptions == NullableContextOptions.Warnings || + compilation.Options.NullableContextOptions == NullableContextOptions.Enable; + } + return new( context.GetBuildGlobalOption(nameof(ConstructorAccessModifier), AccessModifier.Public), context.GetBuildGlobalOption(nameof(GeneratedMethodsAccessModifier), AccessModifier.Public), context.GetBuildGlobalOption(nameof(GenerateXmlDocument), true), - compilationOptions is not null && (compilationOptions.NullableContextOptions == NullableContextOptions.Warnings || compilationOptions.NullableContextOptions == NullableContextOptions.Enable) + supportNullableReferenceTypes, + supportNullableStaticAnalysis, + compilation?.LanguageVersion ?? LanguageVersion.Default ); } diff --git a/src/MapTo/Sources/MapClassSource.cs b/src/MapTo/Sources/MapClassSource.cs index 3aa5f9b..72c435e 100644 --- a/src/MapTo/Sources/MapClassSource.cs +++ b/src/MapTo/Sources/MapClassSource.cs @@ -1,5 +1,4 @@ -using System.Linq; -using MapTo.Extensions; +using MapTo.Extensions; using static MapTo.Sources.Constants; namespace MapTo.Sources @@ -12,7 +11,7 @@ namespace MapTo.Sources .WriteLine(GeneratedFilesHeader) .WriteNullableContextOptionIf(model.Options.SupportNullableReferenceTypes) .WriteLine() - .WriteUsings(model) + .WriteUsings(model.Usings) .WriteLine() // Namespace declaration @@ -24,7 +23,9 @@ namespace MapTo.Sources .WriteOpeningBracket() // Class body - .GenerateConstructor(model) + .GenerateSecondaryConstructor(model) + .WriteLine() + .GeneratePrivateConstructor(model) .WriteLine() .GenerateFactoryMethod(model) @@ -41,13 +42,7 @@ namespace MapTo.Sources return new(builder.ToString(), $"{model.ClassName}.g.cs"); } - private static SourceBuilder WriteUsings(this SourceBuilder builder, MappingModel model) - { - model.Usings.Sort().ForEach(u => builder.WriteLine($"using {u};")); - return builder; - } - - private static SourceBuilder GenerateConstructor(this SourceBuilder builder, MappingModel model) + private static SourceBuilder GenerateSecondaryConstructor(this SourceBuilder builder, MappingModel model) { var sourceClassParameterName = model.SourceClassName.ToCamelCase(); @@ -61,12 +56,25 @@ namespace MapTo.Sources .WriteLine($"/// {sourceClassParameterName} is null"); } - var baseConstructor = model.HasMappedBaseClass ? $" : base({sourceClassParameterName})" : string.Empty; - + return builder + .WriteLine($"{model.Options.ConstructorAccessModifier.ToLowercaseString()} {model.ClassName}({model.SourceClassName} {sourceClassParameterName})") + .WriteLine($" : this(new {MappingContextSource.ClassName}(), {sourceClassParameterName}) {{ }}"); + } + + private static SourceBuilder GeneratePrivateConstructor(this SourceBuilder builder, MappingModel model) + { + var sourceClassParameterName = model.SourceClassName.ToCamelCase(); + const string mappingContextParameterName = "context"; + + var baseConstructor = model.HasMappedBaseClass ? $" : base({mappingContextParameterName}, {sourceClassParameterName})" : string.Empty; + builder - .WriteLine($"{model.Options.ConstructorAccessModifier.ToLowercaseString()} {model.ClassName}({model.SourceClassName} {sourceClassParameterName}){baseConstructor}") + .WriteLine($"private protected {model.ClassName}({MappingContextSource.ClassName} {mappingContextParameterName}, {model.SourceClassName} {sourceClassParameterName}){baseConstructor}") .WriteOpeningBracket() + .WriteLine($"if ({mappingContextParameterName} == null) throw new ArgumentNullException(nameof({mappingContextParameterName}));") .WriteLine($"if ({sourceClassParameterName} == null) throw new ArgumentNullException(nameof({sourceClassParameterName}));") + .WriteLine() + .WriteLine($"{mappingContextParameterName}.{MappingContextSource.RegisterMethodName}({sourceClassParameterName}, this);") .WriteLine(); foreach (var property in model.MappedProperties) @@ -75,13 +83,13 @@ namespace MapTo.Sources { if (property.IsEnumerable) { - builder.WriteLine($"{property.Name} = {sourceClassParameterName}.{property.SourcePropertyName}.Select({property.MappedSourcePropertyTypeName}To{property.EnumerableTypeArgument}Extensions.To{property.EnumerableTypeArgument}).ToList();"); + builder.WriteLine($"{property.Name} = {sourceClassParameterName}.{property.SourcePropertyName}.Select({mappingContextParameterName}.{MappingContextSource.MapMethodName}<{property.MappedSourcePropertyTypeName}, {property.EnumerableTypeArgument}>).ToList();"); } else { builder.WriteLine(property.MappedSourcePropertyTypeName is null ? $"{property.Name} = {sourceClassParameterName}.{property.SourcePropertyName};" - : $"{property.Name} = {sourceClassParameterName}.{property.SourcePropertyName}.To{property.Type}();"); + : $"{property.Name} = {mappingContextParameterName}.{MappingContextSource.MapMethodName}<{property.MappedSourcePropertyTypeName}, {property.Type}>({sourceClassParameterName}.{property.SourcePropertyName});"); } } else @@ -104,10 +112,10 @@ namespace MapTo.Sources return builder .GenerateConvertorMethodsXmlDocs(model, sourceClassParameterName) - .WriteLineIf(model.Options.SupportNullableReferenceTypes, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") + .WriteLineIf(model.Options.SupportNullableStaticAnalysis, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") .WriteLine($"{model.Options.GeneratedMethodsAccessModifier.ToLowercaseString()} static {model.ClassName}{model.Options.NullableReferenceSyntax} From({model.SourceClassName}{model.Options.NullableReferenceSyntax} {sourceClassParameterName})") .WriteOpeningBracket() - .WriteLine($"return {sourceClassParameterName} == null ? null : new {model.ClassName}({sourceClassParameterName});") + .WriteLine($"return {sourceClassParameterName} == null ? null : {MappingContextSource.ClassName}.{MappingContextSource.FactoryMethodName}<{model.SourceClassName}, {model.ClassName}>({sourceClassParameterName});") .WriteClosingBracket(); } @@ -142,7 +150,7 @@ namespace MapTo.Sources return builder .GenerateConvertorMethodsXmlDocs(model, sourceClassParameterName) - .WriteLineIf(model.Options.SupportNullableReferenceTypes, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") + .WriteLineIf(model.Options.SupportNullableStaticAnalysis, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") .WriteLine($"{model.Options.GeneratedMethodsAccessModifier.ToLowercaseString()} static {model.ClassName}{model.Options.NullableReferenceSyntax} To{model.ClassName}(this {model.SourceClassName}{model.Options.NullableReferenceSyntax} {sourceClassParameterName})") .WriteOpeningBracket() .WriteLine($"return {sourceClassParameterName} == null ? null : new {model.ClassName}({sourceClassParameterName});") diff --git a/src/MapTo/Sources/MappingContextSource.cs b/src/MapTo/Sources/MappingContextSource.cs new file mode 100644 index 0000000..76b6286 --- /dev/null +++ b/src/MapTo/Sources/MappingContextSource.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using static MapTo.Sources.Constants; + +namespace MapTo.Sources +{ + internal static class MappingContextSource + { + internal const string ClassName = "MappingContext"; + internal const string FullyQualifiedName = RootNamespace + "." + ClassName; + internal const string FactoryMethodName = "Create"; + internal const string RegisterMethodName = "Register"; + internal const string MapMethodName = "MapFromWithContext"; + + internal static SourceCode Generate(SourceGenerationOptions options) + { + var usings = new List { "System", "System.Collections.Generic", "System.Reflection" }; + + using var builder = new SourceBuilder() + .WriteLine(GeneratedFilesHeader) + .WriteLine() + .WriteUsings(usings) + .WriteLine() + + // Namespace declaration + .WriteLine($"namespace {RootNamespace}") + .WriteOpeningBracket() + + // Class declaration + .WriteLine($"internal sealed class {ClassName}") + .WriteOpeningBracket() + + .WriteLine("private readonly Dictionary _cache;") + .WriteLine() + + // Constructor + .WriteLine($"internal {ClassName}()") + .WriteOpeningBracket() + .WriteLine("_cache = new Dictionary(1);") + .WriteClosingBracket() + .WriteLine() + + // Factory + .WriteLine($"internal static TMapped {FactoryMethodName}(TOriginal original)") + .WriteOpeningBracket() + .WriteLine("if (original == null) throw new ArgumentNullException(nameof(original));") + .WriteLine() + .WriteLine("var context = new MappingContext();") + .WriteLine("var mapped = context.MapFromWithContext(original);") + .WriteLine() + .WriteLine("if (mapped == null)") + .WriteOpeningBracket() + .WriteLine("throw new InvalidOperationException();") + .WriteClosingBracket() + .WriteLine() + .WriteLine("return mapped;") + .WriteClosingBracket() + .WriteLine() + + // MapFromWithContext method + .WriteLine($"internal TMapped MapFromWithContext(TOriginal original)") + .WriteOpeningBracket() + .WriteLine("if (original == null)") + .WriteOpeningBracket() + .WriteLine("return default(TMapped);") + .WriteClosingBracket() + .WriteLine() + .WriteLine("if (!TryGetValue(original, out var mapped))") + .WriteOpeningBracket() + .WriteLine("var instance = Activator.CreateInstance(typeof(TMapped), BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { this, original }, null);") + .WriteLine("if (instance != null)") + .WriteOpeningBracket() + .WriteLine("mapped = (TMapped)instance;") + .WriteClosingBracket() + .WriteClosingBracket() + .WriteLine() + .WriteLine("return mapped;") + .WriteClosingBracket() + .WriteLine() + + // Register method + .WriteLine("internal void Register(TOriginal original, TMapped mapped)") + .WriteOpeningBracket() + .WriteLine("if (original == null) throw new ArgumentNullException(nameof(original));") + .WriteLine("if (mapped == null) throw new ArgumentNullException(nameof(mapped));") + .WriteLine() + .WriteLine("if (!_cache.ContainsKey(original))") + .WriteOpeningBracket() + .WriteLine("_cache.Add(original, mapped);") + .WriteClosingBracket() + .WriteClosingBracket() + .WriteLine() + + // TryGetValue method + .WriteLine("private bool TryGetValue(TOriginal original, out TMapped mapped)") + .WriteOpeningBracket() + .WriteLine("if (original != null && _cache.TryGetValue(original, out var value))") + .WriteOpeningBracket() + .WriteLine("mapped = (TMapped)value;") + .WriteLine("return true;") + .WriteClosingBracket() + .WriteLine() + .WriteLine("mapped = default(TMapped);") + .WriteLine("return false;") + .WriteClosingBracket() + + // End class declaration + .WriteClosingBracket() + + // End namespace declaration + .WriteClosingBracket(); + + return new(builder.ToString(), $"{ClassName}.g.cs"); + } + } +} \ No newline at end of file diff --git a/src/MapTo/Sources/SourceBuilder.cs b/src/MapTo/Sources/SourceBuilder.cs index be32ce6..c15320b 100644 --- a/src/MapTo/Sources/SourceBuilder.cs +++ b/src/MapTo/Sources/SourceBuilder.cs @@ -1,6 +1,8 @@ using System; using System.CodeDom.Compiler; +using System.Collections.Generic; using System.IO; +using System.Linq; namespace MapTo.Sources { @@ -8,7 +10,7 @@ namespace MapTo.Sources { private readonly StringWriter _writer; private readonly IndentedTextWriter _indentedWriter; - + public SourceBuilder() { _writer = new StringWriter(); @@ -21,12 +23,12 @@ namespace MapTo.Sources _writer.Dispose(); _indentedWriter.Dispose(); } - + public SourceBuilder WriteLine(string? value = null) { if (string.IsNullOrWhiteSpace(value)) { - _indentedWriter.WriteLineNoTabs(string.Empty); + _indentedWriter.WriteLineNoTabs(string.Empty); } else { @@ -64,6 +66,23 @@ namespace MapTo.Sources return this; } + public SourceBuilder WriteUsings(IEnumerable usings) + { + foreach (var u in usings.OrderBy(s => s)) + { + WriteUsing(u); + } + + return this; + } + + public SourceBuilder WriteUsing(string u) + { + WriteLine($"using {u};"); + + return this; + } + /// public override string ToString() => _writer.ToString(); } diff --git a/test/MapTo.Integration.Tests/CyclicReferenceTests.cs b/test/MapTo.Integration.Tests/CyclicReferenceTests.cs new file mode 100644 index 0000000..189d16f --- /dev/null +++ b/test/MapTo.Integration.Tests/CyclicReferenceTests.cs @@ -0,0 +1,85 @@ +using System.Linq; +using MapTo.Integration.Tests.Data.Models; +using MapTo.Integration.Tests.Data.ViewModels; +using Shouldly; +using Xunit; + +namespace MapTo.Integration.Tests +{ + public class CyclicReferenceTests + { + [Fact] + public void VerifySelfReference() + { + // Arrange + var manager = new Manager { Id = 1, EmployeeCode = "M001", Level = 100 }; + manager.Manager = manager; + + // Act + var result = manager.ToManagerViewModel(); + + // Assert + result.Id.ShouldBe(manager.Id); + result.EmployeeCode.ShouldBe(manager.EmployeeCode); + result.Level.ShouldBe(manager.Level); + result.Manager.ShouldBeSameAs(result); + } + + [Fact] + public void VerifyNestedReference() + { + // Arrange + var manager1 = new Manager { Id = 100, EmployeeCode = "M001", Level = 100 }; + var manager2 = new Manager { Id = 102, EmployeeCode = "M002", Level = 100 }; + + var employee1 = new Employee { Id = 200, EmployeeCode = "E001"}; + var employee2 = new Employee { Id = 201, EmployeeCode = "E002"}; + + employee1.Manager = manager1; + employee2.Manager = manager2; + + manager2.Manager = manager1; + + // Act + var manager1ViewModel = manager1.ToManagerViewModel(); + + // Assert + manager1ViewModel.Id.ShouldBe(manager1.Id); + manager1ViewModel.Manager.ShouldBeNull(); + manager1ViewModel.Employees.Count.ShouldBe(2); + manager1ViewModel.Employees[0].Id.ShouldBe(employee1.Id); + manager1ViewModel.Employees[0].Manager.ShouldBeSameAs(manager1ViewModel); + manager1ViewModel.Employees[1].Id.ShouldBe(manager2.Id); + manager1ViewModel.Employees[1].Manager.ShouldBeSameAs(manager1ViewModel); + } + + [Fact] + public void VerifyNestedSelfReference() + { + // Arrange + var manager1 = new Manager { Id = 100, EmployeeCode = "M001", Level = 100 }; + var manager3 = new Manager { Id = 101, EmployeeCode = "M003", Level = 100 }; + var manager2 = new Manager { Id = 102, EmployeeCode = "M002", Level = 100 }; + + var employee1 = new Employee { Id = 200, EmployeeCode = "E001"}; + var employee2 = new Employee { Id = 201, EmployeeCode = "E002"}; + var employee3 = new Employee { Id = 202, EmployeeCode = "E003"}; + + employee1.Manager = manager1; + employee2.Manager = manager2; + employee3.Manager = manager3; + + manager2.Manager = manager1; + manager3.Manager = manager2; + + // Act + var manager3ViewModel = manager3.ToManagerViewModel(); + + // Assert + manager3ViewModel.Manager.ShouldNotBeNull(); + manager3ViewModel.Manager.Id.ShouldBe(manager2.Id); + manager3ViewModel.Manager.Manager.Id.ShouldBe(manager1.Id); + manager3ViewModel.Employees.All(e => ReferenceEquals(e.Manager, manager3ViewModel)).ShouldBeTrue(); + } + } +} \ No newline at end of file diff --git a/test/MapTo.Integration.Tests/Data/Models/Employee.cs b/test/MapTo.Integration.Tests/Data/Models/Employee.cs new file mode 100644 index 0000000..2944fc5 --- /dev/null +++ b/test/MapTo.Integration.Tests/Data/Models/Employee.cs @@ -0,0 +1,29 @@ +namespace MapTo.Integration.Tests.Data.Models +{ + public class Employee + { + private Manager _manager; + + public int Id { get; set; } + + public string EmployeeCode { get; set; } + + public Manager Manager + { + get => _manager; + set + { + if (value == null) + { + _manager.Employees.Remove(this); + } + else + { + value.Employees.Add(this); + } + + _manager = value; + } + } + } +} \ No newline at end of file diff --git a/test/MapTo.Integration.Tests/Data/Models/Manager.cs b/test/MapTo.Integration.Tests/Data/Models/Manager.cs new file mode 100644 index 0000000..ffafe25 --- /dev/null +++ b/test/MapTo.Integration.Tests/Data/Models/Manager.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace MapTo.Integration.Tests.Data.Models +{ + public class Manager : Employee + { + public int Level { get; set; } + + public List Employees { get; set; } = new(); + } +} \ No newline at end of file diff --git a/test/MapTo.Integration.Tests/Data/ViewModels/EmployeeViewModel.cs b/test/MapTo.Integration.Tests/Data/ViewModels/EmployeeViewModel.cs new file mode 100644 index 0000000..8ebaf88 --- /dev/null +++ b/test/MapTo.Integration.Tests/Data/ViewModels/EmployeeViewModel.cs @@ -0,0 +1,14 @@ +using MapTo.Integration.Tests.Data.Models; + +namespace MapTo.Integration.Tests.Data.ViewModels +{ + [MapFrom(typeof(Employee))] + public partial class EmployeeViewModel + { + public int Id { get; set; } + + public string EmployeeCode { get; set; } + + public ManagerViewModel Manager { get; set; } + } +} \ No newline at end of file diff --git a/test/MapTo.Integration.Tests/Data/ViewModels/ManagerViewModel.cs b/test/MapTo.Integration.Tests/Data/ViewModels/ManagerViewModel.cs new file mode 100644 index 0000000..d085c24 --- /dev/null +++ b/test/MapTo.Integration.Tests/Data/ViewModels/ManagerViewModel.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using MapTo.Integration.Tests.Data.Models; + +namespace MapTo.Integration.Tests.Data.ViewModels +{ + [MapFrom(typeof(Manager))] + public partial class ManagerViewModel : EmployeeViewModel + { + public int Level { get; set; } + + public List Employees { get; set; } = new(); + } +} \ No newline at end of file diff --git a/test/MapTo.Integration.Tests/MapTo.Integration.Tests.csproj b/test/MapTo.Integration.Tests/MapTo.Integration.Tests.csproj new file mode 100644 index 0000000..419c12d --- /dev/null +++ b/test/MapTo.Integration.Tests/MapTo.Integration.Tests.csproj @@ -0,0 +1,26 @@ + + + + net5.0 + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/test/MapTo.Tests/Extensions/ShouldlyExtensions.cs b/test/MapTo.Tests/Extensions/ShouldlyExtensions.cs index 36b9580..f7aeb57 100644 --- a/test/MapTo.Tests/Extensions/ShouldlyExtensions.cs +++ b/test/MapTo.Tests/Extensions/ShouldlyExtensions.cs @@ -21,6 +21,16 @@ namespace MapTo.Tests.Extensions syntax.ShouldBe(expectedSource, customMessage); } + internal static void ShouldContainPartialSource(this IEnumerable syntaxTree, string typeName, string expectedSource, string customMessage = null) + { + var syntax = syntaxTree + .Select(s => s.ToString().Trim()) + .SingleOrDefault(s => s.Contains(typeName)); + + syntax.ShouldNotBeNullOrWhiteSpace(); + syntax.ShouldContainWithoutWhitespace(expectedSource, customMessage); + } + internal static void ShouldContainPartialSource(this SyntaxTree syntaxTree, string expectedSource, string customMessage = null) { var syntax = syntaxTree.ToString(); diff --git a/test/MapTo.Tests/IgnorePropertyAttributeTests.cs b/test/MapTo.Tests/IgnorePropertyAttributeTests.cs index f92f56d..a28f9c0 100644 --- a/test/MapTo.Tests/IgnorePropertyAttributeTests.cs +++ b/test/MapTo.Tests/IgnorePropertyAttributeTests.cs @@ -53,9 +53,15 @@ namespace MapTo partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; diff --git a/test/MapTo.Tests/Infrastructure/CSharpGenerator.cs b/test/MapTo.Tests/Infrastructure/CSharpGenerator.cs index c5c8d67..ae98a85 100644 --- a/test/MapTo.Tests/Infrastructure/CSharpGenerator.cs +++ b/test/MapTo.Tests/Infrastructure/CSharpGenerator.cs @@ -11,10 +11,25 @@ namespace MapTo.Tests.Infrastructure { internal static class CSharpGenerator { - internal static (Compilation compilation, ImmutableArray diagnostics) GetOutputCompilation(string source, bool assertCompilation = false, IDictionary analyzerConfigOptions = null, NullableContextOptions nullableContextOptions = NullableContextOptions.Disable) => - GetOutputCompilation(new[] { source }, assertCompilation, analyzerConfigOptions, nullableContextOptions); + internal static (Compilation compilation, ImmutableArray diagnostics) GetOutputCompilation( + string source, + bool assertCompilation = false, + IDictionary analyzerConfigOptions = null, + NullableContextOptions nullableContextOptions = NullableContextOptions.Disable, + LanguageVersion languageVersion = LanguageVersion.CSharp7_3) => + GetOutputCompilation( + new[] { source }, + assertCompilation, + analyzerConfigOptions, + nullableContextOptions, + languageVersion); - internal static (Compilation compilation, ImmutableArray diagnostics) GetOutputCompilation(IEnumerable sources, bool assertCompilation = false, IDictionary analyzerConfigOptions = null, NullableContextOptions nullableContextOptions = NullableContextOptions.Disable) + internal static (Compilation compilation, ImmutableArray diagnostics) GetOutputCompilation( + IEnumerable sources, + bool assertCompilation = false, + IDictionary analyzerConfigOptions = null, + NullableContextOptions nullableContextOptions = NullableContextOptions.Disable, + LanguageVersion languageVersion = LanguageVersion.CSharp7_3) { var references = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location)) @@ -23,10 +38,10 @@ namespace MapTo.Tests.Infrastructure var compilation = CSharpCompilation.Create( $"{typeof(CSharpGenerator).Assembly.GetName().Name}.Dynamic", - sources.Select((source, index) => CSharpSyntaxTree.ParseText(source, path: $"Test{index:00}.g.cs")), + sources.Select((source, index) => CSharpSyntaxTree.ParseText(source, path: $"Test{index:00}.g.cs", options: new CSharpParseOptions(languageVersion))), references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: nullableContextOptions)); - + if (assertCompilation) { // NB: fail tests when the injected program isn't valid _before_ running generators @@ -35,7 +50,8 @@ namespace MapTo.Tests.Infrastructure var driver = CSharpGeneratorDriver.Create( new[] { new MapToGenerator() }, - optionsProvider: new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions) + optionsProvider: new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions), + parseOptions: new CSharpParseOptions(languageVersion) ); driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generateDiagnostics); diff --git a/test/MapTo.Tests/MapPropertyTests.cs b/test/MapTo.Tests/MapPropertyTests.cs index bcd70e6..48904f6 100644 --- a/test/MapTo.Tests/MapPropertyTests.cs +++ b/test/MapTo.Tests/MapPropertyTests.cs @@ -4,7 +4,7 @@ using MapTo.Sources; using MapTo.Tests.Extensions; using MapTo.Tests.Infrastructure; using Microsoft.CodeAnalysis; -using Shouldly; +using Microsoft.CodeAnalysis.CSharp; using Xunit; using static MapTo.Tests.Common; @@ -20,6 +20,7 @@ namespace MapTo.Tests // Arrange const string source = ""; var nullableSyntax = nullableContextOptions == NullableContextOptions.Enable ? "?" : string.Empty; + var languageVersion = nullableContextOptions == NullableContextOptions.Enable ? LanguageVersion.CSharp8 : LanguageVersion.CSharp7_3; var expectedInterface = $@" {Constants.GeneratedFilesHeader} {(nullableContextOptions == NullableContextOptions.Enable ? $"#nullable enable{Environment.NewLine}" : string.Empty)} @@ -36,8 +37,9 @@ namespace MapTo ".Trim(); // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: nullableContextOptions); + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: nullableContextOptions, languageVersion: languageVersion); + // Assert diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(MapPropertyAttributeSource.AttributeName, expectedInterface); @@ -61,9 +63,15 @@ namespace MapTo partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; diff --git a/test/MapTo.Tests/MapToTests.cs b/test/MapTo.Tests/MapToTests.cs index c7817e5..4aa4988 100644 --- a/test/MapTo.Tests/MapToTests.cs +++ b/test/MapTo.Tests/MapToTests.cs @@ -40,7 +40,7 @@ namespace MapTo // Assert diagnostics.ShouldBeSuccessful(); - compilation.SyntaxTrees.ShouldContainSource(MapFromAttributeSource.AttributeName, ExpectedAttribute); + compilation.SyntaxTrees.ShouldContainSource(MapFromAttributeSource.AttributeClassName, ExpectedAttribute); } [Fact] @@ -84,7 +84,7 @@ namespace MapTo var expectedFactory = @" internal static Foo From(Baz baz) { - return baz == null ? null : new Foo(baz); + return baz == null ? null : MappingContext.Create(baz); }".Trim(); // Act @@ -123,6 +123,7 @@ namespace Test const string expectedResult = @" // +using MapTo; using System; namespace Test @@ -130,9 +131,15 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; } "; @@ -193,6 +200,7 @@ namespace Test const string expectedResult = @" // +using MapTo; using System; namespace Test @@ -200,9 +208,15 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; } "; @@ -250,6 +264,7 @@ namespace Test // using Bazaar; +using MapTo; using System; namespace Test @@ -273,9 +288,15 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; @@ -299,7 +320,7 @@ namespace Test const string expectedResult = @" public static Foo From(Baz baz) { - return baz == null ? null : new Foo(baz); + return baz == null ? null : MappingContext.Create(baz); } "; @@ -358,13 +379,19 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; - InnerProp1 = baz.InnerProp1.ToB(); + InnerProp1 = context.MapFromWithContext(baz.InnerProp1); } ".Trim(); @@ -444,9 +471,15 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; @@ -469,11 +502,16 @@ namespace Test var sources = GetEmployeeManagerSourceText(); const string expectedResult = @" -public ManagerViewModel(Manager manager) : base(manager) +private protected ManagerViewModel(MappingContext context, Manager manager) : base(context, manager) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (manager == null) throw new ArgumentNullException(nameof(manager)); + context.Register(manager, this); + Level = manager.Level; + Employees = manager.Employees.Select(context.MapFromWithContext).ToList(); +} "; // Act @@ -500,12 +538,19 @@ namespace Test.ViewModels { partial class ManagerViewModel { - public ManagerViewModel(Manager manager) : base(manager) + public ManagerViewModel(Manager manager) + : this(new MappingContext(), manager) { } + + private protected ManagerViewModel(MappingContext context, Manager manager) : base(context, manager) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (manager == null) throw new ArgumentNullException(nameof(manager)); + context.Register(manager, this); + Level = manager.Level; - Employees = manager.Employees.Select(EmployeeToEmployeeViewModelExtensions.ToEmployeeViewModel).ToList(); + Employees = manager.Employees.Select(context.MapFromWithContext).ToList(); + } "; // Act @@ -533,12 +578,19 @@ namespace Test.ViewModels2 { partial class ManagerViewModel { - public ManagerViewModel(Manager manager) : base(manager) + public ManagerViewModel(Manager manager) + : this(new MappingContext(), manager) { } + + private protected ManagerViewModel(MappingContext context, Manager manager) : base(context, manager) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (manager == null) throw new ArgumentNullException(nameof(manager)); + context.Register(manager, this); + Level = manager.Level; - Employees = manager.Employees.Select(EmployeeToEmployeeViewModelExtensions.ToEmployeeViewModel).ToList(); + Employees = manager.Employees.Select(context.MapFromWithContext).ToList(); + } "; // Act diff --git a/test/MapTo.Tests/MapTypeConverterTests.cs b/test/MapTo.Tests/MapTypeConverterTests.cs index d4f3863..ec2c456 100644 --- a/test/MapTo.Tests/MapTypeConverterTests.cs +++ b/test/MapTo.Tests/MapTypeConverterTests.cs @@ -3,7 +3,7 @@ using MapTo.Sources; using MapTo.Tests.Extensions; using MapTo.Tests.Infrastructure; using Microsoft.CodeAnalysis; -using Shouldly; +using Microsoft.CodeAnalysis.CSharp; using Xunit; using static MapTo.Tests.Common; @@ -77,7 +77,7 @@ namespace MapTo ".Trim(); // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: NullableContextOptions.Enable); + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: NullableContextOptions.Enable, languageVersion: LanguageVersion.CSharp8); // Assert diagnostics.ShouldBeSuccessful(); @@ -128,7 +128,7 @@ namespace MapTo ".Trim(); // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: NullableContextOptions.Enable); + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: NullableContextOptions.Enable, languageVersion: LanguageVersion.CSharp8); // Assert diagnostics.ShouldBeSuccessful(); @@ -220,9 +220,15 @@ namespace Test partial class Foo { public Foo(Baz baz) + : this(new MappingContext(), baz) { } + + private protected Foo(MappingContext context, Baz baz) { + if (context == null) throw new ArgumentNullException(nameof(context)); if (baz == null) throw new ArgumentNullException(nameof(baz)); + context.Register(baz, this); + Prop1 = baz.Prop1; Prop2 = baz.Prop2; Prop3 = baz.Prop3; diff --git a/test/MapTo.Tests/MappingContextTests.cs b/test/MapTo.Tests/MappingContextTests.cs new file mode 100644 index 0000000..410a0c5 --- /dev/null +++ b/test/MapTo.Tests/MappingContextTests.cs @@ -0,0 +1,102 @@ +using MapTo.Sources; +using MapTo.Tests.Extensions; +using MapTo.Tests.Infrastructure; +using Xunit; +using static MapTo.Tests.Common; + +namespace MapTo.Tests +{ + public class MappingContextTests + { + [Fact] + public void VerifyMappingContextSource() + { + // Arrange + const string source = ""; + var expected = @" +// + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace MapTo +{ + internal sealed class MappingContext + { + private readonly Dictionary _cache; + + internal MappingContext() + { + _cache = new Dictionary(1); + } + + internal static TMapped Create(TOriginal original) + { + if (original == null) throw new ArgumentNullException(nameof(original)); + + var context = new MappingContext(); + var mapped = context.MapFromWithContext(original); + + if (mapped == null) + { + throw new InvalidOperationException(); + } + + return mapped; + } + + internal TMapped MapFromWithContext(TOriginal original) + { + if (original == null) + { + return default(TMapped); + } + + if (!TryGetValue(original, out var mapped)) + { + var instance = Activator.CreateInstance(typeof(TMapped), BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { this, original }, null); + if (instance != null) + { + mapped = (TMapped)instance; + } + } + + return mapped; + } + + internal void Register(TOriginal original, TMapped mapped) + { + if (original == null) throw new ArgumentNullException(nameof(original)); + if (mapped == null) throw new ArgumentNullException(nameof(mapped)); + + if (!_cache.ContainsKey(original)) + { + _cache.Add(original, mapped); + } + } + + private bool TryGetValue(TOriginal original, out TMapped mapped) + { + if (original != null && _cache.TryGetValue(original, out var value)) + { + mapped = (TMapped)value; + return true; + } + + mapped = default(TMapped); + return false; + } + } +} +".Trim(); + + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); + + // Assert + diagnostics.ShouldBeSuccessful(); + compilation.SyntaxTrees.ShouldContainSource(MappingContextSource.ClassName, expected); + } + } +} \ No newline at end of file diff --git a/test/TestConsoleApp/Program.cs b/test/TestConsoleApp/Program.cs index 4a70c5f..be8a84d 100644 --- a/test/TestConsoleApp/Program.cs +++ b/test/TestConsoleApp/Program.cs @@ -1,4 +1,5 @@ using System; +using MapTo; using TestConsoleApp.Data.Models; using TestConsoleApp.ViewModels; using TestConsoleApp.ViewModels2; @@ -10,6 +11,14 @@ namespace TestConsoleApp private static void Main(string[] args) { //UserTest(); + CyclicReferenceTest(); + + // EmployeeManagerTest(); + Console.WriteLine("done"); + } + + private static void EmployeeManagerTest() + { var manager1 = new Manager { Id = 1, @@ -46,6 +55,19 @@ namespace TestConsoleApp employee1.ToEmployeeViewModel(); } + private static ManagerViewModel CyclicReferenceTest() + { + var manager1 = new Manager + { + Id = 1, + EmployeeCode = "M001", + Level = 100 + }; + + manager1.Manager = manager1; + return manager1.ToManagerViewModel(); + } + private static void UserTest() { var user = new User diff --git a/version.json b/version.json index b97be27..1d0f172 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.6", + "version": "0.7", "semVer1NumericIdentifierPadding": 1, "publicReleaseRefSpec": [ "^refs/heads/master$",