diff --git a/docs/04_CustomMapping.md b/docs/04_CustomMapping.md index 5d3e000..4724d5e 100644 --- a/docs/04_CustomMapping.md +++ b/docs/04_CustomMapping.md @@ -10,7 +10,7 @@ Facet supports custom mapping logic for advanced scenarios via multiple interfac | `IFacetMapConfiguration` | Synchronous mapping | Fast, in-memory operations | | `IFacetMapConfigurationAsync` | Asynchronous mapping | I/O operations, database calls, API calls | | `IFacetMapConfigurationHybrid` | Combined sync/async | Optimal performance with mixed operations | -| `IFacetProjectionMapConfiguration` | Expression-based projection mapping | Computed properties in EF Core `Projection` | +| `IFacetProjectionMapConfiguration` | Expression-based projection mapping | Computed properties in EF Core `Projection`; can be used standalone (without `IFacetMapConfiguration`) to reuse expressions in constructors | ### Instance Mappers (With Dependency Injection Support) | Interface | Purpose | Use Case | @@ -575,6 +575,7 @@ public partial class UnitDto { ... } Use `IFacetProjectionMapConfiguration` when: - You have a computed property (e.g. `FullName = FirstName + " " + LastName`) that must work in EF Core `Select` queries - Your `Map()` method sets some properties that are expressible as SQL and others that are not (DI-dependent); you want the SQL-translatable ones in the `Projection` +- You want to **write mapping logic once** and reuse it in both projections and constructors (see [Standalone usage](#standalone-usage-without-ifacetmapconfiguration) below) ### Division of responsibility @@ -583,7 +584,9 @@ Use `IFacetProjectionMapConfiguration` when: | `IFacetMapConfiguration` — `Map()` | Constructors, `FromSource()` | Imperative logic, DI-dependent work, anything not SQL-translatable | | `IFacetProjectionMapConfiguration` - `ConfigureProjection()` | `Projection` build (once, lazy) | Expression-only mappings that EF Core can translate to SQL | -### Example +### Example: Combined with IFacetMapConfiguration + +When you implement both interfaces, `Map()` runs in constructors and `ConfigureProjection()` builds the projection. This is useful when some mappings are DI-dependent and cannot be expressed as SQL. ```csharp public class UserDto325MapConfig @@ -613,6 +616,34 @@ public partial class UserDto } ``` +### Standalone usage (without IFacetMapConfiguration) + +You can implement `IFacetProjectionMapConfiguration` **without** `IFacetMapConfiguration`. The generator will compile the projection expressions into a cached `Action` and invoke it in constructors and `FromSource()`. This lets you write your mapping logic once and reuse it everywhere — no code duplication. + +```csharp +public class EmployeeDtoMapConfig + : IFacetProjectionMapConfiguration +{ + public static void ConfigureProjection(IFacetProjectionBuilder builder) + { + builder.Map(d => d.FullName, s => s.FirstName + " " + s.LastName); + builder.Map(d => d.TotalPay, s => s.HourlyRate * s.HoursWorked); + } +} + +[Facet(typeof(EmployeeEntity), Configuration = typeof(EmployeeDtoMapConfig), GenerateProjection = true)] +public partial class EmployeeDto +{ + public string FullName { get; set; } = string.Empty; + public decimal TotalPay { get; set; } +} +``` + +With this setup: +- **Constructors** and **`FromSource()`** compile the expressions once (lazy, cached) and apply them after auto-mapped properties are set +- **`Projection`** inlines the expressions into a `MemberInitExpression` for EF Core SQL translation +- Both paths share the same `ConfigureProjection()` logic — single source of truth + When the generator detects that the `Configuration` type implements `IFacetProjectionMapConfiguration`, it switches the generated `Projection` property from a static expression literal to a **lazily-built expression tree**: ```csharp diff --git a/docs/13_AnalyzerRules.md b/docs/13_AnalyzerRules.md index 959d837..074a7b1 100644 --- a/docs/13_AnalyzerRules.md +++ b/docs/13_AnalyzerRules.md @@ -198,7 +198,7 @@ public partial class UserDto { } #### Description -Configuration types must implement `IFacetMapConfiguration`, `IFacetMapConfigurationAsync`, or provide a static `Map` method. +Configuration types must implement `IFacetMapConfiguration`, `IFacetMapConfigurationAsync`, `IFacetProjectionMapConfiguration`, or provide a static `Map` method. #### Bad Code @@ -215,7 +215,7 @@ public partial class UserDto { } #### Good Code ```csharp -// Option 1: Implement interface +// Option 1: Implement IFacetMapConfiguration public class UserMapper : IFacetMapConfiguration { public static void Map(User source, UserDto target) @@ -224,7 +224,16 @@ public class UserMapper : IFacetMapConfiguration } } -// Option 2: Provide static Map method +// Option 2: Implement IFacetProjectionMapConfiguration (expression-only, reused in constructors) +public class UserMapper : IFacetProjectionMapConfiguration +{ + public static void ConfigureProjection(IFacetProjectionBuilder builder) + { + builder.Map(d => d.FullName, s => s.FirstName + " " + s.LastName); + } +} + +// Option 3: Provide static Map method public class UserMapper { public static void Map(User source, UserDto target) diff --git a/src/Facet.Mapping/README.md b/src/Facet.Mapping/README.md index 00651ec..e7ec84f 100644 --- a/src/Facet.Mapping/README.md +++ b/src/Facet.Mapping/README.md @@ -21,9 +21,10 @@ With **Facet.Mapping**, you can go further and define custom logic like combinin 3. Point the `[Facet(...)]` attribute to the config class using `Configuration = typeof(...)`. ### Projection Mapping (EF Core-compatible computed properties) -1. Implement `IFacetProjectionMapConfiguration` alongside `IFacetMapConfiguration`. +1. Implement `IFacetProjectionMapConfiguration` — either alongside `IFacetMapConfiguration`, or on its own. 2. Define a static `ConfigureProjection` method that registers expression bindings via the builder. 3. The generator detects the interface and switches `Projection` to a lazily-built `MemberInitExpression` — fully translatable by EF Core. +4. When used without `IFacetMapConfiguration`, the generator also compiles the expressions into a cached `Action` and invokes it in constructors, no code duplication needed. ### Reverse Mapping (DTO → Entity) 1. Implement the `IFacetToSourceConfiguration` interface. diff --git a/src/Facet/Analyzers/FacetAttributeAnalyzer.cs b/src/Facet/Analyzers/FacetAttributeAnalyzer.cs index 6e64361..33386f9 100644 --- a/src/Facet/Analyzers/FacetAttributeAnalyzer.cs +++ b/src/Facet/Analyzers/FacetAttributeAnalyzer.cs @@ -53,11 +53,11 @@ public class FacetAttributeAnalyzer : DiagnosticAnalyzer public static readonly DiagnosticDescriptor InvalidConfigurationTypeRule = new DiagnosticDescriptor( "FAC006", "Configuration type does not implement required interface", - "Configuration type '{0}' must implement IFacetMapConfiguration or have a static Map method", + "Configuration type '{0}' must implement IFacetMapConfiguration, IFacetProjectionMapConfiguration, or have a static Map method", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Configuration types must implement the appropriate IFacetMapConfiguration interface or provide a static Map method."); + description: "Configuration types must implement IFacetMapConfiguration, IFacetProjectionMapConfiguration, or provide a static Map method."); // FAC007: Invalid NestedFacets type public static readonly DiagnosticDescriptor InvalidNestedFacetRule = new DiagnosticDescriptor( @@ -787,7 +787,17 @@ private static bool ImplementsConfigurationInterface(INamedTypeSymbol configurat SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, sourceType) && SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, targetType)); - return mapMethod != null; + if (mapMethod != null) + return true; + + // Check for IFacetProjectionMapConfiguration (projection-only config) + var projectionInterface = configurationType.AllInterfaces.FirstOrDefault(i => + i.IsGenericType && + i.ConstructedFrom.ToDisplayString() == "Facet.Mapping.IFacetProjectionMapConfiguration" && + SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], sourceType) && + SymbolEqualityComparer.Default.Equals(i.TypeArguments[1], targetType)); + + return projectionInterface != null; } private static IEnumerable GetAllPublicMembers(INamedTypeSymbol type) diff --git a/src/Facet/FacetTarget.cs b/src/Facet/FacetTarget.cs index 7a07ac6..613e514 100644 --- a/src/Facet/FacetTarget.cs +++ b/src/Facet/FacetTarget.cs @@ -82,6 +82,14 @@ internal sealed class FacetTargetModel : IEquatable /// public bool HasProjectionMapConfiguration { get; } + /// + /// When true, the type implements + /// IFacetMapConfiguration<TSource, TTarget> and provides an imperative Map() method. + /// When false but is true, the constructor will + /// compile the projection expressions into a cached Action<TSource, TTarget> instead. + /// + public bool HasMapConfiguration { get; } + public FacetTargetModel( string name, string? @namespace, @@ -118,7 +126,8 @@ public FacetTargetModel( string? toSourceConfigurationTypeName = null, bool baseHidesFacetMembers = false, bool hasProjectionMapConfiguration = false, - bool baseHidesFromSource = false) + bool baseHidesFromSource = false, + bool hasMapConfiguration = false) { Name = name; Namespace = @namespace; @@ -155,6 +164,7 @@ public FacetTargetModel( ToSourceConfigurationTypeName = toSourceConfigurationTypeName; BaseHidesFacetMembers = baseHidesFacetMembers; HasProjectionMapConfiguration = hasProjectionMapConfiguration; + HasMapConfiguration = hasMapConfiguration; BaseHidesFromSource = baseHidesFromSource; } @@ -197,6 +207,7 @@ public bool Equals(FacetTargetModel? other) && ToSourceConfigurationTypeName == other.ToSourceConfigurationTypeName && BaseHidesFacetMembers == other.BaseHidesFacetMembers && HasProjectionMapConfiguration == other.HasProjectionMapConfiguration + && HasMapConfiguration == other.HasMapConfiguration && BaseHidesFromSource == other.BaseHidesFromSource; } @@ -235,6 +246,7 @@ public override int GetHashCode() hash = hash * 31 + (ToSourceConfigurationTypeName?.GetHashCode() ?? 0); hash = hash * 31 + BaseHidesFacetMembers.GetHashCode(); hash = hash * 31 + HasProjectionMapConfiguration.GetHashCode(); + hash = hash * 31 + HasMapConfiguration.GetHashCode(); hash = hash * 31 + BaseHidesFromSource.GetHashCode(); hash = hash * 31 + Members.Length.GetHashCode(); diff --git a/src/Facet/Generators/FacetGenerators/CodeBuilder.cs b/src/Facet/Generators/FacetGenerators/CodeBuilder.cs index 24cdff1..a4807cf 100644 --- a/src/Facet/Generators/FacetGenerators/CodeBuilder.cs +++ b/src/Facet/Generators/FacetGenerators/CodeBuilder.cs @@ -117,6 +117,12 @@ public static string Generate(FacetTargetModel model, Dictionary 0 ? simpleName.Substring(0, angleBracket) : simpleName; } + /// + /// Generates a lazily-compiled Action<TSource, TTarget> from + /// ConfigureProjection expressions. Called when the configuration type + /// implements IFacetProjectionMapConfiguration but not IFacetMapConfiguration, + /// allowing users to write mapping logic once as expressions and reuse it in both + /// projections (EF Core) and constructors (in-memory). + /// + private static void GenerateProjectionMapAction(StringBuilder sb, FacetTargetModel model, string memberIndent) + { + var src = model.SourceTypeName; + var tgt = model.Name; + var bodyIndent = memberIndent + " "; + + sb.AppendLine(); + sb.AppendLine($"{memberIndent}private static global::System.Action<{src}, {tgt}>? __projectionMapAction;"); + sb.AppendLine(); + sb.AppendLine($"{memberIndent}private static global::System.Action<{src}, {tgt}> __GetProjectionMapAction()"); + sb.AppendLine($"{memberIndent}{{"); + sb.AppendLine($"{bodyIndent}return global::System.Threading.LazyInitializer.EnsureInitialized(ref __projectionMapAction, () =>"); + sb.AppendLine($"{bodyIndent}{{"); + + var innerIndent = bodyIndent + " "; + + sb.AppendLine($"{innerIndent}var __builder = new global::Facet.Mapping.FacetProjectionBuilder<{src}, {tgt}>();"); + sb.AppendLine($"{innerIndent}global::{model.ConfigurationTypeName}.ConfigureProjection(__builder);"); + sb.AppendLine(); + sb.AppendLine($"{innerIndent}var __sourceParam = global::System.Linq.Expressions.Expression.Parameter(typeof({src}), \"source\");"); + sb.AppendLine($"{innerIndent}var __targetParam = global::System.Linq.Expressions.Expression.Parameter(typeof({tgt}), \"target\");"); + sb.AppendLine($"{innerIndent}var __assignments = new global::System.Collections.Generic.List();"); + sb.AppendLine(); + sb.AppendLine($"{innerIndent}foreach (var (__member, __expr) in __builder.Mappings)"); + sb.AppendLine($"{innerIndent}{{"); + sb.AppendLine($"{innerIndent} var __body = global::Facet.Mapping.ParameterReplacer.Replace(__expr, __sourceParam);"); + sb.AppendLine($"{innerIndent} __assignments.Add(global::System.Linq.Expressions.Expression.Assign("); + sb.AppendLine($"{innerIndent} global::System.Linq.Expressions.Expression.MakeMemberAccess(__targetParam, __member), __body));"); + sb.AppendLine($"{innerIndent}}}"); + sb.AppendLine(); + sb.AppendLine($"{innerIndent}if (__assignments.Count == 0)"); + sb.AppendLine($"{innerIndent} return (_, _) => {{ }};"); + sb.AppendLine(); + sb.AppendLine($"{innerIndent}var __block = global::System.Linq.Expressions.Expression.Block(__assignments);"); + sb.AppendLine($"{innerIndent}return global::System.Linq.Expressions.Expression.Lambda>("); + sb.AppendLine($"{innerIndent} __block, __sourceParam, __targetParam).Compile();"); + + sb.AppendLine($"{bodyIndent}}})!;"); + sb.AppendLine($"{memberIndent}}}"); + } + private static void GenerateFileHeader(StringBuilder sb) { sb.AppendLine("// "); diff --git a/src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs b/src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs index 18db24b..62f04f5 100644 --- a/src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs +++ b/src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs @@ -196,7 +196,7 @@ private static void GenerateMainConstructorBody( var sourceValue = ExpressionBuilder.GetSourceValueExpression(m, "source"); sb.AppendLine($" this.{m.Name} = {sourceValue};"); } - sb.AppendLine($" global::{model.ConfigurationTypeName}.Map(source, this);"); + sb.AppendLine($" {GetMappingCall(model, "source", "this")};"); } else { @@ -222,7 +222,7 @@ private static void GenerateMainConstructorBody( { sb.AppendLine($" {model.BeforeMapConfigurationTypeName}.BeforeMap(source, this);"); } - sb.AppendLine($" global::{model.ConfigurationTypeName}.Map(source, this);"); + sb.AppendLine($" {GetMappingCall(model, "source", "this")};"); if (hasAfterMap) { sb.AppendLine($" {model.AfterMapConfigurationTypeName}.AfterMap(source, this);"); @@ -295,7 +295,7 @@ private static void GenerateDepthAwareConstructor( else if (hasCustomMapping && !model.HasExistingPrimaryConstructor) { // For positional records/record structs with custom mapping - sb.AppendLine($" global::{model.ConfigurationTypeName}.Map(source, this);"); + sb.AppendLine($" {GetMappingCall(model, "source", "this")};"); } sb.AppendLine(" }"); @@ -332,7 +332,7 @@ private static void GenerateDepthAwareConstructorBody( var sourceValue = ExpressionBuilder.GetSourceValueExpression(m, "source", model.MaxDepth, true, model.PreserveReferences); sb.AppendLine($" this.{m.Name} = {sourceValue};"); } - sb.AppendLine($" global::{model.ConfigurationTypeName}.Map(source, this);"); + sb.AppendLine($" {GetMappingCall(model, "source", "this")};"); } else { @@ -382,7 +382,7 @@ private static void GenerateFromSourceFactoryMethod(StringBuilder sb, FacetTarge { sb.AppendLine($" // Custom mapper creates and returns the instance"); sb.AppendLine($" var instance = new {model.Name}();"); - sb.AppendLine($" {model.ConfigurationTypeName}.Map(source, instance);"); + sb.AppendLine($" {GetMappingCall(model, "source", "instance")};"); sb.AppendLine($" return instance;"); } else @@ -437,4 +437,18 @@ private static void GenerateFactoryMethodForExistingPrimaryConstructor(StringBui } #endregion + + /// + /// Returns the appropriate mapping call for the constructor body. + /// When the config only implements IFacetProjectionMapConfiguration (no Map method), + /// delegates to the compiled projection action. Otherwise calls Map() directly. + /// + private static string GetMappingCall(FacetTargetModel model, string sourceExpr, string targetExpr) + { + if (!model.HasMapConfiguration && model.HasProjectionMapConfiguration) + { + return $"__GetProjectionMapAction()({sourceExpr}, {targetExpr})"; + } + return $"global::{model.ConfigurationTypeName}.Map({sourceExpr}, {targetExpr})"; + } } diff --git a/src/Facet/Generators/FacetGenerators/ModelBuilder.cs b/src/Facet/Generators/FacetGenerators/ModelBuilder.cs index 63e016d..4ea53d9 100644 --- a/src/Facet/Generators/FacetGenerators/ModelBuilder.cs +++ b/src/Facet/Generators/FacetGenerators/ModelBuilder.cs @@ -118,8 +118,10 @@ internal static class ModelBuilder var toSourceConfigurationTypeName = AttributeParser.ExtractToSourceConfigurationTypeName(attribute); // Detect if the configuration type implements IFacetProjectionMapConfiguration + // and/or IFacetMapConfiguration var configTypeSymbol = AttributeParser.ExtractConfigurationTypeSymbol(attribute); var hasProjectionMapConfiguration = false; + var hasMapConfiguration = false; if (configTypeSymbol != null) { var projectionConfigInterface = context.SemanticModel.Compilation @@ -129,6 +131,14 @@ internal static class ModelBuilder hasProjectionMapConfiguration = configTypeSymbol.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, projectionConfigInterface)); } + + var mapConfigInterface = context.SemanticModel.Compilation + .GetTypeByMetadataName(FacetConstants.MapConfigurationInterfaceFullName); + if (mapConfigInterface != null) + { + hasMapConfiguration = configTypeSymbol.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, mapConfigInterface)); + } } // Extract CollectionTargetType parameter @@ -282,7 +292,8 @@ internal static class ModelBuilder toSourceConfigurationTypeName, baseHidesFacetMembers, hasProjectionMapConfiguration, - baseHidesFromSource); + baseHidesFromSource, + hasMapConfiguration); } #region Private Helper Methods diff --git a/src/Facet/Generators/Shared/FacetConstants.cs b/src/Facet/Generators/Shared/FacetConstants.cs index 6e3f900..011ff5d 100644 --- a/src/Facet/Generators/Shared/FacetConstants.cs +++ b/src/Facet/Generators/Shared/FacetConstants.cs @@ -31,6 +31,12 @@ private static string GetGeneratorVersion() /// public const string FacetAttributeFullName = "Facet.FacetAttribute"; + /// + /// The metadata name of the IFacetMapConfiguration open generic interface. + /// Used by ModelBuilder to detect whether a configuration type implements imperative Map(). + /// + public const string MapConfigurationInterfaceFullName = "Facet.Mapping.IFacetMapConfiguration`2"; + /// /// The metadata name of the IFacetProjectionMapConfiguration open generic interface. /// Used by ModelBuilder to detect whether a configuration type opts into lazy projection building. diff --git a/test/Facet.Tests/UnitTests/Core/Facet/ProjectionOnlyMapConfigTests.cs b/test/Facet.Tests/UnitTests/Core/Facet/ProjectionOnlyMapConfigTests.cs new file mode 100644 index 0000000..91181b4 --- /dev/null +++ b/test/Facet.Tests/UnitTests/Core/Facet/ProjectionOnlyMapConfigTests.cs @@ -0,0 +1,127 @@ +using System.Linq.Expressions; + +namespace Facet.Tests.UnitTests.Core.Facet; + +public class EmployeeEntity332 +{ + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public decimal HourlyRate { get; set; } + public int HoursWorked { get; set; } +} + +/// +/// Configuration that ONLY implements IFacetProjectionMapConfiguration (no IFacetMapConfiguration). +/// The generator should compile these expressions into a cached Action for the constructor. +/// +public class EmployeeDto332MapConfig + : IFacetProjectionMapConfiguration +{ + public static void ConfigureProjection(IFacetProjectionBuilder builder) + { + builder.Map(d => d.FullName, s => s.FirstName + " " + s.LastName); + builder.Map(d => d.TotalPay, s => s.HourlyRate * s.HoursWorked); + } +} + +[Facet(typeof(EmployeeEntity332), Configuration = typeof(EmployeeDto332MapConfig), GenerateProjection = true)] +public partial class EmployeeDto332 +{ + public string FullName { get; set; } = string.Empty; + public decimal TotalPay { get; set; } +} + +/// +/// Tests for projection-only configuration reuse: when a config class only implements +/// IFacetProjectionMapConfiguration, the constructor compiles and invokes those expressions. +/// +public class ProjectionOnlyMapConfigTests +{ + [Fact] + public void Constructor_ShouldApplyProjectionExpressions() + { + var source = new EmployeeEntity332 + { + Id = 1, + FirstName = "Jane", + LastName = "Doe", + HourlyRate = 50m, + HoursWorked = 40 + }; + + var dto = new EmployeeDto332(source); + + dto.Id.Should().Be(1); + dto.FullName.Should().Be("Jane Doe", "ConfigureProjection expressions should be compiled and applied in the constructor"); + dto.TotalPay.Should().Be(2000m, "ConfigureProjection expressions should be compiled and applied in the constructor"); + } + + [Fact] + public void FromSource_ShouldApplyProjectionExpressions() + { + var source = new EmployeeEntity332 + { + Id = 2, + FirstName = "John", + LastName = "Smith", + HourlyRate = 75m, + HoursWorked = 30 + }; + + var dto = EmployeeDto332.FromSource(source); + + dto.Id.Should().Be(2); + dto.FullName.Should().Be("John Smith"); + dto.TotalPay.Should().Be(2250m); + } + + [Fact] + public void Projection_ShouldWorkForEfCoreQueries() + { + var source = new EmployeeEntity332 + { + Id = 3, + FirstName = "Alice", + LastName = "Walker", + HourlyRate = 100m, + HoursWorked = 20 + }; + + var compiled = EmployeeDto332.Projection.Compile(); + var dto = compiled(source); + + dto.Id.Should().Be(3); + dto.FullName.Should().Be("Alice Walker"); + dto.TotalPay.Should().Be(2000m); + } + + [Fact] + public void Projection_ShouldReturnPureMemberInitExpression() + { + var expr = EmployeeDto332.Projection; + + expr.Body.NodeType.Should().Be(ExpressionType.MemberInit, + "the Projection body must be a MemberInitExpression so EF Core can translate it"); + } + + [Fact] + public void Constructor_And_Projection_ShouldProduceSameResults() + { + var source = new EmployeeEntity332 + { + Id = 4, + FirstName = "Bob", + LastName = "Builder", + HourlyRate = 60m, + HoursWorked = 35 + }; + + var fromCtor = new EmployeeDto332(source); + var fromProjection = EmployeeDto332.Projection.Compile()(source); + + fromCtor.Id.Should().Be(fromProjection.Id); + fromCtor.FullName.Should().Be(fromProjection.FullName); + fromCtor.TotalPay.Should().Be(fromProjection.TotalPay); + } +}