diff --git a/src/MapTo/MapToGenerator.cs b/src/MapTo/MapToGenerator.cs index 6f7a62c..7a3a372 100644 --- a/src/MapTo/MapToGenerator.cs +++ b/src/MapTo/MapToGenerator.cs @@ -25,7 +25,8 @@ namespace MapTo .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, MapTypeConverterAttributeSource.Generate(options)) + .AddSource(ref context, MapPropertyAttributeSource.Generate(options)); if (context.SyntaxReceiver is MapToSyntaxReceiver receiver && receiver.CandidateClasses.Any()) { diff --git a/src/MapTo/MappingContext.cs b/src/MapTo/MappingContext.cs index 6c7df1c..c10e984 100644 --- a/src/MapTo/MappingContext.cs +++ b/src/MapTo/MappingContext.cs @@ -24,19 +24,24 @@ namespace MapTo TypeConverterInterfaceTypeSymbol = compilation.GetTypeByMetadataName(ITypeConverterSource.FullyQualifiedName) ?? throw new TypeLoadException($"Unable to find '{ITypeConverterSource.FullyQualifiedName}' type."); + + MapPropertyAttributeTypeSymbol = compilation.GetTypeByMetadataName(MapPropertyAttributeSource.FullyQualifiedName) + ?? throw new TypeLoadException($"Unable to find '{MapPropertyAttributeSource.FullyQualifiedName}' type."); } private Compilation Compilation { get; } - public INamedTypeSymbol MapTypeConverterAttributeTypeSymbol { get; } - - public INamedTypeSymbol TypeConverterInterfaceTypeSymbol { get; } - public MappingModel? Model { get; private set; } public ImmutableArray Diagnostics { get; private set; } public INamedTypeSymbol IgnorePropertyAttributeTypeSymbol { get; } + + public INamedTypeSymbol MapTypeConverterAttributeTypeSymbol { get; } + + public INamedTypeSymbol TypeConverterInterfaceTypeSymbol { get; } + + public INamedTypeSymbol MapPropertyAttributeTypeSymbol { get; } internal static MappingContext Create(Compilation compilation, ClassDeclarationSyntax classSyntax, SourceGenerationOptions sourceGenerationOptions) { @@ -102,7 +107,7 @@ namespace MapTo foreach (var property in classProperties) { - var sourceProperty = sourceProperties.FindProperty(property); + var sourceProperty = FindSourceProperty(context, sourceProperties, property); if (sourceProperty is null) { continue; @@ -138,12 +143,23 @@ namespace MapTo converterParameters.AddRange(GetTypeConverterParameters(typeConverterAttribute)); } - mappedProperties.Add(new MappedProperty(property.Name, converterFullyQualifiedName, converterParameters.ToImmutableArray())); + mappedProperties.Add(new MappedProperty(property.Name, converterFullyQualifiedName, converterParameters.ToImmutableArray(), sourceProperty.Name)); } return mappedProperties.ToImmutableArray(); } + private static IPropertySymbol? FindSourceProperty(MappingContext context, IEnumerable sourceProperties, IPropertySymbol property) + { + var propertyName = property + .GetAttribute(context.MapPropertyAttributeTypeSymbol) + ?.NamedArguments + .SingleOrDefault(a => a.Key == MapPropertyAttributeSource.SourcePropertyNamePropertyName) + .Value.Value as string ?? property.Name; + + return sourceProperties.SingleOrDefault(p => p.Name == propertyName); + } + private static INamedTypeSymbol? GetTypeConverterBaseInterface(MappingContext context, ITypeSymbol converterTypeSymbol, IPropertySymbol property, IPropertySymbol sourceProperty) { return converterTypeSymbol.AllInterfaces diff --git a/src/MapTo/Models.cs b/src/MapTo/Models.cs index e676f53..9fe9f83 100644 --- a/src/MapTo/Models.cs +++ b/src/MapTo/Models.cs @@ -7,7 +7,11 @@ namespace MapTo { internal record SourceCode(string Text, string HintName); - internal record MappedProperty(string Name, string? TypeConverter, ImmutableArray TypeConverterParameters); + internal record MappedProperty( + string Name, + string? TypeConverter, + ImmutableArray TypeConverterParameters, + string SourcePropertyName); internal record MappingModel ( SourceGenerationOptions Options, diff --git a/src/MapTo/Sources/MapClassSource.cs b/src/MapTo/Sources/MapClassSource.cs index d5e3bac..943fab0 100644 --- a/src/MapTo/Sources/MapClassSource.cs +++ b/src/MapTo/Sources/MapClassSource.cs @@ -70,7 +70,7 @@ namespace MapTo.Sources { if (property.TypeConverter is null) { - builder.WriteLine($"{property.Name} = {sourceClassParameterName}.{property.Name};"); + builder.WriteLine($"{property.Name} = {sourceClassParameterName}.{property.SourcePropertyName};"); } else { diff --git a/src/MapTo/Sources/MapPropertyAttributeSource.cs b/src/MapTo/Sources/MapPropertyAttributeSource.cs new file mode 100644 index 0000000..dacdbfc --- /dev/null +++ b/src/MapTo/Sources/MapPropertyAttributeSource.cs @@ -0,0 +1,60 @@ +using static MapTo.Sources.Constants; + +namespace MapTo.Sources +{ + internal static class MapPropertyAttributeSource + { + internal const string AttributeName = "MapProperty"; + internal const string AttributeClassName = AttributeName + "Attribute"; + internal const string FullyQualifiedName = RootNamespace + "." + AttributeClassName; + internal const string SourcePropertyNamePropertyName = "SourcePropertyName"; + + internal static SourceCode Generate(SourceGenerationOptions options) + { + using var builder = new SourceBuilder() + .WriteLine(GeneratedFilesHeader) + .WriteNullableContextOptionIf(options.SupportNullableReferenceTypes) + .WriteLine() + .WriteLine("using System;") + .WriteLine() + .WriteLine($"namespace {RootNamespace}") + .WriteOpeningBracket(); + + if (options.GenerateXmlDocument) + { + builder + .WriteLine("/// ") + .WriteLine("/// Specifies the mapping behavior of annotated property.") + .WriteLine("/// ") + .WriteLine("/// ") + .WriteLine($"/// {AttributeClassName} has a number of uses:") + .WriteLine("/// ") + .WriteLine("/// By default properties with same name will get mapped. This attribute allows the names to be different.") + .WriteLine("/// Indicates that a property should be mapped when member serialization is set to opt-in.") + .WriteLine("/// ") + .WriteLine("/// "); + } + + builder + .WriteLine("[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]") + .WriteLine($"public sealed class {AttributeClassName} : Attribute") + .WriteOpeningBracket(); + + if (options.GenerateXmlDocument) + { + builder + .WriteLine("/// ") + .WriteLine("/// Gets or sets the property name of the object to mapping from.") + .WriteLine("/// "); + } + + builder + .WriteLine($"public string{options.NullableReferenceSyntax} {SourcePropertyNamePropertyName} {{ get; set; }}") + .WriteClosingBracket() // class + .WriteClosingBracket(); // namespace + + + return new(builder.ToString(), $"{AttributeClassName}.g.cs"); + } + } +} \ No newline at end of file diff --git a/test/MapTo.Tests/Tests.cs b/test/MapTo.Tests/Tests.cs index 39aad33..a91eb2e 100644 --- a/test/MapTo.Tests/Tests.cs +++ b/test/MapTo.Tests/Tests.cs @@ -384,7 +384,13 @@ namespace Test { // Arrange const string source = ""; - var expectedTypes = new[] { IgnorePropertyAttributeSource.AttributeName, MapFromAttributeSource.AttributeName, ITypeConverterSource.InterfaceName }; + var expectedTypes = new[] + { + IgnorePropertyAttributeSource.AttributeName, + MapFromAttributeSource.AttributeName, + ITypeConverterSource.InterfaceName, + MapPropertyAttributeSource.AttributeName + }; // Act var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source); @@ -542,7 +548,7 @@ namespace MapTo diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(ITypeConverterSource.InterfaceName, expectedInterface); } - + [Fact] public void VerifyMapTypeConverterAttribute() { @@ -578,7 +584,7 @@ namespace MapTo diagnostics.ShouldBeSuccessful(); compilation.SyntaxTrees.ShouldContainSource(MapTypeConverterAttributeSource.AttributeName, expectedInterface); } - + [Fact] public void VerifyMapTypeConverterAttributeWithNullableOptionOn() { @@ -759,6 +765,73 @@ namespace Test compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedSyntax); } + [Theory] + [InlineData(NullableContextOptions.Disable)] + [InlineData(NullableContextOptions.Enable)] + public void VerifyMapPropertyAttribute(NullableContextOptions nullableContextOptions) + { + // Arrange + const string source = ""; + var nullableSyntax = nullableContextOptions == NullableContextOptions.Enable ? "?" : string.Empty; + var expectedInterface = $@" +{Constants.GeneratedFilesHeader} +{(nullableContextOptions == NullableContextOptions.Enable ? $"#nullable enable{Environment.NewLine}": string.Empty)} +using System; + +namespace MapTo +{{ + [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] + public sealed class MapPropertyAttribute : Attribute + {{ + public string{nullableSyntax} SourcePropertyName {{ get; set; }} + }} +}} +".Trim(); + + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, nullableContextOptions: nullableContextOptions); + + // Assert + diagnostics.ShouldBeSuccessful(); + compilation.SyntaxTrees.ShouldContainSource(MapPropertyAttributeSource.AttributeName, expectedInterface); + } + + [Fact] + public void When_MapPropertyFound_Should_UseItToMapToSourceProperty() + { + // Arrange + var source = GetSourceText(new SourceGeneratorOptions( + true, + PropertyBuilder: builder => + { + builder + .PadLeft(Indent2).AppendLine("[MapProperty(SourcePropertyName = nameof(Baz.Prop3))]") + .PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"); + }, + SourcePropertyBuilder: builder => builder.PadLeft(Indent2).AppendLine("public int Prop4 { get; set; }"))); + + var expectedResult = @" + partial class Foo + { + public Foo(Test.Models.Baz baz) + { + if (baz == null) throw new ArgumentNullException(nameof(baz)); + + Prop1 = baz.Prop1; + Prop2 = baz.Prop2; + Prop3 = baz.Prop3; + Prop4 = baz.Prop3; + } +".Trim(); + + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); + + // Assert + diagnostics.ShouldBeSuccessful(); + compilation.SyntaxTrees.Last().ToString().ShouldContain(expectedResult); + } + private static PropertyDeclarationSyntax GetPropertyDeclarationSyntax(SyntaxTree syntaxTree, string targetPropertyName, string targetClass = "Foo") { return syntaxTree.GetRoot()