Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions docs/04_CustomMapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Facet supports custom mapping logic for advanced scenarios via multiple interfac
| `IFacetMapConfiguration<TSource, TTarget>` | Synchronous mapping | Fast, in-memory operations |
| `IFacetMapConfigurationAsync<TSource, TTarget>` | Asynchronous mapping | I/O operations, database calls, API calls |
| `IFacetMapConfigurationHybrid<TSource, TTarget>` | Combined sync/async | Optimal performance with mixed operations |
| `IFacetProjectionMapConfiguration<TSource, TTarget>` | Expression-based projection mapping | Computed properties in EF Core `Projection` |
| `IFacetProjectionMapConfiguration<TSource, TTarget>` | 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 |
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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<TSource, TTarget>` 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<EmployeeEntity, EmployeeDto>
{
public static void ConfigureProjection(IFacetProjectionBuilder<EmployeeEntity, EmployeeDto> 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
Expand Down
15 changes: 12 additions & 3 deletions docs/13_AnalyzerRules.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public partial class UserDto { }

#### Description

Configuration types must implement `IFacetMapConfiguration<TSource, TTarget>`, `IFacetMapConfigurationAsync<TSource, TTarget>`, or provide a static `Map` method.
Configuration types must implement `IFacetMapConfiguration<TSource, TTarget>`, `IFacetMapConfigurationAsync<TSource, TTarget>`, `IFacetProjectionMapConfiguration<TSource, TTarget>`, or provide a static `Map` method.

#### Bad Code

Expand All @@ -215,7 +215,7 @@ public partial class UserDto { }
#### Good Code

```csharp
// Option 1: Implement interface
// Option 1: Implement IFacetMapConfiguration
public class UserMapper : IFacetMapConfiguration<User, UserDto>
{
public static void Map(User source, UserDto target)
Expand All @@ -224,7 +224,16 @@ public class UserMapper : IFacetMapConfiguration<User, UserDto>
}
}

// Option 2: Provide static Map method
// Option 2: Implement IFacetProjectionMapConfiguration (expression-only, reused in constructors)
public class UserMapper : IFacetProjectionMapConfiguration<User, UserDto>
{
public static void ConfigureProjection(IFacetProjectionBuilder<User, UserDto> 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)
Expand Down
3 changes: 2 additions & 1 deletion src/Facet.Mapping/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSource, TTarget>` alongside `IFacetMapConfiguration<TSource, TTarget>`.
1. Implement `IFacetProjectionMapConfiguration<TSource, TTarget>` — either alongside `IFacetMapConfiguration<TSource, TTarget>`, 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<TFacet, TSource>` interface.
Expand Down
16 changes: 13 additions & 3 deletions src/Facet/Analyzers/FacetAttributeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<TSource, TTarget> (projection-only config)
var projectionInterface = configurationType.AllInterfaces.FirstOrDefault(i =>
i.IsGenericType &&
i.ConstructedFrom.ToDisplayString() == "Facet.Mapping.IFacetProjectionMapConfiguration<TSource, TTarget>" &&
SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], sourceType) &&
SymbolEqualityComparer.Default.Equals(i.TypeArguments[1], targetType));

return projectionInterface != null;
}

private static IEnumerable<ISymbol> GetAllPublicMembers(INamedTypeSymbol type)
Expand Down
14 changes: 13 additions & 1 deletion src/Facet/FacetTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ internal sealed class FacetTargetModel : IEquatable<FacetTargetModel>
/// </summary>
public bool HasProjectionMapConfiguration { get; }

/// <summary>
/// When true, the <see cref="ConfigurationTypeName"/> type implements
/// <c>IFacetMapConfiguration&lt;TSource, TTarget&gt;</c> and provides an imperative <c>Map()</c> method.
/// When false but <see cref="HasProjectionMapConfiguration"/> is true, the constructor will
/// compile the projection expressions into a cached <c>Action&lt;TSource, TTarget&gt;</c> instead.
/// </summary>
public bool HasMapConfiguration { get; }

public FacetTargetModel(
string name,
string? @namespace,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -155,6 +164,7 @@ public FacetTargetModel(
ToSourceConfigurationTypeName = toSourceConfigurationTypeName;
BaseHidesFacetMembers = baseHidesFacetMembers;
HasProjectionMapConfiguration = hasProjectionMapConfiguration;
HasMapConfiguration = hasMapConfiguration;
BaseHidesFromSource = baseHidesFromSource;
}

Expand Down Expand Up @@ -197,6 +207,7 @@ public bool Equals(FacetTargetModel? other)
&& ToSourceConfigurationTypeName == other.ToSourceConfigurationTypeName
&& BaseHidesFacetMembers == other.BaseHidesFacetMembers
&& HasProjectionMapConfiguration == other.HasProjectionMapConfiguration
&& HasMapConfiguration == other.HasMapConfiguration
&& BaseHidesFromSource == other.BaseHidesFromSource;
}

Expand Down Expand Up @@ -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();

Expand Down
60 changes: 60 additions & 0 deletions src/Facet/Generators/FacetGenerators/CodeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ public static string Generate(FacetTargetModel model, Dictionary<string, FacetTa
ConstructorGenerator.GenerateConstructor(sb, model, isPositional, hasInitOnlyProperties, hasCustomMapping, hasRequiredProperties);
}

// Generate compiled projection-map action when config only provides ConfigureProjection (no Map)
if (hasCustomMapping && !model.HasMapConfiguration && model.HasProjectionMapConfiguration)
{
GenerateProjectionMapAction(sb, model, memberIndent);
}

// Generate copy constructor
if (model.GenerateCopyConstructor)
{
Expand Down Expand Up @@ -295,6 +301,12 @@ public static string GenerateCombined(

ConstructorGenerator.GenerateConstructor(
sb, model, isPositional, modelHasInitOnly, hasCustomMapping, modelHasRequired);

// Generate compiled projection-map action when config only provides ConfigureProjection (no Map)
if (hasCustomMapping && !model.HasMapConfiguration && model.HasProjectionMapConfiguration)
{
GenerateProjectionMapAction(sb, model, memberIndent);
}
}

// Shared: copy constructor (from primary model)
Expand Down Expand Up @@ -379,6 +391,54 @@ private static string GetSourceSimpleName(FacetTargetModel model)
return angleBracket > 0 ? simpleName.Substring(0, angleBracket) : simpleName;
}

/// <summary>
/// Generates a lazily-compiled <c>Action&lt;TSource, TTarget&gt;</c> from
/// <c>ConfigureProjection</c> expressions. Called when the configuration type
/// implements <c>IFacetProjectionMapConfiguration</c> but not <c>IFacetMapConfiguration</c>,
/// allowing users to write mapping logic once as expressions and reuse it in both
/// projections (EF Core) and constructors (in-memory).
/// </summary>
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<global::System.Linq.Expressions.Expression>();");
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<global::System.Action<{src}, {tgt}>>(");
sb.AppendLine($"{innerIndent} __block, __sourceParam, __targetParam).Compile();");

sb.AppendLine($"{bodyIndent}}})!;");
sb.AppendLine($"{memberIndent}}}");
}

private static void GenerateFileHeader(StringBuilder sb)
{
sb.AppendLine("// <auto-generated>");
Expand Down
24 changes: 19 additions & 5 deletions src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);");
Expand Down Expand Up @@ -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(" }");
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -437,4 +437,18 @@ private static void GenerateFactoryMethodForExistingPrimaryConstructor(StringBui
}

#endregion

/// <summary>
/// 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.
/// </summary>
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})";
}
}
Loading
Loading