Fix cyclic reference issue.

This commit is contained in:
Mohammadreza Taikandi 2021-04-09 07:48:23 +01:00
parent 9f9fb2d158
commit 3d0f9e5bbb
24 changed files with 652 additions and 62 deletions

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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
/// <inheritdoc />
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;
}
}

View File

@ -24,7 +24,7 @@ namespace MapTo
internal MappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, ClassDeclarationSyntax classSyntax)
{
_diagnostics = new List<Diagnostic>();
_usings = new List<string> { "System" };
_usings = new List<string> { "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);

View File

@ -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
);
}

View File

@ -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($"/// <exception cref=\"ArgumentNullException\">{sourceClassParameterName} is null</exception>");
}
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});")

View File

@ -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<string> { "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<object, object> _cache;")
.WriteLine()
// Constructor
.WriteLine($"internal {ClassName}()")
.WriteOpeningBracket()
.WriteLine("_cache = new Dictionary<object, object>(1);")
.WriteClosingBracket()
.WriteLine()
// Factory
.WriteLine($"internal static TMapped {FactoryMethodName}<TOriginal, TMapped>(TOriginal original)")
.WriteOpeningBracket()
.WriteLine("if (original == null) throw new ArgumentNullException(nameof(original));")
.WriteLine()
.WriteLine("var context = new MappingContext();")
.WriteLine("var mapped = context.MapFromWithContext<TOriginal, TMapped>(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, TMapped>(TOriginal original)")
.WriteOpeningBracket()
.WriteLine("if (original == null)")
.WriteOpeningBracket()
.WriteLine("return default(TMapped);")
.WriteClosingBracket()
.WriteLine()
.WriteLine("if (!TryGetValue<TOriginal, TMapped>(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, TMapped>(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, TMapped>(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");
}
}
}

View File

@ -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<string> usings)
{
foreach (var u in usings.OrderBy(s => s))
{
WriteUsing(u);
}
return this;
}
public SourceBuilder WriteUsing(string u)
{
WriteLine($"using {u};");
return this;
}
/// <inheritdoc />
public override string ToString() => _writer.ToString();
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<Employee> Employees { get; set; } = new();
}
}

View File

@ -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; }
}
}

View File

@ -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<EmployeeViewModel> Employees { get; set; } = new();
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MapTo\MapTo.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="Shouldly" Version="4.0.1" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="1.3.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@ -21,6 +21,16 @@ namespace MapTo.Tests.Extensions
syntax.ShouldBe(expectedSource, customMessage);
}
internal static void ShouldContainPartialSource(this IEnumerable<SyntaxTree> 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();

View File

@ -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;

View File

@ -11,10 +11,25 @@ namespace MapTo.Tests.Infrastructure
{
internal static class CSharpGenerator
{
internal static (Compilation compilation, ImmutableArray<Diagnostic> diagnostics) GetOutputCompilation(string source, bool assertCompilation = false, IDictionary<string, string> analyzerConfigOptions = null, NullableContextOptions nullableContextOptions = NullableContextOptions.Disable) =>
GetOutputCompilation(new[] { source }, assertCompilation, analyzerConfigOptions, nullableContextOptions);
internal static (Compilation compilation, ImmutableArray<Diagnostic> diagnostics) GetOutputCompilation(
string source,
bool assertCompilation = false,
IDictionary<string, string> analyzerConfigOptions = null,
NullableContextOptions nullableContextOptions = NullableContextOptions.Disable,
LanguageVersion languageVersion = LanguageVersion.CSharp7_3) =>
GetOutputCompilation(
new[] { source },
assertCompilation,
analyzerConfigOptions,
nullableContextOptions,
languageVersion);
internal static (Compilation compilation, ImmutableArray<Diagnostic> diagnostics) GetOutputCompilation(IEnumerable<string> sources, bool assertCompilation = false, IDictionary<string, string> analyzerConfigOptions = null, NullableContextOptions nullableContextOptions = NullableContextOptions.Disable)
internal static (Compilation compilation, ImmutableArray<Diagnostic> diagnostics) GetOutputCompilation(
IEnumerable<string> sources,
bool assertCompilation = false,
IDictionary<string, string> 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);

View File

@ -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;

View File

@ -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, Foo>(baz);
}".Trim();
// Act
@ -123,6 +123,7 @@ namespace Test
const string expectedResult = @"
// <auto-generated />
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 = @"
// <auto-generated />
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
// <auto-generated />
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, Foo>(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<A, B>(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<Employee, EmployeeViewModel>).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<Employee, EmployeeViewModel>).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<Employee, EmployeeViewModel>).ToList();
}
";
// Act

View File

@ -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;

View File

@ -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 = @"
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Reflection;
namespace MapTo
{
internal sealed class MappingContext
{
private readonly Dictionary<object, object> _cache;
internal MappingContext()
{
_cache = new Dictionary<object, object>(1);
}
internal static TMapped Create<TOriginal, TMapped>(TOriginal original)
{
if (original == null) throw new ArgumentNullException(nameof(original));
var context = new MappingContext();
var mapped = context.MapFromWithContext<TOriginal, TMapped>(original);
if (mapped == null)
{
throw new InvalidOperationException();
}
return mapped;
}
internal TMapped MapFromWithContext<TOriginal, TMapped>(TOriginal original)
{
if (original == null)
{
return default(TMapped);
}
if (!TryGetValue<TOriginal, TMapped>(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, TMapped>(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, TMapped>(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);
}
}
}

View File

@ -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

View File

@ -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$",