diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..d304a9d --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,159 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: Publish Preview +run-name: '[Preview] PR #${{ github.event.pull_request.number }} — ${{ github.event.pull_request.title }}' + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + - master + - develop + +# Cancel any in-progress publish for the same PR when a new commit is pushed +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + +defaults: + run: + shell: pwsh + +jobs: + publish-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Read base version from Directory.Build.props + id: base_version + shell: bash + run: | + VERSION=$(grep -oP '(?<=)[^<]+' Directory.Build.props) + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Base version: $VERSION" + + - name: Compute preview version + id: preview_version + shell: bash + run: | + PREVIEW_VERSION="${{ steps.base_version.outputs.VERSION }}-preview.pr${{ github.event.pull_request.number }}.${{ github.run_number }}" + echo "PREVIEW_VERSION=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT" + echo "Preview version: $PREVIEW_VERSION" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore -p:Version=${{ steps.preview_version.outputs.PREVIEW_VERSION }} + + - name: Pack NuGet packages + run: dotnet pack --configuration Release --no-build -p:Version=${{ steps.preview_version.outputs.PREVIEW_VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg --output ./artifacts + + - name: List generated packages + run: Get-ChildItem ./artifacts | Select-Object Name | Format-Table + + - name: Publish preview packages to NuGet + run: | + foreach ($file in Get-ChildItem "./artifacts" -Recurse -Include *.nupkg,*.snupkg) { + Write-Host "Processing package: $($file.Name)" + + $apiKey = $null + if ($file.Name -like "Facet.Mapping.Expressions.*") { + Write-Host "Publishing Facet.Mapping.Expressions package..." + $apiKey = "${{ secrets.NUGET_API_KEY_EXPRESSIONS }}" + } elseif ($file.Name -like "Facet.Mapping.*") { + Write-Host "Publishing Facet.Mapping package..." + $apiKey = "${{ secrets.NUGET_MAP_API_KEY }}" + } elseif ($file.Name -like "Facet.Extensions.EFCore.Mapping.*") { + Write-Host "Publishing Facet.Extensions.EFCore.Mapping package..." + $apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS_EF_MAPPING }}" + } elseif ($file.Name -like "Facet.Extensions.EFCore.*") { + Write-Host "Publishing Facet.Extensions.EFCore package..." + $apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS_EF }}" + } elseif ($file.Name -like "Facet.Extensions.*") { + Write-Host "Publishing Facet.Extensions package..." + $apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS }}" + } elseif ($file.Name -like "Facet.Dashboard.*") { + Write-Host "Publishing Facet.Dashboard package..." + $apiKey = "${{ secrets.NUGET_API_KEY_DASHBOARD }}" + } elseif ($file.Name -like "Facet.Attributes.*") { + Write-Host "Publishing Facet.Attributes package..." + $apiKey = "${{ secrets.NUGET_ATTRIBUTES_API_KEY }}" + } elseif ($file.Name -like "Facet.*") { + Write-Host "Publishing Facet package..." + $apiKey = "${{ secrets.NUGET_API_KEY }}" + } else { + Write-Host "Skipping unknown package: $($file.Name)" + continue + } + + dotnet nuget push $file ` + --api-key $apiKey ` + --source https://api.nuget.org/v3/index.json ` + --skip-duplicate + } + + - name: Comment preview version on PR + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.preview_version.outputs.PREVIEW_VERSION }}'; + const body = [ + '## 📦 Preview packages published', + '', + `Version: \`${version}\``, + '', + 'Install with:', + '```', + `dotnet add package Facet --version ${version}`, + '```', + '', + '_This pre-release is published automatically from this PR and will be overwritten on the next push._', + ].join('\n'); + + // Update existing bot comment if present, otherwise create a new one + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.find( + c => c.user.type === 'Bot' && c.body.includes('Preview packages published') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } diff --git a/README.md b/README.md index 4431cc3..684880a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Instead of manually creating each facet, **Facet** auto-generates them from a si - **`GenerateEquality`** - generate value-based `Equals`, `GetHashCode`, `==`, `!=` for class DTOs ### Advanced Features +- **Multi-source mapping** — a single target class can carry multiple `[Facet]` attributes, each mapping from a different source type; produces per-source constructors, projections (`ProjectionFrom{Source}`), and reverse-mapping methods (`To{Source}()`) - **`[Flatten]`** - collapse nested object graphs into top-level properties - **`[Wrapper]`** - reference-based delegation for facades, ViewModels, and decorators - **`[GenerateDtos]`** - auto-generate full CRUD DTO sets (Create, Update, Response, Query, Upsert, Patch) diff --git a/docs/06_AdvancedScenarios.md b/docs/06_AdvancedScenarios.md index 4b8b90e..88394ba 100644 --- a/docs/06_AdvancedScenarios.md +++ b/docs/06_AdvancedScenarios.md @@ -36,8 +36,97 @@ public partial class UserSummaryDto { } public partial class UserHRDto { } ``` +--- + +## Multiple Source Types to One Target + +Since Facet v6, a single target class can carry **multiple `[Facet]` attributes**, each pointing to a **different** source type. The generator emits a single partial class containing: + +- A **union of all mapped properties** (deduplicated by name; first-occurrence wins). +- **Per-source constructors** and `FromSource` factory method overloads (naturally overloaded by parameter type — no naming conflict). +- **Per-source projection expressions** named `ProjectionFrom{SourceTypeName}` to avoid static-property collisions. +- **Per-source `ToSource` methods** named `To{SourceTypeName}()` to avoid method-signature conflicts; the deprecated `BackTo` alias is **not** generated for multi-source facets. +- Shared artefacts (parameterless constructor, copy constructor, equality) are generated once from the **primary** (first) attribute. + +### Motivation + +A common scenario in domain-driven or layered architectures is a "drop-down" or "summary" DTO that can be populated from multiple different source types — an EF Core entity **and** a domain DTO both containing the same logical data: + +```csharp +// Two different source representations of the same concept +public partial class UnitEntity : ModifiedByBaseEntity { /* Id, Name, ... */ } +public partial class UnitDto : FacetsModifiedByBaseDto { /* Id, Name, ... */ } + +// One unified "display" target that can accept both +[Facet(typeof(UnitEntity), Include = [nameof(UnitEntity.Name)])] +[Facet(typeof(UnitDto), Include = [nameof(UnitDto.Name)])] +public partial class UnitDropDownDto; +``` + +### Generated API + +For the example above Facet generates a single `UnitDropDownDto` class with: + +```csharp +// Constructors (overloaded by source type — no ambiguity) +var a = new UnitDropDownDto(unitEntity); +var b = new UnitDropDownDto(unitDto); + +// Factory methods (overloaded) +var c = UnitDropDownDto.FromSource(unitEntity); +var d = UnitDropDownDto.FromSource(unitDto); + +// Per-source LINQ projections +IQueryable q1 = dbContext.Units + .Select(UnitDropDownDto.ProjectionFromUnitEntity); + +IQueryable q2 = unitDtos.AsQueryable() + .Select(UnitDropDownDto.ProjectionFromUnitDto); +``` + +### Union of Members + +When source types share properties (e.g. both have `Id` and `Name`) the property is generated **once** (first-definition wins). Exclusive properties from each source type are also included: + +```csharp +public class EntityA { public int Id { get; set; } public string Name { get; set; } public string EntityAOnly { get; set; } } +public class EntityB { public int Id { get; set; } public string Name { get; set; } public string EntityBOnly { get; set; } } + +[Facet(typeof(EntityA))] +[Facet(typeof(EntityB))] +public partial class UnionDto; +// Generated properties: Id, Name, EntityAOnly, EntityBOnly +``` + +### Reverse Mapping (ToSource) + +When `GenerateToSource = true` is specified on an attribute, a `To{SourceTypeName}()` method is generated for that source type: + +```csharp +[Facet(typeof(UnitEntity), Include = [nameof(UnitEntity.Name)], GenerateToSource = true)] +[Facet(typeof(UnitDto), Include = [nameof(UnitDto.Name)])] +public partial class UnitDropDownDto; + +// Generated: +// public UnitEntity ToUnitEntity() { ... } +// (no ToUnitDto because GenerateToSource was not set on the second attribute) +``` + +### Important Notes + +| Behaviour | Detail | +|-----------|--------| +| **Projection names** | Always `ProjectionFrom{SourceSimpleName}` for multi-source targets | +| **ToSource names** | Always `To{SourceSimpleName}()` for multi-source targets — no `BackTo()` alias | +| **Single-source behaviour** | Unchanged: `Projection`, `ToSource()`, and `BackTo()` are still generated with the original names | +| **Member deduplication** | Properties with the same name across multiple sources are generated once; the type from the first mapping is used | +| **Configuration** | `Configuration`, `BeforeMap`, `AfterMap`, and `ToSourceConfiguration` are each read independently per attribute | + +--- + ## Include vs Exclude Patterns + ### Include Pattern - Building Focused DTOs Use the `Include` pattern when you want facets with only specific properties: diff --git a/docs/README.md b/docs/README.md index c52fd32..cf070e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Welcome to the Facet documentation! This index will help you navigate all availa - [Extension Methods](05_Extensions.md): Extension Methods (LINQ, EF Core, etc.) - [Advanced Scenarios](06_AdvancedScenarios.md): Advanced Usage Scenarios - Multiple facets from one source + - **Multiple source types to one target** (multi-source mapping) - Include/Exclude patterns - Nested Facets (single objects & collections) - Collection support (List, Array, ICollection, IEnumerable, IReadOnlyList, IReadOnlyCollection, Immutable collections, and custom types) diff --git a/src/Facet/Generators/FacetGenerators/CodeBuilder.cs b/src/Facet/Generators/FacetGenerators/CodeBuilder.cs index 9fc1c74..24cdff1 100644 --- a/src/Facet/Generators/FacetGenerators/CodeBuilder.cs +++ b/src/Facet/Generators/FacetGenerators/CodeBuilder.cs @@ -156,7 +156,228 @@ public static string Generate(FacetTargetModel model, Dictionary + /// Dispatches to for a single-source facet or to + /// when the same target type carries multiple + /// [Facet] attributes (multi-source scenario). + /// + public static string GenerateForGroup( + IReadOnlyList models, + Dictionary facetLookup) + { + if (models.Count == 1) + return Generate(models[0], facetLookup); + + return GenerateCombined(models, facetLookup); + } + + /// + /// Generates a single partial-class file that combines mappings from multiple source types + /// to the same target type. + /// + /// Properties are the union of all members across every source mapping (deduplicated by name, + /// first-definition wins). Constructor/factory/projection/ToSource artefacts are generated + /// once per source. Projection and ToSource use source-specific names + /// (ProjectionFrom{SourceSimpleName} / To{SourceSimpleName}) to avoid conflicts. + /// Shared artefacts (parameterless constructor, copy constructor, equality) are generated from + /// the primary (first) model only. + /// + /// + public static string GenerateCombined( + IReadOnlyList models, + Dictionary facetLookup) + { + var primaryModel = models[0]; + var sb = new StringBuilder(); + GenerateFileHeader(sb); + + // Collect namespaces and static-using directives from ALL models + var namespacesToImport = new HashSet(); + var staticUsingTypes = new HashSet(); + foreach (var m in models) + { + foreach (var ns in CodeGenerationHelpers.CollectNamespaces(m)) + namespacesToImport.Add(ns); + foreach (var su in CodeGenerationHelpers.CollectStaticUsingTypes(m)) + staticUsingTypes.Add(su); + } + + foreach (var ns in namespacesToImport.OrderBy(x => x)) + sb.AppendLine($"using {ns};"); + + foreach (var type in staticUsingTypes.OrderBy(x => x)) + sb.AppendLine($"using static {type};"); + + sb.AppendLine(); + + // Enable nullable context if ANY model needs it + var needsNullable = models.Any(m => + m.Members.Any(mem => !mem.IsValueType && mem.TypeName.EndsWith("?")) + || m.MaxDepth > 0 + || m.PreserveReferences); + if (needsNullable) + { + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(primaryModel.Namespace)) + sb.AppendLine($"namespace {primaryModel.Namespace};"); + + var containingTypeIndent = GenerateContainingTypeHierarchy(sb, primaryModel); + + if (!string.IsNullOrWhiteSpace(primaryModel.TypeXmlDocumentation)) + { + var indentedDoc = primaryModel.TypeXmlDocumentation!.Replace("\n", $"\n{containingTypeIndent}"); + sb.AppendLine($"{containingTypeIndent}{indentedDoc}"); + } + + var keyword = GetTypeKeyword(primaryModel); + + // Build the union of all members across source models, deduplicating by name (first-wins). + var seenMemberNames = new HashSet(); + var unionMembers = new System.Collections.Generic.List(); + foreach (var m in models) + { + foreach (var member in m.Members) + { + if (seenMemberNames.Add(member.Name)) + unionMembers.Add(member); + } + } + + var hasInitOnlyUnion = unionMembers.Any(m => m.IsInitOnly); + var hasRequiredUnion = unionMembers.Any(m => m.IsRequired); + + // Positional record logic uses the primary model's member set for the declaration + var isPositional = primaryModel.IsRecord && !primaryModel.HasExistingPrimaryConstructor + && !(primaryModel.TypeKind == TypeKind.Class && hasRequiredUnion); + var shouldGenerateEquality = primaryModel.GenerateEquality && !primaryModel.IsRecord; + + if (isPositional) + { + // Use the primary model for positional declaration (shares the primary source's shape) + GeneratePositionalDeclaration(sb, primaryModel, keyword, containingTypeIndent); + } + + if (shouldGenerateEquality) + { + sb.AppendLine($"{containingTypeIndent}{primaryModel.Accessibility} partial {keyword} {primaryModel.Name} : {EqualityGenerator.GetEquatableInterface(primaryModel)}"); + } + else + { + sb.AppendLine($"{containingTypeIndent}{primaryModel.Accessibility} partial {keyword} {primaryModel.Name}"); + } + sb.AppendLine($"{containingTypeIndent}{{"); + + var memberIndent = containingTypeIndent + " "; + + // Generate union of properties once + if (!isPositional || primaryModel.HasExistingPrimaryConstructor) + { + // Build a synthetic model view with union members for MemberGenerator + MemberGenerator.GenerateMembers(sb, primaryModel, memberIndent, unionMembers); + } + + // Shared: parameterless constructor (from primary model) + if (primaryModel.GenerateParameterlessConstructor) + ConstructorGenerator.GenerateParameterlessConstructor(sb, primaryModel, isPositional); + + // Per-source: constructors + FromSource factory methods + foreach (var model in models) + { + if (!model.GenerateConstructor) continue; + + var hasCustomMapping = !string.IsNullOrWhiteSpace(model.ConfigurationTypeName); + var needsDepthTracking = model.MaxDepth > 0 || model.PreserveReferences; + var modelHasInitOnly = model.Members.Any(mem => mem.IsInitOnly); + var modelHasRequired = model.Members.Any(mem => mem.IsRequired); + + ConstructorGenerator.GenerateConstructor( + sb, model, isPositional, modelHasInitOnly, hasCustomMapping, modelHasRequired); + } + + // Shared: copy constructor (from primary model) + if (primaryModel.GenerateCopyConstructor) + CopyConstructorGenerator.Generate(sb, primaryModel, memberIndent); + + // Per-source: projections (use source-specific names to avoid static property conflicts) + foreach (var model in models) + { + if (!model.GenerateExpressionProjection) continue; + + var projectionName = GetProjectionName(model, models); + ProjectionGenerator.GenerateProjectionProperty(sb, model, memberIndent, facetLookup, projectionName); + } + + // Per-source: ToSource methods (use source-specific names to avoid method conflicts) + foreach (var model in models) + { + if (!model.GenerateToSource) continue; + + var toSourceName = GetToSourceMethodName(model, models); + ToSourceGenerator.Generate(sb, model, toSourceName); + } + + // Per-source: FlattenTo + foreach (var model in models) + { + if (model.FlattenToTypes.Length > 0) + FlattenToGenerator.Generate(sb, model, memberIndent, facetLookup); + } + + // Shared: equality members (from primary model) + if (shouldGenerateEquality) + EqualityGenerator.Generate(sb, primaryModel, memberIndent); + + sb.AppendLine($"{containingTypeIndent}}}"); + CloseContainingTypeHierarchy(sb, primaryModel, containingTypeIndent); + + return sb.ToString(); + } + + /// + /// Returns the property name to use for the Projection expression of the given model. + /// + /// Single-source: "Projection" (backward-compatible). + /// Multi-source: "ProjectionFrom{SourceSimpleName}". + /// + /// + private static string GetProjectionName(FacetTargetModel model, IReadOnlyList allModels) + { + if (allModels.Count <= 1) + return "Projection"; + + return "ProjectionFrom" + GetSourceSimpleName(model); + } + + /// + /// Returns the method name to use for the ToSource conversion of the given model. + /// + /// Single-source: null"ToSource" + deprecated BackTo alias. + /// Multi-source: "To{SourceSimpleName}" (no BackTo alias). + /// + /// + private static string? GetToSourceMethodName(FacetTargetModel model, IReadOnlyList allModels) + { + if (allModels.Count <= 1) + return null; // Use default "ToSource" + BackTo + + return "To" + GetSourceSimpleName(model); + } + + /// + /// Extracts the simple (unqualified) type name from a model's fully-qualified + /// , stripping everything from the + /// first < onwards so that generic types (e.g. List<String>) + /// produce a valid C# identifier fragment (List). + /// + private static string GetSourceSimpleName(FacetTargetModel model) + { + var simpleName = CodeGenerationHelpers.GetSimpleTypeName(model.SourceTypeName); + var angleBracket = simpleName.IndexOf('<'); + return angleBracket > 0 ? simpleName.Substring(0, angleBracket) : simpleName; + } private static void GenerateFileHeader(StringBuilder sb) { @@ -222,6 +443,4 @@ private static void GeneratePositionalDeclaration(StringBuilder sb, FacetTargetM sb.AppendLine($"{indent}{model.Accessibility} partial {keyword} {model.Name}({parameters});"); sb.AppendLine($"{indent}#pragma warning restore CS1591"); } - - #endregion } diff --git a/src/Facet/Generators/FacetGenerators/FacetGenerator.cs b/src/Facet/Generators/FacetGenerators/FacetGenerator.cs index dd6dd30..9bb3179 100644 --- a/src/Facet/Generators/FacetGenerators/FacetGenerator.cs +++ b/src/Facet/Generators/FacetGenerators/FacetGenerator.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; using System.Linq; using System.Text; @@ -22,7 +23,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, token) => (ctx, token)) .Combine(globalOptions) - .Select(static (combined, token) => ModelBuilder.BuildModel(combined.Left.ctx, combined.Right, combined.Left.token)) + // Each context may carry multiple [Facet] attributes; build one model per attribute. + .SelectMany(static (combined, token) => ModelBuilder.BuildModels(combined.Left.ctx, combined.Right, combined.Left.token)) .Where(static m => m is not null); // Collect all facet models to enable nested facet lookup during generation @@ -32,18 +34,30 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { spc.CancellationToken.ThrowIfCancellationRequested(); - // Build a lookup dictionary for nested facet resolution - var facetLookup = models + // Build a lookup dictionary for nested facet resolution. + // When multiple models share the same FullName (multi-source scenario), only the first + // model is kept in the lookup; that is sufficient for nested-facet type resolution. + var facetLookup = new Dictionary(); + foreach (var model in models) + { + if (model is not null && !facetLookup.ContainsKey(model.FullName)) + facetLookup[model.FullName] = model; + } + + // Group models by target type FullName. Multiple models for the same target arise when + // the target class carries more than one [Facet] attribute (different source types). + var modelsByTarget = models .Where(m => m is not null) - .ToDictionary(m => m!.FullName, m => m!); + .GroupBy(m => m!.FullName) + .ToList(); - // Generate code for each facet with access to all facet models - foreach (var model in models) + foreach (var group in modelsByTarget) { - if (model is null) continue; + spc.CancellationToken.ThrowIfCancellationRequested(); - var code = CodeBuilder.Generate(model, facetLookup); - spc.AddSource($"{model.FullName}.g.cs", SourceText.From(code, Encoding.UTF8)); + var modelsForTarget = group.Select(m => m!).ToList(); + var code = CodeBuilder.GenerateForGroup(modelsForTarget, facetLookup); + spc.AddSource($"{group.Key}.g.cs", SourceText.From(code, Encoding.UTF8)); } }); } diff --git a/src/Facet/Generators/FacetGenerators/MemberGenerator.cs b/src/Facet/Generators/FacetGenerators/MemberGenerator.cs index 59c9810..8eacf5d 100644 --- a/src/Facet/Generators/FacetGenerators/MemberGenerator.cs +++ b/src/Facet/Generators/FacetGenerators/MemberGenerator.cs @@ -11,12 +11,23 @@ internal static class MemberGenerator /// /// Generates member declarations (properties and fields) for the target type. /// - public static void GenerateMembers(StringBuilder sb, FacetTargetModel model, string memberIndent) + /// + /// When non-, generates declarations for this set of members instead of + /// . Used by the multi-source combined generator to emit + /// the union of all source members. + /// + public static void GenerateMembers( + StringBuilder sb, + FacetTargetModel model, + string memberIndent, + System.Collections.Generic.IReadOnlyList? membersOverride = null) { // Create a HashSet for efficient lookup of base class member names var baseClassMembers = new System.Collections.Generic.HashSet(model.BaseClassMemberNames); - foreach (var m in model.Members) + var members = membersOverride ?? (System.Collections.Generic.IReadOnlyList)model.Members; + + foreach (var m in members) { // Skip user-declared properties (those with [MapFrom] or [MapWhen] attribute) if (m.IsUserDeclared) diff --git a/src/Facet/Generators/FacetGenerators/ModelBuilder.cs b/src/Facet/Generators/FacetGenerators/ModelBuilder.cs index a971434..63e016d 100644 --- a/src/Facet/Generators/FacetGenerators/ModelBuilder.cs +++ b/src/Facet/Generators/FacetGenerators/ModelBuilder.cs @@ -14,8 +14,32 @@ namespace Facet.Generators; /// internal static class ModelBuilder { + /// + /// Builds one per [Facet] attribute found on the target + /// type, allowing multiple source-type mappings to the same target class. + /// + public static ImmutableArray BuildModels( + GeneratorAttributeSyntaxContext context, + GlobalConfigurationDefaults globalDefaults, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (context.TargetSymbol is not INamedTypeSymbol) return ImmutableArray.Empty; + if (context.Attributes.Length == 0) return ImmutableArray.Empty; + + var builder = ImmutableArray.CreateBuilder(context.Attributes.Length); + foreach (var attribute in context.Attributes) + { + token.ThrowIfCancellationRequested(); + builder.Add(BuildModelForAttribute(context, attribute, globalDefaults, token)); + } + return builder.ToImmutable(); + } + /// /// Builds a FacetTargetModel from the generator attribute syntax context. + /// Only processes the first [Facet] attribute found on the type. + /// Use to process all attributes. /// public static FacetTargetModel? BuildModel( GeneratorAttributeSyntaxContext context, @@ -23,11 +47,20 @@ internal static class ModelBuilder CancellationToken token) { token.ThrowIfCancellationRequested(); - if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; + if (context.TargetSymbol is not INamedTypeSymbol) return null; if (context.Attributes.Length == 0) return null; - var attribute = context.Attributes[0]; + return BuildModelForAttribute(context, context.Attributes[0], globalDefaults, token); + } + + private static FacetTargetModel? BuildModelForAttribute( + GeneratorAttributeSyntaxContext context, + AttributeData attribute, + GlobalConfigurationDefaults globalDefaults, + CancellationToken token) + { token.ThrowIfCancellationRequested(); + if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) return null; var sourceType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; if (sourceType == null) return null; diff --git a/src/Facet/Generators/FacetGenerators/ProjectionGenerator.cs b/src/Facet/Generators/FacetGenerators/ProjectionGenerator.cs index 5ea5f57..1827757 100644 --- a/src/Facet/Generators/FacetGenerators/ProjectionGenerator.cs +++ b/src/Facet/Generators/FacetGenerators/ProjectionGenerator.cs @@ -13,12 +13,19 @@ internal static class ProjectionGenerator /// /// Generates the projection property for LINQ/EF Core query optimization. /// + /// + /// The name to use for the generated static property. + /// Defaults to "Projection" when . + /// Pass a custom name (e.g. "ProjectionFromUnitEntity") for multi-source facets. + /// public static void GenerateProjectionProperty( StringBuilder sb, FacetTargetModel model, string memberIndent, - Dictionary facetLookup) + Dictionary facetLookup, + string? projectionPropertyName = null) { + var propertyName = projectionPropertyName ?? "Projection"; sb.AppendLine(); if (model.HasExistingPrimaryConstructor && model.IsRecord) @@ -27,13 +34,13 @@ public static void GenerateProjectionProperty( } else if (model.HasProjectionMapConfiguration) { - GenerateProjectionDocumentation(sb, model, memberIndent); - GenerateLazyProjection(sb, model, memberIndent, facetLookup); + GenerateProjectionDocumentation(sb, model, memberIndent, propertyName); + GenerateLazyProjection(sb, model, memberIndent, facetLookup, propertyName); } else { - GenerateProjectionDocumentation(sb, model, memberIndent); - sb.AppendLine($"{memberIndent}public static {(model.BaseHidesFacetMembers ? "new " : "")}Expression> Projection =>"); + GenerateProjectionDocumentation(sb, model, memberIndent, propertyName); + sb.AppendLine($"{memberIndent}public static {(model.BaseHidesFacetMembers ? "new " : "")}Expression> {propertyName} =>"); // Generate object initializer projection for EF Core compatibility GenerateProjectionExpression(sb, model, memberIndent, facetLookup); @@ -50,23 +57,29 @@ private static void GenerateLazyProjection( StringBuilder sb, FacetTargetModel model, string memberIndent, - Dictionary _) + Dictionary _, + string propertyName = "Projection") { var newModifier = model.BaseHidesFacetMembers ? "new " : ""; var src = model.SourceTypeName; var tgt = model.Name; + // Derive a unique backing-field name from the property name to avoid collisions in multi-source scenarios. + var safeName = string.IsNullOrEmpty(propertyName) ? "Projection" : propertyName; + var backingFieldName = "_" + char.ToLowerInvariant(safeName[0]) + safeName.Substring(1); + var buildMethodName = "Build" + safeName; + // Backing field - sb.AppendLine($"{memberIndent}private static global::System.Linq.Expressions.Expression>? _projection;"); + sb.AppendLine($"{memberIndent}private static global::System.Linq.Expressions.Expression>? {backingFieldName};"); sb.AppendLine(); // Projection property - sb.AppendLine($"{memberIndent}public static {newModifier}global::System.Linq.Expressions.Expression> Projection"); - sb.AppendLine($"{memberIndent} => global::System.Threading.LazyInitializer.EnsureInitialized(ref _projection, BuildProjection);"); + sb.AppendLine($"{memberIndent}public static {newModifier}global::System.Linq.Expressions.Expression> {propertyName}"); + sb.AppendLine($"{memberIndent} => global::System.Threading.LazyInitializer.EnsureInitialized(ref {backingFieldName}, {buildMethodName});"); sb.AppendLine(); // BuildProjection() method - sb.AppendLine($"{memberIndent}private static global::System.Linq.Expressions.Expression> BuildProjection()"); + sb.AppendLine($"{memberIndent}private static global::System.Linq.Expressions.Expression> {buildMethodName}()"); sb.AppendLine($"{memberIndent}{{"); var bodyIndent = memberIndent + " "; @@ -155,7 +168,7 @@ private static void GenerateProjectionNotSupportedComment(StringBuilder sb, Face } - private static void GenerateProjectionDocumentation(StringBuilder sb, FacetTargetModel model, string memberIndent) + private static void GenerateProjectionDocumentation(StringBuilder sb, FacetTargetModel model, string memberIndent, string propertyName = "Projection") { // Generate projection XML documentation sb.AppendLine($"{memberIndent}/// "); @@ -167,7 +180,7 @@ private static void GenerateProjectionDocumentation(StringBuilder sb, FacetTarge sb.AppendLine($"{memberIndent}/// "); sb.AppendLine($"{memberIndent}/// var dtos = context.{CodeGenerationHelpers.GetSimpleTypeName(model.SourceTypeName)}s"); sb.AppendLine($"{memberIndent}/// .Where(x => x.IsActive)"); - sb.AppendLine($"{memberIndent}/// .Select({model.Name}.Projection)"); + sb.AppendLine($"{memberIndent}/// .Select({model.Name}.{propertyName})"); sb.AppendLine($"{memberIndent}/// .ToList();"); sb.AppendLine($"{memberIndent}/// "); sb.AppendLine($"{memberIndent}/// "); diff --git a/src/Facet/Generators/FacetGenerators/ToSourceGenerator.cs b/src/Facet/Generators/FacetGenerators/ToSourceGenerator.cs index b2e3914..a16ee24 100644 --- a/src/Facet/Generators/FacetGenerators/ToSourceGenerator.cs +++ b/src/Facet/Generators/FacetGenerators/ToSourceGenerator.cs @@ -13,15 +13,24 @@ internal static class ToSourceGenerator /// /// Generates the ToSource and BackTo methods that convert the facet type back to the source type. /// - public static void Generate(StringBuilder sb, FacetTargetModel model) + /// + /// The name to use for the generated method. + /// When , the default names ToSource and BackTo are used. + /// When provided (for multi-source facets), only the specified method is generated without the + /// deprecated BackTo alias. + /// + public static void Generate(StringBuilder sb, FacetTargetModel model, string? toSourceMethodName = null) { + var methodName = toSourceMethodName ?? "ToSource"; + var isCustomName = toSourceMethodName != null; + // Generate the main ToSource method sb.AppendLine(); sb.AppendLine(" /// "); - sb.AppendLine($" /// Converts this instance of to an instance of the source type."); + sb.AppendLine($" /// Converts this instance of to an instance of ."); sb.AppendLine(" /// "); - sb.AppendLine($" /// An instance of the source type with properties mapped from this instance."); - sb.AppendLine($" public {(model.BaseHidesFacetMembers ? "new " : "")}{model.SourceTypeName} ToSource()"); + sb.AppendLine($" /// An instance of with properties mapped from this instance."); + sb.AppendLine($" public {(model.BaseHidesFacetMembers ? "new " : "")}{model.SourceTypeName} {methodName}()"); sb.AppendLine(" {"); if (model.SourceHasPositionalConstructor) @@ -35,14 +44,17 @@ public static void Generate(StringBuilder sb, FacetTargetModel model) sb.AppendLine(" }"); - // Generate the deprecated BackTo method that calls ToSource - sb.AppendLine(); - sb.AppendLine(" /// "); - sb.AppendLine($" /// Converts this instance of to an instance of the source type."); - sb.AppendLine(" /// "); - sb.AppendLine($" /// An instance of the source type with properties mapped from this instance."); - sb.AppendLine(" [global::System.Obsolete(\"Use ToSource() instead. This method will be removed in a future version.\")]"); - sb.AppendLine($" public {(model.BaseHidesFacetMembers ? "new " : "")}{model.SourceTypeName} BackTo() => ToSource();"); + // Generate the deprecated BackTo method only for the default (single-source) naming + if (!isCustomName) + { + sb.AppendLine(); + sb.AppendLine(" /// "); + sb.AppendLine($" /// Converts this instance of to an instance of the source type."); + sb.AppendLine(" /// "); + sb.AppendLine($" /// An instance of the source type with properties mapped from this instance."); + sb.AppendLine(" [global::System.Obsolete(\"Use ToSource() instead. This method will be removed in a future version.\")]"); + sb.AppendLine($" public {(model.BaseHidesFacetMembers ? "new " : "")}{model.SourceTypeName} BackTo() => ToSource();"); + } } private static void GeneratePositionalToSource(StringBuilder sb, FacetTargetModel model) diff --git a/test/Facet.Tests/TestModels/TestDtos.cs b/test/Facet.Tests/TestModels/TestDtos.cs index 8d8dd6a..abc2752 100644 --- a/test/Facet.Tests/TestModels/TestDtos.cs +++ b/test/Facet.Tests/TestModels/TestDtos.cs @@ -309,4 +309,33 @@ public static void Map(OrderDto facet, JsonStoredEntity target) public partial class OrderDto { public OrderMetadata? Metadata { get; set; } -} \ No newline at end of file +} + +// ────────────────────────────────────────────────────────────────────────────── +// Multi-source mapping test DTOs (GitHub issue: map different source types to the +// same target type). +// ────────────────────────────────────────────────────────────────────────────── + +/// +/// A DTO that can be constructed from either or +/// – both share Id and Name. +/// +[Facet(typeof(MultiSourceEntityA), Include = new[] { nameof(MultiSourceEntityA.Id), nameof(MultiSourceEntityA.Name) })] +[Facet(typeof(MultiSourceEntityB), Include = new[] { nameof(MultiSourceEntityB.Id), nameof(MultiSourceEntityB.Name) })] +public partial class MultiSourceDto; + +/// +/// A DTO that maps from A (with ToSource) and from B (without ToSource), +/// used to verify per-source ToSource method generation. +/// +[Facet(typeof(MultiSourceEntityA), Include = new[] { nameof(MultiSourceEntityA.Id), nameof(MultiSourceEntityA.Name) }, GenerateToSource = true)] +[Facet(typeof(MultiSourceEntityB), Include = new[] { nameof(MultiSourceEntityB.Id), nameof(MultiSourceEntityB.Name) })] +public partial class MultiSourceWithToSourceDto; + +/// +/// A DTO that maps from A and B including their exclusive properties, used to verify +/// the union-of-members behaviour. +/// +[Facet(typeof(MultiSourceEntityA))] +[Facet(typeof(MultiSourceEntityB))] +public partial class MultiSourceUnionDto; diff --git a/test/Facet.Tests/TestModels/TestEntities.cs b/test/Facet.Tests/TestModels/TestEntities.cs index c12d282..f911fa8 100644 --- a/test/Facet.Tests/TestModels/TestEntities.cs +++ b/test/Facet.Tests/TestModels/TestEntities.cs @@ -444,3 +444,32 @@ public class JsonStoredEntity public string Name { get; set; } = string.Empty; public string MetadataJson { get; set; } = "{}"; } + +// ────────────────────────────────────────────────────────────────────────────── +// Multi-source mapping test entities (GitHub issue: map different source types +// to the same target type). +// ────────────────────────────────────────────────────────────────────────────── + +/// Source type A for multi-source facet tests. +public class MultiSourceEntityA +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string OnlyInA { get; set; } = string.Empty; +} + +/// Source type B for multi-source facet tests. +public class MultiSourceEntityB +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string OnlyInB { get; set; } = string.Empty; +} + +/// Source type C (record) for multi-source facet tests. +public record MultiSourceEntityC +{ + public required int Id { get; init; } + public required string Name { get; init; } +} + diff --git a/test/Facet.Tests/UnitTests/Core/Facet/MultiSourceMappingTests.cs b/test/Facet.Tests/UnitTests/Core/Facet/MultiSourceMappingTests.cs new file mode 100644 index 0000000..ea876a0 --- /dev/null +++ b/test/Facet.Tests/UnitTests/Core/Facet/MultiSourceMappingTests.cs @@ -0,0 +1,142 @@ +using Facet.Tests.TestModels; + +namespace Facet.Tests.UnitTests.Core.Facet; + +/// +/// Tests for the multi-source mapping feature: a single target class decorated with +/// multiple [Facet] attributes, each specifying a different source type. +/// +public class MultiSourceMappingTests +{ + // ── Construction from source A ──────────────────────────────────────────── + + [Fact] + public void Constructor_FromEntityA_MapsSharedProperties() + { + var source = new MultiSourceEntityA { Id = 1, Name = "Alpha", OnlyInA = "A-only" }; + + var dto = new MultiSourceDto(source); + + dto.Id.Should().Be(1); + dto.Name.Should().Be("Alpha"); + } + + [Fact] + public void Constructor_FromEntityB_MapsSharedProperties() + { + var source = new MultiSourceEntityB { Id = 2, Name = "Beta", OnlyInB = "B-only" }; + + var dto = new MultiSourceDto(source); + + dto.Id.Should().Be(2); + dto.Name.Should().Be("Beta"); + } + + // ── FromSource factory methods ──────────────────────────────────────────── + + [Fact] + public void FromSource_WithEntityA_ReturnsCorrectlyMappedDto() + { + var source = new MultiSourceEntityA { Id = 10, Name = "EntityA" }; + + var dto = MultiSourceDto.FromSource(source); + + dto.Id.Should().Be(10); + dto.Name.Should().Be("EntityA"); + } + + [Fact] + public void FromSource_WithEntityB_ReturnsCorrectlyMappedDto() + { + var source = new MultiSourceEntityB { Id = 20, Name = "EntityB" }; + + var dto = MultiSourceDto.FromSource(source); + + dto.Id.Should().Be(20); + dto.Name.Should().Be("EntityB"); + } + + // ── Projection expressions ──────────────────────────────────────────────── + + [Fact] + public void Projection_FromEntityA_CanProjectList() + { + var sources = new List + { + new() { Id = 1, Name = "One" }, + new() { Id = 2, Name = "Two" }, + }; + + var dtos = sources.AsQueryable().Select(MultiSourceDto.ProjectionFromMultiSourceEntityA).ToList(); + + dtos.Should().HaveCount(2); + dtos[0].Id.Should().Be(1); + dtos[1].Name.Should().Be("Two"); + } + + [Fact] + public void Projection_FromEntityB_CanProjectList() + { + var sources = new List + { + new() { Id = 3, Name = "Three" }, + }; + + var dtos = sources.AsQueryable().Select(MultiSourceDto.ProjectionFromMultiSourceEntityB).ToList(); + + dtos.Should().HaveCount(1); + dtos[0].Id.Should().Be(3); + dtos[0].Name.Should().Be("Three"); + } + + // ── ToSource methods ────────────────────────────────────────────────────── + + [Fact] + public void ToMultiSourceEntityA_ReturnsEntityWithMappedProperties() + { + var dto = new MultiSourceWithToSourceDto { Id = 5, Name = "Five" }; + + var entity = dto.ToMultiSourceEntityA(); + + entity.Id.Should().Be(5); + entity.Name.Should().Be("Five"); + } + + // ── Union-of-members behaviour ──────────────────────────────────────────── + + [Fact] + public void UnionDto_ContainsMembersFromBothSources() + { + // Verify that the union DTO exposes properties contributed by BOTH source types. + var dtoType = typeof(MultiSourceUnionDto); + + dtoType.GetProperty(nameof(MultiSourceEntityA.Id)).Should().NotBeNull("Id is present in both sources"); + dtoType.GetProperty(nameof(MultiSourceEntityA.Name)).Should().NotBeNull("Name is present in both sources"); + dtoType.GetProperty(nameof(MultiSourceEntityA.OnlyInA)).Should().NotBeNull("OnlyInA is contributed by EntityA"); + dtoType.GetProperty(nameof(MultiSourceEntityB.OnlyInB)).Should().NotBeNull("OnlyInB is contributed by EntityB"); + } + + [Fact] + public void UnionDto_ConstructorFromA_MapsAllAProperties() + { + var source = new MultiSourceEntityA { Id = 7, Name = "Seven", OnlyInA = "aaa" }; + + var dto = new MultiSourceUnionDto(source); + + dto.Id.Should().Be(7); + dto.Name.Should().Be("Seven"); + dto.OnlyInA.Should().Be("aaa"); + } + + [Fact] + public void UnionDto_ConstructorFromB_MapsAllBProperties() + { + var source = new MultiSourceEntityB { Id = 8, Name = "Eight", OnlyInB = "bbb" }; + + var dto = new MultiSourceUnionDto(source); + + dto.Id.Should().Be(8); + dto.Name.Should().Be("Eight"); + dto.OnlyInB.Should().Be("bbb"); + } +}