Skip to content

Commit c709ebd

Browse files
committed
Add MapFromAttribute for explicit property mapping
Theme: Enhanced DTO mapping and source generator support -- Introduced MapFromAttribute to allow explicit mapping of DTO/detail properties from source entity properties, supporting both string literals and nameof expressions. -- Updated source generator logic to recognize and process MapFromAttribute, including robust extraction of property names via constructor, named arguments, and syntax parsing. -- Added CustomProductDetails partial class using AutoSelect and MapFrom attributes, with generated projection and conversion methods for Product entities. -- Added unit tests to verify MapFrom-based mapping, covering both code generation and runtime behavior. -- Updated documentation to describe MapFromAttribute usage. -- Updated project configuration and references to support new attribute and generator features. These changes improve clarity and flexibility when mapping between types with differing property names.
1 parent d44d90c commit c709ebd

11 files changed

Lines changed: 315 additions & 3 deletions

File tree

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<GenFrwkVer>netstandard2.0</GenFrwkVer>
66
</PropertyGroup>
77
<PropertyGroup>
8-
<ExtSrcGenVer>0.1.12</ExtSrcGenVer>
8+
<ExtSrcGenVer>0.1.13</ExtSrcGenVer>
99
</PropertyGroup>
1010
<PropertyGroup>
1111
<Ver>0.4.0</Ver>

src/RoyalCode.SmartSelector.Demo/Details/ProductDetails.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@ public partial class ProductDetails
1111
public DateTime? CreatedAt { get; set; }
1212
public DateTime? UpdatedAt { get; set; }
1313
}
14+
15+
[AutoSelect<Product>]
16+
public partial class CustomProductDetails
17+
{
18+
[MapFrom("Id")]
19+
public int CustomId { get; set; }
20+
[MapFrom(nameof(Product.Name))]
21+
public string CustomName { get; set; } = default!;
22+
[MapFrom(nameof(Product.IsActive))]
23+
public bool CustomIsActive { get; set; }
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using RoyalCode.SmartSelector.Demo.Entities;
2+
using System.Linq.Expressions;
3+
4+
namespace RoyalCode.SmartSelector.Demo.Details;
5+
6+
public partial class CustomProductDetails
7+
{
8+
private static Func<Product, CustomProductDetails> selectProductFunc;
9+
10+
public static Expression<Func<Product, CustomProductDetails>> SelectProductExpression { get; } = a => new CustomProductDetails
11+
{
12+
CustomId = a.Id,
13+
CustomName = a.Name,
14+
CustomIsActive = a.IsActive
15+
};
16+
17+
public static CustomProductDetails From(Product product) => (selectProductFunc ??= SelectProductExpression.Compile())(product);
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using RoyalCode.SmartSelector.Demo.Entities;
2+
3+
namespace RoyalCode.SmartSelector.Demo.Details;
4+
5+
public static class CustomProductDetails_Extensions
6+
{
7+
public static IQueryable<CustomProductDetails> SelectCustomProductDetails(this IQueryable<Product> query)
8+
{
9+
return query.Select(CustomProductDetails.SelectProductExpression);
10+
}
11+
12+
public static IEnumerable<CustomProductDetails> SelectCustomProductDetails(this IEnumerable<Product> enumerable)
13+
{
14+
return enumerable.Select(CustomProductDetails.From);
15+
}
16+
17+
public static CustomProductDetails ToCustomProductDetails(this Product product) => CustomProductDetails.From(product);
18+
}

src/RoyalCode.SmartSelector.Demo/Tests/ProductDemoTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,20 @@ public void Select_ProductDetails_From_List()
9494
Assert.Contains(details, d => d.Name == "Hot dog 2");
9595
Assert.Contains(details, d => d.Name == "Hot dog 3");
9696
}
97+
98+
[Fact]
99+
public void Create_CustomProductDetails_From_Product()
100+
{
101+
// arrange
102+
var product = new Product("Hot dog 1");
103+
104+
// act
105+
var details = CustomProductDetails.From(product);
106+
107+
// assert
108+
Assert.NotNull(details);
109+
Assert.Equal(product.Id, details.CustomId);
110+
Assert.Equal(product.Name, details.CustomName);
111+
Assert.Equal(product.IsActive, details.CustomIsActive);
112+
}
97113
}

src/RoyalCode.SmartSelector.Generators/Generators/AutoPropertiesGenerator.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ internal static class AutoPropertiesGenerator
1010

1111
private const string AutoPropertiesAttributeName = "AutoProperties"; // non generic form
1212
private const string AutoPropertiesGenericAttributeName = "AutoProperties"; // generic form base identifier
13+
internal const string MapFromAttributeName = "MapFromAttribute";
1314

