├── assets ├── Facet.png └── FacetLogo.png ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── sync-docs-to-wiki.yml │ └── test.yml ├── src ├── Facet │ ├── AnalyzerReleases.Unshipped.md │ ├── SymbolNameExtensions.cs │ ├── AnalyzerReleases.Shipped.md │ ├── Generators │ │ ├── WrapperGenerators │ │ │ ├── WrapperGenerator.cs │ │ │ └── WrapperConstructorGenerator.cs │ │ ├── FlattenGenerators │ │ │ ├── FlattenGenerator.cs │ │ │ └── FlattenModels.cs │ │ ├── FacetGenerators │ │ │ ├── FacetGenerator.cs │ │ │ ├── MemberGenerator.cs │ │ │ ├── NullabilityAnalyzer.cs │ │ │ └── ToSourceGenerator.cs │ │ └── Shared │ │ │ └── FacetConstants.cs │ ├── Facet.csproj │ ├── GenerateDtosTargetModel.cs │ ├── Analyzers │ │ └── SourceSignatureCodeFixProvider.cs │ └── WrapperTarget.cs ├── Facet.Dashboard │ ├── Templates │ │ ├── member-row.html │ │ ├── members-table.html │ │ ├── empty-state.html │ │ ├── facet-card.html │ │ └── source-card.html │ ├── Facet.Dashboard.csproj │ ├── FacetDashboardOptions.cs │ ├── TemplateEngine.cs │ └── FacetMemberInfo.cs ├── Facet.Attributes │ ├── Facet.Attributes.csproj │ ├── README.md │ ├── Optional.cs │ ├── WrapperAttribute.cs │ ├── MapWhenAttribute.cs │ └── MapFromAttribute.cs ├── Facet.Mapping │ ├── Facet.Mapping.csproj │ ├── IFacetMapConfiguration.cs │ ├── IFacetMapConfigurationAsync.cs │ ├── IFacetMapConfigurationWithReturn.cs │ └── IFacetMapConfigurationHybrid.cs ├── Facet.Extensions │ ├── Facet.Extensions.csproj │ ├── FacetCache.cs │ └── FacetSourceCache.cs ├── Facet.Extensions.EFCore │ └── Facet.Extensions.EFCore.csproj ├── Facet.Mapping.Expressions │ ├── Facet.Mapping.Expressions.csproj │ └── ParameterReplacer.cs └── Facet.Extensions.EFCore.Mapping │ └── Facet.Extensions.EFCore.Mapping.csproj ├── test ├── Facet.Tests │ ├── UnitTests │ │ ├── Core │ │ │ ├── GenerateDtos │ │ │ │ ├── GenerateDtosErrorHandlingTests.cs │ │ │ │ └── GenerateDtosSimpleTest.cs │ │ │ └── Facet │ │ │ │ ├── StaticClassNestedTypeTests.cs │ │ │ │ ├── AccessibilityTests.cs │ │ │ │ ├── BasicMappingTests.cs │ │ │ │ ├── NullableCollectionNestedFacetsTests.cs │ │ │ │ ├── NullableForeignKeyTests.cs │ │ │ │ ├── InheritedMemberTests.cs │ │ │ │ └── ProjectionStructureTests.cs │ │ ├── BasicMappingCollectionTests.cs │ │ ├── Features │ │ │ ├── BackToRequiredFieldsTests.cs │ │ │ ├── ToSourceRequiredFieldsTests.cs │ │ │ └── NullableHandlingTests.cs │ │ └── Wrapper │ │ │ ├── NestedWrapperTests.cs │ │ │ ├── BasicWrapperTests.cs │ │ │ └── ReadOnlyWrapperTests.cs │ ├── GlobalUsings.cs │ ├── TestModels │ │ ├── StaticClassTestModels.cs │ │ ├── GlobalNamespaceTestEntities.cs │ │ ├── CircularReferenceTestModels.cs │ │ └── TestEntities.cs │ └── Facet.Tests.csproj ├── setup.md ├── run-tests.sh └── run-tests.bat ├── NuGet.Config ├── LICENSE.txt ├── docs ├── 01_Facetting.md ├── 02_QuickStart.md ├── README.md ├── 16_SourceSignature.md └── 12_GeneratedFilesOutput.md ├── benchmark ├── benchmark │ └── Facet.Benchmark │ │ ├── quick-test.ps1 │ │ └── QuickTestBenchmark.cs ├── super-quick-test.ps1 ├── Facet.Benchmark │ ├── Facet.Benchmark.csproj │ ├── SuperQuickBenchmark.cs │ ├── FacetDTOs.cs │ ├── MapperlyMappers.cs │ └── ManualDTOs.cs ├── quick-test.ps1 └── FacetBenchmark.sln ├── .gitattributes ├── Directory.Build.props └── Directory.Packages.props /assets/Facet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tim-Maes/Facet/HEAD/assets/Facet.png -------------------------------------------------------------------------------- /assets/FacetLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tim-Maes/Facet/HEAD/assets/FacetLogo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Tim-Maes] 4 | custom: ["https://www.paypal.me/TimMaes91", "https://www.buymeacoffee.com/GRq3xSA"] 5 | -------------------------------------------------------------------------------- /src/Facet/AnalyzerReleases.Unshipped.md: -------------------------------------------------------------------------------- 1 | ; Unshipped analyzer release 2 | ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md 3 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/GenerateDtos/GenerateDtosErrorHandlingTests.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tim-Maes/Facet/HEAD/test/Facet.Tests/UnitTests/Core/GenerateDtos/GenerateDtosErrorHandlingTests.cs -------------------------------------------------------------------------------- /src/Facet.Dashboard/Templates/member-row.html: -------------------------------------------------------------------------------- 1 | 2 | {{memberName}} 3 | {{memberType}} 4 |
{{memberBadges}}
5 | 6 | -------------------------------------------------------------------------------- /test/Facet.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; 3 | global using Facet.Extensions; 4 | global using Facet.Mapping; 5 | global using System; 6 | global using System.Collections.Generic; 7 | global using System.Linq; 8 | global using System.Threading.Tasks; -------------------------------------------------------------------------------- /src/Facet.Dashboard/Templates/members-table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{memberRows}} 11 | 12 |
NameTypeModifiers
13 | -------------------------------------------------------------------------------- /src/Facet/SymbolNameExtensions.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | 3 | namespace Facet; 4 | 5 | public static class SymbolNameExtensions 6 | { 7 | public static string GetSafeName(this string symbol) 8 | { 9 | return GeneratorUtilities.StripGlobalPrefix(symbol) 10 | .Replace("<", "_") 11 | .Replace(">", "_"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/setup.md: -------------------------------------------------------------------------------- 1 | # Facet Test Setup - Implementation Summary 2 | 3 | ### Running Tests 4 | 5 | **Option 1: Quick Command** 6 | ```bash 7 | dotnet test test/Facet.Tests 8 | ``` 9 | 10 | **Option 2: Test Scripts** 11 | ```bash 12 | # Windows 13 | run-tests.bat 14 | 15 | # Linux/macOS 16 | ./run-tests.sh 17 | ``` 18 | 19 | **Option 3: IDE Integration** 20 | 21 | - Visual Studio: Test Explorer 22 | - VS Code: Testing panel with C# extension 23 | - JetBrains Rider: Unit Tests window 24 | -------------------------------------------------------------------------------- /test/Facet.Tests/TestModels/StaticClassTestModels.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Example1 2 | { 3 | public static class Foo 4 | { 5 | public sealed record Bar 6 | { 7 | public string Name { get; set; } = string.Empty; 8 | public int Value { get; set; } 9 | } 10 | } 11 | } 12 | 13 | namespace Facet.Tests.TestModels.StaticClassTest 14 | { 15 | [Facet.Facet(typeof(Application.Example1.Foo.Bar))] 16 | public sealed partial record BarDto; 17 | } 18 | -------------------------------------------------------------------------------- /test/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Building and Running Facet Unit Tests..." 3 | echo 4 | 5 | echo "Restoring packages..." 6 | dotnet restore test/FacetTest.sln 7 | 8 | echo 9 | echo "Building solution..." 10 | dotnet build test/FacetTest.sln --configuration Release --no-restore 11 | 12 | if [ $? -ne 0 ]; then 13 | echo "Build failed!" 14 | exit 1 15 | fi 16 | 17 | echo 18 | echo "Running unit tests..." 19 | dotnet test test/Facet.Tests --configuration Release --no-build --verbosity normal 20 | 21 | echo 22 | echo "All tests completed!" -------------------------------------------------------------------------------- /src/Facet/AnalyzerReleases.Shipped.md: -------------------------------------------------------------------------------- 1 | ## Release 2.9.0 2 | 3 | ### New Rules 4 | 5 | | Rule ID | Category | Severity | Notes | 6 | |---------|-------------|----------|-------------------------------------------------------------------------------| 7 | | FAC001 | Usage | Error | Type must be annotated with [Facet] | 8 | | FAC002 | Usage | Info | Consider using the two-generic variant of this method for better performance | 9 | -------------------------------------------------------------------------------- /test/run-tests.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Building and Running Facet Unit Tests... 3 | echo. 4 | 5 | echo Restoring packages... 6 | dotnet restore test/FacetTest.sln 7 | 8 | echo. 9 | echo Building solution... 10 | dotnet build test/FacetTest.sln --configuration Release --no-restore 11 | 12 | if errorlevel 1 ( 13 | echo Build failed! 14 | pause 15 | exit /b 1 16 | ) 17 | 18 | echo. 19 | echo Running unit tests... 20 | dotnet test test/Facet.Tests --configuration Release --no-build --verbosity normal 21 | 22 | echo. 23 | echo All tests completed! 24 | pause -------------------------------------------------------------------------------- /src/Facet.Dashboard/Templates/empty-state.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 |

No Facets Found

8 |

No types with [Facet] attribute were discovered in your assemblies.

9 |

Make sure your facet types are public and the assemblies are loaded.

10 |
11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "nuget" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | groups: 17 | actions: 18 | patterns: 19 | - 'actions/*' 20 | -------------------------------------------------------------------------------- /src/Facet.Dashboard/Templates/facet-card.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{facetName}} 4 | {{typeKind}} 5 |
6 |
7 | {{constructorIcon}} Constructor 8 | {{projectionIcon}} Projection 9 | {{toSourceIcon}} ToSource 10 |
11 | {{exclusions}} 12 | {{inclusions}} 13 |
{{memberCount}} member{{memberPlural}}
14 |
15 | -------------------------------------------------------------------------------- /src/Facet.Attributes/Facet.Attributes.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0;net6.0;net8.0;net9.0;net10.0 5 | true 6 | Facet.Attributes 7 | Facet.Attributes 8 | Runtime attributes for the Facet source generator. This package contains only the attributes and enums needed at runtime. 9 | facet source-generator attributes dto projection 10 | README.md 11 | enable 12 | latest 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Facet.Mapping/Facet.Mapping.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Mapping 7 | Advanced static mapping configuration support for the Facet source generator with async capabilities. 8 | facet mapping source-generator dto compile-time async 9 | Facet.Mapping 10 | README.md 11 | 12 | 13 | 14 | 15 | 16 | True 17 | content\ 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/Facet.Tests/TestModels/GlobalNamespaceTestEntities.cs: -------------------------------------------------------------------------------- 1 | using Facet; 2 | 3 | [GenerateDtos(Types = DtoTypes.All, OutputType = OutputType.Record)] 4 | public class TestGlobalNamespaceEntity 5 | { 6 | public int Id { get; set; } 7 | public string Name { get; set; } = string.Empty; 8 | public DateTime CreatedAt { get; set; } 9 | public DateTime LastUpdatedAt { get; set; } 10 | public string Description { get; set; } = string.Empty; 11 | public bool IsActive { get; set; } 12 | } 13 | 14 | [GenerateAuditableDtos(Types = DtoTypes.All, OutputType = OutputType.Class)] 15 | public class TestGlobalAuditableEntity 16 | { 17 | public int Id { get; set; } 18 | public string Code { get; set; } = string.Empty; 19 | public string Value { get; set; } = string.Empty; 20 | public DateTime CreatedAt { get; set; } 21 | public string CreatedBy { get; set; } = string.Empty; 22 | } 23 | -------------------------------------------------------------------------------- /src/Facet.Mapping/IFacetMapConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Mapping; 2 | 3 | /// 4 | /// Allows defining custom mapping logic between a source and target Facet-generated type. 5 | /// 6 | /// The source type 7 | /// The target Facet type 8 | public interface IFacetMapConfiguration 9 | { 10 | static abstract void Map(TSource source, TTarget target); 11 | } 12 | 13 | /// 14 | /// Instance-based interface for defining custom mapping logic with dependency injection support. 15 | /// Use this interface when you need to inject services into your mapper. 16 | /// 17 | /// The source type 18 | /// The target Facet type 19 | public interface IFacetMapConfigurationInstance 20 | { 21 | void Map(TSource source, TTarget target); 22 | } 23 | -------------------------------------------------------------------------------- /src/Facet.Extensions/Facet.Extensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Extensions 7 | Provider-agnostic extension methods for Facet. 8 | README.md 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tim Maes 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/Facet.Attributes/README.md: -------------------------------------------------------------------------------- 1 | # Facet.Attributes 2 | 3 | Runtime attributes for the [Facet](https://github.com/Siphonophora/Facet) source generator. 4 | 5 | This package contains only the attribute classes and enums needed at runtime. For the source generator itself, install the `Facet` package. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | dotnet add package Facet 11 | ``` 12 | 13 | The `Facet` package automatically includes `Facet.Attributes` as a dependency. 14 | 15 | You should **not** need to install `Facet.Attributes` directly unless you're building custom tooling. 16 | 17 | ## Attributes Included 18 | 19 | - `[Facet]` - Generate facets/DTOs from source types 20 | - `[Flatten]` - Generate flattened projections with nested properties as top-level properties 21 | - `[MapFrom]` - Custom property mapping 22 | - `[MapWhen]` - Conditional property mapping 23 | - `[GenerateDtos]` - Batch DTO generation 24 | - `[Wrapper]` - Generate wrapper types 25 | 26 | ## AOT Compatibility 27 | 28 | This package is fully compatible with AOT (Ahead-Of-Time) compilation, including .NET MAUI applications. The separation of attributes from the source generator ensures that Roslyn dependencies do not leak into your runtime. 29 | -------------------------------------------------------------------------------- /docs/01_Facetting.md: -------------------------------------------------------------------------------- 1 | # What is Facetting? 2 | 3 | Facetting is the process of defining **focused views** of a larger model at compile time. 4 | 5 | Instead of manually writing separate DTOs, mappers, and projections, **Facet** allows you to declare what you want to keep — and generates everything else. 6 | 7 | You can think of it like **carving out a specific facet** of a gem: 8 | 9 | - The part you care about 10 | - Leaving the rest behind. 11 | 12 | ## Why Facetting? 13 | 14 | - Reduce duplication across DTOs, projections, and ViewModels 15 | - Maintain strong typing with no runtime cost 16 | - Stay DRY (Don't Repeat Yourself) without sacrificing performance 17 | - Works seamlessly with LINQ providers like Entity Framework 18 | 19 | ## Example 20 | 21 | Source model: 22 | 23 | ```csharp 24 | public class User 25 | { 26 | public string FirstName { get; set; } 27 | public string LastName { get; set; } 28 | public string Email { get; set; } 29 | } 30 | ``` 31 | 32 | Define a facet: 33 | ```csharp 34 | [Facet(typeof(User), exclude: nameof(User.Email))] 35 | public partial class UserDto { } 36 | ``` 37 | 38 | You get: 39 | 40 | - A mapped constructor 41 | - A LINQ Expression Projection 42 | - A partial class or record ready to extend 43 | -------------------------------------------------------------------------------- /src/Facet.Dashboard/Templates/source-card.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
{{initial}}
5 |
6 |
{{sourceName}}
7 |
{{sourceNamespace}}
8 |
9 |
10 |
11 |
12 | {{facetCount}} facet{{facetPlural}} 13 | {{propertyCount}} properties 14 |
15 | 16 | 17 | 18 |
19 |
20 |
21 |

Source Properties

22 | {{membersTable}} 23 | 24 |

Generated Facets

25 |
26 | {{facetCards}} 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /src/Facet.Extensions.EFCore/Facet.Extensions.EFCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Extensions.EFCore 7 | EF Core async extension methods for Facet. 8 | README.md 9 | Facet.Extensions.EFCore 10 | https://www.github.com/Tim-Maes/Facet 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Facet/Generators/WrapperGenerators/WrapperGenerator.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using Microsoft.CodeAnalysis; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using Microsoft.CodeAnalysis.Text; 5 | using System.Linq; 6 | using System.Text; 7 | 8 | namespace Facet.Generators; 9 | 10 | [Generator(LanguageNames.CSharp)] 11 | public sealed class WrapperGenerator : IIncrementalGenerator 12 | { 13 | public void Initialize(IncrementalGeneratorInitializationContext context) 14 | { 15 | var wrappers = context.SyntaxProvider 16 | .ForAttributeWithMetadataName( 17 | FacetConstants.WrapperAttributeFullName, 18 | predicate: static (node, _) => node is TypeDeclarationSyntax, 19 | transform: static (ctx, token) => WrapperModelBuilder.BuildModel(ctx, token)) 20 | .Where(static m => m is not null); 21 | 22 | context.RegisterSourceOutput(wrappers, static (spc, model) => 23 | { 24 | if (model is null) return; 25 | 26 | spc.CancellationToken.ThrowIfCancellationRequested(); 27 | 28 | var code = WrapperCodeBuilder.Generate(model); 29 | spc.AddSource($"{model.FullName}.Wrapper.g.cs", SourceText.From(code, Encoding.UTF8)); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /benchmark/benchmark/Facet.Benchmark/quick-test.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # Quick benchmark runner script 4 | # This runs a subset of benchmarks for quick testing 5 | 6 | Write-Host "?? Quick Facet Benchmark Test" -ForegroundColor Green 7 | Write-Host "==============================" 8 | 9 | # Build in release mode 10 | Write-Host "Building in Release mode..." -ForegroundColor Yellow 11 | dotnet build -c Release --verbosity quiet 12 | 13 | if ($LASTEXITCODE -ne 0) { 14 | Write-Host "? Build failed!" -ForegroundColor Red 15 | exit 1 16 | } 17 | 18 | Write-Host "? Build successful!" -ForegroundColor Green 19 | 20 | # Run a quick single benchmark to test 21 | Write-Host "`nRunning quick benchmark test..." -ForegroundColor Yellow 22 | Write-Host "This will run only a few methods for faster results.`n" -ForegroundColor Cyan 23 | 24 | # Create a temporary benchmark config with fewer iterations 25 | $env:BENCHMARK_FILTER = "*FacetUserBasic*|*MapsterUserBasic*|*MapperlyUserBasic*" 26 | 27 | dotnet run -c Release -- single 28 | 29 | Write-Host "`n? Quick benchmark test completed!" -ForegroundColor Green 30 | Write-Host "?? Check the BenchmarkDotNet.Artifacts folder for detailed results" -ForegroundColor Cyan 31 | Write-Host "?? Look for .md, .html, .json, and .csv files in the results directory" -ForegroundColor Cyan -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/StaticClassNestedTypeTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels.StaticClassTest; 2 | 3 | namespace Facet.Tests.UnitTests.Core.Facet; 4 | 5 | /// 6 | /// Tests for issue #145: Generator fails to create static imports 7 | /// 8 | public class StaticClassNestedTypeTests 9 | { 10 | [Fact] 11 | public void Facet_ShouldGenerateCorrectly_WhenSourceTypeIsNestedInStaticClass() 12 | { 13 | // Arrange 14 | var bar = new Application.Example1.Foo.Bar 15 | { 16 | Name = "Test", 17 | Value = 42 18 | }; 19 | 20 | // Act 21 | var dto = new BarDto(bar); 22 | 23 | // Assert 24 | dto.Should().NotBeNull(); 25 | dto.Name.Should().Be("Test"); 26 | dto.Value.Should().Be(42); 27 | } 28 | 29 | [Fact] 30 | public void Facet_ShouldMap_WhenSourceTypeIsNestedInStaticClass() 31 | { 32 | // Arrange 33 | var bar = new Application.Example1.Foo.Bar 34 | { 35 | Name = "Test", 36 | Value = 42 37 | }; 38 | 39 | // Act 40 | var dto = bar.ToFacet(); 41 | 42 | // Assert 43 | dto.Should().NotBeNull(); 44 | dto.Name.Should().Be("Test"); 45 | dto.Value.Should().Be(42); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Facet/Generators/FlattenGenerators/FlattenGenerator.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using Facet.Generators; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; 5 | using Microsoft.CodeAnalysis.Text; 6 | using System.Linq; 7 | using System.Text; 8 | 9 | namespace Facet.Generators; 10 | 11 | [Generator(LanguageNames.CSharp)] 12 | public sealed class FlattenGenerator : IIncrementalGenerator 13 | { 14 | private const string FlattenAttributeFullName = "Facet.FlattenAttribute"; 15 | 16 | public void Initialize(IncrementalGeneratorInitializationContext context) 17 | { 18 | var flattenTargets = context.SyntaxProvider 19 | .ForAttributeWithMetadataName( 20 | FlattenAttributeFullName, 21 | predicate: static (node, _) => node is TypeDeclarationSyntax, 22 | transform: static (ctx, token) => FlattenModelBuilder.BuildModel(ctx, token)) 23 | .Where(static m => m is not null); 24 | 25 | context.RegisterSourceOutput(flattenTargets, static (spc, model) => 26 | { 27 | if (model is null) return; 28 | 29 | spc.CancellationToken.ThrowIfCancellationRequested(); 30 | 31 | var code = FlattenCodeBuilder.Generate(model); 32 | spc.AddSource($"{model.FullName}.g.cs", SourceText.From(code, Encoding.UTF8)); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Facet/Generators/WrapperGenerators/WrapperConstructorGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace Facet.Generators; 4 | 5 | /// 6 | /// Generates constructors for wrapper types. 7 | /// 8 | internal static class WrapperConstructorGenerator 9 | { 10 | /// 11 | /// Generates a constructor that stores a reference to the source object. 12 | /// 13 | public static void GenerateConstructor(StringBuilder sb, WrapperTargetModel model, string indent) 14 | { 15 | sb.AppendLine(); 16 | sb.AppendLine($"{indent}/// "); 17 | sb.AppendLine($"{indent}/// Initializes a new instance of the {model.Name} wrapper."); 18 | sb.AppendLine($"{indent}/// "); 19 | sb.AppendLine($"{indent}/// The source object to wrap."); 20 | sb.AppendLine($"{indent}/// Thrown when source is null."); 21 | 22 | // Extract the simple type name from fully qualified name for the parameter 23 | var sourceParamName = "source"; 24 | 25 | sb.AppendLine($"{indent}public {model.Name}({model.SourceTypeName} {sourceParamName})"); 26 | sb.AppendLine($"{indent}{{"); 27 | 28 | // Add null check 29 | sb.AppendLine($"{indent} {model.SourceFieldName} = {sourceParamName} ?? throw new global::System.ArgumentNullException(nameof({sourceParamName}));"); 30 | 31 | sb.AppendLine($"{indent}}}"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/BasicMappingCollectionTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels; 2 | using Facet.Tests.Utilities; 3 | 4 | namespace Facet.Tests.UnitTests; 5 | 6 | public class BasicMappingCollectionTests 7 | { 8 | [Fact] 9 | public void SelectFacets_ShouldMapBasicProperties_WhenMappingUserToDto() 10 | { 11 | // Arrange 12 | var users = TestDataFactory.CreateUsers(); 13 | 14 | // Act 15 | var dtos = users.SelectFacets().ToList(); 16 | 17 | // Assert 18 | dtos.Should().NotBeNull(); 19 | dtos.Should().HaveCount(users.Count); 20 | dtos[0].FirstName.Should().Be(users[0].FirstName); 21 | dtos[1].FirstName.Should().Be(users[1].FirstName); 22 | dtos[2].FirstName.Should().Be(users[2].FirstName); 23 | dtos[2].IsActive.Should().Be(users[2].IsActive); 24 | } 25 | 26 | [Fact] 27 | public void SelectFacetsShorthand_ShouldMapBasicProperties_WhenMappingUserToDto() 28 | { 29 | // Arrange 30 | var users = TestDataFactory.CreateUsers(); 31 | 32 | // Act 33 | var dtos = users.SelectFacets().ToList(); 34 | 35 | // Assert 36 | dtos.Should().NotBeNull(); 37 | dtos.Should().HaveCount(users.Count); 38 | dtos[0].FirstName.Should().Be(users[0].FirstName); 39 | dtos[1].FirstName.Should().Be(users[1].FirstName); 40 | dtos[2].FirstName.Should().Be(users[2].FirstName); 41 | dtos[2].IsActive.Should().Be(users[2].IsActive); 42 | } 43 | } -------------------------------------------------------------------------------- /benchmark/benchmark/Facet.Benchmark/QuickTestBenchmark.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Engines; 3 | using Facet.Benchmark.Models; 4 | using Facet.Benchmark.DTOs; 5 | using Facet.Benchmark.Mappers; 6 | using Facet.Extensions; 7 | using Mapster; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | 11 | namespace Facet.Benchmark.Benchmarks; 12 | 13 | /// 14 | /// Quick test benchmark with fewer iterations for faster results 15 | /// 16 | [SimpleJob(RunStrategy.ColdStart, iterationCount: 3, warmupCount: 1)] 17 | [MemoryDiagnoser] 18 | [MarkdownExporter] 19 | public class QuickTestBenchmark 20 | { 21 | private User _user = null!; 22 | private UserMapper _userMapper = null!; 23 | 24 | [GlobalSetup] 25 | public void Setup() 26 | { 27 | var testData = TestDataGenerator.CreateTestDataSet(1, 1, 1); 28 | _user = testData.Users[0]; 29 | _userMapper = new UserMapper(); 30 | 31 | // Configure Mapster 32 | TypeAdapterConfig.NewConfig().Compile(); 33 | } 34 | 35 | [Benchmark(Baseline = true)] 36 | public UserBasicDto FacetMapping() 37 | { 38 | return _user.ToFacet(); 39 | } 40 | 41 | [Benchmark] 42 | public UserBasicManualDto MapsterMapping() 43 | { 44 | return _user.Adapt(); 45 | } 46 | 47 | [Benchmark] 48 | public UserBasicManualDto MapperlyMapping() 49 | { 50 | return _userMapper.ToBasicDto(_user); 51 | } 52 | } -------------------------------------------------------------------------------- /src/Facet.Mapping.Expressions/Facet.Mapping.Expressions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Mapping.Expressions 7 | Expression tree transformation and mapping utilities for Facet DTOs. Transform predicates, selectors, and other expressions between source entities and their Facet projections. 8 | facet expressions linq predicates mapping source-generator dto compile-time 9 | Facet.Mapping.Expressions 10 | README.md 11 | https://www.github.com/Tim-Maes/Facet 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | True 23 | content\ 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Facet.Dashboard/Facet.Dashboard.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Dashboard 7 | Facet Dashboard 8 | Swagger-like dashboard for visualizing Facet source types and their generated facets in ASP.NET Core applications. 9 | facet dashboard visualization swagger aspnetcore dto projection source-generator 10 | README.md 11 | enable 12 | enable 13 | true 14 | true 15 | true 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Facet.Extensions.EFCore.Mapping/Facet.Extensions.EFCore.Mapping.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net9.0;net10.0 5 | true 6 | Facet.Extensions.EFCore.Mapping 7 | Advanced custom async mapper support for Facet with EF Core queries. Enables complex mappings that cannot be expressed as SQL projections. 8 | README.md 9 | Facet.Extensions.EFCore.Mapping 10 | https://www.github.com/Tim-Maes/Facet 11 | facet efcore entity-framework mapping async dto source-generator 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: build 4 | run-name: '[Build] ${{ github.event.head_commit.message || github.event.pull_request.title || github.ref_name }}' 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - "main" 10 | - "master" 11 | - "develop" 12 | pull_request: 13 | branches: 14 | - "*" 15 | 16 | permissions: 17 | contents: read 18 | checks: write 19 | pull-requests: write 20 | 21 | env: 22 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 23 | DOTNET_NOLOGO: true 24 | NuGetDirectory: ${{ github.workspace }}/nuget 25 | 26 | defaults: 27 | run: 28 | shell: pwsh 29 | 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v6 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: Setup .NET 39 | uses: actions/setup-dotnet@v5 40 | with: 41 | dotnet-version: | 42 | 8.0.x 43 | 9.0.x 44 | 10.0.x 45 | 46 | - name: Restore dependencies 47 | run: dotnet restore 48 | 49 | - name: Build Solution 50 | run: dotnet build --configuration Release --no-restore 51 | 52 | - name: Pack NuGet Packages 53 | run: dotnet pack --configuration Release --no-build --output ${{ env.NuGetDirectory }} 54 | 55 | - name: Upload build artifacts 56 | uses: actions/upload-artifact@v6 57 | with: 58 | name: nuget-packages 59 | if-no-files-found: error 60 | retention-days: 7 61 | path: ${{ env.NuGetDirectory }}/*.nupkg -------------------------------------------------------------------------------- /docs/02_QuickStart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will help you get up and running with Facet in just a few steps. 4 | 5 | ## 1. Install the NuGet Package 6 | 7 | ``` 8 | dotnet add package Facet 9 | ``` 10 | 11 | For LINQ helpers: 12 | ``` 13 | dotnet add package Facet.Extensions 14 | ``` 15 | 16 | ## 2. Define Your Source Model 17 | 18 | ```csharp 19 | public class Person 20 | { 21 | public string Name { get; set; } 22 | public string Email { get; set; } 23 | public int Age { get; set; } 24 | } 25 | ``` 26 | 27 | ## 3. Create a Facet (DTO/Projection) 28 | 29 | ```csharp 30 | using Facet; 31 | 32 | // Class 33 | [Facet(typeof(Person), exclude: nameof(Person.Email))] 34 | public partial class PersonDto { } 35 | 36 | // Record (inferred from 'record' keyword) 37 | [Facet(typeof(Person))] 38 | public partial record PersonDto { } 39 | 40 | // Struct (inferred from 'struct' keyword) 41 | [Facet(typeof(Person))] 42 | public partial struct PersonDto { } 43 | ``` 44 | 45 | ## 4. Use the Generated Type 46 | 47 | ```csharp 48 | var person = new Person { Name = "Alice", Email = "a@b.com", Age = 30 }; 49 | 50 | var dto = new PersonDto(person); // Uses generated constructor 51 | ``` 52 | 53 | ## 5. LINQ Integration 54 | 55 | ```csharp 56 | var query = dbContext.People.Select(PersonDto.Projection).ToList(); 57 | ``` 58 | 59 | Or with Facet.Extensions: 60 | 61 | ```csharp 62 | using Facet.Extensions; 63 | 64 | var dto = person.ToFacet(); 65 | 66 | var dtos = personList.SelectFacets(); 67 | ``` 68 | 69 | --- 70 | 71 | See the [Attribute Reference](03_AttributeReference.md) and [Extension Methods](05_Extensions.md) for more details. 72 | -------------------------------------------------------------------------------- /.github/workflows/sync-docs-to-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Sync Docs to Wiki 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'docs/**' 9 | workflow_dispatch: # Allow manual trigger 10 | 11 | jobs: 12 | sync-wiki: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Sync docs to wiki 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: | 25 | # Configure git 26 | git config --global user.name "github-actions[bot]" 27 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 28 | 29 | # Clone the wiki repository 30 | git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.wiki.git" wiki 31 | 32 | # Remove existing wiki content (except .git) 33 | find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + 34 | 35 | # Copy docs folder contents to wiki 36 | cp -r docs/* wiki/ 37 | 38 | # Rename README.md to Home.md if it exists (wiki home page) 39 | if [ -f wiki/README.md ]; then 40 | mv wiki/README.md wiki/Home.md 41 | fi 42 | 43 | # Commit and push changes 44 | cd wiki 45 | git add -A 46 | 47 | if git diff --staged --quiet; then 48 | echo "No changes to sync" 49 | else 50 | git commit -m "Sync docs from main repository" 51 | git push 52 | fi 53 | -------------------------------------------------------------------------------- /src/Facet/Generators/FacetGenerators/FacetGenerator.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using Microsoft.CodeAnalysis; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using Microsoft.CodeAnalysis.Text; 5 | using System.Linq; 6 | using System.Text; 7 | 8 | namespace Facet.Generators; 9 | 10 | [Generator(LanguageNames.CSharp)] 11 | public sealed class FacetGenerator : IIncrementalGenerator 12 | { 13 | public void Initialize(IncrementalGeneratorInitializationContext context) 14 | { 15 | var facets = context.SyntaxProvider 16 | .ForAttributeWithMetadataName( 17 | FacetConstants.FacetAttributeFullName, 18 | predicate: static (node, _) => node is TypeDeclarationSyntax, 19 | transform: static (ctx, token) => ModelBuilder.BuildModel(ctx, token)) 20 | .Where(static m => m is not null); 21 | 22 | // Collect all facet models to enable nested facet lookup during generation 23 | var allFacets = facets.Collect(); 24 | 25 | context.RegisterSourceOutput(allFacets, static (spc, models) => 26 | { 27 | spc.CancellationToken.ThrowIfCancellationRequested(); 28 | 29 | // Build a lookup dictionary for nested facet resolution 30 | var facetLookup = models 31 | .Where(m => m is not null) 32 | .ToDictionary(m => m!.FullName, m => m!); 33 | 34 | // Generate code for each facet with access to all facet models 35 | foreach (var model in models) 36 | { 37 | if (model is null) continue; 38 | 39 | var code = CodeBuilder.Generate(model, facetLookup); 40 | spc.AddSource($"{model.FullName}.g.cs", SourceText.From(code, Encoding.UTF8)); 41 | } 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/Facet.Tests/TestModels/CircularReferenceTestModels.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.TestModels; 2 | 3 | // Models with circular references 4 | public class Author 5 | { 6 | public int Id { get; set; } 7 | public string Name { get; set; } = string.Empty; 8 | public List Books { get; set; } = new(); 9 | } 10 | 11 | public class Book 12 | { 13 | public int Id { get; set; } 14 | public string Title { get; set; } = string.Empty; 15 | public Author? Author { get; set; } 16 | } 17 | 18 | // Self-referencing model 19 | public class OrgEmployee 20 | { 21 | public int Id { get; set; } 22 | public string Name { get; set; } = string.Empty; 23 | public OrgEmployee? Manager { get; set; } 24 | public List DirectReports { get; set; } = new(); 25 | } 26 | 27 | // Facets with MaxDepth for depth limiting (without reference tracking) 28 | [Facet(typeof(Author), MaxDepth = 2, PreserveReferences = false, NestedFacets = [typeof(BookFacetWithDepth)], GenerateToSource = true)] 29 | public partial record AuthorFacetWithDepth; 30 | 31 | [Facet(typeof(Book), MaxDepth = 2, PreserveReferences = false, NestedFacets = [typeof(AuthorFacetWithDepth)], GenerateToSource = true)] 32 | public partial record BookFacetWithDepth; 33 | 34 | // Facets with PreserveReferences for runtime tracking (also needs MaxDepth to prevent generator SO) 35 | [Facet(typeof(Author), MaxDepth = 3, PreserveReferences = true, NestedFacets = [typeof(BookFacetWithTracking)])] 36 | public partial record AuthorFacetWithTracking; 37 | 38 | [Facet(typeof(Book), MaxDepth = 3, PreserveReferences = true, NestedFacets = [typeof(AuthorFacetWithTracking)])] 39 | public partial record BookFacetWithTracking; 40 | 41 | // Self-referencing facet with both MaxDepth and PreserveReferences 42 | [Facet(typeof(OrgEmployee), MaxDepth = 5, PreserveReferences = true, NestedFacets = [typeof(OrgEmployeeFacet)])] 43 | public partial record OrgEmployeeFacet; 44 | -------------------------------------------------------------------------------- /benchmark/super-quick-test.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # Super quick benchmark runner script 4 | # This runs minimal benchmarks for the fastest possible comparison 5 | 6 | Write-Host "?? Super Quick Facet Benchmark Test" -ForegroundColor Green 7 | Write-Host "=====================================" 8 | Write-Host "Minimal iterations for fastest possible comparison (< 1 minute)" -ForegroundColor Cyan 9 | Write-Host "" 10 | 11 | # Build in Release mode 12 | Write-Host "Building in Release mode..." -ForegroundColor Yellow 13 | dotnet build -c Release --verbosity quiet --nologo 14 | 15 | if ($LASTEXITCODE -ne 0) { 16 | Write-Host "? Build failed!" -ForegroundColor Red 17 | exit 1 18 | } 19 | 20 | Write-Host "? Build successful!" -ForegroundColor Green 21 | 22 | # Run the super quick benchmark 23 | Write-Host "`nRunning super quick benchmark..." -ForegroundColor Yellow 24 | Write-Host "?? Configuration: 1 warmup, 2 measurements per test" -ForegroundColor Cyan 25 | Write-Host "?? Testing only essential scenarios with minimal data.`n" -ForegroundColor Cyan 26 | 27 | Write-Host "Running SuperQuickBenchmark..." -ForegroundColor Yellow 28 | dotnet run -c Release --verbosity quiet -- super 29 | 30 | Write-Host "`n?? Super quick benchmark completed!" -ForegroundColor Green 31 | Write-Host "?? Results in BenchmarkDotNet.Artifacts folder" -ForegroundColor Cyan 32 | Write-Host "" 33 | Write-Host "?? For more comprehensive testing:" -ForegroundColor Yellow 34 | Write-Host " ? Quick: ./quick-test.ps1 (~2 minutes)" -ForegroundColor White 35 | Write-Host " ?? Full: dotnet run -c Release -- all (~10+ minutes)" -ForegroundColor White 36 | Write-Host "" 37 | Write-Host "?? Super quick optimizations:" -ForegroundColor Cyan 38 | Write-Host " - 1 warmup iteration" -ForegroundColor White 39 | Write-Host " - 2 measurement iterations only" -ForegroundColor White 40 | Write-Host " - 10 items max in collections" -ForegroundColor White 41 | Write-Host " - Memory diagnostics disabled" -ForegroundColor White 42 | Write-Host " - Results show relative performance only" -ForegroundColor White -------------------------------------------------------------------------------- /benchmark/Facet.Benchmark/Facet.Benchmark.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | latest 7 | enable 8 | true 9 | 10 | 11 | 12 | 13 | QUICK_BENCHMARK 14 | true 15 | pdbonly 16 | true 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/Facet.Mapping.Expressions/ParameterReplacer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq.Expressions; 3 | 4 | namespace Facet.Mapping.Expressions; 5 | 6 | /// 7 | /// Expression visitor that replaces parameter expressions with new parameter expressions. 8 | /// Used to substitute parameters when transforming lambda expressions between different types. 9 | /// 10 | internal class ParameterReplacer : ExpressionVisitor 11 | { 12 | private readonly Dictionary _parameterMap; 13 | 14 | /// 15 | /// Initializes a new instance that replaces a single parameter. 16 | /// 17 | /// The parameter to replace 18 | /// The parameter to replace it with 19 | public ParameterReplacer(ParameterExpression oldParameter, ParameterExpression newParameter) 20 | { 21 | _parameterMap = new Dictionary 22 | { 23 | { oldParameter, newParameter } 24 | }; 25 | } 26 | 27 | /// 28 | /// Initializes a new instance that can replace multiple parameters. 29 | /// 30 | /// Dictionary mapping old parameters to new parameters 31 | public ParameterReplacer(Dictionary parameterMap) 32 | { 33 | _parameterMap = parameterMap ?? new Dictionary(); 34 | } 35 | 36 | /// 37 | /// Visits parameter expressions and replaces them if they're in the mapping. 38 | /// 39 | /// The parameter expression to visit 40 | /// The replacement parameter if found, otherwise the original parameter 41 | protected override Expression VisitParameter(ParameterExpression node) 42 | { 43 | if (_parameterMap.TryGetValue(node, out var replacement)) 44 | { 45 | return replacement; 46 | } 47 | 48 | return base.VisitParameter(node); 49 | } 50 | } -------------------------------------------------------------------------------- /src/Facet.Dashboard/FacetDashboardOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Dashboard; 2 | 3 | /// 4 | /// Configuration options for the Facet Dashboard. 5 | /// 6 | public sealed class FacetDashboardOptions 7 | { 8 | /// 9 | /// Gets or sets the route prefix for the dashboard. Default is "/facets". 10 | /// 11 | public string RoutePrefix { get; set; } = "/facets"; 12 | 13 | /// 14 | /// Gets or sets the title displayed in the dashboard. Default is "Facet Dashboard". 15 | /// 16 | public string Title { get; set; } = "Facet Dashboard"; 17 | 18 | /// 19 | /// Gets or sets whether to include system assemblies when discovering facets. 20 | /// Default is false. 21 | /// 22 | public bool IncludeSystemAssemblies { get; set; } = false; 23 | 24 | /// 25 | /// Gets or sets additional assemblies to scan for facets. 26 | /// 27 | public ICollection AdditionalAssemblies { get; } = new List(); 28 | 29 | /// 30 | /// Gets or sets whether the dashboard requires authentication. 31 | /// Default is false. 32 | /// 33 | public bool RequireAuthentication { get; set; } = false; 34 | 35 | /// 36 | /// Gets or sets the authentication policy name to use when RequireAuthentication is true. 37 | /// 38 | public string? AuthenticationPolicy { get; set; } 39 | 40 | /// 41 | /// Gets or sets whether to expose the JSON API endpoint. 42 | /// Default is true. 43 | /// 44 | public bool EnableJsonApi { get; set; } = true; 45 | 46 | /// 47 | /// Gets or sets the accent color for the dashboard theme. 48 | /// Default is "#6366f1" (Indigo). 49 | /// 50 | public string AccentColor { get; set; } = "#6366f1"; 51 | 52 | /// 53 | /// Gets or sets whether to enable dark mode by default. 54 | /// Default is false (uses system preference). 55 | /// 56 | public bool DefaultDarkMode { get; set; } = false; 57 | } 58 | -------------------------------------------------------------------------------- /test/Facet.Tests/Facet.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | false 8 | true 9 | true 10 | $(BaseIntermediateOutputPath)Generated 11 | 12 | $(NoWarn);FAC004 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: test 4 | run-name: '[Test] ${{ github.event.head_commit.message || github.event.pull_request.title || github.event.workflow_run.display_title || github.ref_name }}' 5 | on: 6 | workflow_run: 7 | workflows: ["build"] 8 | types: 9 | - completed 10 | pull_request: 11 | branches: 12 | - "*" 13 | push: 14 | branches: 15 | - "master" 16 | 17 | permissions: 18 | contents: read 19 | checks: write 20 | pull-requests: write 21 | 22 | env: 23 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 24 | DOTNET_NOLOGO: true 25 | 26 | defaults: 27 | run: 28 | shell: pwsh 29 | 30 | jobs: 31 | test: 32 | runs-on: ubuntu-latest 33 | if: | 34 | (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || 35 | (github.event_name != 'workflow_run') 36 | steps: 37 | - uses: actions/checkout@v6 38 | with: 39 | fetch-depth: 0 40 | ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} 41 | 42 | - name: Setup .NET 43 | uses: actions/setup-dotnet@v5 44 | with: 45 | dotnet-version: | 46 | 8.0.x 47 | 9.0.x 48 | 10.0.x 49 | 50 | - name: Restore dependencies 51 | run: dotnet restore 52 | 53 | - name: Build Solution 54 | run: dotnet build --configuration Release --no-restore 55 | 56 | - name: Run Unit Tests 57 | run: dotnet test --configuration Release --no-build --verbosity normal --logger trx --results-directory TestResults 58 | 59 | - name: Upload Test Results 60 | uses: actions/upload-artifact@v6 61 | if: success() || failure() 62 | with: 63 | name: test-results-${{ github.run_id }} 64 | path: TestResults/*.trx 65 | retention-days: 7 66 | 67 | - name: Publish Test Results 68 | uses: dorny/test-reporter@v2 69 | if: success() || failure() 70 | with: 71 | name: Unit Test Results 72 | path: TestResults/*.trx 73 | reporter: dotnet-trx 74 | fail-on-error: false 75 | token: ${{ secrets.GITHUB_TOKEN }} 76 | -------------------------------------------------------------------------------- /src/Facet.Mapping/IFacetMapConfigurationAsync.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Facet.Mapping; 5 | 6 | /// 7 | /// Allows defining async custom mapping logic between a source and target Facet-generated type. 8 | /// Use this interface when your mapping logic requires async operations like database calls, API calls, or I/O operations. 9 | /// 10 | /// The source type 11 | /// The target Facet type 12 | public interface IFacetMapConfigurationAsync 13 | { 14 | /// 15 | /// Asynchronously maps source to target with custom logic. 16 | /// This method is called after the standard property copying is completed. 17 | /// 18 | /// The source object 19 | /// The target object with basic properties already copied 20 | /// Cancellation token to cancel the async operation 21 | /// A task representing the async mapping operation 22 | static abstract Task MapAsync(TSource source, TTarget target, CancellationToken cancellationToken = default); 23 | } 24 | 25 | /// 26 | /// Instance-based interface for defining async custom mapping logic with dependency injection support. 27 | /// Use this interface when your async mapping logic requires injected services. 28 | /// 29 | /// The source type 30 | /// The target Facet type 31 | public interface IFacetMapConfigurationAsyncInstance 32 | { 33 | /// 34 | /// Asynchronously maps source to target with custom logic. 35 | /// This method is called after the standard property copying is completed. 36 | /// 37 | /// The source object 38 | /// The target object with basic properties already copied 39 | /// Cancellation token to cancel the async operation 40 | /// A task representing the async mapping operation 41 | Task MapAsync(TSource source, TTarget target, CancellationToken cancellationToken = default); 42 | } -------------------------------------------------------------------------------- /benchmark/quick-test.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # Quick benchmark runner script 4 | # This runs optimized benchmarks for faster comparison testing 5 | 6 | Write-Host "? Quick Facet Benchmark Test" -ForegroundColor Green 7 | Write-Host "==============================" 8 | Write-Host "Running optimized benchmarks for faster comparison" -ForegroundColor Cyan 9 | Write-Host "" 10 | 11 | # Build in Release mode 12 | Write-Host "Building in Release mode..." -ForegroundColor Yellow 13 | dotnet build -c Release --verbosity quiet 14 | 15 | if ($LASTEXITCODE -ne 0) { 16 | Write-Host "? Build failed!" -ForegroundColor Red 17 | exit 1 18 | } 19 | 20 | Write-Host "? Build successful!" -ForegroundColor Green 21 | 22 | # Run the quick comparison benchmark 23 | Write-Host "`nRunning quick comparison benchmark..." -ForegroundColor Yellow 24 | Write-Host "?? Configuration: 1 warmup iteration, 5 measurement iterations" -ForegroundColor Cyan 25 | Write-Host "?? Testing key scenarios with small datasets (25 items).`n" -ForegroundColor Cyan 26 | 27 | Write-Host "Running QuickComparisonBenchmark..." -ForegroundColor Yellow 28 | dotnet run -c Release -- quick 29 | 30 | Write-Host "`n? Quick benchmark test completed!" -ForegroundColor Green 31 | Write-Host "?? Check the BenchmarkDotNet.Artifacts folder for detailed results" -ForegroundColor Cyan 32 | Write-Host "?? Look for .md, .html, .json, and .csv files in the results directory" -ForegroundColor Cyan 33 | Write-Host "" 34 | Write-Host "?? For even faster testing:" -ForegroundColor Yellow 35 | Write-Host " ?? Super Quick: ./super-quick-test.ps1 (~30 seconds)" -ForegroundColor White 36 | Write-Host "" 37 | Write-Host "?? For comprehensive benchmarks:" -ForegroundColor Yellow 38 | Write-Host " ?? Full Suite: dotnet run -c Release -- all (~10+ minutes)" -ForegroundColor White 39 | Write-Host "" 40 | Write-Host "?? Quick benchmark optimizations:" -ForegroundColor Cyan 41 | Write-Host " - 1 warmup iteration (vs 3 in full benchmarks)" -ForegroundColor White 42 | Write-Host " - 5 measurement iterations (vs 10 in full benchmarks)" -ForegroundColor White 43 | Write-Host " - Small datasets (25 items vs 1000+ in full benchmarks)" -ForegroundColor White 44 | Write-Host " - Results sufficient for performance comparison" -ForegroundColor White 45 | Write-Host "" 46 | Write-Host "?? Expected completion time: ~2 minutes" -ForegroundColor Green -------------------------------------------------------------------------------- /src/Facet.Dashboard/TemplateEngine.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text; 3 | 4 | namespace Facet.Dashboard; 5 | 6 | /// 7 | /// Simple template engine for rendering HTML templates with token replacement. 8 | /// 9 | internal static class TemplateEngine 10 | { 11 | private static readonly Dictionary _templateCache = new(); 12 | private static readonly object _lock = new(); 13 | 14 | /// 15 | /// Loads a template from embedded resources. 16 | /// 17 | public static string LoadTemplate(string templateName) 18 | { 19 | lock (_lock) 20 | { 21 | if (_templateCache.TryGetValue(templateName, out var cached)) 22 | return cached; 23 | 24 | var assembly = Assembly.GetExecutingAssembly(); 25 | var resourceName = $"Facet.Dashboard.Templates.{templateName}"; 26 | 27 | using var stream = assembly.GetManifestResourceStream(resourceName); 28 | if (stream == null) 29 | throw new InvalidOperationException($"Template '{templateName}' not found in embedded resources."); 30 | 31 | using var reader = new StreamReader(stream); 32 | var content = reader.ReadToEnd(); 33 | _templateCache[templateName] = content; 34 | return content; 35 | } 36 | } 37 | 38 | /// 39 | /// Renders a template by replacing tokens with values. 40 | /// 41 | public static string Render(string template, Dictionary tokens) 42 | { 43 | var result = template; 44 | foreach (var kvp in tokens) 45 | { 46 | result = result.Replace($"{{{{{kvp.Key}}}}}", kvp.Value); 47 | } 48 | return result; 49 | } 50 | 51 | /// 52 | /// Renders a template by name with token replacement. 53 | /// 54 | public static string RenderTemplate(string templateName, Dictionary tokens) 55 | { 56 | var template = LoadTemplate(templateName); 57 | return Render(template, tokens); 58 | } 59 | 60 | /// 61 | /// Clears the template cache (useful for development/testing). 62 | /// 63 | public static void ClearCache() 64 | { 65 | lock (_lock) 66 | { 67 | _templateCache.Clear(); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Features/BackToRequiredFieldsTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels; 2 | using Facet.Tests.Utilities; 3 | 4 | namespace Facet.Tests.UnitTests.Features; 5 | 6 | public class BackToRequiredFieldsTests 7 | { 8 | [Fact] 9 | public void BackTo_ShouldWork_WithExcludedRequiredFields() 10 | { 11 | // Arrange 12 | var eventLog = new EventLog 13 | { 14 | Id = "test-event", 15 | EventType = "TestEvent", 16 | Timestamp = DateTime.UtcNow, 17 | Message = "Test message", 18 | UserId = "user123", 19 | Source = "TestSource" // This required field will be excluded from the DTO 20 | }; 21 | 22 | var facet = eventLog.ToFacet(); 23 | 24 | // Act 25 | var result = facet.BackTo(); 26 | 27 | // Assert 28 | result.Should().NotBeNull(); 29 | result.Id.Should().Be("test-event"); 30 | result.EventType.Should().Be("TestEvent"); 31 | result.Timestamp.Should().Be(eventLog.Timestamp); 32 | result.Message.Should().Be("Test message"); 33 | result.UserId.Should().Be("user123"); 34 | result.Source.Should().Be(string.Empty); 35 | } 36 | 37 | [Fact] 38 | public void BackTo_ShouldProvideDefaultValues_ForExcludedRequiredFields() 39 | { 40 | // Arrange 41 | var originalEventLog = new EventLog 42 | { 43 | Id = "event-123", 44 | EventType = "UserLogin", 45 | Timestamp = DateTime.UtcNow, 46 | Message = "User logged in successfully", 47 | UserId = "user-456", 48 | Source = "WebApp" // This required field will be excluded in the DTO 49 | }; 50 | 51 | var eventLogDto = originalEventLog.ToFacet(); 52 | 53 | // Act 54 | var mappedEventLog = eventLogDto.BackTo(); 55 | 56 | // Assert 57 | mappedEventLog.Should().NotBeNull(); 58 | mappedEventLog.Id.Should().Be("event-123"); 59 | mappedEventLog.EventType.Should().Be("UserLogin"); 60 | mappedEventLog.Timestamp.Should().Be(originalEventLog.Timestamp); 61 | mappedEventLog.Message.Should().Be("User logged in successfully"); 62 | mappedEventLog.UserId.Should().Be("user-456"); 63 | 64 | mappedEventLog.Source.Should().Be(string.Empty); // String default value 65 | } 66 | } -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Features/ToSourceRequiredFieldsTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels; 2 | using Facet.Tests.Utilities; 3 | 4 | namespace Facet.Tests.UnitTests.Features; 5 | 6 | public class ToSourceRequiredFieldsTests 7 | { 8 | [Fact] 9 | public void ToSource_ShouldWork_WithExcludedRequiredFields() 10 | { 11 | // Arrange 12 | var eventLog = new EventLog 13 | { 14 | Id = "test-event", 15 | EventType = "TestEvent", 16 | Timestamp = DateTime.UtcNow, 17 | Message = "Test message", 18 | UserId = "user123", 19 | Source = "TestSource" // This required field will be excluded from the DTO 20 | }; 21 | 22 | var facet = eventLog.ToFacet(); 23 | 24 | // Act 25 | var result = facet.ToSource(); 26 | 27 | // Assert 28 | result.Should().NotBeNull(); 29 | result.Id.Should().Be("test-event"); 30 | result.EventType.Should().Be("TestEvent"); 31 | result.Timestamp.Should().Be(eventLog.Timestamp); 32 | result.Message.Should().Be("Test message"); 33 | result.UserId.Should().Be("user123"); 34 | result.Source.Should().Be(string.Empty); 35 | } 36 | 37 | [Fact] 38 | public void ToSource_ShouldProvideDefaultValues_ForExcludedRequiredFields() 39 | { 40 | // Arrange 41 | var originalEventLog = new EventLog 42 | { 43 | Id = "event-123", 44 | EventType = "UserLogin", 45 | Timestamp = DateTime.UtcNow, 46 | Message = "User logged in successfully", 47 | UserId = "user-456", 48 | Source = "WebApp" // This required field will be excluded in the DTO 49 | }; 50 | 51 | var eventLogDto = originalEventLog.ToFacet(); 52 | 53 | // Act 54 | var mappedEventLog = eventLogDto.ToSource(); 55 | 56 | // Assert 57 | mappedEventLog.Should().NotBeNull(); 58 | mappedEventLog.Id.Should().Be("event-123"); 59 | mappedEventLog.EventType.Should().Be("UserLogin"); 60 | mappedEventLog.Timestamp.Should().Be(originalEventLog.Timestamp); 61 | mappedEventLog.Message.Should().Be("User logged in successfully"); 62 | mappedEventLog.UserId.Should().Be("user-456"); 63 | 64 | mappedEventLog.Source.Should().Be(string.Empty); // String default value 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Facet Documentation Index 2 | 3 | Welcome to the Facet documentation! This index will help you navigate all available guides and references for using Facet and its extensions. 4 | 5 | ## Table of Contents 6 | 7 | - [Facetting](01_Facetting.md): Introduction to Facetting 8 | - [Quick Start](02_QuickStart.md): Quick Start Guide 9 | - [Attribute Reference](03_AttributeReference.md): Facet Attribute Reference 10 | - [Custom Mapping](04_CustomMapping.md): Custom Mapping with IFacetMapConfiguration & Async Support 11 | - [Property Mapping](15_MapFromAttribute.md): Declarative property renaming with MapFrom attribute 12 | - [MapWhen Conditional Mapping](17_MapWhen.md): Conditionally map properties based on source values 13 | - [Extension Methods](05_Extensions.md): Extension Methods (LINQ, EF Core, etc.) 14 | - [Advanced Scenarios](06_AdvancedScenarios.md): Advanced Usage Scenarios 15 | - Multiple facets from one source 16 | - Include/Exclude patterns 17 | - Nested Facets (single objects & collections) 18 | - Collection support (List, Array, ICollection, IEnumerable) 19 | - Inheritance and base classes 20 | - [Generated Files Output Configuration](12_GeneratedFilesOutput.md): Configure where generated files are written and make them visible in Solution Explorer 21 | - [What is Being Generated?](07_WhatIsBeingGenerated.md): Before/After Examples 22 | - [Async Mapping Guide](08_AsyncMapping.md): Asynchronous Mapping with Facet.Mapping 23 | - [GenerateDtos Attribute](09_GenerateDtosAttribute.md): Auto-generate CRUD DTOs with GenerateDtos & GenerateAuditableDtos 24 | - [Expression Mapping](10_ExpressionMapping.md): Transform business logic expressions between entities and DTOs with Facet.Mapping.Expressions 25 | - [Flatten Attribute](11_FlattenAttribute.md): Automatically flatten nested objects into top-level properties for API responses and reports 26 | - [Wrapper Attribute](14_WrapperAttribute.md): Generate reference-based wrappers for facade and decorator patterns with property delegation 27 | - [Analyzer Rules](13_AnalyzerRules.md): Complete guide to Facet's Roslyn analyzers and diagnostic rules 28 | - [Source Signature Change Tracking](16_SourceSignature.md): Track source entity changes with compile-time signature verification 29 | 30 | ## Ecosystem packages 31 | - [Facet.Extensions.EFCore](../src/Facet.Extensions.EFCore/README.md): EF Core Async Extension Methods 32 | - [Facet.Mapping Reference](../src/Facet.Mapping/README.md): Complete Facet.Mapping Documentation 33 | - [Facet.Mapping.Expressions Reference](../src/Facet.Mapping.Expressions/README.md): Complete Expression Mapping Documentation 34 | 35 | -------------------------------------------------------------------------------- /benchmark/Facet.Benchmark/SuperQuickBenchmark.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Engines; 3 | using Facet.Benchmark.Models; 4 | using Facet.Benchmark.DTOs; 5 | using Facet.Benchmark.Mappers; 6 | using Facet.Extensions; 7 | using Mapster; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | 11 | namespace Facet.Benchmark.Benchmarks; 12 | 13 | /// 14 | /// Super fast minimal benchmark for development testing 15 | /// Uses absolute minimum iterations for quickest possible feedback 16 | /// 17 | [SimpleJob(RunStrategy.Monitoring, warmupCount: 1, iterationCount: 2)] 18 | [MemoryDiagnoser(false)] // Disable memory diagnostics for speed 19 | [MarkdownExporter] 20 | public class SuperQuickBenchmark 21 | { 22 | private User _user = null!; 23 | private List _users = null!; 24 | private UserMapper _userMapper = null!; 25 | 26 | [GlobalSetup] 27 | public void Setup() 28 | { 29 | // Minimal test data 30 | var testData = TestDataGenerator.CreateTestDataSet(1, 1, 1); 31 | _user = testData.Users[0]; 32 | _users = TestDataGenerator.GenerateUsers(10); // Very small collection 33 | 34 | _userMapper = new UserMapper(); 35 | 36 | // Configure Mapster 37 | TypeAdapterConfig.NewConfig().Compile(); 38 | } 39 | 40 | [Benchmark(Baseline = true, Description = "Facet - Single Mapping")] 41 | public UserBasicDto FacetSingle() 42 | { 43 | return _user.ToFacet(); 44 | } 45 | 46 | [Benchmark(Description = "Mapster - Single Mapping")] 47 | public UserBasicManualDto MapsterSingle() 48 | { 49 | return _user.Adapt(); 50 | } 51 | 52 | [Benchmark(Description = "Mapperly - Single Mapping")] 53 | public UserBasicManualDto MapperlySingle() 54 | { 55 | return _userMapper.ToBasicDto(_user); 56 | } 57 | 58 | [Benchmark(Description = "Facet - Collection (10 items)")] 59 | public List FacetCollection() 60 | { 61 | // Use optimized SelectFacets which pre-allocates and caches the mapper 62 | return (List)_users.SelectFacets(); 63 | } 64 | 65 | [Benchmark(Description = "Mapster - Collection (10 items)")] 66 | public List MapsterCollection() 67 | { 68 | return _users.Adapt>(); 69 | } 70 | 71 | [Benchmark(Description = "Mapperly - Collection (10 items)")] 72 | public List MapperlyCollection() 73 | { 74 | return _users.Select(_userMapper.ToBasicDto).ToList(); 75 | } 76 | } -------------------------------------------------------------------------------- /benchmark/Facet.Benchmark/FacetDTOs.cs: -------------------------------------------------------------------------------- 1 | using Facet.Benchmark.Models; 2 | 3 | namespace Facet.Benchmark.DTOs; 4 | 5 | /// 6 | /// Basic user DTO using Facet - excludes sensitive information 7 | /// 8 | [Facet(typeof(User), 9 | nameof(User.Salary), 10 | nameof(User.Manager), 11 | nameof(User.DirectReports), 12 | nameof(User.UserRoles))] 13 | public partial class UserBasicDto 14 | { 15 | } 16 | 17 | /// 18 | /// Detailed user DTO using Facet - includes more information but still excludes navigation properties 19 | /// 20 | [Facet(typeof(User), 21 | nameof(User.Manager), 22 | nameof(User.DirectReports), 23 | nameof(User.UserRoles), 24 | nameof(User.Address))] 25 | public partial class UserDetailedDto 26 | { 27 | } 28 | 29 | /// 30 | /// Simple user DTO with only essential fields 31 | /// 32 | [Facet(typeof(User), 33 | nameof(User.PhoneNumber), 34 | nameof(User.UpdatedAt), 35 | nameof(User.ManagerId), 36 | nameof(User.Department), 37 | nameof(User.JobTitle), 38 | nameof(User.LastLoginAt), 39 | nameof(User.LoginCount), 40 | nameof(User.Salary), 41 | nameof(User.Manager), 42 | nameof(User.DirectReports), 43 | nameof(User.Address), 44 | nameof(User.UserRoles))] 45 | public partial class UserSimpleDto 46 | { 47 | } 48 | 49 | /// 50 | /// Product DTO using Facet - excludes navigation properties 51 | /// 52 | [Facet(typeof(Product), 53 | nameof(Product.Category), 54 | nameof(Product.OrderItems))] 55 | public partial class ProductDto 56 | { 57 | } 58 | 59 | /// 60 | /// Simple product DTO with only basic information 61 | /// 62 | [Facet(typeof(Product), 63 | nameof(Product.Description), 64 | nameof(Product.StockQuantity), 65 | nameof(Product.SKU), 66 | nameof(Product.UpdatedAt), 67 | nameof(Product.CategoryId), 68 | nameof(Product.Category), 69 | nameof(Product.OrderItems))] 70 | public partial class ProductSimpleDto 71 | { 72 | } 73 | 74 | /// 75 | /// Address DTO using Facet - excludes navigation properties 76 | /// 77 | [Facet(typeof(Address), nameof(Address.User))] 78 | public partial class AddressDto 79 | { 80 | } 81 | 82 | /// 83 | /// Order DTO using Facet - excludes navigation properties 84 | /// 85 | [Facet(typeof(Order), 86 | nameof(Order.User), 87 | nameof(Order.OrderItems))] 88 | public partial class OrderDto 89 | { 90 | } 91 | 92 | /// 93 | /// Category DTO using Facet - excludes navigation properties 94 | /// 95 | [Facet(typeof(Category), nameof(Category.Products))] 96 | public partial class CategoryDto 97 | { 98 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5.2.0-alpha 5 | 6 | 7 | Tim Maes 8 | https://github.com/Tim-Maes/Facet 9 | git 10 | MIT 11 | latest 12 | enable 13 | true 14 | 15 | 16 | false 17 | 18 | 19 | 20 | 28 | $(NoWarn);MSB3277;1591 29 | 30 | 31 | 32 | 33 | 34 | true 35 | true 36 | snupkg 37 | 38 | 39 | portable 40 | true 41 | 42 | true 43 | 44 | 45 | true 46 | true 47 | 48 | 49 | true 50 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Features/NullableHandlingTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels; 2 | using Facet.Tests.Utilities; 3 | 4 | namespace Facet.Tests.UnitTests.Features; 5 | 6 | public class NullableHandlingTests 7 | { 8 | [Fact] 9 | public void ToFacet_ShouldPreserveNullableStringTypes_WhenMappingToFacet() 10 | { 11 | // Arrange 12 | var testEntity = new NullableTestEntity 13 | { 14 | Test1 = true, 15 | Test2 = false, 16 | Test3 = "Non-nullable string", 17 | Test4 = null 18 | }; 19 | 20 | // Act 21 | var dto = testEntity.ToFacet(); 22 | 23 | // Assert 24 | dto.Should().NotBeNull(); 25 | dto.Test1.Should().Be(true); 26 | dto.Test2.Should().Be(false); 27 | dto.Test3.Should().Be("Non-nullable string"); 28 | dto.Test4.Should().BeNull(); 29 | } 30 | 31 | [Fact] 32 | public void NullableTestDto_ShouldHaveCorrectPropertyTypes() 33 | { 34 | // Arrange & Act 35 | var dtoType = typeof(NullableTestDto); 36 | 37 | // Assert 38 | var test1Property = dtoType.GetProperty("Test1"); 39 | var test2Property = dtoType.GetProperty("Test2"); 40 | var test3Property = dtoType.GetProperty("Test3"); 41 | var test4Property = dtoType.GetProperty("Test4"); 42 | 43 | test1Property.Should().NotBeNull(); 44 | test1Property!.PropertyType.Should().Be(); 45 | 46 | test2Property.Should().NotBeNull(); 47 | test2Property!.PropertyType.Should().Be(typeof(bool?)); 48 | 49 | test3Property.Should().NotBeNull(); 50 | test3Property!.PropertyType.Should().Be(); 51 | 52 | test4Property.Should().NotBeNull(); 53 | test4Property!.PropertyType.Should().Be(); 54 | 55 | // In C# 8+ with nullable reference types enabled, 56 | // we need to check the nullable annotation context 57 | var nullabilityContext = new System.Reflection.NullabilityInfoContext(); 58 | var test4NullabilityInfo = nullabilityContext.Create(test4Property); 59 | 60 | // Test4 should allow null values 61 | test4NullabilityInfo.ReadState.Should().Be(System.Reflection.NullabilityState.Nullable); 62 | } 63 | 64 | [Theory] 65 | [InlineData(null)] 66 | [InlineData("Some value")] 67 | public void ToFacet_ShouldHandleNullableStringAssignment_Correctly(string? testValue) 68 | { 69 | // Arrange 70 | var testEntity = new NullableTestEntity 71 | { 72 | Test1 = false, 73 | Test2 = null, 74 | Test3 = "Always has value", 75 | Test4 = testValue 76 | }; 77 | 78 | // Act 79 | var dto = testEntity.ToFacet(); 80 | 81 | // Assert 82 | dto.Test4.Should().Be(testValue); 83 | } 84 | } -------------------------------------------------------------------------------- /src/Facet/Generators/FacetGenerators/MemberGenerator.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using System.Text; 3 | 4 | namespace Facet.Generators; 5 | 6 | /// 7 | /// Generates member declarations (properties and fields) for facet types. 8 | /// 9 | internal static class MemberGenerator 10 | { 11 | /// 12 | /// Generates member declarations (properties and fields) for the target type. 13 | /// 14 | public static void GenerateMembers(StringBuilder sb, FacetTargetModel model, string memberIndent) 15 | { 16 | // Create a HashSet for efficient lookup of base class member names 17 | var baseClassMembers = new System.Collections.Generic.HashSet(model.BaseClassMemberNames); 18 | 19 | foreach (var m in model.Members) 20 | { 21 | // Skip user-declared properties (those with [MapFrom] or [MapWhen] attribute) 22 | if (m.IsUserDeclared) 23 | continue; 24 | 25 | // Skip properties that already exist in base classes to avoid "hides inherited member" warning 26 | if (baseClassMembers.Contains(m.Name)) 27 | continue; 28 | 29 | // Generate member XML documentation if available 30 | if (!string.IsNullOrWhiteSpace(m.XmlDocumentation)) 31 | { 32 | var indentedDocumentation = m.XmlDocumentation!.Replace("\n", $"\n{memberIndent}"); 33 | sb.AppendLine($"{memberIndent}{indentedDocumentation}"); 34 | } 35 | 36 | // Generate attributes if any 37 | foreach (var attribute in m.Attributes) 38 | { 39 | sb.AppendLine($"{memberIndent}{attribute}"); 40 | } 41 | 42 | if (m.Kind == FacetMemberKind.Property) 43 | { 44 | GenerateProperty(sb, m, memberIndent); 45 | } 46 | else 47 | { 48 | GenerateField(sb, m, memberIndent); 49 | } 50 | } 51 | } 52 | 53 | private static void GenerateProperty(StringBuilder sb, FacetMember member, string indent) 54 | { 55 | var propDef = $"public {member.TypeName} {member.Name}"; 56 | 57 | if (member.IsInitOnly) 58 | { 59 | propDef += " { get; init; }"; 60 | } 61 | else 62 | { 63 | propDef += " { get; set; }"; 64 | } 65 | 66 | if (member.IsRequired) 67 | { 68 | propDef = $"required {propDef}"; 69 | } 70 | 71 | sb.AppendLine($"{indent}{propDef}"); 72 | } 73 | 74 | private static void GenerateField(StringBuilder sb, FacetMember member, string indent) 75 | { 76 | var fieldDef = $"public {member.TypeName} {member.Name};"; 77 | if (member.IsRequired) 78 | { 79 | fieldDef = $"required {fieldDef}"; 80 | } 81 | sb.AppendLine($"{indent}{fieldDef}"); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Facet/Generators/FacetGenerators/NullabilityAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using Microsoft.CodeAnalysis; 3 | 4 | namespace Facet.Generators; 5 | 6 | /// 7 | /// Analyzes and provides utilities for handling nullable types consistently across the generator. 8 | /// 9 | internal static class NullabilityAnalyzer 10 | { 11 | /// 12 | /// Checks if a type name represents a nullable type (ends with '?'). 13 | /// 14 | public static bool IsNullableTypeName(string typeName) 15 | { 16 | return !string.IsNullOrEmpty(typeName) && typeName.EndsWith("?"); 17 | } 18 | 19 | /// 20 | /// Checks if a type symbol is nullable based on its annotation. 21 | /// 22 | public static bool IsNullableType(ITypeSymbol type) 23 | { 24 | return type.NullableAnnotation == NullableAnnotation.Annotated; 25 | } 26 | 27 | /// 28 | /// Checks if a type is a nullable reference type, considering both annotation and type kind. 29 | /// 30 | public static bool IsNullableReferenceType(ITypeSymbol type) 31 | { 32 | return !type.IsValueType && type.NullableAnnotation == NullableAnnotation.Annotated; 33 | } 34 | 35 | /// 36 | /// Checks if a type should be treated as nullable for safety. 37 | /// This includes explicitly nullable types and reference types without explicit non-null annotation. 38 | /// 39 | public static bool ShouldTreatAsNullable(ITypeSymbol type, bool isRequired = false) 40 | { 41 | // Value types with nullable annotation are explicitly nullable 42 | if (type.IsValueType) 43 | { 44 | return type.NullableAnnotation == NullableAnnotation.Annotated; 45 | } 46 | 47 | // Reference types 48 | // If explicitly annotated as nullable 49 | if (type.NullableAnnotation == NullableAnnotation.Annotated) 50 | { 51 | return true; 52 | } 53 | 54 | // If explicitly annotated as non-nullable AND required, treat as non-nullable 55 | if (type.NullableAnnotation == NullableAnnotation.NotAnnotated && isRequired) 56 | { 57 | return false; 58 | } 59 | 60 | // For safety, treat other reference types as potentially nullable 61 | return true; 62 | } 63 | 64 | /// 65 | /// Removes the nullable marker ('?') from a type name if present. 66 | /// 67 | public static string StripNullableMarker(string typeName) 68 | { 69 | return typeName.TrimEnd('?'); 70 | } 71 | 72 | /// 73 | /// Makes a type name nullable by adding '?' if it's not already nullable. 74 | /// 75 | public static string MakeNullable(string typeName) 76 | { 77 | if (string.IsNullOrEmpty(typeName)) 78 | { 79 | return typeName; 80 | } 81 | 82 | return IsNullableTypeName(typeName) ? typeName : typeName + "?"; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /benchmark/Facet.Benchmark/MapperlyMappers.cs: -------------------------------------------------------------------------------- 1 | using Facet.Benchmark.Models; 2 | using Facet.Benchmark.DTOs; 3 | using Riok.Mapperly.Abstractions; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Facet.Benchmark.Mappers; 8 | 9 | /// 10 | /// Mapperly mappers for benchmarking 11 | /// These use compile-time code generation similar to Facet 12 | /// 13 | 14 | [Mapper] 15 | public partial class UserMapper 16 | { 17 | public partial UserBasicManualDto ToBasicDto(User user); 18 | public partial UserDetailedManualDto ToDetailedDto(User user); 19 | public partial UserSimpleManualDto ToSimpleDto(User user); 20 | 21 | // Bulk mapping methods 22 | public partial IQueryable ProjectToBasicDto(IQueryable users); 23 | public partial IQueryable ProjectToDetailedDto(IQueryable users); 24 | public partial IQueryable ProjectToSimpleDto(IQueryable users); 25 | 26 | // List mapping methods 27 | public partial List ToBasicDtoList(List users); 28 | public partial List ToDetailedDtoList(List users); 29 | public partial List ToSimpleDtoList(List users); 30 | } 31 | 32 | [Mapper] 33 | public partial class ProductMapper 34 | { 35 | public partial ProductManualDto ToDto(Product product); 36 | public partial ProductSimpleManualDto ToSimpleDto(Product product); 37 | 38 | // Bulk mapping methods 39 | public partial IQueryable ProjectToDto(IQueryable products); 40 | public partial IQueryable ProjectToSimpleDto(IQueryable products); 41 | 42 | // List mapping methods 43 | public partial List ToDtoList(List products); 44 | public partial List ToSimpleDtoList(List products); 45 | } 46 | 47 | [Mapper] 48 | public partial class AddressMapper 49 | { 50 | public partial AddressManualDto ToDto(Address address); 51 | 52 | // Bulk mapping methods 53 | public partial IQueryable ProjectToDto(IQueryable
addresses); 54 | 55 | // List mapping methods 56 | public partial List ToDtoList(List
addresses); 57 | } 58 | 59 | [Mapper] 60 | public partial class OrderMapper 61 | { 62 | public partial OrderManualDto ToDto(Order order); 63 | 64 | // Bulk mapping methods 65 | public partial IQueryable ProjectToDto(IQueryable orders); 66 | 67 | // List mapping methods 68 | public partial List ToDtoList(List orders); 69 | } 70 | 71 | [Mapper] 72 | public partial class CategoryMapper 73 | { 74 | public partial CategoryManualDto ToDto(Category category); 75 | 76 | // Bulk mapping methods 77 | public partial IQueryable ProjectToDto(IQueryable categories); 78 | 79 | // List mapping methods 80 | public partial List ToDtoList(List categories); 81 | } -------------------------------------------------------------------------------- /benchmark/FacetBenchmark.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.0.31903.59 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Facet", "..\src\Facet\Facet.csproj", "{E1B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Facet.Extensions", "..\src\Facet.Extensions\Facet.Extensions.csproj", "{E2B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Facet.Mapping", "..\src\Facet.Mapping\Facet.Mapping.csproj", "{E4B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}" 10 | EndProject 11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Facet.Extensions.EFCore", "..\src\Facet.Extensions.EFCore\Facet.Extensions.EFCore.csproj", "{E2786633-C705-DC6C-05A1-CF19DDE62B32}" 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Facet.Benchmark", "Facet.Benchmark\Facet.Benchmark.csproj", "{B1B2B3C4-8F4A-4C8B-9D1E-F2C3A4B5D6E8}" 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Release|Any CPU = Release|Any CPU 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {E1B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {E1B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {E1B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {E1B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {E2B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {E2B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {E2B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {E2B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {E4B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {E4B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {E4B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {E4B5B3C1-8F4A-4C8B-9D1E-F2C3A4B5D6E7}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {E2786633-C705-DC6C-05A1-CF19DDE62B32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {E2786633-C705-DC6C-05A1-CF19DDE62B32}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {E2786633-C705-DC6C-05A1-CF19DDE62B32}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {E2786633-C705-DC6C-05A1-CF19DDE62B32}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {B1B2B3C4-8F4A-4C8B-9D1E-F2C3A4B5D6E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {B1B2B3C4-8F4A-4C8B-9D1E-F2C3A4B5D6E8}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {B1B2B3C4-8F4A-4C8B-9D1E-F2C3A4B5D6E8}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {B1B2B3C4-8F4A-4C8B-9D1E-F2C3A4B5D6E8}.Release|Any CPU.Build.0 = Release|Any CPU 41 | EndGlobalSection 42 | GlobalSection(SolutionProperties) = preSolution 43 | HideSolutionNode = FALSE 44 | EndGlobalSection 45 | EndGlobal -------------------------------------------------------------------------------- /src/Facet.Dashboard/FacetMemberInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Dashboard; 2 | 3 | /// 4 | /// Represents information about a property or field on a type. 5 | /// 6 | public sealed class FacetMemberInfo 7 | { 8 | /// 9 | /// Gets the name of the member. 10 | /// 11 | public string Name { get; } 12 | 13 | /// 14 | /// Gets the type name of the member. 15 | /// 16 | public string TypeName { get; } 17 | 18 | /// 19 | /// Gets whether this is a property (true) or field (false). 20 | /// 21 | public bool IsProperty { get; } 22 | 23 | /// 24 | /// Gets whether the member is nullable. 25 | /// 26 | public bool IsNullable { get; } 27 | 28 | /// 29 | /// Gets whether the member is required. 30 | /// 31 | public bool IsRequired { get; } 32 | 33 | /// 34 | /// Gets whether the member has an init-only setter. 35 | /// 36 | public bool IsInitOnly { get; } 37 | 38 | /// 39 | /// Gets whether the member is read-only. 40 | /// 41 | public bool IsReadOnly { get; } 42 | 43 | /// 44 | /// Gets the XML documentation summary if available. 45 | /// 46 | public string? XmlDocumentation { get; } 47 | 48 | /// 49 | /// Gets the attributes applied to this member. 50 | /// 51 | public IReadOnlyList Attributes { get; } 52 | 53 | /// 54 | /// Gets whether this member maps to a nested facet. 55 | /// 56 | public bool IsNestedFacet { get; } 57 | 58 | /// 59 | /// Gets whether this member is a collection. 60 | /// 61 | public bool IsCollection { get; } 62 | 63 | /// 64 | /// Gets the source property name if mapped from a different name. 65 | /// 66 | public string? MappedFromProperty { get; } 67 | 68 | /// 69 | /// Creates a new instance of . 70 | /// 71 | public FacetMemberInfo( 72 | string name, 73 | string typeName, 74 | bool isProperty, 75 | bool isNullable, 76 | bool isRequired, 77 | bool isInitOnly, 78 | bool isReadOnly, 79 | string? xmlDocumentation, 80 | IEnumerable? attributes, 81 | bool isNestedFacet, 82 | bool isCollection, 83 | string? mappedFromProperty) 84 | { 85 | Name = name ?? throw new ArgumentNullException(nameof(name)); 86 | TypeName = typeName ?? throw new ArgumentNullException(nameof(typeName)); 87 | IsProperty = isProperty; 88 | IsNullable = isNullable; 89 | IsRequired = isRequired; 90 | IsInitOnly = isInitOnly; 91 | IsReadOnly = isReadOnly; 92 | XmlDocumentation = xmlDocumentation; 93 | Attributes = attributes?.ToList().AsReadOnly() ?? new List().AsReadOnly(); 94 | IsNestedFacet = isNestedFacet; 95 | IsCollection = isCollection; 96 | MappedFromProperty = mappedFromProperty; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/GenerateDtos/GenerateDtosSimpleTest.cs: -------------------------------------------------------------------------------- 1 | using Facet.Extensions; 2 | using Facet.Tests.TestModels; 3 | using System.Reflection; 4 | 5 | namespace Facet.Tests.UnitTests.Core.GenerateDtos; 6 | 7 | /// 8 | /// Simple test to verify that DTOs are generated and can be instantiated 9 | /// 10 | public class GenerateDtosSimpleTest 11 | { 12 | [Fact] 13 | public void GeneratedDtos_ShouldBeCreated() 14 | { 15 | // This simple test just verifies the generated types exist 16 | var responseType = typeof(TestUserResponse); 17 | responseType.Should().NotBeNull(); 18 | 19 | // Check what DTOs are actually available in this assembly 20 | var assembly = Assembly.GetAssembly(typeof(TestUser)); 21 | var allTypes = assembly?.GetTypes() 22 | .Where(t => t.Name.StartsWith("TestUser")) 23 | .ToList() ?? new List(); 24 | 25 | // We know TestUserResponse exists based on compilation 26 | allTypes.Should().Contain(t => t.Name == "TestUserResponse"); 27 | 28 | // Log available types for debugging 29 | var typeNames = string.Join(", ", allTypes.Select(t => t.Name)); 30 | Console.WriteLine($"Available TestUser types: {typeNames}"); 31 | } 32 | 33 | [Fact] 34 | public void GeneratedDto_ShouldHaveFacetAttribute() 35 | { 36 | // Check if the generated DTO has the Facet attribute 37 | var responseType = typeof(TestUserResponse); 38 | var attributes = responseType.GetCustomAttributes(typeof(FacetAttribute), false); 39 | 40 | attributes.Should().NotBeEmpty("Generated DTOs should have [Facet] attribute"); 41 | } 42 | 43 | [Fact] 44 | public void ToFacet_ShouldWork_WithBasicMapping() 45 | { 46 | // Arrange 47 | var user = new TestUser 48 | { 49 | Id = 1, 50 | FirstName = "John", 51 | LastName = "Doe", 52 | Email = "john@example.com" 53 | }; 54 | 55 | // Act - Simple conversion test 56 | var responseDto = user.ToFacet(); 57 | 58 | // Assert 59 | responseDto.Should().NotBeNull(); 60 | } 61 | 62 | [Fact] 63 | public void AvailableGeneratedTypes_ShouldIncludeExpectedDtos() 64 | { 65 | // Get all types in the test assembly that start with TestUser 66 | var assembly = Assembly.GetAssembly(typeof(TestUser)); 67 | var testUserTypes = assembly?.GetTypes() 68 | .Where(t => t.Name.StartsWith("TestUser")) 69 | .Select(t => t.Name) 70 | .OrderBy(name => name) 71 | .ToList() ?? new List(); 72 | 73 | // We should have at least TestUserResponse 74 | testUserTypes.Should().Contain("TestUserResponse"); 75 | 76 | // Check what other DTOs were generated 77 | Console.WriteLine($"Generated DTOs: {string.Join(", ", testUserTypes)}"); 78 | 79 | // The test entity specifies DtoTypes.All, so we should have multiple DTOs 80 | testUserTypes.Count.Should().BeGreaterThan(1, "DtoTypes.All should generate multiple DTOs"); 81 | } 82 | } -------------------------------------------------------------------------------- /src/Facet.Extensions/FacetCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using System.Reflection; 4 | using System.Reflection.Emit; 5 | 6 | namespace Facet; 7 | 8 | /// 9 | /// Provides a cached mapping delegate used by 10 | /// ToFacet<TSource, TTarget> to efficiently construct instances 11 | /// from values. 12 | /// 13 | /// The source model type. 14 | /// 15 | /// The target DTO or facet type. Must expose either a public static FromSource() 16 | /// factory method, or a public constructor accepting a instance. 17 | /// 18 | /// 19 | /// This type performs reflection only once per / 20 | /// combination, precompiling a delegate for reuse in all subsequent mappings. 21 | /// 22 | /// 23 | /// Thrown when no usable FromSource factory or compatible constructor is found on . 24 | /// 25 | internal static class FacetCache 26 | where TTarget : class 27 | { 28 | public static readonly Func Mapper = CreateMapper(); 29 | 30 | private static Func CreateMapper() 31 | { 32 | // Check for static FromSource factory method first (preferred for init-only properties) 33 | var fromSource = typeof(TTarget).GetMethod( 34 | "FromSource", 35 | BindingFlags.Public | BindingFlags.Static, 36 | null, 37 | new[] { typeof(TSource) }, 38 | null); 39 | 40 | if (fromSource != null) 41 | { 42 | return (Func)Delegate.CreateDelegate( 43 | typeof(Func), fromSource); 44 | } 45 | 46 | var ctor = typeof(TTarget).GetConstructor(new[] { typeof(TSource) }); 47 | 48 | if (ctor != null) 49 | { 50 | var method = new DynamicMethod( 51 | name: $"Create_{typeof(TTarget).Name}_From_{typeof(TSource).Name}", 52 | returnType: typeof(TTarget), 53 | parameterTypes: new[] { typeof(TSource) }, 54 | m: typeof(FacetCache).Module, 55 | skipVisibility: true); 56 | 57 | var il = method.GetILGenerator(); 58 | 59 | // Load the parameter onto the stack 60 | il.Emit(OpCodes.Ldarg_0); 61 | 62 | // Call the constructor 63 | il.Emit(OpCodes.Newobj, ctor); 64 | 65 | // Return the new instance 66 | il.Emit(OpCodes.Ret); 67 | 68 | return (Func)method.CreateDelegate(typeof(Func)); 69 | } 70 | 71 | // If neither works, provide a helpful error message 72 | throw new InvalidOperationException( 73 | $"Unable to map {typeof(TSource).Name} to {typeof(TTarget).Name}: " + 74 | $"no compatible FromSource or ctor found."); 75 | } 76 | } -------------------------------------------------------------------------------- /src/Facet.Mapping/IFacetMapConfigurationWithReturn.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Facet.Mapping; 4 | 5 | /// 6 | /// Enhanced interface for defining custom mapping logic that supports init-only properties and records. 7 | /// 8 | /// The source type 9 | /// The target Facet type 10 | /// 11 | /// This interface is obsolete and will be removed in a future version. 12 | /// For init-only properties, use the generated FromSource factory method instead: 13 | /// 14 | /// // Instead of implementing IFacetMapConfigurationWithReturn 15 | /// var dto = MyDto.FromSource(source); // Uses generated factory method 16 | /// 17 | /// Or use object initializer syntax with the generated constructor for custom logic. 18 | /// 19 | [Obsolete("Use the generated FromSource factory method for init-only properties instead. This interface will be removed in a future version.")] 20 | public interface IFacetMapConfigurationWithReturn 21 | { 22 | /// 23 | /// Maps source to target with custom logic, returning a new target instance. 24 | /// This method is called instead of the standard property copying when init-only properties need to be set. 25 | /// 26 | /// The source object 27 | /// The initial target object (may be ignored for init-only scenarios) 28 | /// A new target instance with all properties set, including init-only properties 29 | static abstract TTarget Map(TSource source, TTarget target); 30 | } 31 | 32 | /// 33 | /// Instance-based interface for defining custom mapping logic that supports init-only properties with dependency injection. 34 | /// 35 | /// The source type 36 | /// The target Facet type 37 | /// 38 | /// This interface is obsolete and will be removed in a future version. 39 | /// For init-only properties with DI, use the generated FromSource factory method instead: 40 | /// 41 | /// // Instead of implementing IFacetMapConfigurationWithReturnInstance 42 | /// var dto = MyDto.FromSource(source); // Uses generated factory method 43 | /// 44 | /// Or use object initializer syntax with the generated constructor for custom logic. 45 | /// 46 | [Obsolete("Use the generated FromSource factory method for init-only properties instead. This interface will be removed in a future version.")] 47 | public interface IFacetMapConfigurationWithReturnInstance 48 | { 49 | /// 50 | /// Maps source to target with custom logic, returning a new target instance. 51 | /// This method is called instead of the standard property copying when init-only properties need to be set. 52 | /// 53 | /// The source object 54 | /// The initial target object (may be ignored for init-only scenarios) 55 | /// A new target instance with all properties set, including init-only properties 56 | TTarget Map(TSource source, TTarget target); 57 | } -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Wrapper/NestedWrapperTests.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.UnitTests.Wrapper; 2 | 3 | public class Address 4 | { 5 | public string Street { get; set; } = string.Empty; 6 | public string City { get; set; } = string.Empty; 7 | public string ZipCode { get; set; } = string.Empty; 8 | public string Country { get; set; } = string.Empty; 9 | } 10 | 11 | public class Person 12 | { 13 | public int Id { get; set; } 14 | public string Name { get; set; } = string.Empty; 15 | public Address Address { get; set; } = new(); 16 | public string SocialSecurityNumber { get; set; } = string.Empty; 17 | } 18 | 19 | [Wrapper(typeof(Address), "Country")] 20 | public partial class PublicAddressWrapper { } 21 | 22 | [Wrapper(typeof(Person), "SocialSecurityNumber", NestedWrappers = new[] { typeof(PublicAddressWrapper) })] 23 | public partial class PublicPersonWrapper { } 24 | 25 | public partial class NestedWrapperTests 26 | { 27 | 28 | [Fact] 29 | public void Nested_Wrapper_Should_Wrap_Nested_Properties() 30 | { 31 | // Arrange 32 | var person = new Person 33 | { 34 | Id = 1, 35 | Name = "John Doe", 36 | Address = new Address 37 | { 38 | Street = "123 Main St", 39 | City = "Springfield", 40 | ZipCode = "12345", 41 | Country = "USA" 42 | }, 43 | SocialSecurityNumber = "123-45-6789" 44 | }; 45 | 46 | // Act 47 | var wrapper = new PublicPersonWrapper(person); 48 | 49 | // Assert 50 | wrapper.Id.Should().Be(1); 51 | wrapper.Name.Should().Be("John Doe"); 52 | wrapper.Address.Should().NotBeNull(); 53 | wrapper.Address.Street.Should().Be("123 Main St"); 54 | wrapper.Address.City.Should().Be("Springfield"); 55 | wrapper.Address.ZipCode.Should().Be("12345"); 56 | 57 | // Country should be excluded by PublicAddressWrapper 58 | wrapper.Address.GetType().GetProperty("Country").Should().BeNull(); 59 | } 60 | 61 | [Fact] 62 | public void Nested_Wrapper_Changes_Should_Propagate_To_Source() 63 | { 64 | // Arrange 65 | var person = new Person 66 | { 67 | Id = 1, 68 | Name = "Jane Smith", 69 | Address = new Address { City = "Boston" } 70 | }; 71 | var wrapper = new PublicPersonWrapper(person); 72 | 73 | // Act 74 | wrapper.Address.City = "New York"; 75 | 76 | // Assert 77 | person.Address.City.Should().Be("New York"); 78 | } 79 | 80 | [Fact] 81 | public void Nested_Wrapper_Should_Unwrap_Correctly() 82 | { 83 | // Arrange 84 | var person = new Person { Address = new Address { City = "Seattle" } }; 85 | var wrapper = new PublicPersonWrapper(person); 86 | 87 | // Act 88 | var unwrapped = wrapper.Unwrap(); 89 | 90 | // Assert 91 | unwrapped.Should().BeSameAs(person); 92 | unwrapped.Address.City.Should().Be("Seattle"); 93 | } 94 | 95 | [Fact] 96 | public void Nested_Wrapper_With_Nullable_Should_Handle_Null() 97 | { 98 | // Arrange 99 | var person = new Person { Address = null! }; 100 | 101 | // Act 102 | var wrapper = new PublicPersonWrapper(person); 103 | 104 | // Assert 105 | wrapper.Should().NotBeNull(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Facet.Extensions/FacetSourceCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using System.Reflection; 4 | using System.Reflection.Emit; 5 | 6 | namespace Facet; 7 | 8 | /// 9 | /// Provides a cached mapping delegate used by 10 | /// ToSource<TFacet, TFacetSource> to efficiently construct instances 11 | /// from values. 12 | /// 13 | /// The facet type that is annotated with [Facet(typeof(TFacetSource))]. 14 | /// 15 | /// The target entity type. Must expose either a public static FromFacet() 16 | /// factory method, or a public constructor accepting a instance. 17 | /// 18 | /// 19 | /// This type performs reflection only once per / 20 | /// combination, precompiling a delegate for reuse in all subsequent mappings. 21 | /// 22 | /// 23 | /// Thrown when no usable FromFacet factory or compatible constructor is found on . 24 | /// 25 | internal static class FacetSourceCache 26 | where TFacet : class 27 | where TFacetSource : class 28 | { 29 | public static readonly Func Mapper = CreateMapper(); 30 | 31 | private static Func CreateMapper() 32 | { 33 | // Look for the ToSource() method first (new name), then BackTo() for backwards compatibility 34 | var toEntityMethod = typeof(TFacet).GetMethod( 35 | "ToSource", 36 | BindingFlags.Public | BindingFlags.Instance, 37 | null, 38 | Type.EmptyTypes, 39 | null); 40 | 41 | // Fall back to BackTo for backwards compatibility with older generated code 42 | toEntityMethod ??= typeof(TFacet).GetMethod( 43 | "BackTo", 44 | BindingFlags.Public | BindingFlags.Instance, 45 | null, 46 | Type.EmptyTypes, 47 | null); 48 | 49 | if (toEntityMethod != null && toEntityMethod.ReturnType == typeof(TFacetSource)) 50 | { 51 | var method = new DynamicMethod( 52 | name: $"Call_{typeof(TFacet).Name}_ToSource", 53 | returnType: typeof(TFacetSource), 54 | parameterTypes: new[] { typeof(TFacet) }, 55 | m: typeof(FacetSourceCache).Module, 56 | skipVisibility: true); 57 | 58 | var il = method.GetILGenerator(); 59 | 60 | // Load the facet parameter onto the stack 61 | il.Emit(OpCodes.Ldarg_0); 62 | 63 | // Call the ToSource/BackTo method 64 | il.Emit(OpCodes.Callvirt, toEntityMethod); 65 | 66 | // Return the result 67 | il.Emit(OpCodes.Ret); 68 | 69 | return (Func)method.CreateDelegate(typeof(Func)); 70 | } 71 | 72 | // If no ToSource/BackTo method is found, provide a helpful error message 73 | throw new InvalidOperationException( 74 | $"Unable to map {typeof(TFacet).Name} to {typeof(TFacetSource).Name}: " + 75 | $"no ToSource() method found on the facet type. Ensure the facet is properly generated with source generation."); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Facet.Attributes/Optional.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Facet; 4 | 5 | /// 6 | /// Represents an optional value that can be either specified or unspecified. 7 | /// This is useful for PATCH operations where you need to distinguish between 8 | /// a value that was not provided and a value that was explicitly set to null or a specific value. 9 | /// 10 | /// The type of the optional value. 11 | public readonly struct Optional 12 | { 13 | private readonly T _value; 14 | private readonly bool _hasValue; 15 | 16 | /// 17 | /// Gets a value indicating whether this optional has a value. 18 | /// 19 | public bool HasValue => _hasValue; 20 | 21 | /// 22 | /// Gets the value of this optional. 23 | /// 24 | /// Thrown when HasValue is false. 25 | public T Value 26 | { 27 | get 28 | { 29 | if (!_hasValue) 30 | throw new InvalidOperationException("Optional does not have a value."); 31 | return _value; 32 | } 33 | } 34 | 35 | /// 36 | /// Creates an optional with a value. 37 | /// 38 | /// The value to wrap. 39 | public Optional(T value) 40 | { 41 | _value = value; 42 | _hasValue = true; 43 | } 44 | 45 | /// 46 | /// Gets the value if present, otherwise returns the default value for the type. 47 | /// 48 | /// The default value to return if no value is present. 49 | /// The value if present, otherwise the default value. 50 | public T GetValueOrDefault(T defaultValue = default!) 51 | { 52 | return _hasValue ? _value : defaultValue; 53 | } 54 | 55 | /// 56 | /// Implicitly converts a value to an Optional. 57 | /// 58 | public static implicit operator Optional(T value) => new(value); 59 | 60 | /// 61 | /// Converts the optional to its string representation. 62 | /// 63 | public override string ToString() 64 | { 65 | return _hasValue ? _value?.ToString() ?? "null" : "unspecified"; 66 | } 67 | 68 | /// 69 | /// Determines whether this optional equals another optional. 70 | /// 71 | public override bool Equals(object? obj) 72 | { 73 | if (obj is not Optional other) 74 | return false; 75 | 76 | if (!_hasValue && !other._hasValue) 77 | return true; 78 | 79 | if (_hasValue != other._hasValue) 80 | return false; 81 | 82 | return Equals(_value, other._value); 83 | } 84 | 85 | /// 86 | /// Gets the hash code for this optional. 87 | /// 88 | public override int GetHashCode() 89 | { 90 | if (!_hasValue) 91 | return 0; 92 | 93 | return _value?.GetHashCode() ?? 1; 94 | } 95 | 96 | /// 97 | /// Determines whether two optionals are equal. 98 | /// 99 | public static bool operator ==(Optional left, Optional right) => left.Equals(right); 100 | 101 | /// 102 | /// Determines whether two optionals are not equal. 103 | /// 104 | public static bool operator !=(Optional left, Optional right) => !left.Equals(right); 105 | } 106 | -------------------------------------------------------------------------------- /src/Facet/Facet.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | true 6 | Facet 7 | A Roslyn source generator for models and projections. 8 | source-generator dto projection facet mapper 9 | true 10 | Facet 11 | README.md 12 | true 13 | true 14 | true 15 | 16 | 17 | 18 | false 19 | false 20 | 21 | 22 | $(NoWarn);RS1038 23 | 24 | 25 | 26 | 27 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | <_FacetAnalyzerAssemblies Include="$(OutputPath)$(AssemblyName).dll" /> 62 | 63 | <_FacetAnalyzerAssemblies Include="$(OutputPath)Facet.Attributes.dll" /> 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/AccessibilityTests.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.UnitTests.Core.Facet; 2 | 3 | // Test entities 4 | public class AccessibilityTestEntity 5 | { 6 | public int Id { get; set; } 7 | public string Name { get; set; } = string.Empty; 8 | } 9 | 10 | // Internal facet - should generate with internal accessibility 11 | [Facet(typeof(AccessibilityTestEntity), GenerateToSource = true)] 12 | internal partial class InternalFacet; 13 | 14 | // Public facet - should generate with public accessibility 15 | [Facet(typeof(AccessibilityTestEntity), GenerateToSource = true)] 16 | public partial class PublicFacet; 17 | 18 | public class AccessibilityTests 19 | { 20 | [Fact] 21 | public void InternalFacet_ShouldCompileAndWork() 22 | { 23 | // Arrange 24 | var entity = new AccessibilityTestEntity 25 | { 26 | Id = 1, 27 | Name = "Test" 28 | }; 29 | 30 | // Act 31 | var facet = new InternalFacet(entity); 32 | 33 | // Assert 34 | facet.Should().NotBeNull(); 35 | facet.Id.Should().Be(1); 36 | facet.Name.Should().Be("Test"); 37 | } 38 | 39 | [Fact] 40 | public void PublicFacet_ShouldCompileAndWork() 41 | { 42 | // Arrange 43 | var entity = new AccessibilityTestEntity 44 | { 45 | Id = 2, 46 | Name = "Public Test" 47 | }; 48 | 49 | // Act 50 | var facet = new PublicFacet(entity); 51 | 52 | // Assert 53 | facet.Should().NotBeNull(); 54 | facet.Id.Should().Be(2); 55 | facet.Name.Should().Be("Public Test"); 56 | } 57 | 58 | [Fact] 59 | public void InternalFacet_ShouldHaveInternalAccessibility() 60 | { 61 | // Arrange & Act 62 | var facetType = typeof(InternalFacet); 63 | 64 | // Assert - Type should not be public (internal types are not public) 65 | facetType.IsPublic.Should().BeFalse("InternalFacet should have internal accessibility"); 66 | facetType.IsNotPublic.Should().BeTrue("InternalFacet should have internal accessibility"); 67 | } 68 | 69 | [Fact] 70 | public void PublicFacet_ShouldHavePublicAccessibility() 71 | { 72 | // Arrange & Act 73 | var facetType = typeof(PublicFacet); 74 | 75 | // Assert 76 | facetType.IsPublic.Should().BeTrue("PublicFacet should have public accessibility"); 77 | } 78 | 79 | [Fact] 80 | public void InternalFacet_ToSource_ShouldWork() 81 | { 82 | // Arrange 83 | var facet = new InternalFacet 84 | { 85 | Id = 3, 86 | Name = "ToSource Test" 87 | }; 88 | 89 | // Act 90 | var entity = facet.ToSource(); 91 | 92 | // Assert 93 | entity.Should().NotBeNull(); 94 | entity.Id.Should().Be(3); 95 | entity.Name.Should().Be("ToSource Test"); 96 | } 97 | 98 | [Fact] 99 | public void InternalFacet_Projection_ShouldWork() 100 | { 101 | // Arrange 102 | var entities = new[] 103 | { 104 | new AccessibilityTestEntity { Id = 1, Name = "First" }, 105 | new AccessibilityTestEntity { Id = 2, Name = "Second" } 106 | }.AsQueryable(); 107 | 108 | // Act 109 | var facets = entities.Select(InternalFacet.Projection).ToList(); 110 | 111 | // Assert 112 | facets.Should().HaveCount(2); 113 | facets[0].Id.Should().Be(1); 114 | facets[1].Name.Should().Be("Second"); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Facet.Mapping/IFacetMapConfigurationHybrid.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Facet.Mapping; 6 | 7 | /// 8 | /// Provides both synchronous and asynchronous mapping capabilities in a single interface. 9 | /// 10 | /// The source type 11 | /// The target Facet type 12 | /// 13 | /// This interface is obsolete and will be removed in a future version. 14 | /// Instead of implementing this hybrid interface, directly implement both base interfaces: 15 | /// 16 | /// public class MyMapper : 17 | /// IFacetMapConfiguration<TSource, TTarget>, 18 | /// IFacetMapConfigurationAsync<TSource, TTarget> 19 | /// { 20 | /// public static void Map(TSource source, TTarget target) { ... } 21 | /// public static Task MapAsync(TSource source, TTarget target, CancellationToken ct) { ... } 22 | /// } 23 | /// 24 | /// This provides the same functionality with clearer intent and better discoverability. 25 | /// 26 | [Obsolete("Implement IFacetMapConfiguration and IFacetMapConfigurationAsync directly instead. This interface will be removed in a future version.")] 27 | public interface IFacetMapConfigurationHybrid : 28 | IFacetMapConfiguration, 29 | IFacetMapConfigurationAsync 30 | { 31 | // This interface combines both sync and async mapping capabilities. 32 | // Implementations must provide both Map() and MapAsync() methods. 33 | // 34 | // Typical usage pattern: 35 | // - Map(): Fast, synchronous operations (property copying, calculations) 36 | // - MapAsync(): Expensive, async operations (database queries, API calls) 37 | } 38 | 39 | /// 40 | /// Instance-based interface that provides both synchronous and asynchronous mapping capabilities 41 | /// with dependency injection support. 42 | /// 43 | /// The source type 44 | /// The target Facet type 45 | /// 46 | /// This interface is obsolete and will be removed in a future version. 47 | /// Instead of implementing this hybrid interface, directly implement both base interfaces: 48 | /// 49 | /// public class MyMapper : 50 | /// IFacetMapConfigurationInstance<TSource, TTarget>, 51 | /// IFacetMapConfigurationAsyncInstance<TSource, TTarget> 52 | /// { 53 | /// public void Map(TSource source, TTarget target) { ... } 54 | /// public Task MapAsync(TSource source, TTarget target, CancellationToken ct) { ... } 55 | /// } 56 | /// 57 | /// This provides the same functionality with clearer intent and better discoverability. 58 | /// 59 | [Obsolete("Implement IFacetMapConfigurationInstance and IFacetMapConfigurationAsyncInstance directly instead. This interface will be removed in a future version.")] 60 | public interface IFacetMapConfigurationHybridInstance : 61 | IFacetMapConfigurationInstance, 62 | IFacetMapConfigurationAsyncInstance 63 | { 64 | // This interface combines both sync and async mapping capabilities for instance-based mappers. 65 | // Implementations must provide both Map() and MapAsync() methods. 66 | // 67 | // Typical usage pattern: 68 | // - Map(): Fast, synchronous operations (property copying, calculations) 69 | // - MapAsync(): Expensive, async operations (database queries, API calls) 70 | } -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/BasicMappingTests.cs: -------------------------------------------------------------------------------- 1 | using Facet.Tests.TestModels; 2 | using Facet.Tests.Utilities; 3 | 4 | namespace Facet.Tests.UnitTests.Core.Facet; 5 | 6 | public class BasicMappingTests 7 | { 8 | [Fact] 9 | public void ToFacet_ShouldMapBasicProperties_WhenMappingUserToDto() 10 | { 11 | // Arrange 12 | var user = TestDataFactory.CreateUser("John", "Doe", "john@example.com"); 13 | 14 | // Act 15 | var dto = user.ToFacet(); 16 | 17 | // Assert 18 | dto.Should().NotBeNull(); 19 | dto.Id.Should().Be(user.Id); 20 | dto.FirstName.Should().Be("John"); 21 | dto.LastName.Should().Be("Doe"); 22 | dto.Email.Should().Be("john@example.com"); 23 | dto.IsActive.Should().Be(user.IsActive); 24 | dto.DateOfBirth.Should().Be(user.DateOfBirth); 25 | dto.LastLoginAt.Should().Be(user.LastLoginAt); 26 | } 27 | 28 | [Fact] 29 | public void ToFacet_ShouldExcludeSpecifiedProperties_WhenMappingUser() 30 | { 31 | // Arrange 32 | var user = TestDataFactory.CreateUser(); 33 | 34 | // Act 35 | var dto = user.ToFacet(); 36 | 37 | // Assert 38 | var dtoType = dto.GetType(); 39 | dtoType.GetProperty("Password").Should().BeNull("Password should be excluded"); 40 | dtoType.GetProperty("CreatedAt").Should().BeNull("CreatedAt should be excluded"); 41 | } 42 | 43 | [Fact] 44 | public void ToFacet_ShouldMapProductProperties_ExcludingInternalNotes() 45 | { 46 | // Arrange 47 | var product = TestDataFactory.CreateProduct("Test Product", 49.99m); 48 | 49 | // Act 50 | var dto = product.ToFacet(); 51 | 52 | // Assert 53 | dto.Should().NotBeNull(); 54 | dto.Id.Should().Be(product.Id); 55 | dto.Name.Should().Be("Test Product"); 56 | dto.Description.Should().Be(product.Description); 57 | dto.Price.Should().Be(49.99m); 58 | dto.IsAvailable.Should().Be(product.IsAvailable); 59 | 60 | var dtoType = dto.GetType(); 61 | dtoType.GetProperty("InternalNotes").Should().BeNull("InternalNotes should be excluded"); 62 | } 63 | 64 | [Fact] 65 | public void ToFacet_ShouldHandleNullableProperties_Correctly() 66 | { 67 | // Arrange 68 | var user = TestDataFactory.CreateUser(); 69 | user.LastLoginAt = null; 70 | 71 | // Act 72 | var dto = user.ToFacet(); 73 | 74 | // Assert 75 | dto.LastLoginAt.Should().BeNull(); 76 | } 77 | 78 | [Theory] 79 | [InlineData(true)] 80 | [InlineData(false)] 81 | public void ToFacet_ShouldPreserveBooleanValues_ForIsActiveProperty(bool isActive) 82 | { 83 | // Arrange 84 | var user = TestDataFactory.CreateUser(isActive: isActive); 85 | 86 | // Act 87 | var dto = user.ToFacet(); 88 | 89 | // Assert 90 | dto.IsActive.Should().Be(isActive); 91 | } 92 | 93 | [Fact] 94 | public void ToFacet_ShouldMapMultipleUsers_WithDifferentData() 95 | { 96 | // Arrange 97 | var users = TestDataFactory.CreateUsers(); 98 | 99 | // Act 100 | var dtos = users.Select(u => u.ToFacet()).ToList(); 101 | 102 | // Assert 103 | dtos.Should().HaveCount(3); 104 | dtos[0].FirstName.Should().Be("Alice"); 105 | dtos[1].FirstName.Should().Be("Bob"); 106 | dtos[2].FirstName.Should().Be("Charlie"); 107 | dtos[2].IsActive.Should().BeFalse(); 108 | } 109 | } -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/NullableCollectionNestedFacetsTests.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.UnitTests.Core.Facet; 2 | 3 | public class StringLookup 4 | { 5 | public int Id { get; set; } 6 | public string Name { get; set; } = string.Empty; 7 | public string Value { get; set; } = string.Empty; 8 | } 9 | 10 | public class StringIdentifier 11 | { 12 | public int Id { get; set; } 13 | public string Name { get; set; } = string.Empty; 14 | public List StringLookups { get; set; } = new(); 15 | } 16 | 17 | // Facet DTOs 18 | [Facet(typeof(StringLookup), NullableProperties = true, GenerateToSource = true)] 19 | public partial class StringLookupDto; 20 | 21 | [Facet(typeof(StringIdentifier), 22 | Include = [nameof(StringIdentifier.Id), nameof(StringIdentifier.Name), nameof(StringIdentifier.StringLookups)], 23 | NestedFacets = [typeof(StringLookupDto)], 24 | NullableProperties = true, 25 | GenerateToSource = true)] 26 | public partial class StringIdentifierLookupDto; 27 | 28 | public class NullableCollectionNestedFacetsTests 29 | { 30 | [Fact] 31 | public void Constructor_ShouldHandleCollectionNestedFacet_WithNullableProperties() 32 | { 33 | // Arrange 34 | var stringIdentifier = new StringIdentifier 35 | { 36 | Id = 1, 37 | Name = "Test Identifier", 38 | StringLookups = new List 39 | { 40 | new() { Id = 10, Name = "Lookup1", Value = "Value1" }, 41 | new() { Id = 20, Name = "Lookup2", Value = "Value2" } 42 | } 43 | }; 44 | 45 | // Act 46 | var dto = new StringIdentifierLookupDto(stringIdentifier); 47 | 48 | // Assert 49 | dto.Should().NotBeNull(); 50 | dto.Id.Should().Be(1); 51 | dto.Name.Should().Be("Test Identifier"); 52 | dto.StringLookups.Should().NotBeNull(); 53 | dto.StringLookups.Should().HaveCount(2); 54 | } 55 | 56 | [Fact] 57 | public void Projection_ShouldHandleCollectionNestedFacet_WithNullableProperties() 58 | { 59 | // Arrange 60 | var identifiers = new[] 61 | { 62 | new StringIdentifier 63 | { 64 | Id = 1, 65 | Name = "Identifier 1", 66 | StringLookups = new List 67 | { 68 | new() { Id = 10, Name = "Lookup1", Value = "Value1" } 69 | } 70 | } 71 | }.AsQueryable(); 72 | 73 | // Act 74 | var dtos = identifiers.Select(StringIdentifierLookupDto.Projection).ToList(); 75 | 76 | // Assert 77 | dtos.Should().HaveCount(1); 78 | dtos[0].Id.Should().Be(1); 79 | dtos[0].StringLookups.Should().NotBeNull(); 80 | dtos[0].StringLookups!.Should().HaveCount(1); 81 | } 82 | 83 | [Fact] 84 | public void ToSource_ShouldHandleCollectionNestedFacet_WithNullableProperties() 85 | { 86 | // Arrange 87 | var dto = new StringIdentifierLookupDto 88 | { 89 | Id = 1, 90 | Name = "Test Identifier", 91 | StringLookups = new List 92 | { 93 | new() { Id = 10, Name = "Lookup1", Value = "Value1" }, 94 | new() { Id = 20, Name = "Lookup2", Value = "Value2" } 95 | } 96 | }; 97 | 98 | // Act 99 | var entity = dto.ToSource(); 100 | 101 | // Assert 102 | entity.Should().NotBeNull(); 103 | entity.Id.Should().Be(1); 104 | entity.Name.Should().Be("Test Identifier"); 105 | entity.StringLookups.Should().HaveCount(2); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Facet.Attributes/WrapperAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Facet; 4 | 5 | /// 6 | /// Indicates that this class should be generated as a wrapper that delegates to a source type instance. 7 | /// Unlike which creates value copies, Wrapper creates a reference-based 8 | /// delegate pattern where property access forwards to the wrapped source object. 9 | /// 10 | /// 11 | /// Use cases include: 12 | /// - Decorator pattern: Add behavior/validation without modifying domain models 13 | /// - Facade pattern: Hide sensitive properties while exposing others 14 | /// - ViewModel layers: Expose subset of domain model properties with reference semantics 15 | /// - Memory efficiency: Avoid duplicating large object graphs 16 | /// 17 | /// Note: Wrappers maintain a reference to the source object, so changes to wrapper properties 18 | /// affect the underlying source object. 19 | /// 20 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] 21 | public sealed class WrapperAttribute : Attribute 22 | { 23 | /// 24 | /// The type to wrap and delegate to. 25 | /// 26 | public Type SourceType { get; } 27 | 28 | /// 29 | /// An array of property or field names to exclude from the generated wrapper. 30 | /// This property is mutually exclusive with . 31 | /// 32 | public string[] Exclude { get; } 33 | 34 | /// 35 | /// An array of property or field names to include in the generated wrapper. 36 | /// When specified, only these properties will be included. 37 | /// This property is mutually exclusive with . 38 | /// 39 | public string[]? Include { get; set; } 40 | 41 | /// 42 | /// Whether to include public fields from the source type (default: false). 43 | /// 44 | public bool IncludeFields { get; set; } = false; 45 | 46 | /// 47 | /// When true, generates read-only properties (getters only) that cannot modify the source object. 48 | /// When false (default), generates mutable properties with both getters and setters. 49 | /// Useful for creating immutable facades or read-only views of domain objects. 50 | /// 51 | public bool ReadOnly { get; set; } = false; 52 | 53 | /// 54 | /// An array of nested wrapper types for complex/nested properties. 55 | /// When specified, properties with matching types will be automatically wrapped. 56 | /// 57 | public Type[]? NestedWrappers { get; set; } 58 | 59 | /// 60 | /// When true, copies attributes from the source type members to the generated wrapper members. 61 | /// Only copies attributes that are valid on the target (excludes internal compiler attributes and non-copiable attributes). 62 | /// Default is false. 63 | /// 64 | public bool CopyAttributes { get; set; } = false; 65 | 66 | /// 67 | /// If true, generated files will use the full type name (namespace + containing types) 68 | /// to avoid collisions. Default is false (shorter file names). 69 | /// 70 | public bool UseFullName { get; set; } = false; 71 | 72 | /// 73 | /// Creates a new WrapperAttribute that targets a given source type and excludes specified members. 74 | /// 75 | /// The type to wrap and delegate to. 76 | /// The names of the properties or fields to exclude. 77 | public WrapperAttribute(Type sourceType, params string[] exclude) 78 | { 79 | SourceType = sourceType; 80 | Exclude = exclude ?? Array.Empty(); 81 | Include = null; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /benchmark/Facet.Benchmark/ManualDTOs.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Benchmark.DTOs; 2 | 3 | using System; 4 | 5 | /// 6 | /// Manual DTO classes for Mapster and Mapperly benchmarking 7 | /// These mirror the Facet-generated DTOs but are manually written 8 | /// 9 | 10 | public class UserBasicManualDto 11 | { 12 | public int Id { get; set; } 13 | public string FirstName { get; set; } = string.Empty; 14 | public string LastName { get; set; } = string.Empty; 15 | public string Email { get; set; } = string.Empty; 16 | public string? PhoneNumber { get; set; } 17 | public DateTime CreatedAt { get; set; } 18 | public DateTime? UpdatedAt { get; set; } 19 | public bool IsActive { get; set; } 20 | public int? ManagerId { get; set; } 21 | public string? Department { get; set; } 22 | public string? JobTitle { get; set; } 23 | public DateTime? LastLoginAt { get; set; } 24 | public int LoginCount { get; set; } 25 | } 26 | 27 | public class UserDetailedManualDto 28 | { 29 | public int Id { get; set; } 30 | public string FirstName { get; set; } = string.Empty; 31 | public string LastName { get; set; } = string.Empty; 32 | public string Email { get; set; } = string.Empty; 33 | public string? PhoneNumber { get; set; } 34 | public DateTime CreatedAt { get; set; } 35 | public DateTime? UpdatedAt { get; set; } 36 | public bool IsActive { get; set; } 37 | public decimal Salary { get; set; } 38 | public int? ManagerId { get; set; } 39 | public string? Department { get; set; } 40 | public string? JobTitle { get; set; } 41 | public DateTime? LastLoginAt { get; set; } 42 | public int LoginCount { get; set; } 43 | } 44 | 45 | public class UserSimpleManualDto 46 | { 47 | public int Id { get; set; } 48 | public string FirstName { get; set; } = string.Empty; 49 | public string LastName { get; set; } = string.Empty; 50 | public string Email { get; set; } = string.Empty; 51 | public DateTime CreatedAt { get; set; } 52 | public bool IsActive { get; set; } 53 | } 54 | 55 | public class ProductManualDto 56 | { 57 | public int Id { get; set; } 58 | public string Name { get; set; } = string.Empty; 59 | public string? Description { get; set; } 60 | public decimal Price { get; set; } 61 | public int StockQuantity { get; set; } 62 | public string? SKU { get; set; } 63 | public bool IsActive { get; set; } 64 | public DateTime CreatedAt { get; set; } 65 | public DateTime? UpdatedAt { get; set; } 66 | public int CategoryId { get; set; } 67 | } 68 | 69 | public class ProductSimpleManualDto 70 | { 71 | public int Id { get; set; } 72 | public string Name { get; set; } = string.Empty; 73 | public decimal Price { get; set; } 74 | public bool IsActive { get; set; } 75 | public DateTime CreatedAt { get; set; } 76 | } 77 | 78 | public class AddressManualDto 79 | { 80 | public int Id { get; set; } 81 | public int UserId { get; set; } 82 | public string Street { get; set; } = string.Empty; 83 | public string City { get; set; } = string.Empty; 84 | public string PostalCode { get; set; } = string.Empty; 85 | public string Country { get; set; } = string.Empty; 86 | public string? State { get; set; } 87 | } 88 | 89 | public class OrderManualDto 90 | { 91 | public int Id { get; set; } 92 | public int UserId { get; set; } 93 | public DateTime OrderDate { get; set; } 94 | public decimal TotalAmount { get; set; } 95 | public string Status { get; set; } = "Pending"; 96 | public string? Notes { get; set; } 97 | } 98 | 99 | public class CategoryManualDto 100 | { 101 | public int Id { get; set; } 102 | public string Name { get; set; } = string.Empty; 103 | public string? Description { get; set; } 104 | } -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Wrapper/BasicWrapperTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | 3 | namespace Facet.Tests.UnitTests.Wrapper; 4 | 5 | /// 6 | /// Tests for basic wrapper functionality 7 | /// 8 | public partial class BasicWrapperTests 9 | { 10 | // Test domain model 11 | public class User 12 | { 13 | public int Id { get; set; } 14 | public string FirstName { get; set; } = string.Empty; 15 | public string LastName { get; set; } = string.Empty; 16 | public string Email { get; set; } = string.Empty; 17 | public string Password { get; set; } = string.Empty; 18 | public decimal Salary { get; set; } 19 | } 20 | 21 | // Wrapper that excludes sensitive properties 22 | [Wrapper(typeof(User), "Password", "Salary")] 23 | public partial class PublicUserWrapper { } 24 | 25 | [Fact] 26 | public void Wrapper_Should_Delegate_To_Source_Object() 27 | { 28 | // Arrange 29 | var user = new User 30 | { 31 | Id = 1, 32 | FirstName = "John", 33 | LastName = "Doe", 34 | Email = "john@example.com", 35 | Password = "secret123", 36 | Salary = 75000 37 | }; 38 | 39 | // Act 40 | var wrapper = new PublicUserWrapper(user); 41 | 42 | // Assert 43 | wrapper.Id.Should().Be(1); 44 | wrapper.FirstName.Should().Be("John"); 45 | wrapper.LastName.Should().Be("Doe"); 46 | wrapper.Email.Should().Be("john@example.com"); 47 | } 48 | 49 | [Fact] 50 | public void Wrapper_Should_Propagate_Changes_To_Source() 51 | { 52 | // Arrange 53 | var user = new User 54 | { 55 | Id = 1, 56 | FirstName = "John", 57 | LastName = "Doe", 58 | Email = "john@example.com" 59 | }; 60 | 61 | var wrapper = new PublicUserWrapper(user); 62 | 63 | // Act 64 | wrapper.FirstName = "Jane"; 65 | wrapper.Email = "jane@example.com"; 66 | 67 | // Assert 68 | user.FirstName.Should().Be("Jane", "changes to wrapper should affect source"); 69 | user.Email.Should().Be("jane@example.com", "changes to wrapper should affect source"); 70 | } 71 | 72 | [Fact] 73 | public void Wrapper_Should_Exclude_Specified_Properties() 74 | { 75 | // The wrapper should not have Password or Salary properties 76 | // This is verified at compile time - if this test compiles, it works 77 | var user = new User { Password = "secret", Salary = 75000 }; 78 | var wrapper = new PublicUserWrapper(user); 79 | 80 | // These should compile 81 | _ = wrapper.FirstName; 82 | _ = wrapper.Email; 83 | 84 | // These should NOT compile (would be caught by the compiler): 85 | // _ = wrapper.Password; // CS1061: 'PublicUserWrapper' does not contain a definition for 'Password' 86 | // _ = wrapper.Salary; // CS1061: 'PublicUserWrapper' does not contain a definition for 'Salary' 87 | } 88 | 89 | [Fact] 90 | public void Wrapper_Unwrap_Should_Return_Source_Object() 91 | { 92 | // Arrange 93 | var user = new User { Id = 1, FirstName = "John" }; 94 | var wrapper = new PublicUserWrapper(user); 95 | 96 | // Act 97 | var unwrapped = wrapper.Unwrap(); 98 | 99 | // Assert 100 | unwrapped.Should().BeSameAs(user, "Unwrap should return the original source object"); 101 | } 102 | 103 | [Fact] 104 | public void Wrapper_Constructor_Should_Throw_On_Null() 105 | { 106 | // Act 107 | Action act = () => new PublicUserWrapper(null!); 108 | 109 | // Assert 110 | act.Should().Throw() 111 | .WithParameterName("source"); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Facet/Generators/FlattenGenerators/FlattenModels.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using Facet.Generators; 3 | using Microsoft.CodeAnalysis; 4 | using System.Collections.Generic; 5 | using System.Collections.Immutable; 6 | 7 | namespace Facet.Generators; 8 | 9 | /// 10 | /// Represents a flattened target type model. 11 | /// 12 | internal sealed class FlattenTargetModel 13 | { 14 | public FlattenTargetModel( 15 | string name, 16 | string? ns, 17 | string fullName, 18 | TypeKind typeKind, 19 | bool isRecord, 20 | bool generateParameterlessConstructor, 21 | bool generateProjection, 22 | string sourceTypeName, 23 | ImmutableArray properties, 24 | string? typeXmlDocumentation, 25 | ImmutableArray containingTypes, 26 | bool useFullName, 27 | FlattenNamingStrategy namingStrategy, 28 | int maxDepth) 29 | { 30 | Name = name; 31 | Namespace = ns; 32 | FullName = fullName; 33 | TypeKind = typeKind; 34 | IsRecord = isRecord; 35 | GenerateParameterlessConstructor = generateParameterlessConstructor; 36 | GenerateProjection = generateProjection; 37 | SourceTypeName = sourceTypeName; 38 | Properties = properties; 39 | TypeXmlDocumentation = typeXmlDocumentation; 40 | ContainingTypes = containingTypes; 41 | UseFullName = useFullName; 42 | NamingStrategy = namingStrategy; 43 | MaxDepth = maxDepth; 44 | } 45 | 46 | public string Name { get; } 47 | public string? Namespace { get; } 48 | public string FullName { get; } 49 | public TypeKind TypeKind { get; } 50 | public bool IsRecord { get; } 51 | public bool GenerateParameterlessConstructor { get; } 52 | public bool GenerateProjection { get; } 53 | public string SourceTypeName { get; } 54 | public ImmutableArray Properties { get; } 55 | public string? TypeXmlDocumentation { get; } 56 | public ImmutableArray ContainingTypes { get; } 57 | public bool UseFullName { get; } 58 | public FlattenNamingStrategy NamingStrategy { get; } 59 | public int MaxDepth { get; } 60 | } 61 | 62 | /// 63 | /// Represents a property in a flattened target type. 64 | /// 65 | internal sealed class FlattenProperty 66 | { 67 | public FlattenProperty( 68 | string name, 69 | string typeName, 70 | string sourcePath, 71 | ImmutableArray pathSegments, 72 | bool isValueType, 73 | string? xmlDocumentation) 74 | { 75 | Name = name; 76 | TypeName = typeName; 77 | SourcePath = sourcePath; 78 | PathSegments = pathSegments; 79 | IsValueType = isValueType; 80 | XmlDocumentation = xmlDocumentation; 81 | } 82 | 83 | /// 84 | /// The name of the flattened property (e.g., "AddressStreet"). 85 | /// 86 | public string Name { get; } 87 | 88 | /// 89 | /// The fully qualified type name of the property. 90 | /// 91 | public string TypeName { get; } 92 | 93 | /// 94 | /// The source path to this property (e.g., "Address.Street"). 95 | /// 96 | public string SourcePath { get; } 97 | 98 | /// 99 | /// The path segments as an array (e.g., ["Address", "Street"]). 100 | /// 101 | public ImmutableArray PathSegments { get; } 102 | 103 | /// 104 | /// Whether this property is a value type. 105 | /// 106 | public bool IsValueType { get; } 107 | 108 | /// 109 | /// XML documentation for this property, if available. 110 | /// 111 | public string? XmlDocumentation { get; } 112 | } 113 | -------------------------------------------------------------------------------- /src/Facet.Attributes/MapWhenAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Facet; 4 | 5 | /// 6 | /// Specifies that a property should only be mapped when a condition is met. 7 | /// The condition is evaluated against the source object's properties. 8 | /// 9 | /// 10 | /// 11 | /// The allows conditional property mapping based on source values: 12 | /// 13 | /// 14 | /// 15 | /// Boolean check: [MapWhen("IsActive")] 16 | /// 17 | /// 18 | /// Equality: [MapWhen("Status == OrderStatus.Completed")] 19 | /// 20 | /// 21 | /// Null check: [MapWhen("Email != null")] 22 | /// 23 | /// 24 | /// Comparison: [MapWhen("Age >= 18")] 25 | /// 26 | /// 27 | /// 28 | /// When the condition is false, the property is set to its default value (or the specified ). 29 | /// Multiple s on the same property are combined with AND logic. 30 | /// 31 | /// 32 | /// 33 | /// 34 | /// [Facet(typeof(Order))] 35 | /// public partial class OrderDto 36 | /// { 37 | /// public OrderStatus Status { get; set; } 38 | /// 39 | /// [MapWhen("Status == OrderStatus.Completed")] 40 | /// public DateTime? CompletedAt { get; set; } 41 | /// 42 | /// [MapWhen("Price != null", Default = 0)] 43 | /// public decimal Price { get; set; } 44 | /// } 45 | /// 46 | /// 47 | [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] 48 | public sealed class MapWhenAttribute : Attribute 49 | { 50 | /// 51 | /// The condition expression to evaluate against the source object. 52 | /// Uses C# expression syntax with source property names. 53 | /// 54 | /// 55 | /// 56 | /// Supported operators: 57 | /// 58 | /// 59 | /// Comparison: ==, !=, <, >, <=, >= 60 | /// Logical: &&, ||, ! 61 | /// Null: ??, ?. 62 | /// 63 | /// 64 | /// Property names refer to source object properties. For example, "IsActive" 65 | /// checks source.IsActive. 66 | /// 67 | /// 68 | public string Condition { get; } 69 | 70 | /// 71 | /// Default value when the condition is false. 72 | /// If not specified, uses default(T) for value types or null for reference types. 73 | /// 74 | /// 75 | /// 76 | /// For non-nullable value types, you should specify a default value to avoid 77 | /// compilation errors. For example: 78 | /// 79 | /// 80 | /// [MapWhen("HasPrice", Default = 0)] 81 | /// public decimal Price { get; set; } 82 | /// 83 | /// 84 | public object? Default { get; set; } 85 | 86 | /// 87 | /// Whether to include this condition in the generated Projection expression. 88 | /// Default is true. 89 | /// 90 | /// 91 | /// 92 | /// Set to false for conditions that cannot be translated to SQL by Entity Framework Core. 93 | /// When false, the condition will only be evaluated in the constructor, not in LINQ projections. 94 | /// 95 | /// 96 | public bool IncludeInProjection { get; set; } = true; 97 | 98 | /// 99 | /// Creates a new MapWhenAttribute with the specified condition. 100 | /// 101 | /// The condition expression to evaluate against the source object. 102 | public MapWhenAttribute(string condition) 103 | { 104 | Condition = condition ?? throw new ArgumentNullException(nameof(condition)); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Facet/GenerateDtosTargetModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.Linq; 4 | 5 | namespace Facet; 6 | 7 | internal sealed class GenerateDtosTargetModel : IEquatable 8 | { 9 | public string SourceTypeName { get; } 10 | public string? SourceNamespace { get; } 11 | public string? TargetNamespace { get; } 12 | public DtoTypes Types { get; } 13 | public OutputType OutputType { get; } 14 | public string? Prefix { get; } 15 | public string? Suffix { get; } 16 | public bool IncludeFields { get; } 17 | public bool GenerateConstructors { get; } 18 | public bool GenerateProjections { get; } 19 | public ImmutableArray ExcludeProperties { get; } 20 | public ImmutableArray Members { get; } 21 | public bool UseFullName { get; } 22 | 23 | public GenerateDtosTargetModel( 24 | string sourceTypeName, 25 | string? sourceNamespace, 26 | string? targetNamespace, 27 | DtoTypes types, 28 | OutputType outputType, 29 | string? prefix, 30 | string? suffix, 31 | bool includeFields, 32 | bool generateConstructors, 33 | bool generateProjections, 34 | ImmutableArray excludeProperties, 35 | ImmutableArray members, 36 | bool useFullName) 37 | { 38 | SourceTypeName = sourceTypeName; 39 | SourceNamespace = sourceNamespace; 40 | TargetNamespace = targetNamespace; 41 | Types = types; 42 | OutputType = outputType; 43 | Prefix = prefix; 44 | Suffix = suffix; 45 | IncludeFields = includeFields; 46 | GenerateConstructors = generateConstructors; 47 | GenerateProjections = generateProjections; 48 | ExcludeProperties = excludeProperties; 49 | Members = members; 50 | UseFullName = useFullName; 51 | } 52 | 53 | public bool Equals(GenerateDtosTargetModel? other) 54 | { 55 | if (other is null) return false; 56 | if (ReferenceEquals(this, other)) return true; 57 | 58 | return SourceTypeName == other.SourceTypeName 59 | && SourceNamespace == other.SourceNamespace 60 | && TargetNamespace == other.TargetNamespace 61 | && Types == other.Types 62 | && OutputType == other.OutputType 63 | && Prefix == other.Prefix 64 | && Suffix == other.Suffix 65 | && IncludeFields == other.IncludeFields 66 | && GenerateConstructors == other.GenerateConstructors 67 | && GenerateProjections == other.GenerateProjections 68 | && ExcludeProperties.SequenceEqual(other.ExcludeProperties) 69 | && Members.SequenceEqual(other.Members) 70 | && UseFullName == other.UseFullName; 71 | } 72 | 73 | public override bool Equals(object? obj) => obj is GenerateDtosTargetModel other && Equals(other); 74 | 75 | public override int GetHashCode() 76 | { 77 | unchecked 78 | { 79 | int hash = 17; 80 | hash = hash * 31 + (SourceTypeName?.GetHashCode() ?? 0); 81 | hash = hash * 31 + (SourceNamespace?.GetHashCode() ?? 0); 82 | hash = hash * 31 + (TargetNamespace?.GetHashCode() ?? 0); 83 | hash = hash * 31 + Types.GetHashCode(); 84 | hash = hash * 31 + OutputType.GetHashCode(); 85 | hash = hash * 31 + (Prefix?.GetHashCode() ?? 0); 86 | hash = hash * 31 + (Suffix?.GetHashCode() ?? 0); 87 | hash = hash * 31 + IncludeFields.GetHashCode(); 88 | hash = hash * 31 + GenerateConstructors.GetHashCode(); 89 | hash = hash * 31 + GenerateProjections.GetHashCode(); 90 | hash = hash * 31 + UseFullName.GetHashCode(); 91 | 92 | foreach (var prop in ExcludeProperties) 93 | hash = hash * 31 + prop.GetHashCode(); 94 | 95 | foreach (var member in Members) 96 | hash = hash * 31 + member.GetHashCode(); 97 | 98 | return hash; 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /src/Facet/Generators/FacetGenerators/ToSourceGenerator.cs: -------------------------------------------------------------------------------- 1 | using Facet.Generators.Shared; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | 6 | namespace Facet.Generators; 7 | 8 | /// 9 | /// Generates ToSource methods for converting facet instances back to their source types. 10 | /// 11 | internal static class ToSourceGenerator 12 | { 13 | /// 14 | /// Generates the ToSource and BackTo methods that convert the facet type back to the source type. 15 | /// 16 | public static void Generate(StringBuilder sb, FacetTargetModel model) 17 | { 18 | // Generate the main ToSource method 19 | sb.AppendLine(); 20 | sb.AppendLine(" /// "); 21 | sb.AppendLine($" /// Converts this instance of to an instance of the source type."); 22 | sb.AppendLine(" /// "); 23 | sb.AppendLine($" /// An instance of the source type with properties mapped from this instance."); 24 | sb.AppendLine($" public {model.SourceTypeName} ToSource()"); 25 | sb.AppendLine(" {"); 26 | 27 | if (model.SourceHasPositionalConstructor) 28 | { 29 | GeneratePositionalToSource(sb, model); 30 | } 31 | else 32 | { 33 | GenerateObjectInitializerToSource(sb, model); 34 | } 35 | 36 | sb.AppendLine(" }"); 37 | 38 | // Generate the deprecated BackTo method that calls ToSource 39 | sb.AppendLine(); 40 | sb.AppendLine(" /// "); 41 | sb.AppendLine($" /// Converts this instance of to an instance of the source type."); 42 | sb.AppendLine(" /// "); 43 | sb.AppendLine($" /// An instance of the source type with properties mapped from this instance."); 44 | sb.AppendLine(" [global::System.Obsolete(\"Use ToSource() instead. This method will be removed in a future version.\")]"); 45 | sb.AppendLine($" public {model.SourceTypeName} BackTo() => ToSource();"); 46 | } 47 | 48 | private static void GeneratePositionalToSource(StringBuilder sb, FacetTargetModel model) 49 | { 50 | // For source types with positional constructors (like records), use positional syntax 51 | // Only include members that are reversible 52 | var constructorArgs = string.Join(", ", 53 | model.Members 54 | .Where(m => m.MapFromReversible) 55 | .Select(m => ExpressionBuilder.GetToSourceValueExpression(m))); 56 | sb.AppendLine($" return new {model.SourceTypeName}({constructorArgs});"); 57 | } 58 | 59 | private static void GenerateObjectInitializerToSource(StringBuilder sb, FacetTargetModel model) 60 | { 61 | // For source types without positional constructors, use object initializer syntax 62 | sb.AppendLine($" return new {model.SourceTypeName}"); 63 | sb.AppendLine(" {"); 64 | 65 | var propertyAssignments = new List(); 66 | 67 | // Add assignments for included properties (only if reversible) 68 | foreach (var member in model.Members) 69 | { 70 | // Skip non-reversible members 71 | if (!member.MapFromReversible) 72 | continue; 73 | 74 | var toSourceValue = ExpressionBuilder.GetToSourceValueExpression(member); 75 | // Use SourcePropertyName for the target property name (supports MapFrom) 76 | propertyAssignments.Add($" {member.SourcePropertyName} = {toSourceValue}"); 77 | } 78 | 79 | // Add default values for excluded required members 80 | foreach (var excludedMember in model.ExcludedRequiredMembers) 81 | { 82 | var defaultValue = GeneratorUtilities.GetDefaultValueForType(excludedMember.TypeName); 83 | propertyAssignments.Add($" {excludedMember.Name} = {defaultValue}"); 84 | } 85 | 86 | sb.AppendLine(string.Join(",\n", propertyAssignments)); 87 | sb.AppendLine(" };"); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/Facet.Tests/TestModels/TestEntities.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.TestModels; 2 | 3 | public class User 4 | { 5 | public int Id { get; set; } 6 | public string FirstName { get; set; } = string.Empty; 7 | public string LastName { get; set; } = string.Empty; 8 | public string Email { get; set; } = string.Empty; 9 | public DateTime DateOfBirth { get; set; } 10 | public string Password { get; set; } = string.Empty; 11 | public bool IsActive { get; set; } 12 | public DateTime CreatedAt { get; set; } 13 | public DateTime? LastLoginAt { get; set; } 14 | public string AddedProperty { get; set; } = string.Empty; 15 | } 16 | 17 | public class Product 18 | { 19 | public int Id { get; set; } 20 | public string Name { get; set; } = string.Empty; 21 | public string Description { get; set; } = string.Empty; 22 | public decimal Price { get; set; } 23 | public int CategoryId { get; set; } 24 | public bool IsAvailable { get; set; } 25 | public DateTime CreatedAt { get; set; } 26 | public string InternalNotes { get; set; } = string.Empty; 27 | } 28 | 29 | public class Employee : User 30 | { 31 | public string EmployeeId { get; set; } = string.Empty; 32 | public string Department { get; set; } = string.Empty; 33 | public decimal Salary { get; set; } 34 | public DateTime HireDate { get; set; } 35 | } 36 | 37 | public class Manager : Employee 38 | { 39 | public string TeamName { get; set; } = string.Empty; 40 | public int TeamSize { get; set; } 41 | public decimal Budget { get; set; } 42 | } 43 | 44 | public record ClassicUser(string Id, string FirstName, string LastName, string? Email); 45 | 46 | public record ModernUser 47 | { 48 | public required string Id { get; init; } 49 | public required string FirstName { get; init; } 50 | public required string LastName { get; init; } 51 | public string? Email { get; set; } 52 | public DateTime CreatedAt { get; init; } = DateTime.UtcNow; 53 | public string? Bio { get; set; } 54 | public string? PasswordHash { get; init; } 55 | } 56 | 57 | public record EventLog 58 | { 59 | public required string Id { get; init; } 60 | public required string EventType { get; init; } 61 | public required DateTime Timestamp { get; init; } 62 | public string? Message { get; init; } 63 | public string? UserId { get; init; } 64 | public required string Source { get; init; } 65 | } 66 | 67 | public enum UserStatus 68 | { 69 | Active, 70 | Inactive, 71 | Pending, 72 | Suspended 73 | } 74 | 75 | public class UserWithEnum 76 | { 77 | public int Id { get; set; } 78 | public string Name { get; set; } = string.Empty; 79 | public UserStatus Status { get; set; } 80 | public string Email { get; set; } = string.Empty; 81 | } 82 | 83 | public sealed class NullableTestEntity 84 | { 85 | public bool Test1 { get; set; } 86 | public bool? Test2 { get; set; } 87 | public string Test3 { get; set; } = string.Empty; 88 | public string? Test4 { get; set; } = null; 89 | } 90 | 91 | // Test entity with fields for include functionality testing 92 | public class EntityWithFields 93 | { 94 | public int Id; 95 | public string Name = string.Empty; 96 | public int Age; 97 | public string Email { get; set; } = string.Empty; 98 | } 99 | 100 | public record Dummy(string Name, int Age); 101 | 102 | public class UserForNestedFacet 103 | { 104 | public int Id { get; set; } 105 | public string Name { get; set; } = string.Empty; 106 | public UserAddressForNestedFacet Address { get; set; } = new(); 107 | } 108 | 109 | public class UserAddressForNestedFacet 110 | { 111 | public string Street { get; set; } = string.Empty; 112 | public string City { get; set; } = string.Empty; 113 | public string FormattedAddress => $"{Street}, {City}"; 114 | } 115 | 116 | // Test entities for inherited property exclusion 117 | public abstract class BaseEntity 118 | { 119 | public TPkKey Id { get; set; } = default!; 120 | } 121 | 122 | public class Category : BaseEntity 123 | { 124 | public required string Name { get; set; } 125 | public string? Description { get; set; } 126 | } 127 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/NullableForeignKeyTests.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.UnitTests.Core.Facet; 2 | 3 | // Test entities that mimic EF Core entities with nullable foreign keys but non-nullable navigation properties 4 | // This is a common pattern where the FK is nullable but the navigation property is not marked with ? 5 | #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor 6 | 7 | public class DataExampleEntity 8 | { 9 | public int Id { get; set; } 10 | public string Code { get; set; } = string.Empty; 11 | 12 | public int? StringResourceId { get; set; } 13 | public virtual StringResourceEntity StringResource { get; set; } // Non-nullable but can be null at runtime 14 | 15 | public int? ExtendedDataId { get; set; } 16 | public virtual ExtendedDataEntity ExtendedData { get; set; } // Non-nullable but can be null at runtime 17 | } 18 | 19 | public class StringResourceEntity 20 | { 21 | public int Id { get; set; } 22 | public string Name { get; set; } = string.Empty; 23 | } 24 | 25 | public class ExtendedDataEntity 26 | { 27 | public int Id { get; set; } 28 | public string Metadata { get; set; } = string.Empty; 29 | } 30 | 31 | #pragma warning restore CS8618 32 | 33 | // Facet DTOs 34 | [Facet(typeof(StringResourceEntity))] 35 | public partial class StringResourceDto; 36 | 37 | [Facet(typeof(ExtendedDataEntity))] 38 | public partial class ExtendedDto; 39 | 40 | [Facet( 41 | typeof(DataExampleEntity), 42 | NestedFacets = [typeof(StringResourceDto), typeof(ExtendedDto)])] 43 | public partial class DataExampleFacet; 44 | 45 | public class NullableForeignKeyTests 46 | { 47 | [Fact] 48 | public void Projection_ShouldHandleNullNavigationProperty_WhenForeignKeyIsNull() 49 | { 50 | // Arrange 51 | var dataExamples = new[] 52 | { 53 | new DataExampleEntity 54 | { 55 | Id = 1, 56 | Code = "TEST001", 57 | StringResourceId = null, 58 | StringResource = null!, 59 | ExtendedDataId = null, 60 | ExtendedData = null! 61 | }, 62 | new DataExampleEntity 63 | { 64 | Id = 2, 65 | Code = "TEST002", 66 | StringResourceId = 100, 67 | StringResource = new StringResourceEntity { Id = 100, Name = "Resource 1" }, 68 | ExtendedDataId = 200, 69 | ExtendedData = new ExtendedDataEntity { Id = 200, Metadata = "Metadata 1" } 70 | } 71 | }.AsQueryable(); 72 | 73 | // Act 74 | var dtos = dataExamples.Select(DataExampleFacet.Projection).ToList(); 75 | 76 | // Assert 77 | dtos.Should().HaveCount(2); 78 | 79 | // First item has null navigation properties 80 | dtos[0].Id.Should().Be(1); 81 | dtos[0].Code.Should().Be("TEST001"); 82 | dtos[0].StringResource.Should().BeNull(); 83 | dtos[0].ExtendedData.Should().BeNull(); 84 | 85 | // Second item has populated navigation properties 86 | dtos[1].Id.Should().Be(2); 87 | dtos[1].Code.Should().Be("TEST002"); 88 | dtos[1].StringResource.Should().NotBeNull(); 89 | dtos[1].StringResource!.Id.Should().Be(100); 90 | dtos[1].ExtendedData.Should().NotBeNull(); 91 | dtos[1].ExtendedData!.Id.Should().Be(200); 92 | } 93 | 94 | [Fact] 95 | public void Constructor_ShouldHandleNullNavigationProperty_WhenForeignKeyIsNull() 96 | { 97 | // Arrange 98 | var dataExample = new DataExampleEntity 99 | { 100 | Id = 1, 101 | Code = "TEST001", 102 | StringResourceId = null, 103 | StringResource = null!, 104 | ExtendedDataId = null, 105 | ExtendedData = null! 106 | }; 107 | 108 | // Act - This should not throw 109 | var dto = new DataExampleFacet(dataExample); 110 | 111 | // Assert 112 | dto.Id.Should().Be(1); 113 | dto.StringResource.Should().BeNull(); 114 | dto.ExtendedData.Should().BeNull(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Wrapper/ReadOnlyWrapperTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | 3 | namespace Facet.Tests.UnitTests.Wrapper; 4 | 5 | /// 6 | /// Tests for ReadOnly wrapper functionality 7 | /// 8 | public partial class ReadOnlyWrapperTests 9 | { 10 | // Test domain model 11 | public class Product 12 | { 13 | public int Id { get; set; } 14 | public string Name { get; set; } = string.Empty; 15 | public string Description { get; set; } = string.Empty; 16 | public decimal Price { get; set; } 17 | public int Stock { get; set; } 18 | } 19 | 20 | // Read-only wrapper - prevents modifications 21 | [Wrapper(typeof(Product), ReadOnly = true)] 22 | public partial class ReadOnlyProductWrapper { } 23 | 24 | // Regular mutable wrapper for comparison 25 | [Wrapper(typeof(Product))] 26 | public partial class MutableProductWrapper { } 27 | 28 | [Fact] 29 | public void ReadOnlyWrapper_Should_Delegate_To_Source_For_Reading() 30 | { 31 | // Arrange 32 | var product = new Product 33 | { 34 | Id = 1, 35 | Name = "Laptop", 36 | Description = "High-performance laptop", 37 | Price = 1299.99m, 38 | Stock = 10 39 | }; 40 | 41 | // Act 42 | var wrapper = new ReadOnlyProductWrapper(product); 43 | 44 | // Assert 45 | wrapper.Id.Should().Be(1); 46 | wrapper.Name.Should().Be("Laptop"); 47 | wrapper.Description.Should().Be("High-performance laptop"); 48 | wrapper.Price.Should().Be(1299.99m); 49 | wrapper.Stock.Should().Be(10); 50 | } 51 | 52 | [Fact] 53 | public void ReadOnlyWrapper_Should_Not_Have_Setters() 54 | { 55 | // This test verifies at compile-time that ReadOnly wrappers don't have setters 56 | // If this test compiles, it proves the setters are missing 57 | 58 | var product = new Product { Id = 1, Name = "Test" }; 59 | var wrapper = new ReadOnlyProductWrapper(product); 60 | 61 | // These should compile (getters exist) 62 | _ = wrapper.Id; 63 | _ = wrapper.Name; 64 | 65 | // These should NOT compile (setters don't exist): 66 | // wrapper.Id = 2; // CS0200: Property or indexer 'ReadOnlyProductWrapper.Id' cannot be assigned to -- it is read only 67 | // wrapper.Name = "New"; // CS0200: Property or indexer 'ReadOnlyProductWrapper.Name' cannot be assigned to -- it is read only 68 | } 69 | 70 | [Fact] 71 | public void ReadOnlyWrapper_Should_Reflect_Source_Changes() 72 | { 73 | // Arrange 74 | var product = new Product { Id = 1, Name = "Original" }; 75 | var wrapper = new ReadOnlyProductWrapper(product); 76 | 77 | // Act - modify source directly 78 | product.Name = "Updated"; 79 | product.Price = 99.99m; 80 | 81 | // Assert - wrapper reflects the changes 82 | wrapper.Name.Should().Be("Updated"); 83 | wrapper.Price.Should().Be(99.99m); 84 | } 85 | 86 | [Fact] 87 | public void MutableWrapper_Should_Have_Setters() 88 | { 89 | // Arrange 90 | var product = new Product { Id = 1, Name = "Original", Price = 100m }; 91 | var wrapper = new MutableProductWrapper(product); 92 | 93 | // Act - modify through wrapper (should compile and work) 94 | wrapper.Name = "Modified"; 95 | wrapper.Price = 150m; 96 | 97 | // Assert - source is modified 98 | product.Name.Should().Be("Modified"); 99 | product.Price.Should().Be(150m); 100 | } 101 | 102 | [Fact] 103 | public void ReadOnlyWrapper_Constructor_Should_Throw_On_Null() 104 | { 105 | // Act 106 | Action act = () => new ReadOnlyProductWrapper(null!); 107 | 108 | // Assert 109 | act.Should().Throw() 110 | .WithParameterName("source"); 111 | } 112 | 113 | [Fact] 114 | public void ReadOnlyWrapper_Unwrap_Should_Return_Source() 115 | { 116 | // Arrange 117 | var product = new Product { Id = 1, Name = "Test" }; 118 | var wrapper = new ReadOnlyProductWrapper(product); 119 | 120 | // Act 121 | var unwrapped = wrapper.Unwrap(); 122 | 123 | // Assert 124 | unwrapped.Should().BeSameAs(product); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Facet/Generators/Shared/FacetConstants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | 4 | namespace Facet.Generators.Shared; 5 | 6 | /// 7 | /// Contains constant values used throughout the Facet source generator. 8 | /// Centralizes magic strings and default values to improve maintainability. 9 | /// 10 | internal static class FacetConstants 11 | { 12 | /// 13 | /// The version of the Facet generator, cached for performance. 14 | /// 15 | public static readonly string GeneratorVersion = GetGeneratorVersion(); 16 | 17 | private static string GetGeneratorVersion() 18 | { 19 | try 20 | { 21 | return typeof(FacetConstants).Assembly.GetName().Version?.ToString() ?? "Unknown"; 22 | } 23 | catch 24 | { 25 | return "Unknown"; 26 | } 27 | } 28 | 29 | /// 30 | /// The fully qualified name of the FacetAttribute. 31 | /// 32 | public const string FacetAttributeFullName = "Facet.FacetAttribute"; 33 | 34 | /// 35 | /// The fully qualified name of the WrapperAttribute. 36 | /// 37 | public const string WrapperAttributeFullName = "Facet.WrapperAttribute"; 38 | 39 | /// 40 | /// The fully qualified name of the MapFromAttribute. 41 | /// 42 | public const string MapFromAttributeFullName = "Facet.MapFromAttribute"; 43 | 44 | /// 45 | /// The fully qualified name of the MapWhenAttribute. 46 | /// 47 | public const string MapWhenAttributeFullName = "Facet.MapWhenAttribute"; 48 | 49 | /// 50 | /// The default maximum depth for nested facet traversal to prevent stack overflow. 51 | /// 52 | public const int DefaultMaxDepth = 10; 53 | 54 | /// 55 | /// The default setting for preserving object references during mapping to detect circular references. 56 | /// 57 | public const bool DefaultPreserveReferences = true; 58 | 59 | /// 60 | /// The prefix used to specify global namespace qualification. 61 | /// 62 | public const string GlobalNamespacePrefix = "global::"; 63 | 64 | /// 65 | /// The number of spaces per indentation level in generated code. 66 | /// 67 | public const int SpacesPerIndentLevel = 4; 68 | 69 | /// 70 | /// Standard collection wrapper type names. 71 | /// 72 | public static class CollectionWrappers 73 | { 74 | public const string List = "List"; 75 | public const string IList = "IList"; 76 | public const string ICollection = "ICollection"; 77 | public const string IEnumerable = "IEnumerable"; 78 | public const string IReadOnlyList = "IReadOnlyList"; 79 | public const string IReadOnlyCollection = "IReadOnlyCollection"; 80 | public const string Array = "array"; 81 | } 82 | 83 | /// 84 | /// Common attribute names used in facet generation. 85 | /// 86 | public static class AttributeNames 87 | { 88 | public const string NestedFacets = "NestedFacets"; 89 | public const string NestedWrappers = "NestedWrappers"; 90 | public const string FlattenTo = "FlattenTo"; 91 | public const string Include = "Include"; 92 | public const string Configuration = "Configuration"; 93 | public const string IncludeFields = "IncludeFields"; 94 | public const string GenerateConstructor = "GenerateConstructor"; 95 | public const string GenerateParameterlessConstructor = "GenerateParameterlessConstructor"; 96 | public const string GenerateProjection = "GenerateProjection"; 97 | public const string GenerateToSource = "GenerateToSource"; 98 | public const string PreserveInitOnlyProperties = "PreserveInitOnlyProperties"; 99 | public const string PreserveRequiredProperties = "PreserveRequiredProperties"; 100 | public const string NullableProperties = "NullableProperties"; 101 | public const string CopyAttributes = "CopyAttributes"; 102 | public const string MaxDepth = "MaxDepth"; 103 | public const string PreserveReferences = "PreserveReferences"; 104 | public const string UseFullName = "UseFullName"; 105 | public const string ReadOnly = "ReadOnly"; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /docs/16_SourceSignature.md: -------------------------------------------------------------------------------- 1 | # Source Signature Change Tracking 2 | 3 | The `SourceSignature` property allows you to detect when a source entity's structure changes, helping you catch unintended breaking changes to your DTOs. 4 | 5 | ## Overview 6 | 7 | When you set `SourceSignature` on a `[Facet]` attribute, the analyzer computes a hash of the source type's properties and compares it to the stored signature. If the source entity changes (properties added, removed, or types changed), you'll get a compile-time warning with the new signature. 8 | 9 | ## Usage 10 | 11 | ```csharp 12 | [Facet(typeof(User), SourceSignature = "a1b2c3d4")] 13 | public partial class UserDto; 14 | ``` 15 | 16 | ## How It Works 17 | 18 | 1. **Hash Computation**: The signature is an 8-character SHA-256 hash computed from: 19 | - Property names and their types 20 | - Respects `Include`/`Exclude` filters 21 | - Respects `IncludeFields` setting 22 | 23 | 2. **Compile-Time Check**: The analyzer compares the stored signature against the current computed signature 24 | 25 | 3. **Warning on Mismatch**: If they differ, you get diagnostic `FAC022` with the new signature 26 | 27 | 4. **Code Fix**: Use the provided code fix to automatically update the signature 28 | 29 | ## Example Workflow 30 | 31 | ### Initial Setup 32 | 33 | ```csharp 34 | public class User 35 | { 36 | public int Id { get; set; } 37 | public string Name { get; set; } 38 | public string Email { get; set; } 39 | } 40 | 41 | // First, create your facet without a signature 42 | [Facet(typeof(User))] 43 | public partial class UserDto; 44 | 45 | // Then add SourceSignature to track changes (get initial value from analyzer) 46 | [Facet(typeof(User), SourceSignature = "8f3a2b1c")] 47 | public partial class UserDto; 48 | ``` 49 | 50 | ### When Source Changes 51 | 52 | ```csharp 53 | public class User 54 | { 55 | public int Id { get; set; } 56 | public string Name { get; set; } 57 | public string Email { get; set; } 58 | public DateTime CreatedAt { get; set; } // New property added 59 | } 60 | ``` 61 | 62 | You'll see a warning: 63 | 64 | ``` 65 | FAC022: Source entity 'User' structure has changed. Update SourceSignature to 'd4e5f6a7' to acknowledge this change. 66 | ``` 67 | 68 | ### Acknowledging Changes 69 | 70 | Use the provided code fix (lightbulb/quick action) to automatically update the signature, or manually update it: 71 | 72 | ```csharp 73 | [Facet(typeof(User), SourceSignature = "d4e5f6a7")] 74 | public partial class UserDto; 75 | ``` 76 | 77 | ## Benefits 78 | 79 | - **Intentional Changes**: Forces you to explicitly acknowledge when source entities change 80 | - **Code Review**: Makes structural changes visible in diffs 81 | - **Team Communication**: Alerts team members when shared entities are modified 82 | - **API Stability**: Helps maintain stable DTO contracts 83 | 84 | ## With Include/Exclude 85 | 86 | The signature only considers the properties that will actually be in your facet: 87 | 88 | ```csharp 89 | // Only tracks Id, Name, Email (excludes Password) 90 | [Facet(typeof(User), nameof(User.Password), SourceSignature = "1a2b3c4d")] 91 | public partial class UserDto; 92 | 93 | // Only tracks FirstName and LastName 94 | [Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName)], SourceSignature = "5e6f7a8b")] 95 | public partial class UserNameDto; 96 | ``` 97 | 98 | ## When to Use 99 | 100 | **Recommended for:** 101 | - Public API DTOs where breaking changes affect consumers 102 | - Shared models between services 103 | - DTOs used in serialization/deserialization contracts 104 | - Any facet where stability is critical 105 | 106 | **Optional for:** 107 | - Internal-only DTOs 108 | - Rapidly evolving models during development 109 | - Simple/temporary projections 110 | 111 | ## Diagnostic Reference 112 | 113 | | Code | Severity | Description | 114 | |------|----------|-------------| 115 | | FAC022 | Warning | Source entity structure has changed - signature mismatch | 116 | 117 | ## Tips 118 | 119 | 1. **Start Without Signature**: Create your facet first, then add the signature once the model stabilizes 120 | 121 | 2. **Review Changes**: When you see FAC022, review what changed in the source entity before accepting the new signature 122 | 123 | 3. **Git Blame**: The signature update in your commit history shows when structural changes occurred 124 | 125 | 4. **Multiple Facets**: Each facet can have its own signature tracking the specific properties it uses 126 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/Facet.Attributes/MapFromAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Facet; 4 | 5 | /// 6 | /// Specifies that a property should be mapped from a different source property or expression. 7 | /// This attribute allows declarative property renaming and simple transformations without 8 | /// requiring a full IFacetMapConfiguration implementation. 9 | /// 10 | /// 11 | /// 12 | /// The can be used in several ways: 13 | /// 14 | /// 15 | /// 16 | /// Simple property rename: [MapFrom("FirstName")] maps from source.FirstName 17 | /// 18 | /// 19 | /// Nested property access: [MapFrom("Company.Name")] maps from source.Company.Name 20 | /// 21 | /// 22 | /// Expression: [MapFrom("FirstName + \" \" + LastName")] for computed values 23 | /// 24 | /// 25 | /// 26 | /// When used together with , the auto-generated mappings 27 | /// (including MapFrom) are applied first, then the custom mapper is called, allowing it to override 28 | /// any values if needed. 29 | /// 30 | /// 31 | /// 32 | /// 33 | /// [Facet(typeof(User))] 34 | /// public partial class UserDto 35 | /// { 36 | /// [MapFrom("FirstName")] 37 | /// public string Name { get; set; } 38 | /// 39 | /// [MapFrom("Company.Name")] 40 | /// public string CompanyName { get; set; } 41 | /// 42 | /// [MapFrom("FirstName + \" \" + LastName", Reversible = false)] 43 | /// public string FullName { get; set; } 44 | /// } 45 | /// 46 | /// 47 | [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] 48 | public sealed class MapFromAttribute : Attribute 49 | { 50 | /// 51 | /// The source property name or expression to map from. 52 | /// 53 | /// 54 | /// 55 | /// This can be: 56 | /// 57 | /// 58 | /// A simple property name: "FirstName" 59 | /// A nested property path: "Company.Address.City" 60 | /// A C# expression: "FirstName + \" \" + LastName" 61 | /// 62 | /// 63 | /// When using expressions, the source variable is implicitly available. For example, 64 | /// "FirstName" is equivalent to accessing source.FirstName. 65 | /// 66 | /// 67 | public string Source { get; } 68 | 69 | /// 70 | /// Whether this mapping can be reversed in the ToSource method. 71 | /// Default is false (opt-in). 72 | /// 73 | /// 74 | /// 75 | /// Set to true when you need the mapping to be included in ToSource(). 76 | /// Keep as false (default) for: 77 | /// 78 | /// 79 | /// Read-only DTOs that don't need reverse mapping 80 | /// Computed expressions that cannot be reversed 81 | /// Navigation property paths (e.g., "Company.Name") 82 | /// One-way mappings where reverse mapping doesn't make sense 83 | /// 84 | /// 85 | /// When false, the property will not be included in the ToSource method output. 86 | /// 87 | /// 88 | public bool Reversible { get; set; } = false; 89 | 90 | /// 91 | /// Whether to include this mapping in the generated Projection expression. 92 | /// Default is true. 93 | /// 94 | /// 95 | /// 96 | /// Set to false for mappings that cannot be translated to SQL by Entity Framework Core, 97 | /// such as method calls or complex expressions that require client-side evaluation. 98 | /// 99 | /// 100 | /// When false, the property will not be included in the static Projection expression, 101 | /// but will still be mapped in the constructor. 102 | /// 103 | /// 104 | public bool IncludeInProjection { get; set; } = true; 105 | 106 | /// 107 | /// Creates a new MapFromAttribute that maps from the specified source property or expression. 108 | /// 109 | /// The source property name or expression to map from. 110 | public MapFromAttribute(string source) 111 | { 112 | Source = source ?? throw new ArgumentNullException(nameof(source)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Facet/Analyzers/SourceSignatureCodeFixProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CodeActions; 3 | using Microsoft.CodeAnalysis.CodeFixes; 4 | using Microsoft.CodeAnalysis.CSharp; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using System.Collections.Immutable; 7 | using System.Composition; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Facet.Analyzers; 13 | 14 | [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SourceSignatureCodeFixProvider)), Shared] 15 | public class SourceSignatureCodeFixProvider : CodeFixProvider 16 | { 17 | public sealed override ImmutableArray FixableDiagnosticIds => 18 | ImmutableArray.Create("FAC022"); 19 | 20 | public sealed override FixAllProvider GetFixAllProvider() => 21 | WellKnownFixAllProviders.BatchFixer; 22 | 23 | public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) 24 | { 25 | var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); 26 | if (root == null) return; 27 | 28 | var diagnostic = context.Diagnostics.First(); 29 | var diagnosticSpan = diagnostic.Location.SourceSpan; 30 | 31 | // Find the attribute syntax 32 | var node = root.FindNode(diagnosticSpan); 33 | var attributeSyntax = node.AncestorsAndSelf().OfType().FirstOrDefault(); 34 | 35 | if (attributeSyntax == null) return; 36 | 37 | // Extract the new signature from the diagnostic message 38 | // Message format: "Source entity '{0}' structure has changed. Update SourceSignature to '{1}' to acknowledge this change." 39 | var message = diagnostic.GetMessage(); 40 | var newSignature = ExtractSignatureFromMessage(message); 41 | 42 | if (string.IsNullOrEmpty(newSignature)) return; 43 | 44 | context.RegisterCodeFix( 45 | CodeAction.Create( 46 | title: $"Update SourceSignature to '{newSignature}'", 47 | createChangedDocument: c => UpdateSourceSignatureAsync(context.Document, attributeSyntax, newSignature, c), 48 | equivalenceKey: "UpdateSourceSignature"), 49 | diagnostic); 50 | } 51 | 52 | private static string? ExtractSignatureFromMessage(string message) 53 | { 54 | // Look for "Update SourceSignature to 'XXXXXXXX'" 55 | const string marker = "Update SourceSignature to '"; 56 | var startIndex = message.IndexOf(marker); 57 | if (startIndex < 0) return null; 58 | 59 | startIndex += marker.Length; 60 | var endIndex = message.IndexOf("'", startIndex); 61 | if (endIndex < 0) return null; 62 | 63 | return message.Substring(startIndex, endIndex - startIndex); 64 | } 65 | 66 | private static async Task UpdateSourceSignatureAsync( 67 | Document document, 68 | AttributeSyntax attributeSyntax, 69 | string newSignature, 70 | CancellationToken cancellationToken) 71 | { 72 | var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); 73 | if (root == null) return document; 74 | 75 | // Find existing SourceSignature argument 76 | var existingArg = attributeSyntax.ArgumentList?.Arguments 77 | .FirstOrDefault(a => a.NameEquals?.Name.Identifier.Text == "SourceSignature"); 78 | 79 | AttributeSyntax newAttributeSyntax; 80 | 81 | if (existingArg != null) 82 | { 83 | // Update existing argument 84 | var newArg = existingArg.WithExpression( 85 | SyntaxFactory.LiteralExpression( 86 | SyntaxKind.StringLiteralExpression, 87 | SyntaxFactory.Literal(newSignature))); 88 | 89 | newAttributeSyntax = attributeSyntax.ReplaceNode(existingArg, newArg); 90 | } 91 | else 92 | { 93 | // Add new argument (shouldn't happen for FAC022, but handle gracefully) 94 | var newArg = SyntaxFactory.AttributeArgument( 95 | SyntaxFactory.NameEquals("SourceSignature"), 96 | null, 97 | SyntaxFactory.LiteralExpression( 98 | SyntaxKind.StringLiteralExpression, 99 | SyntaxFactory.Literal(newSignature))); 100 | 101 | var newArgList = attributeSyntax.ArgumentList == null 102 | ? SyntaxFactory.AttributeArgumentList(SyntaxFactory.SingletonSeparatedList(newArg)) 103 | : attributeSyntax.ArgumentList.AddArguments(newArg); 104 | 105 | newAttributeSyntax = attributeSyntax.WithArgumentList(newArgList); 106 | } 107 | 108 | var newRoot = root.ReplaceNode(attributeSyntax, newAttributeSyntax); 109 | return document.WithSyntaxRoot(newRoot); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/InheritedMemberTests.cs: -------------------------------------------------------------------------------- 1 | namespace Facet.Tests.UnitTests.Core.Facet; 2 | 3 | // Test entities 4 | public class InheritedMemberEntity 5 | { 6 | public int Id { get; set; } 7 | public string Name { get; set; } = string.Empty; 8 | public int State { get; set; } 9 | public string LocalName { get; set; } = string.Empty; 10 | public string Description { get; set; } = string.Empty; 11 | } 12 | 13 | // Base class for facets 14 | public abstract class BaseMemberFacet 15 | { 16 | public int Id { get; set; } 17 | public int State { get; set; } 18 | } 19 | 20 | // Facet that inherits from base class - should not generate Id and State again 21 | [Facet(typeof(InheritedMemberEntity), exclude: new[] { "LocalName" })] 22 | public partial class InheritedMemberFacet : BaseMemberFacet 23 | { 24 | } 25 | 26 | // Another base class scenario 27 | public abstract class BaseWithName 28 | { 29 | public int Id { get; set; } 30 | public string Name { get; set; } = string.Empty; 31 | } 32 | 33 | [Facet(typeof(InheritedMemberEntity), Include = new[] { "Id", "Name", "Description" })] 34 | public partial class InheritedIncludeFacet : BaseWithName 35 | { 36 | } 37 | 38 | public class InheritedMemberTests 39 | { 40 | [Fact] 41 | public void Constructor_ShouldNotGenerateDuplicateProperties() 42 | { 43 | // Arrange 44 | var entity = new InheritedMemberEntity 45 | { 46 | Id = 1, 47 | Name = "Test", 48 | State = 42, 49 | LocalName = "Local", 50 | Description = "Description" 51 | }; 52 | 53 | // Act 54 | var facet = new InheritedMemberFacet(entity); 55 | 56 | // Assert 57 | facet.Id.Should().Be(1); 58 | facet.State.Should().Be(42); 59 | facet.Name.Should().Be("Test"); 60 | facet.Description.Should().Be("Description"); 61 | } 62 | 63 | [Fact] 64 | public void FacetType_ShouldNotHaveDuplicateProperties() 65 | { 66 | // Verify that the facet type doesn't have duplicate properties 67 | var facetType = typeof(InheritedMemberFacet); 68 | var declaredProperties = facetType.GetProperties(System.Reflection.BindingFlags.DeclaredOnly | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); 69 | 70 | // Id and State should NOT be in declared properties since they're inherited 71 | var propertyNames = declaredProperties.Select(p => p.Name).ToList(); 72 | propertyNames.Should().NotContain("Id"); 73 | propertyNames.Should().NotContain("State"); 74 | 75 | // Name and Description should be in declared properties (not in base) 76 | propertyNames.Should().Contain("Name"); 77 | propertyNames.Should().Contain("Description"); 78 | } 79 | 80 | [Fact] 81 | public void IncludeMode_ShouldNotGenerateDuplicateProperties() 82 | { 83 | // Arrange 84 | var entity = new InheritedMemberEntity 85 | { 86 | Id = 2, 87 | Name = "Test2", 88 | Description = "Desc2" 89 | }; 90 | 91 | // Act 92 | var facet = new InheritedIncludeFacet(entity); 93 | 94 | // Assert 95 | facet.Id.Should().Be(2); 96 | facet.Name.Should().Be("Test2"); 97 | facet.Description.Should().Be("Desc2"); 98 | 99 | // Verify no duplicate properties 100 | var facetType = typeof(InheritedIncludeFacet); 101 | var declaredProperties = facetType.GetProperties(System.Reflection.BindingFlags.DeclaredOnly | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); 102 | var propertyNames = declaredProperties.Select(p => p.Name).ToList(); 103 | 104 | // Id and Name should NOT be declared since they're inherited 105 | propertyNames.Should().NotContain("Id"); 106 | propertyNames.Should().NotContain("Name"); 107 | 108 | // Description should be declared 109 | propertyNames.Should().Contain("Description"); 110 | } 111 | 112 | [Fact] 113 | public void Projection_ShouldWorkWithInheritedProperties() 114 | { 115 | // Arrange 116 | var entities = new[] 117 | { 118 | new InheritedMemberEntity { Id = 1, Name = "Test1", State = 10, Description = "Desc1" }, 119 | new InheritedMemberEntity { Id = 2, Name = "Test2", State = 20, Description = "Desc2" } 120 | }.AsQueryable(); 121 | 122 | // Act 123 | var facets = entities.Select(InheritedMemberFacet.Projection).ToList(); 124 | 125 | // Assert 126 | facets.Should().HaveCount(2); 127 | facets[0].Id.Should().Be(1); 128 | facets[0].State.Should().Be(10); 129 | facets[0].Name.Should().Be("Test1"); 130 | facets[1].Id.Should().Be(2); 131 | facets[1].State.Should().Be(20); 132 | facets[1].Name.Should().Be("Test2"); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/Facet.Tests/UnitTests/Core/Facet/ProjectionStructureTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using Facet.Tests.TestModels; 3 | 4 | namespace Facet.Tests.UnitTests.Core.Facet; 5 | 6 | /// 7 | /// Tests to verify the structure of generated projection expressions, 8 | /// particularly for EF Core compatibility with nested facets. 9 | /// 10 | public class ProjectionStructureTests 11 | { 12 | [Fact] 13 | public void Projection_ShouldUseObjectInitializer_NotConstructor() 14 | { 15 | // Arrange & Act 16 | var projection = CompanyFacet.Projection; 17 | 18 | // Assert 19 | projection.Should().NotBeNull(); 20 | 21 | // The projection should be a lambda expression 22 | var body = projection.Body; 23 | body.Should().BeOfType( 24 | "EF Core can translate MemberInitExpression (object initializer) but not constructor calls"); 25 | 26 | var memberInit = (MemberInitExpression)body; 27 | 28 | // Verify it's initializing properties, not calling a constructor with parameters 29 | memberInit.NewExpression.Arguments.Should().BeEmpty( 30 | "Object initializer should use parameterless constructor"); 31 | 32 | // Verify it has member bindings for properties 33 | memberInit.Bindings.Should().NotBeEmpty("Should have property assignments"); 34 | } 35 | 36 | [Fact] 37 | public void Projection_WithNestedFacet_ShouldAccessNavigationPropertyMembers() 38 | { 39 | // Arrange & Act 40 | var projection = CompanyFacet.Projection; 41 | 42 | // Assert 43 | var body = (MemberInitExpression)projection.Body; 44 | 45 | // Find the HeadquartersAddress binding 46 | var addressBinding = body.Bindings 47 | .OfType() 48 | .FirstOrDefault(b => b.Member.Name == "HeadquartersAddress"); 49 | 50 | addressBinding.Should().NotBeNull("Should have HeadquartersAddress property assignment"); 51 | 52 | // The expression should access source.HeadquartersAddress 53 | // This is critical for EF Core to know to load the navigation property 54 | var addressExpression = addressBinding!.Expression.ToString(); 55 | addressExpression.Should().Contain("source.HeadquartersAddress", 56 | "Projection must access the navigation property for EF Core to load it"); 57 | } 58 | 59 | [Fact] 60 | public void Projection_WithCollectionNestedFacet_ShouldAccessCollectionMembers() 61 | { 62 | // Arrange & Act 63 | var projection = OrderFacet.Projection; 64 | 65 | // Assert 66 | var body = (MemberInitExpression)projection.Body; 67 | 68 | // Find the Items collection binding 69 | var itemsBinding = body.Bindings 70 | .OfType() 71 | .FirstOrDefault(b => b.Member.Name == "Items"); 72 | 73 | itemsBinding.Should().NotBeNull("Should have Items collection property assignment"); 74 | 75 | // The expression should use Select on source.Items 76 | var itemsExpression = itemsBinding!.Expression.ToString(); 77 | itemsExpression.Should().Contain("source.Items", 78 | "Projection must access the navigation collection for EF Core to load it"); 79 | itemsExpression.Should().Contain("Select", 80 | "Collection projection should use Select"); 81 | } 82 | 83 | [Fact] 84 | public void Projection_ToString_ShouldShowObjectInitializerSyntax() 85 | { 86 | // Arrange & Act 87 | var projection = CompanyFacet.Projection; 88 | var projectionString = projection.ToString(); 89 | 90 | // Assert 91 | // Should see "source => new CompanyFacet { ... }" 92 | // NOT "source => new CompanyFacet(source)" 93 | projectionString.Should().NotContain("new CompanyFacet(source)", 94 | "Should use object initializer, not constructor call"); 95 | } 96 | 97 | [Fact] 98 | public void Projection_WithNullableNestedFacet_ShouldHaveNullCheck() 99 | { 100 | // Arrange & Act 101 | var projection = DataTableFacetDto.Projection; 102 | 103 | // Assert 104 | var body = (MemberInitExpression)projection.Body; 105 | 106 | // Find the ExtendedData binding (nullable nested facet) 107 | var extendedDataBinding = body.Bindings 108 | .OfType() 109 | .FirstOrDefault(b => b.Member.Name == "ExtendedData"); 110 | 111 | extendedDataBinding.Should().NotBeNull("Should have ExtendedData property assignment"); 112 | 113 | // The expression should have a conditional for null check 114 | var expression = extendedDataBinding!.Expression; 115 | expression.NodeType.Should().Be(ExpressionType.Conditional, 116 | "Nullable nested facet should use conditional expression (ternary operator)"); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /docs/12_GeneratedFilesOutput.md: -------------------------------------------------------------------------------- 1 | # Generated Files Output Configuration 2 | 3 | By default, Roslyn source generators (including Facet) output generated files to the `obj/Generated/` folder, which is hidden from the Solution Explorer. However, you can configure where generated files are written using standard MSBuild properties. 4 | 5 | ## Making Generated Files Visible 6 | 7 | To make generated files visible in your project and control where they are output, add the following properties to your `.csproj` file: 8 | 9 | ```xml 10 | 11 | true 12 | Generated 13 | 14 | ``` 15 | 16 | ### Configuration Options 17 | 18 | #### 1. Output to Project Folder (Recommended) 19 | 20 | Place generated files in a `Generated` folder within your project: 21 | 22 | ```xml 23 | 24 | true 25 | Generated 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ``` 35 | 36 | This configuration: 37 | - Makes generated files visible in Solution Explorer 38 | - Allows you to browse and inspect generated code 39 | - Excludes them from compilation (they're already compiled as generated files) 40 | - Useful for debugging and understanding what code is being generated 41 | 42 | #### 2. Output to obj Folder (Default Behavior) 43 | 44 | Keep generated files in the obj folder but make them visible: 45 | 46 | ```xml 47 | 48 | true 49 | $(BaseIntermediateOutputPath)Generated 50 | 51 | ``` 52 | 53 | This is what Facet's test projects use internally. 54 | 55 | #### 3. Output to Shared Project 56 | 57 | Generate files in a separate shared project: 58 | 59 | ```xml 60 | 61 | true 62 | ..\MySharedProject\Generated 63 | 64 | ``` 65 | 66 | **Important**: When outputting to a shared project, ensure the types being generated are `partial` so they can be merged with the declarations in your source project. 67 | 68 | ## File Structure 69 | 70 | With the default configuration, generated files are organized by generator: 71 | 72 | ``` 73 | Generated/ 74 | ├── Facet/ 75 | │ ├── Facet.Generators.FacetGenerator/ 76 | │ │ ├── UserDto.g.cs 77 | │ │ ├── ProductDto.g.cs 78 | │ │ └── ... 79 | │ ├── Facet.Generators.FlattenGenerator/ 80 | │ │ └── ... 81 | │ └── Facet.Generators.GenerateDtosGenerator/ 82 | │ └── ... 83 | └── OtherGenerator/ 84 | └── ... 85 | ``` 86 | 87 | ## Benefits of Visible Generated Files 88 | 89 | 1. **Debugging**: Easier to see what code is being generated and debug issues 90 | 2. **Learning**: Understand how Facet generates code by inspecting the output 91 | 3. **Code Reviews**: Include generated files in code reviews if needed 92 | 4. **Documentation**: Auto-generated code serves as documentation 93 | 94 | ## Important Notes 95 | 96 | - **Do NOT manually edit generated files** - they will be overwritten on the next build 97 | - Generated files are **recreated on every build** based on your source code and attributes 98 | - **Do NOT commit generated files to source control** unless you have a specific reason (add to `.gitignore`) 99 | - When placing files in the project folder, **always exclude them from compilation** using `` 100 | 101 | ## Example .gitignore 102 | 103 | If you choose to make generated files visible in your project, add this to your `.gitignore`: 104 | 105 | ```gitignore 106 | # Facet generated files 107 | Generated/ 108 | **/Generated/ 109 | ``` 110 | 111 | ## Troubleshooting 112 | 113 | ### Duplicate Type Definitions 114 | 115 | If you see errors about duplicate type definitions, ensure you've excluded the Generated folder from compilation: 116 | 117 | ```xml 118 | 119 | 120 | 121 | ``` 122 | 123 | ### Files Not Appearing 124 | 125 | 1. Clean and rebuild your solution 126 | 2. Verify `EmitCompilerGeneratedFiles` is set to `true` 127 | 3. Check the output path exists and is accessible 128 | 4. Ensure you're using a recent .NET SDK (6.0+) 129 | 130 | ### Performance Considerations 131 | 132 | Emitting generated files to disk has minimal performance impact. However, if you have thousands of generated files and are using source control, consider: 133 | - Keeping them in the `obj` folder (default) 134 | - Adding the output directory to `.gitignore` 135 | 136 | -------------------------------------------------------------------------------- /src/Facet/WrapperTarget.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using System.Linq; 6 | 7 | namespace Facet; 8 | 9 | internal sealed class WrapperTargetModel : IEquatable 10 | { 11 | public string Name { get; } 12 | public string? Namespace { get; } 13 | public string FullName { get; } 14 | public TypeKind TypeKind { get; } 15 | public bool IsRecord { get; } 16 | public string SourceTypeName { get; } 17 | public ImmutableArray SourceContainingTypes { get; } 18 | public ImmutableArray Members { get; } 19 | public string? TypeXmlDocumentation { get; } 20 | public ImmutableArray ContainingTypes { get; } 21 | public bool UseFullName { get; } 22 | public bool CopyAttributes { get; } 23 | public bool ReadOnly { get; } 24 | public string SourceFieldName { get; } 25 | 26 | public WrapperTargetModel( 27 | string name, 28 | string? @namespace, 29 | string fullName, 30 | TypeKind typeKind, 31 | bool isRecord, 32 | string sourceTypeName, 33 | ImmutableArray sourceContainingTypes, 34 | ImmutableArray members, 35 | string? typeXmlDocumentation = null, 36 | ImmutableArray containingTypes = default, 37 | bool useFullName = false, 38 | bool copyAttributes = false, 39 | bool readOnly = false, 40 | string sourceFieldName = "_source") 41 | { 42 | Name = name; 43 | Namespace = @namespace; 44 | FullName = fullName; 45 | TypeKind = typeKind; 46 | IsRecord = isRecord; 47 | SourceTypeName = sourceTypeName; 48 | SourceContainingTypes = sourceContainingTypes.IsDefault ? ImmutableArray.Empty : sourceContainingTypes; 49 | Members = members; 50 | TypeXmlDocumentation = typeXmlDocumentation; 51 | ContainingTypes = containingTypes.IsDefault ? ImmutableArray.Empty : containingTypes; 52 | UseFullName = useFullName; 53 | CopyAttributes = copyAttributes; 54 | ReadOnly = readOnly; 55 | SourceFieldName = sourceFieldName; 56 | } 57 | 58 | public bool Equals(WrapperTargetModel? other) 59 | { 60 | if (other is null) return false; 61 | if (ReferenceEquals(this, other)) return true; 62 | 63 | return Name == other.Name 64 | && Namespace == other.Namespace 65 | && FullName == other.FullName 66 | && TypeKind == other.TypeKind 67 | && IsRecord == other.IsRecord 68 | && SourceTypeName == other.SourceTypeName 69 | && SourceContainingTypes.SequenceEqual(other.SourceContainingTypes) 70 | && TypeXmlDocumentation == other.TypeXmlDocumentation 71 | && Members.SequenceEqual(other.Members) 72 | && ContainingTypes.SequenceEqual(other.ContainingTypes) 73 | && UseFullName == other.UseFullName 74 | && CopyAttributes == other.CopyAttributes 75 | && ReadOnly == other.ReadOnly 76 | && SourceFieldName == other.SourceFieldName; 77 | } 78 | 79 | public override bool Equals(object? obj) => obj is WrapperTargetModel other && Equals(other); 80 | 81 | public override int GetHashCode() 82 | { 83 | unchecked 84 | { 85 | int hash = 17; 86 | hash = hash * 31 + (Name?.GetHashCode() ?? 0); 87 | hash = hash * 31 + (Namespace?.GetHashCode() ?? 0); 88 | hash = hash * 31 + (FullName?.GetHashCode() ?? 0); 89 | hash = hash * 31 + TypeKind.GetHashCode(); 90 | hash = hash * 31 + IsRecord.GetHashCode(); 91 | hash = hash * 31 + (SourceTypeName?.GetHashCode() ?? 0); 92 | hash = hash * 31 + (TypeXmlDocumentation?.GetHashCode() ?? 0); 93 | hash = hash * 31 + UseFullName.GetHashCode(); 94 | hash = hash * 31 + CopyAttributes.GetHashCode(); 95 | hash = hash * 31 + ReadOnly.GetHashCode(); 96 | hash = hash * 31 + (SourceFieldName?.GetHashCode() ?? 0); 97 | hash = hash * 31 + Members.Length.GetHashCode(); 98 | 99 | foreach (var member in Members) 100 | hash = hash * 31 + member.GetHashCode(); 101 | 102 | foreach (var containingType in ContainingTypes) 103 | hash = hash * 31 + (containingType?.GetHashCode() ?? 0); 104 | 105 | foreach (var sourceContainingType in SourceContainingTypes) 106 | hash = hash * 31 + (sourceContainingType?.GetHashCode() ?? 0); 107 | 108 | return hash; 109 | } 110 | } 111 | 112 | internal static IEqualityComparer Comparer { get; } = new WrapperTargetModelEqualityComparer(); 113 | 114 | internal sealed class WrapperTargetModelEqualityComparer : IEqualityComparer 115 | { 116 | public bool Equals(WrapperTargetModel? x, WrapperTargetModel? y) => x?.Equals(y) ?? y is null; 117 | public int GetHashCode(WrapperTargetModel obj) => obj.GetHashCode(); 118 | } 119 | } 120 | --------------------------------------------------------------------------------