1415
internal static MatchOptions MatchOptions { get; } = new()
1516
{
1617
OriginPropertiesRetriever = new AutoPropertyOriginPropertiesRetriever(),
1718
AdditionalAssignDescriptorResolvers = [new AutoDetailsAssignDescriptorResolver()],
19+
PropertyNameResolvers = [new MapFromPropertyNameResolver()],
1820
};
1921

2022
internal static bool Predicate(SyntaxNode node, CancellationToken token)
@@ -486,4 +488,108 @@ public bool TryCreateAssignDescriptor(
486488

487489
return true;
488490
}
491+
}
492+
493+
internal class MapFromPropertyNameResolver : IPropertyNameResolver
494+
{
495+
public bool TryResolvePropertyName(IPropertySymbol symbol, out string? propertyName)
496+
{
497+
propertyName = null;
498+
499+
// check if the symbol has the MapFrom attribute
500+
var attr = symbol.GetAttributes()
501+
.FirstOrDefault(attr => attr.AttributeClass?.Name == AutoPropertiesGenerator.MapFromAttributeName);
502+
503+
if (attr is not null)
504+
{
505+
// First, try constructor argument (preferred usage)
506+
if (attr.ConstructorArguments.Length == 1)
507+
{
508+
propertyName = attr.ConstructorArguments[0].Value as string;
509+
if (!string.IsNullOrEmpty(propertyName))
510+
return true;
511+
}
512+
513+
// Fallback: try named argument on the attribute (PropertyName setter)
514+
if (attr.NamedArguments.Length > 0)
515+
{
516+
var named = attr.NamedArguments.FirstOrDefault(a => a.Key == "PropertyName");
517+
if (named.Value.Value is string s && !string.IsNullOrEmpty(s))
518+
{
519+
propertyName = s;
520+
return true;
521+
}
522+
}
523+
524+
// Last resort: inspect declaration syntax to extract literal or nameof
525+
// This handles cases where Roslyn didn't materialize ConstructorArguments in this pipeline
526+
foreach (var decl in symbol.DeclaringSyntaxReferences)
527+
{
528+
var syntax = decl.GetSyntax();
529+
if (syntax is not PropertyDeclarationSyntax pds)
530+
continue;
531+
532+
foreach (var al in pds.AttributeLists)
533+
{
534+
foreach (var a in al.Attributes)
535+
{
536+
var nameText = a.Name switch
537+
{
538+
IdentifierNameSyntax id => id.Identifier.Text,
539+
GenericNameSyntax gn => gn.Identifier.Text,
540+
_ => a.Name.ToString()
541+
};
542+
543+
if (!string.Equals(nameText, "MapFrom", StringComparison.Ordinal))
544+
continue;
545+
546+
if (a.ArgumentList is { Arguments.Count: > 0 })
547+
{
548+
var expr = a.ArgumentList.Arguments[0].Expression;
549+
switch (expr)
550+
{
551+
case LiteralExpressionSyntax les:
552+
var text = les.Token.ValueText;
553+
if (!string.IsNullOrEmpty(text))
554+
{
555+
propertyName = text;
556+
return true;
557+
}
558+
break;
559+
case InvocationExpressionSyntax ies:
560+
// Handle nameof(Member) -> get last identifier as property name
561+
if (ies.Expression is Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax ident
562+
&& string.Equals(ident.Identifier.Text, "nameof", StringComparison.Ordinal)
563+
&& ies.ArgumentList.Arguments.Count == 1)
564+
{
565+
var argExpr = ies.ArgumentList.Arguments[0].Expression;
566+
if (argExpr is IdentifierNameSyntax idArg)
567+
{
568+
propertyName = idArg.Identifier.Text;
569+
if (!string.IsNullOrEmpty(propertyName))
570+
return true;
571+
}
572+
else if (argExpr is MemberAccessExpressionSyntax mae)
573+
{
574+
propertyName = mae.Name.Identifier.Text;
575+
if (!string.IsNullOrEmpty(propertyName))
576+
return true;
577+
}
578+
}
579+
break;
580+
case MemberAccessExpressionSyntax maeExpr:
581+
// If provided directly as something like Product.Name (rare), take the right side
582+
propertyName = maeExpr.Name.Identifier.Text;
583+
if (!string.IsNullOrEmpty(propertyName))
584+
return true;
585+
break;
586+
}
587+
}
588+
}
589+
}
590+
}
591+
}
592+
593+
return false;
594+
}
489595
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using FluentAssertions;
2+
using Microsoft.CodeAnalysis;
3+
4+
namespace RoyalCode.SmartSelector.Tests.Tests;
5+
6+
public partial class MapFromTests
7+
{
8+
[Fact]
9+
public void Select_ProductDetails_With_MapFromAttribute()
10+
{
11+
Util.Compile(Code.Types, out var output, out var diagnostics);
12+
13+
diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty();
14+
15+
var generatedInterface = output.SyntaxTrees.Skip(1).FirstOrDefault()?.ToString();
16+
generatedInterface.Should().Be(Code.ExpectedPartial);
17+
18+
var generatedHandler = output.SyntaxTrees.Skip(2).FirstOrDefault()?.ToString();
19+
generatedHandler.Should().Be(Code.ExpectedExtension);
20+
}
21+
}
22+
23+
file static class Code
24+
{
25+
public const string Types =
26+
"""
27+
using RoyalCode.SmartSelector;
28+
29+
namespace Tests.SmartSelector.Models;
30+
31+
#nullable disable // poco
32+
33+
public abstract class Entity<TId>
34+
{
35+
public TId Id { get; protected set; }
36+
}
37+
38+
public class Product : Entity<Guid>
39+
{
40+
public Product(string name)
41+
{
42+
Name = name;
43+
Active = true;
44+
}
45+
public string Name { get; set; }
46+
public bool Active { get; set; }
47+
}
48+
49+
[AutoSelect<Product>]
50+
public partial class ProductDetails
51+
{
52+
[MapFrom("Id")]
53+
public Guid CustomId { get; set; }
54+
55+
[MapFrom(nameof(Product.Name))]
56+
public string CustomName { get; set; }
57+
58+
[MapFrom(nameof(Product.Active))]
59+
public bool CustomActive { get; set; }
60+
}
61+
""";
62+
63+
public const string ExpectedPartial =
64+
"""
65+
using System.Linq.Expressions;
66+
67+
namespace Tests.SmartSelector.Models;
68+
69+
public partial class ProductDetails
70+
{
71+
private static Func<Product, ProductDetails> selectProductFunc;
72+
73+
public static Expression<Func<Product, ProductDetails>> SelectProductExpression { get; } = a => new ProductDetails
74+
{
75+
CustomId = a.Id,
76+
CustomName = a.Name,
77+
CustomActive = a.Active
78+
};
79+
80+
public static ProductDetails From(Product product) => (selectProductFunc ??= SelectProductExpression.Compile())(product);
81+
}
82+
83+
""";
84+
85+
public const string ExpectedExtension =
86+
"""
87+
88+
namespace Tests.SmartSelector.Models;
89+
90+
public static class ProductDetails_Extensions
91+
{
92+
public static IQueryable<ProductDetails> SelectProductDetails(this IQueryable<Product> query)
93+
{
94+
return query.Select(ProductDetails.SelectProductExpression);
95+
}
96+
97+
public static IEnumerable<ProductDetails> SelectProductDetails(this IEnumerable<Product> enumerable)
98+
{
99+
return enumerable.Select(ProductDetails.From);
100+
}
101+
102+
public static ProductDetails ToProductDetails(this Product product) => ProductDetails.From(product);
103+
}
104+
105+
""";
106+
}

src/RoyalCode.SmartSelector.Tests/Util.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal static void Compile(
3030
MetadataReference.CreateFromFile(typeof(Task).Assembly.Location),
3131
MetadataReference.CreateFromFile(typeof(CancellationToken).Assembly.Location),
3232
MetadataReference.CreateFromFile(typeof(AutoSelectAttribute<>).Assembly.Location),
33+
MetadataReference.CreateFromFile(typeof(MapFromAttribute).Assembly.Location),
3334
};
3435

3536
// create a compilation for the source code.

src/RoyalCode.SmartSelector/.publish/RoyalCode.SmartSelector/RoyalCode.SmartSelector.xml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace RoyalCode.SmartSelector;
2+
3+
#pragma warning disable CS9113 // PropertyName not used
4+
5+
/// <summary>
6+
/// Indicates that the decorated property should map its value from another property with the specified name.
7+
/// </summary>
8+
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
9+
public sealed class MapFromAttribute : Attribute
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of the MapFromAttribute class, specifying the source property name to map from.
13+
/// </summary>
14+
/// <param name="propertyName">The name of the source property to be mapped. Cannot be null or empty.</param>
15+
public MapFromAttribute(string propertyName)
16+
{
17+
PropertyName = propertyName;
18+
}
19+
20+
/// <summary>
21+
/// Gets or sets the name of the source property to map from.
22+
/// </summary>
23+
public string PropertyName { get; set; }
24+
}

0 commit comments

Comments
 (0)