From 91d6eb6ae186824b943b15d50cc7845e73101d8c Mon Sep 17 00:00:00 2001 From: Skuirrels Date: Sat, 15 Aug 2026 11:56:48 +0100 Subject: [PATCH] Add advanced mapping contracts for 1.2.0 --- .github/workflows/docs.yml | 6 +- .github/workflows/test.yml | 46 +- CHANGELOG.md | 26 +- Directory.Build.props | 2 +- DomainMapper.slnx | 2 + README.md | 15 +- .../AdvancedFeatureBenchmarks.cs | 133 ++ .../ComparisonMappingBenchmarks.cs | 34 + .../DomainMapper.Benchmarks.csproj | 1 + .../IncrementalGeneratorBenchmarks.cs | 161 +++ build/package.sh | 13 +- docs/benchmarks.md | 37 + .../analyzer-diagnostics/DMPR105.mdx | 5 + .../analyzer-diagnostics/DMPR106.mdx | 5 + .../analyzer-diagnostics/DMPR107.mdx | 5 + docs/docs/configuration/capabilities.md | 30 +- docs/docs/configuration/collections.md | 16 + docs/docs/configuration/compatibility.md | 14 + docs/docs/configuration/projections.md | 32 + docs/docusaurus.config.js | 2 +- .../DomainMapper.AotSmoke.csproj | 18 + samples/DomainMapper.AotSmoke/Program.cs | 29 + .../DomainMapper.PackageSmoke.csproj | 8 + samples/DomainMapper.PackageSmoke/Program.cs | 45 + .../CollectionUpdatePolicy.cs | 14 + .../MapCollectionAttribute.cs | 15 + .../MapReferenceTrackingAttribute.cs | 11 + .../MapRegistryAttribute.cs | 10 + .../MapRegistryDerivedAttribute.cs | 8 + .../DomainMapper.Projections.csproj | 22 + .../MapProjectionAttribute.cs | 12 + src/DomainMapper/AnalyzerReleases.Shipped.md | 10 + src/DomainMapper/DomainMapper.csproj | 2 +- src/DomainMapper/DomainMapperGenerator.cs | 21 +- src/DomainMapper/Engine/MapperCompiler.cs | 1156 ++++++++++++++++- .../Engine/MapperGenerationInput.cs | 157 +++ .../Engine/MapperGenerationInputComparer.cs | 11 + .../Engine/MappingMethodConfiguration.cs | 10 +- .../DomainMapper.Tests.csproj | 1 + .../Engine/AdvancedProductContractTests.cs | 1094 ++++++++++++++++ .../Engine/GeneratorTestHarness.cs | 13 + 41 files changed, 3186 insertions(+), 66 deletions(-) create mode 100644 benchmarks/DomainMapper.Benchmarks/AdvancedFeatureBenchmarks.cs create mode 100644 benchmarks/DomainMapper.Benchmarks/IncrementalGeneratorBenchmarks.cs create mode 100644 docs/docs/configuration/analyzer-diagnostics/DMPR105.mdx create mode 100644 docs/docs/configuration/analyzer-diagnostics/DMPR106.mdx create mode 100644 docs/docs/configuration/analyzer-diagnostics/DMPR107.mdx create mode 100644 docs/docs/configuration/compatibility.md create mode 100644 docs/docs/configuration/projections.md create mode 100644 samples/DomainMapper.AotSmoke/DomainMapper.AotSmoke.csproj create mode 100644 samples/DomainMapper.AotSmoke/Program.cs create mode 100644 src/DomainMapper.Abstractions/CollectionUpdatePolicy.cs create mode 100644 src/DomainMapper.Abstractions/MapCollectionAttribute.cs create mode 100644 src/DomainMapper.Abstractions/MapReferenceTrackingAttribute.cs create mode 100644 src/DomainMapper.Abstractions/MapRegistryAttribute.cs create mode 100644 src/DomainMapper.Abstractions/MapRegistryDerivedAttribute.cs create mode 100644 src/DomainMapper.Projections/DomainMapper.Projections.csproj create mode 100644 src/DomainMapper.Projections/MapProjectionAttribute.cs create mode 100644 src/DomainMapper/Engine/MapperGenerationInput.cs create mode 100644 src/DomainMapper/Engine/MapperGenerationInputComparer.cs create mode 100644 test/DomainMapper.Tests/Engine/AdvancedProductContractTests.cs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ac9c69..b8492c9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,7 +14,7 @@ on: version: required: false type: string - default: '1.1.0-dev' + default: '1.2.0-dev' workflow_dispatch: inputs: deploy: @@ -33,7 +33,7 @@ on: version: required: false type: string - default: '1.1.0-dev' + default: '1.2.0-dev' description: The version of DomainMapper to be referenced from the documentation pull_request: branches: @@ -99,7 +99,7 @@ jobs: # we never want to deploy with no version set # stop the deployment here - name: Version not set - if: ${{ inputs.version == '' || inputs.version == '1.1.0-dev' }} + if: ${{ inputs.version == '' || inputs.version == '1.2.0-dev' }} run: exit 1 - uses: actions/deploy-pages@v5 id: deployment diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce3616f..19d8b9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - run: sudo apt update && sudo apt -y install zipmerge # zipmerge is used to merge the multi target nupkg - uses: actions/setup-dotnet@v5 - run: dotnet tool restore - - run: RELEASE_VERSION="1.1.0-dev.$GITHUB_RUN_ID" ./build/package.sh + - run: RELEASE_VERSION="1.2.0-dev.$GITHUB_RUN_ID" ./build/package.sh - uses: actions/upload-artifact@v7 with: name: domainmapper-nupkg @@ -85,9 +85,11 @@ jobs: name: domainmapper-nupkg path: artifacts # Rule 52 anonymously probes repository and project URLs, which cannot succeed while the repository is private. - - run: >- - dotnet meziantou.validate-nuget-package --github-token "$GITHUB_TOKEN" - --excluded-rule-ids 52 ./artifacts/*.nupkg + - run: | + for package in ./artifacts/*.nupkg; do + dotnet meziantou.validate-nuget-package --github-token "$GITHUB_TOKEN" \ + --excluded-rule-ids 52 "$package" + done env: GITHUB_TOKEN: ${{ github.token }} integration-test: @@ -126,13 +128,20 @@ jobs: dotnet build -f net${{ matrix.dotnet }} -p:DomainMapperPackageSmokeTargetFramework=net${{ matrix.dotnet }} - -p:DomainMapperNugetPackageVersion="1.1.0-dev.$GITHUB_RUN_ID" + -p:DomainMapperNugetPackageVersion="1.2.0-dev.$GITHUB_RUN_ID" working-directory: ./samples/DomainMapper.PackageSmoke - run: >- dotnet run --no-build -f net${{ matrix.dotnet }} -p:DomainMapperPackageSmokeTargetFramework=net${{ matrix.dotnet }} working-directory: ./samples/DomainMapper.PackageSmoke + - run: >- + dotnet run + -f net${{ matrix.dotnet }} + -p:DomainMapperPackageSmokeTargetFramework=net${{ matrix.dotnet }} + -p:DomainMapperProjectionSmoke=true + -p:DomainMapperNugetPackageVersion="1.2.0-dev.$GITHUB_RUN_ID" + working-directory: ./samples/DomainMapper.PackageSmoke integration-test-net-framework: needs: package runs-on: windows-latest @@ -151,13 +160,20 @@ jobs: dotnet build -f net48 -p:DomainMapperPackageSmokeTargetFramework=net48 - -p:DomainMapperNugetPackageVersion=1.1.0-dev.$env:GITHUB_RUN_ID + -p:DomainMapperNugetPackageVersion=1.2.0-dev.$env:GITHUB_RUN_ID working-directory: ./samples/DomainMapper.PackageSmoke - run: >- dotnet run --no-build -f net48 -p:DomainMapperPackageSmokeTargetFramework=net48 working-directory: ./samples/DomainMapper.PackageSmoke + - run: >- + dotnet run + -f net48 + -p:DomainMapperPackageSmokeTargetFramework=net48 + -p:DomainMapperProjectionSmoke=true + -p:DomainMapperNugetPackageVersion=1.2.0-dev.$env:GITHUB_RUN_ID + working-directory: ./samples/DomainMapper.PackageSmoke sample: runs-on: ubuntu-latest needs: package @@ -175,7 +191,23 @@ jobs: # use nupkg artifact instead of project references - run: dotnet nuget add source "$(pwd)/artifacts" - run: dotnet clean - - run: dotnet build -p:DomainMapperNugetPackageVersion="1.1.0-dev.$GITHUB_RUN_ID" + - run: dotnet build -p:DomainMapperNugetPackageVersion="1.2.0-dev.$GITHUB_RUN_ID" working-directory: ./samples/DomainMapper.Sample - run: dotnet run --no-build working-directory: ./samples/DomainMapper.Sample + native-aot: + needs: package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v5 + - uses: actions/download-artifact@v8 + with: + name: domainmapper-nupkg + path: artifacts + - run: dotnet nuget add source "$(pwd)/artifacts" + - run: >- + dotnet publish samples/DomainMapper.AotSmoke/DomainMapper.AotSmoke.csproj + --configuration Release -p:HUSKY=0 -p:TreatWarningsAsErrors=true + -p:DomainMapperNugetPackageVersion="1.2.0-dev.$GITHUB_RUN_ID" + - run: ./artifacts/publish/DomainMapper.AotSmoke/release/DomainMapper.AotSmoke diff --git a/CHANGELOG.md b/CHANGELOG.md index 263e139..625df42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to DomainMapper are recorded here. The project follows seman ## [Unreleased] +## [1.2.0] - 2026-08-15 + +### Added + +- Explicit `Replace`, `ClearAndFill`, and `Append` existing-target collection policies. +- Invocation-local reference preservation for shared and cyclic mutable object graphs. +- Closed-world runtime dispatch through generated `TryMapRuntime` and `MapRuntime` methods. +- Cached provider-neutral expression projections through the optional `DomainMapper.Projections` package. +- Diagnostics `DMPR105` through `DMPR107` for unsupported reference tracking, projection eligibility, and registry declarations. +- Incremental invalidation, concurrency, trimming, and native AOT validation fixtures. + +### Changed + +- Incremental generator inputs are fingerprinted per mapper and reachable source contract so isolated changes keep unrelated mapper outputs cached. +- The 1.2 packages validate public API compatibility against the 1.1 release. + +### Fixed + +- Reference tracking distinguishes target contracts when one source instance participates in heterogeneous target shapes. +- Projection generation rejects failed mappings, custom delegates, and user-defined conversion calls while retaining pure lifted conversions. +- Runtime registries reject open-world interface ambiguity and value-type derived dispatch, handle nullable annotations, and exclude mappings whose deferred helpers fail. +- Incremental invalidation now includes containing partial types and inherited mapper declarations that affect emitted source. + ## [1.1.0] - 2026-08-15 ### Added @@ -30,6 +53,7 @@ All notable changes to DomainMapper are recorded here. The project follows seman - Compile-time convention mapping for mutable and immutable targets. - Target-owned and mapper-owned factories, collection and dictionary mapping, and existing-target updates. -[Unreleased]: https://github.com/skuirrels/DomainMapper/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/skuirrels/DomainMapper/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/skuirrels/DomainMapper/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/skuirrels/DomainMapper/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/skuirrels/DomainMapper/releases/tag/v1.0.0 diff --git a/Directory.Build.props b/Directory.Build.props index 91f845e..4241dd2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ $(MSBuildThisFileDirectory)artifacts enable enable - 1.1.0-dev + 1.2.0-dev DomainMapper Contributors $(DefineConstants);ENV_NEXT diff --git a/DomainMapper.slnx b/DomainMapper.slnx index 99de827..6ee799c 100644 --- a/DomainMapper.slnx +++ b/DomainMapper.slnx @@ -3,11 +3,13 @@ + + diff --git a/README.md b/README.md index dc136ab..8e168e7 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ **Map data. Preserve invariants.** -DomainMapper is a small compile-time mapper for .NET with a domain-driven design bias. Its source generator emits direct C# and does not use runtime reflection. Version `1.1.0` adds explicit mapping contracts while retaining domain-owned constructors and factories. +DomainMapper is a small compile-time mapper for .NET with a domain-driven design bias. Its source generator emits direct C# and does not use runtime reflection. Version `1.2.0` adds opt-in collection policies, reference preservation, runtime dispatch, and query projections while retaining domain-owned constructors and factories. -## Version 1.1.0 +## Version 1.2.0 Install DomainMapper from NuGet with: ```bash -dotnet add package DomainMapper --version 1.1.0 +dotnet add package DomainMapper --version 1.2.0 ``` ## Domain-first mapping @@ -35,7 +35,7 @@ public static partial class OrderMapper ## Supported mappings -DomainMapper 1.1.0 supports: +DomainMapper 1.2.0 supports: - mutable targets with accessible parameterless constructors; - immutable targets and records with accessible constructors; @@ -48,6 +48,10 @@ DomainMapper 1.1.0 supports: - target/source completeness, typed ignores, and allow-listed partial updates; - conditional and null-aware assignments with constant substitution; - typed completion hooks, mapping composition, and bounded recursion; +- `Replace`, `ClearAndFill`, and `Append` policies for existing-target collections; +- invocation-local reference preservation for mutable cyclic graphs; +- closed-world generated runtime dispatch with explicit derived-source opt-in; +- cached provider-neutral expression projections through the separate `DomainMapper.Projections` contract package; - nested and generic mapper types and generic mapping methods; - direct generated code that enumerates general sequences and preallocates only when the source exposes a count. @@ -55,7 +59,7 @@ Fields participate when they are named by an explicit mapping contract; conventi Construction is fail-closed: every accessible writable target member must be mapped, and source-matched target state that is not writable from the generated mapper is rejected with `DMPR101`. -Query projections, a generated runtime registry, private-member mutation, reference preservation, and derived-type dispatch remain unsupported. +Unsupported projection or tracking shapes fail at build time. Private-member mutation remains unsupported, and no feature scans assemblies, infers persistence semantics, or introduces mutable runtime mapping configuration. ## Build and test @@ -84,6 +88,7 @@ See [the benchmark methodology](docs/benchmarks.md). ## Project layout - `src/DomainMapper.Abstractions` — public, compile-time mapping attributes and policy types. +- `src/DomainMapper.Projections` — optional provider-neutral projection declaration contract. - `src/DomainMapper/Engine` — contract discovery, semantic planning, conversion policy, and C# emission. - `test/DomainMapper.Tests` — engine contract and performance-gate tests. - `benchmarks/DomainMapper.Benchmarks` — balanced DomainMapper-versus-Mapperly evidence. diff --git a/benchmarks/DomainMapper.Benchmarks/AdvancedFeatureBenchmarks.cs b/benchmarks/DomainMapper.Benchmarks/AdvancedFeatureBenchmarks.cs new file mode 100644 index 0000000..9bb928d --- /dev/null +++ b/benchmarks/DomainMapper.Benchmarks/AdvancedFeatureBenchmarks.cs @@ -0,0 +1,133 @@ +using System.Linq.Expressions; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace DomainMapper.Benchmarks; + +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[Config(typeof(BalancedComparisonConfig))] +public class AdvancedFeatureBenchmarks +{ + private static readonly Expression> HandWrittenProjection = + source => new BenchmarkRenamedTarget(source.ID, source.DateCreated, source.Warehouse.Description); + + private readonly BenchmarkFlatSource _flat = new() + { + Id = 42, + Name = "Ada", + Amount = 12.5m, + CreatedAt = DateTimeOffset.UnixEpoch, + }; + private readonly BenchmarkCollectionSource _collection = new([1, 2, 3, 4, 5, 6, 7, 8]); + private readonly BenchmarkCollectionTarget _domainCollection = new(); + private readonly BenchmarkCollectionTarget _handCollection = new(); + private readonly BenchmarkGraphSource _graph; + + public AdvancedFeatureBenchmarks() + { + _graph = new BenchmarkGraphSource { Value = 42 }; + _graph.Next = _graph; + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("RegistryDispatch")] + public object HandWrittenRegistry() => HandWrittenMap(_flat, typeof(BenchmarkFlatTarget)); + + [Benchmark] + [BenchmarkCategory("RegistryDispatch")] + public object DomainMapperRegistry() => DomainMapperBenchmarkMapper.MapRuntime(_flat, typeof(BenchmarkFlatTarget))!; + + [Benchmark(Baseline = true)] + [BenchmarkCategory("ReferenceTracking")] + public BenchmarkGraphTarget HandWrittenReferenceTracking() => HandWrittenMapGraph(_graph); + + [Benchmark] + [BenchmarkCategory("ReferenceTracking")] + public BenchmarkGraphTarget DomainMapperReferenceTracking() => DomainMapperBenchmarkMapper.MapGraph(_graph); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CollectionClearAndFill")] + public BenchmarkCollectionTarget HandWrittenCollectionMutation() + { + _handCollection.Items.Clear(); + foreach (var item in _collection.Items) + _handCollection.Items.Add(item); + return _handCollection; + } + + [Benchmark] + [BenchmarkCategory("CollectionClearAndFill")] + public BenchmarkCollectionTarget DomainMapperCollectionMutation() + { + DomainMapperBenchmarkMapper.UpdateCollection(_collection, _domainCollection); + return _domainCollection; + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("ProjectionRetrieval")] + public Expression> HandWrittenProjectionRetrieval() => HandWrittenProjection; + + [Benchmark] + [BenchmarkCategory("ProjectionRetrieval")] + public Expression> DomainMapperProjectionRetrieval() => + DomainMapperBenchmarkMapper.ProjectRenamed(); + + private static object HandWrittenMap(object source, Type targetType) + { + if (source.GetType() == typeof(BenchmarkFlatSource) && targetType == typeof(BenchmarkFlatTarget)) + { + var typed = (BenchmarkFlatSource)source; + return new BenchmarkFlatTarget + { + Id = typed.Id, + Name = typed.Name, + Amount = typed.Amount, + CreatedAt = typed.CreatedAt, + }; + } + throw new InvalidOperationException(); + } + + private static BenchmarkGraphTarget HandWrittenMapGraph(BenchmarkGraphSource source) + { + var references = new Dictionary(); + return MapNode(source, references); + } + + private static BenchmarkGraphTarget MapNode(BenchmarkGraphSource source, Dictionary references) + { + var referenceKey = new ReferenceKey(source, typeof(BenchmarkGraphTarget)); + if (references.TryGetValue(referenceKey, out var existing)) + return (BenchmarkGraphTarget)existing; + var target = new BenchmarkGraphTarget { Value = source.Value }; + references.Add(referenceKey, target); + target.Next = source.Next is null ? null : MapNode(source.Next, references); + return target; + } + + private readonly struct ReferenceKey : IEquatable + { + private readonly object _source; + private readonly Type _targetType; + + public ReferenceKey(object source, Type targetType) + { + _source = source; + _targetType = targetType; + } + + public bool Equals(ReferenceKey other) => ReferenceEquals(_source, other._source) && _targetType == other._targetType; + + public override bool Equals(object? value) => value is ReferenceKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return (System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(_source) * 397) ^ _targetType.GetHashCode(); + } + } + } +} diff --git a/benchmarks/DomainMapper.Benchmarks/ComparisonMappingBenchmarks.cs b/benchmarks/DomainMapper.Benchmarks/ComparisonMappingBenchmarks.cs index ecd1baa..b170404 100644 --- a/benchmarks/DomainMapper.Benchmarks/ComparisonMappingBenchmarks.cs +++ b/benchmarks/DomainMapper.Benchmarks/ComparisonMappingBenchmarks.cs @@ -1,6 +1,8 @@ +using System.Linq.Expressions; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using DomainMapper.Abstractions; +using DomainMapper.Projections; using MapperlyFactory = Riok.Mapperly.Abstractions.ObjectFactoryAttribute; using MapperlyMapper = Riok.Mapperly.Abstractions.MapperAttribute; using MapperlyMapProperty = Riok.Mapperly.Abstractions.MapPropertyAttribute; @@ -90,6 +92,7 @@ public BenchmarkFlatTarget DomainMapperExistingTarget() } [DomainMapper] +[MapRegistry] public static partial class DomainMapperBenchmarkMapper { public static partial BenchmarkFlatTarget MapFlat(BenchmarkFlatSource source); @@ -113,6 +116,16 @@ public static partial class DomainMapperBenchmarkMapper public static partial BenchmarkAggregate Place(BenchmarkFlatSource source); public static partial BenchmarkIdTarget MapId(BenchmarkIdSource source); + + [MapReferenceTracking] + public static partial BenchmarkGraphTarget MapGraph(BenchmarkGraphSource source); + + [MapOnlyTargetMembers(nameof(BenchmarkCollectionTarget.Items))] + [MapCollection(nameof(BenchmarkCollectionTarget.Items), CollectionUpdatePolicy.ClearAndFill)] + public static partial void UpdateCollection(BenchmarkCollectionSource source, BenchmarkCollectionTarget target); + + [MapProjection(nameof(MapRenamed))] + public static partial Expression> ProjectRenamed(); } #pragma warning disable RMG066 // Mapperly cannot account for members consumed inside a whole-source object factory. @@ -193,6 +206,27 @@ public sealed record BenchmarkRenamedSource(int ID, DateTimeOffset DateCreated, public sealed record BenchmarkRenamedTarget(int EdcId, DateTimeOffset CreatedDate, string TransitWarehouseDescription); +public sealed record BenchmarkCollectionSource(List Items); + +public sealed class BenchmarkCollectionTarget +{ + public List Items { get; } = []; +} + +public sealed class BenchmarkGraphSource +{ + public int Value { get; set; } + + public BenchmarkGraphSource? Next { get; set; } +} + +public sealed class BenchmarkGraphTarget +{ + public int Value { get; set; } + + public BenchmarkGraphTarget? Next { get; set; } +} + public readonly record struct BenchmarkAggregateId(int Value); public sealed class BenchmarkAggregate diff --git a/benchmarks/DomainMapper.Benchmarks/DomainMapper.Benchmarks.csproj b/benchmarks/DomainMapper.Benchmarks/DomainMapper.Benchmarks.csproj index 9e81af8..fa8d838 100644 --- a/benchmarks/DomainMapper.Benchmarks/DomainMapper.Benchmarks.csproj +++ b/benchmarks/DomainMapper.Benchmarks/DomainMapper.Benchmarks.csproj @@ -5,6 +5,7 @@ true + diff --git a/benchmarks/DomainMapper.Benchmarks/IncrementalGeneratorBenchmarks.cs b/benchmarks/DomainMapper.Benchmarks/IncrementalGeneratorBenchmarks.cs new file mode 100644 index 0000000..a85823b --- /dev/null +++ b/benchmarks/DomainMapper.Benchmarks/IncrementalGeneratorBenchmarks.cs @@ -0,0 +1,161 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using DomainMapper.Abstractions; +using DomainMapper.Projections; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace DomainMapper.Benchmarks; + +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[Config(typeof(SourceGeneratorBenchmarkConfig))] +public class IncrementalGeneratorBenchmarks +{ + private GeneratorDriver? _coreDriver; + private CSharpCompilation? _coreCompilation; + private CSharpCompilation? _isolatedEditCompilation; + private GeneratorDriver? _sharedDriver; + private CSharpCompilation? _sharedEditCompilation; + private CSharpCompilation? _registryCompilation; + private CSharpCompilation? _projectionCompilation; + + [Params(1, 16, 64)] + public int MappingCount { get; set; } + + [GlobalSetup] + public void Setup() + { + (_coreCompilation, _isolatedEditCompilation) = BuildIndependentFixture(MappingCount, MapperFeature.Core); + _coreDriver = CreateDriver().RunGenerators(_coreCompilation); + var (sharedCompilation, sharedEditCompilation) = BuildSharedFixture(MappingCount); + _sharedDriver = CreateDriver().RunGenerators(sharedCompilation); + _sharedEditCompilation = sharedEditCompilation; + (_registryCompilation, _) = BuildIndependentFixture(MappingCount, MapperFeature.Registry); + (_projectionCompilation, _) = BuildIndependentFixture(MappingCount, MapperFeature.Projection); + + Validate(_coreDriver, _coreCompilation, MappingCount, "core"); + Validate(_sharedDriver, sharedCompilation, MappingCount, "shared-contract"); + Validate(CreateDriver(), _registryCompilation, MappingCount, "registry"); + Validate(CreateDriver(), _projectionCompilation, MappingCount, "projection"); + } + + [Benchmark] + [BenchmarkCategory("ColdCore")] + public object ColdCore() => CreateDriver().RunGenerators(_coreCompilation!); + + [Benchmark] + [BenchmarkCategory("NoOpCore")] + public object NoOpCore() => _coreDriver!.RunGenerators(_coreCompilation!); + + [Benchmark] + [BenchmarkCategory("IsolatedContractEdit")] + public object IsolatedContractEdit() => _coreDriver!.RunGenerators(_isolatedEditCompilation!); + + [Benchmark] + [BenchmarkCategory("SharedContractEdit")] + public object SharedContractEdit() => _sharedDriver!.RunGenerators(_sharedEditCompilation!); + + [Benchmark] + [BenchmarkCategory("ColdRegistry")] + public object ColdRegistry() => CreateDriver().RunGenerators(_registryCompilation!); + + [Benchmark] + [BenchmarkCategory("ColdProjection")] + public object ColdProjection() => CreateDriver().RunGenerators(_projectionCompilation!); + + private static (CSharpCompilation Initial, CSharpCompilation IsolatedEdit) BuildIndependentFixture( + int mappingCount, + MapperFeature feature + ) + { + var trees = new List(); + for (var index = 0; index < mappingCount; index++) + { + trees.Add(Parse(Contract(index, "int"), $"Contract{index}.cs")); + trees.Add(Parse(Mapper(index, feature), $"Mapper{index}.cs")); + } + var initial = BuildCompilation($"DomainMapper.{feature}.{mappingCount}", trees, feature == MapperFeature.Projection); + var editedTree = Parse(Contract(0, "long"), "Contract0.cs"); + return (initial, initial.ReplaceSyntaxTree(trees[0], editedTree)); + } + + private static (CSharpCompilation Initial, CSharpCompilation SharedEdit) BuildSharedFixture(int mappingCount) + { + var shared = Parse("public sealed record SharedSource(int Value);", "SharedContract.cs"); + var trees = new List { shared }; + for (var index = 0; index < mappingCount; index++) + { + trees.Add(Parse($"public sealed record Target{index}(int Value);", $"Target{index}.cs")); + trees.Add( + Parse( + $"using DomainMapper.Abstractions; [DomainMapper] public static partial class Mapper{index} {{ public static partial Target{index} Map(SharedSource source); }}", + $"Mapper{index}.cs" + ) + ); + } + var initial = BuildCompilation($"DomainMapper.Shared.{mappingCount}", trees, false); + return (initial, initial.ReplaceSyntaxTree(shared, Parse("public sealed record SharedSource(long Value);", "SharedContract.cs"))); + } + + private static CSharpCompilation BuildCompilation(string name, IEnumerable trees, bool includeProjectionReference) + { + var trustedPlatformAssemblies = + ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?.Split(Path.PathSeparator) + ?? throw new InvalidOperationException("Trusted platform assemblies are unavailable."); + var references = trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path)).ToList(); + references.Add(MetadataReference.CreateFromFile(typeof(DomainMapperAttribute).Assembly.Location)); + if (includeProjectionReference) + references.Add(MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location)); + return CSharpCompilation.Create( + name, + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable) + ); + } + + private static CSharpGeneratorDriver CreateDriver() => + CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + parseOptions: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview) + ); + + private static void Validate(GeneratorDriver driver, Compilation compilation, int expectedOutputs, string fixture) + { + var completed = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + var errors = diagnostics.Concat(output.GetDiagnostics()).Where(x => x.Severity == DiagnosticSeverity.Error).ToArray(); + if (errors.Length > 0) + throw new InvalidOperationException($"{fixture} fixture failed: {string.Join(Environment.NewLine, errors.AsEnumerable())}"); + if (completed.GetRunResult().GeneratedTrees.Length != expectedOutputs) + throw new InvalidOperationException($"{fixture} fixture generated an unexpected output count."); + } + + private static SyntaxTree Parse(string source, string path) => + CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), path); + + private static string Contract(int index, string scalarType) => + $"public sealed record Source{index}({scalarType} Value); public sealed record Target{index}({scalarType} Value);"; + + private static string Mapper(int index, MapperFeature feature) + { + var registry = feature == MapperFeature.Registry ? "[MapRegistry]" : string.Empty; + var projectionUsing = + feature == MapperFeature.Projection + ? "using System; using System.Linq.Expressions; using DomainMapper.Projections;" + : string.Empty; + var projection = + feature == MapperFeature.Projection + ? $"[MapProjection(nameof(Map))] public static partial Expression> Project();" + : string.Empty; + return $"using DomainMapper.Abstractions; {projectionUsing} [DomainMapper] {registry} public static partial class Mapper{index} {{ public static partial Target{index} Map(Source{index} source); {projection} }}"; + } + + private enum MapperFeature + { + Core, + Registry, + Projection, + } +} diff --git a/build/package.sh b/build/package.sh index 0528c47..8dd90bb 100755 --- a/build/package.sh +++ b/build/package.sh @@ -7,7 +7,7 @@ set -Eeuo pipefail roslyn_versions=('4.8' '4.11' '4.14' '5.0') -RELEASE_VERSION=${RELEASE_VERSION:-"1.1.0-dev.$(date +%s)"} +RELEASE_VERSION=${RELEASE_VERSION:-"1.2.0-dev.$(date +%s)"} RELEASE_NOTES=${RELEASE_NOTES:-''} # https://stackoverflow.com/a/246128/3302887 @@ -21,6 +21,7 @@ rm -rf "${artifacts_dir:?}"/* artifacts_dir="$(realpath "$artifacts_dir")" source_generator_path="$(realpath "${script_dir}/../src/DomainMapper")" +projections_path="$(realpath "${script_dir}/../src/DomainMapper.Projections")" for roslyn_version in "${roslyn_versions[@]}"; do echo "building for Roslyn ${roslyn_version}" @@ -28,6 +29,7 @@ for roslyn_version in "${roslyn_versions[@]}"; do "$source_generator_path" \ --verbosity quiet \ -c Release \ + /p:HUSKY=0 \ /p:ROSLYN_VERSION="${roslyn_version}" \ -o "${artifacts_dir}/roslyn-${roslyn_version}" \ /p:Version="${RELEASE_VERSION}" \ @@ -36,4 +38,13 @@ done echo "merging multi targets to a single nupkg" zipmerge "${artifacts_dir}/DomainMapper.${RELEASE_VERSION}.nupkg" "${artifacts_dir}"/*/*.nupkg +dotnet pack \ + "$projections_path" \ + --verbosity quiet \ + -c Release \ + /p:HUSKY=0 \ + -o "${artifacts_dir}" \ + /p:Version="${RELEASE_VERSION}" \ + /p:PackageReleaseNotes=\""${RELEASE_NOTES}"\" echo "built ${artifacts_dir}/DomainMapper.${RELEASE_VERSION}.nupkg" +echo "built ${artifacts_dir}/DomainMapper.Projections.${RELEASE_VERSION}.nupkg" diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 96ce7a1..157ebb0 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -15,6 +15,29 @@ DomainMapper compares generated runtime mappings and cold source generation with `SourceGeneratorBenchmarks` runs each incremental generator over the same in-memory Roslyn compilation. The fixture covers mutable and immutable objects, nullable properties, nested records, arrays, lists, read-only collections, dictionaries, generics, and existing-target updates. Setup verifies that generated output compiles before measurement. +`IncrementalGeneratorBenchmarks` adds 1-, 16-, and 64-mapper synthetic consumers. It measures cold core generation, a no-op rerun, an isolated contract edit, a shared-contract edit, and cold registry and projection generation independently. Each fixture verifies both generated output count and compilation before measurement. + +`AdvancedFeatureBenchmarks` compares optional collection mutation, reference tracking, registry dispatch, and cached projection retrieval with equivalent hand-written C#. These scenarios are reported separately so optional behavior cannot hide a core-path regression. + +## Advanced feature development check — 15 August 2026 + +This ShortRun used three measured iterations after three warmups on .NET SDK 10.0.400, .NET 10.0.11, macOS 26.5.2, and an Arm64 Apple M4 Pro. It is a development check, not release-grade evidence; overlapping confidence intervals are reported as no meaningful winner. + +| Scenario | Hand-written median | DomainMapper median | Hand-written allocation | DomainMapper allocation | Winner | +| --------------------------- | ----------------------------------: | ----------------------------------: | ----------------------: | ----------------------: | -------------------- | +| Collection clear/fill | 29.836 ns | 7.489 ns | 0 B | 0 B | DomainMapper | +| Reference tracking | 26.475 ns | 26.044 ns | 272 B | 272 B | No meaningful winner | +| Registry dispatch | 3.808 ns | 4.324 ns | 64 B | 64 B | No meaningful winner | +| Cached projection retrieval | indistinguishable from empty method | indistinguishable from empty method | 0 B | 0 B | Tie | + +The generated registry result includes the nullable-aware `MapRuntime` wrapper over `TryMapRuntime`; its observed absolute overhead versus the one-pair hand-written switch was approximately `0.52 ns` with equal allocation. The matched reference-tracking baseline keys both source identity and target contract, as the generated implementation does. Repeat on dedicated hardware before treating any timing difference as a stable product claim. + +The strengthened incremental cache key was also rechecked after adding compiler-option, preprocessor-symbol, referenced-contract, containing-type, and base-mapper invalidation. The same ShortRun environment measured a `256.482 us` median and `501.2 KB` allocation. Against the prior `212.300 us` development baseline, the time ratio is `1.208x`, within the predefined `1.25x` regression ceiling. + +| Measurement | 1.1 development baseline | 1.2 hardened cache key | Gate | Winner | +| ---------------------- | -----------------------: | ---------------------: | ---------------- | ------------ | +| Median cold generation | 212.300 us | 256.482 us | Pass at `1.208x` | 1.1 baseline | + BenchmarkDotNet runs each implementation in a separate process and alternates execution order. The gate aggregates raw iteration samples by median, checks a one-sided confidence bound, and permits no additional managed allocation. ## Rewrite verification — 12 August 2026 @@ -72,6 +95,20 @@ DOMAINMAPPER_BENCHMARK_JOB=Short \ ./scripts/run-stable-benchmarks.sh ``` +Run the optional-feature comparison independently with: + +```bash +dotnet run -c Release --project benchmarks/DomainMapper.Benchmarks -- \ + --filter '*AdvancedFeatureBenchmarks*' --job Short --join +``` + +Run the build-time matrix independently with: + +```bash +dotnet run -c Release --project benchmarks/DomainMapper.Benchmarks -- \ + --filter '*IncrementalGeneratorBenchmarks*' --job Short --join +``` + Use the default six runtime pairs on dedicated, idle hardware before making a release-level performance claim. ## Regression policy diff --git a/docs/docs/configuration/analyzer-diagnostics/DMPR105.mdx b/docs/docs/configuration/analyzer-diagnostics/DMPR105.mdx new file mode 100644 index 0000000..09029b5 --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/DMPR105.mdx @@ -0,0 +1,5 @@ +# DMPR105 — Reference tracking target is not supported + +Reference preservation requires a non-null source and a mutable target that DomainMapper can allocate before mapping its members. Constructor-only, required/init-only, factory-created, nullable-root, and existing-target shapes are rejected because silently changing their construction semantics could bypass invariants. + +Use a mutable two-phase target, remove `[MapReferenceTracking]`, or map the unsupported graph explicitly in caller-owned code. diff --git a/docs/docs/configuration/analyzer-diagnostics/DMPR106.mdx b/docs/docs/configuration/analyzer-diagnostics/DMPR106.mdx new file mode 100644 index 0000000..fb0543e --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/DMPR106.mdx @@ -0,0 +1,5 @@ +# DMPR106 — Projection contract is not supported + +The declared projection uses behavior outside DomainMapper's provider-neutral expression subset. The diagnostic identifies the affected member and operation and includes a corrective action. + +Keep hooks, conditions, factories, reference tracking, depth guards, collection mutation, additional parameters, recursive shapes, and unsupported conversions on an in-memory mapping. DomainMapper never introduces client-side evaluation as a fallback. diff --git a/docs/docs/configuration/analyzer-diagnostics/DMPR107.mdx b/docs/docs/configuration/analyzer-diagnostics/DMPR107.mdx new file mode 100644 index 0000000..e4ece54 --- /dev/null +++ b/docs/docs/configuration/analyzer-diagnostics/DMPR107.mdx @@ -0,0 +1,5 @@ +# DMPR107 — Runtime registry is invalid + +The generated closed-world registry contains a duplicate or ambiguous source/target pair, or its generated method names conflict with mapper members. + +Keep one eligible mapping per pair, remove overlapping `[MapRegistryDerived]` declarations, or rename conflicting `TryMapRuntime` and `MapRuntime` members. diff --git a/docs/docs/configuration/capabilities.md b/docs/docs/configuration/capabilities.md index 57f1a55..554cfe0 100644 --- a/docs/docs/configuration/capabilities.md +++ b/docs/docs/configuration/capabilities.md @@ -1,6 +1,6 @@ # Capabilities and limitations -This page is the authoritative product contract for DomainMapper 1.1.0. Generated mappings use direct C# calls and member access; none of these features uses runtime reflection or mutable runtime configuration. +This page is the authoritative product contract for the next DomainMapper 1.2 release. Generated mappings use direct C# calls and member access; none of these features uses runtime reflection, assembly scanning, or mutable runtime configuration. ## Explicit mapping contract @@ -29,7 +29,7 @@ public static partial class OrderMapper Convention mapping remains property-only for compatibility with 1.0. Fields participate when an explicit contract such as `MapMember`, `MapTargetMember`, or an existing-target allow-list names them. -Nested paths use generated null propagation. They are in-memory mappings; query projections are not yet supported. +Nested paths use generated null propagation. Eligible contracts can also expose a separately declared, cached expression-tree projection. ## Completeness and ignores @@ -51,7 +51,7 @@ private static bool ShouldApplyDate(BookingUpdate source, Booking target) => `MapOnlyTargetMembers` is an explicit mutation allow-list. Members outside it are not assigned, which makes identity, audit, navigation, ownership, and concurrency state protected by default. A false `MapCondition` preserves the current target member. Reference targets are mutated in place; value-type targets require `ref`. -Collection relationship updates are not inferred. An update may replace an allow-listed collection member, but clear/fill, append, and merge-by-key semantics are not currently generated. +`MapCollection` selects `Replace`, `ClearAndFill`, or `Append` for one allow-listed collection member. Clear/fill and append require a mutable `ICollection` or `IDictionary` contract. They preserve source ordering and duplicates; duplicate dictionary keys follow `IDictionary.Add` and throw. A null source clears under `ClearAndFill`, is a no-op under `Append`, and can instead use `PreserveTarget` or `Throw`. DomainMapper never infers identity, ownership, deletion, or merge-by-key behavior. ## Null behavior @@ -75,7 +75,21 @@ Target-owned factories selected by `MapToFactory` remain mandatory and fail clos Arrays, lists, enumerable/read-only collection interfaces, and mutable/read-only dictionary interfaces are generated without LINQ allocation. Countable indexed inputs use capacity-aware loops; general `IEnumerable` inputs are enumerated once. -Recursive contracts are rejected by default. `[MapMaxDepth(n)]` opts a method into bounded recursion using an integer argument on generated helpers; ordinary non-recursive mappings pay no reference-tracker allocation. Exhaustion returns `default` by default or throws when `ExhaustionBehavior = DepthExhaustionBehavior.Throw`. Reference preservation and merge-by-identity are not currently supported. +Recursive contracts are rejected by default. `[MapMaxDepth(n)]` opts a method into bounded recursion using an integer argument on generated helpers. `[MapReferenceTracking]` instead enables invocation-local reference-identity tracking for mutable targets that can be allocated before their members are assigned. Repeated references, self-cycles, and multi-object cycles reuse the same target instance. A previously tracked reference resolves before the depth policy; depth applies only when allocating a new target. Ordinary mappings allocate no tracker. + +Reference tracking keys each source identity by its generated target contract, is never shared between calls or threads, and does not infer domain or persistence keys. The same source can therefore participate in more than one target shape without confusing their tracked instances. Constructor-only, required/init-only, factory-created, nullable-root, and existing-target tracking shapes fail with `DMPR105` rather than changing construction semantics. + +## Closed-world runtime registry + +`[MapRegistry]` on a mapper generates `TryMapRuntime(object, Type, out object?)` and `MapRuntime(object, Type)`. Only successfully generated static, non-generic, one-parameter create mappings in that mapper participate. Dispatch uses exact source types by default and direct calls; `[MapRegistryDerived]` explicitly permits assignable derived-source dispatch for one pair. Unknown pairs return `false` or make `MapRuntime` throw `InvalidOperationException` with a stable source/target message, and duplicate pairs produce `DMPR107`. + +Registry methods are stateless and thread-safe. Declared collection-to-collection mapping methods can participate like any other known pair. DomainMapper does not scan assemblies, resolve services, or choose a pair from runtime business state. + +## Provider-neutral projections + +Install the independent `DomainMapper.Projections` contract package alongside `DomainMapper`, then bind a parameterless `Expression>` method to an existing mapping with `[MapProjection(nameof(Map))]`. The generator emits one cached expression instance containing direct construction and member access. Consumers compose and execute it using standard query APIs. + +The supported subset includes constructors, member initialization, renames, nested paths, null propagation, null substitution, and implicit pure conversions. Completion hooks, conditions, factories, reference tracking, depth guards, collection mutation, additional parameters, recursive shapes, and unsupported conversions produce `DMPR106`. DomainMapper never compiles the expression, materializes a query, inserts `AsEnumerable`, or catches provider translation failures. ## Mapping composition and inheritance @@ -83,10 +97,10 @@ Accessible inherited properties participate in convention and completeness check ## Current limitations -- No `IQueryable`/EF Core/OData projection expressions. -- No generated runtime registry or Microsoft DI facade. -- No reference-preserving cycle tracker. +- Projection collection transforms and arbitrary method calls are not in the provider-neutral expression subset. +- Reference tracking requires mutable two-phase target construction. +- The runtime registry has no Microsoft DI adapter; direct generated methods remain preferred when types are known. - No unrestricted runtime derived-type dispatch or reflection scanning. - No private-member mutation. -These limitations are replacement blockers for affected eDC paths; those paths must remain on their current implementation or use an explicit application-owned rewrite until the corresponding feature is delivered. +Provider execution behavior, persistence semantics, query filtering/paging, and consumer migration remain the consuming application's responsibility. diff --git a/docs/docs/configuration/collections.md b/docs/docs/configuration/collections.md index ebc1730..b4ed5d0 100644 --- a/docs/docs/configuration/collections.md +++ b/docs/docs/configuration/collections.md @@ -3,3 +3,19 @@ DomainMapper maps arrays, lists, enumerable and read-only collection targets, and mutable or read-only dictionary interfaces element by element. General `IEnumerable` sources are enumerated without assuming a `Count` property or indexer. Generated code preallocates capacity only when the source exposes a count, uses implemented collection interfaces when those capabilities are explicit, and backs interface targets with concrete `List` or `Dictionary` instances. Nested element types use the same constructor and property conventions as root mappings. + +## Existing-target policies + +```csharp +[MapOnlyTargetMembers(nameof(Target.Items))] +[MapCollection(nameof(Target.Items), CollectionUpdatePolicy.ClearAndFill)] +public static partial void Apply(Source source, Target target); +``` + +- `Replace` maps a new collection and assigns it to a writable member. +- `ClearAndFill` clears an existing mutable collection, then adds mapped source elements in source order. +- `Append` keeps existing elements and adds mapped source elements in source order. + +Sequence duplicates are preserved. Dictionary mutation calls `Add`, so a duplicate key throws rather than overwriting silently. A null source clears a clear/fill target and is a no-op for append unless `MapNull` selects `PreserveTarget` or `Throw`. The target collection itself must be non-null at runtime. + +These policies are mechanical. DomainMapper does not match elements by key, infer entity identity, remove orphans, or interpret relationship ownership. diff --git a/docs/docs/configuration/compatibility.md b/docs/docs/configuration/compatibility.md new file mode 100644 index 0000000..9d89ce7 --- /dev/null +++ b/docs/docs/configuration/compatibility.md @@ -0,0 +1,14 @@ +# Compatibility matrix + +| Surface | Supported lanes | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Consumer target frameworks | .NET Framework 4.8, .NET 8, .NET 9, .NET 10 | +| SDK/compiler hosts | .NET SDK 8, 9, and 10 with their supported C# language versions | +| Roslyn analyzer hosts | 4.8, 4.11, 4.14, and 5.0 | +| Runtime abstractions | `netstandard2.0` | +| Optional projection contracts | `netstandard2.0`; BCL expression trees only | +| Trimming/native AOT | Direct mapping, registry dispatch, and reference tracking are validated on .NET 10; projections are explicitly unsupported | + +The package selects a versioned analyzer build deterministically for the active Roslyn host. Clean package consumers validate analyzer loading and generated-code compilation in every target-framework lane. Dropping a lane is a compatibility change governed by semantic versioning. + +Generated mapping and registry methods use no runtime reflection metadata. Projection expressions are immutable and safe to retrieve concurrently in untrimmed applications, but expression construction requires member metadata and generated accessors carry `RequiresUnreferencedCode` where available. diff --git a/docs/docs/configuration/projections.md b/docs/docs/configuration/projections.md new file mode 100644 index 0000000..0c656ef --- /dev/null +++ b/docs/docs/configuration/projections.md @@ -0,0 +1,32 @@ +# Projections + +Projection support is an optional provider-neutral surface. Install both packages: + +```bash +dotnet add package DomainMapper +dotnet add package DomainMapper.Projections +``` + +Declare an ordinary create mapping and a projection method that references it: + +```csharp +using System.Linq.Expressions; +using DomainMapper.Abstractions; +using DomainMapper.Projections; + +[DomainMapper] +public static partial class ContractMapper +{ + [MapMember(nameof(Target.Description), nameof(Source.Detail) + "." + nameof(Detail.Description))] + public static partial Target Map(Source source); + + [MapProjection(nameof(Map))] + public static partial Expression> Project(); +} +``` + +`Project()` returns the same immutable expression instance on every call. The expression contains typed construction, member access, conditional null propagation, and supported conversions; it does not call or compile the in-memory mapper. + +Consumers own query creation, filtering, sorting, paging, tracking, materialization, and provider-specific validation. DomainMapper does not hide translation failures or introduce client evaluation. Unsupported mapping operations produce `DMPR106` at build time. + +Expression-tree construction requires member metadata and is not supported by DomainMapper under trimming or native AOT. Generated projection accessors carry `RequiresUnreferencedCode` on modern target frameworks so trimmed/AOT publication reports the unsupported use. Compiling an expression can additionally require dynamic code. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index dcba05b..f5d65da 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -3,7 +3,7 @@ const { themes } = require('prism-react-renderer'); -const domainMapperVersion = process.env.DOMAINMAPPER_VERSION || '1.1.0-dev'; +const domainMapperVersion = process.env.DOMAINMAPPER_VERSION || '1.2.0-dev'; const environment = process.env.ENVIRONMENT || 'local'; /** @type {import('./src/custom-fields').CustomFields} */ diff --git a/samples/DomainMapper.AotSmoke/DomainMapper.AotSmoke.csproj b/samples/DomainMapper.AotSmoke/DomainMapper.AotSmoke.csproj new file mode 100644 index 0000000..5746b7c --- /dev/null +++ b/samples/DomainMapper.AotSmoke/DomainMapper.AotSmoke.csproj @@ -0,0 +1,18 @@ + + + Exe + true + true + full + true + + + + + + + + + + + diff --git a/samples/DomainMapper.AotSmoke/Program.cs b/samples/DomainMapper.AotSmoke/Program.cs new file mode 100644 index 0000000..dc64751 --- /dev/null +++ b/samples/DomainMapper.AotSmoke/Program.cs @@ -0,0 +1,29 @@ +using DomainMapper.Abstractions; + +var source = new Source { Value = 42 }; +source.Next = source; +var target = Mapper.Map(source); +var runtime = (Target)Mapper.MapRuntime(source, typeof(Target))!; +return ReferenceEquals(target, target.Next) && ReferenceEquals(runtime, runtime.Next) ? 0 : 1; + +public sealed class Source +{ + public int Value { get; set; } + + public Source? Next { get; set; } +} + +public sealed class Target +{ + public int Value { get; set; } + + public Target? Next { get; set; } +} + +[DomainMapper] +[MapRegistry] +public static partial class Mapper +{ + [MapReferenceTracking] + public static partial Target Map(Source source); +} diff --git a/samples/DomainMapper.PackageSmoke/DomainMapper.PackageSmoke.csproj b/samples/DomainMapper.PackageSmoke/DomainMapper.PackageSmoke.csproj index 0a2f5e6..5e5ff9d 100644 --- a/samples/DomainMapper.PackageSmoke/DomainMapper.PackageSmoke.csproj +++ b/samples/DomainMapper.PackageSmoke/DomainMapper.PackageSmoke.csproj @@ -4,6 +4,8 @@ latest $(DomainMapperPackageSmokeTargetFramework) + $(DefineConstants);DOMAINMAPPER_PROJECTION_SMOKE @@ -16,4 +18,10 @@ + + + + + + diff --git a/samples/DomainMapper.PackageSmoke/Program.cs b/samples/DomainMapper.PackageSmoke/Program.cs index 841e57d..1eb741e 100644 --- a/samples/DomainMapper.PackageSmoke/Program.cs +++ b/samples/DomainMapper.PackageSmoke/Program.cs @@ -1,16 +1,47 @@ using DomainMapper.Abstractions; +#if DOMAINMAPPER_PROJECTION_SMOKE +using System.Linq; +using System.Linq.Expressions; +using DomainMapper.Projections; +#endif var target = PackageSmokeMapper.Map(new PackageSmokeSource { Id = 42, Name = "DomainMapper" }); if (target.ExternalId != 42 || target.Name != "DomainMapper") throw new InvalidOperationException("DomainMapper generated an invalid package smoke mapping."); +var graph = new PackageSmokeGraphSource { Value = 7 }; +graph.Next = graph; +var mappedGraph = PackageSmokeMapper.MapGraph(graph); +var runtimeGraph = (PackageSmokeGraphTarget)PackageSmokeMapper.MapRuntime(graph, typeof(PackageSmokeGraphTarget))!; +if (!ReferenceEquals(mappedGraph, mappedGraph.Next) || !ReferenceEquals(runtimeGraph, runtimeGraph.Next)) + throw new InvalidOperationException("DomainMapper generated invalid reference-tracking or registry code."); + +#if DOMAINMAPPER_PROJECTION_SMOKE +var projection = PackageSmokeMapper.Project(); +var projected = new[] +{ + new PackageSmokeSource { Id = 11, Name = "Projection" }, +}.AsQueryable().Select(projection).Single(); +if (!ReferenceEquals(projection, PackageSmokeMapper.Project()) || projected.ExternalId != 11 || projected.Name != "Projection") + throw new InvalidOperationException("DomainMapper generated an invalid optional-package projection."); +#endif + Console.WriteLine("DomainMapper package smoke test passed."); [DomainMapper] +[MapRegistry] public static partial class PackageSmokeMapper { [MapMember(nameof(PackageSmokeTarget.ExternalId), nameof(PackageSmokeSource.Id))] public static partial PackageSmokeTarget Map(PackageSmokeSource source); + + [MapReferenceTracking] + public static partial PackageSmokeGraphTarget MapGraph(PackageSmokeGraphSource source); + +#if DOMAINMAPPER_PROJECTION_SMOKE + [MapProjection(nameof(Map))] + public static partial Expression> Project(); +#endif } public sealed class PackageSmokeSource @@ -26,3 +57,17 @@ public sealed class PackageSmokeTarget public string Name { get; set; } = string.Empty; } + +public sealed class PackageSmokeGraphSource +{ + public int Value { get; set; } + + public PackageSmokeGraphSource? Next { get; set; } +} + +public sealed class PackageSmokeGraphTarget +{ + public int Value { get; set; } + + public PackageSmokeGraphTarget? Next { get; set; } +} diff --git a/src/DomainMapper.Abstractions/CollectionUpdatePolicy.cs b/src/DomainMapper.Abstractions/CollectionUpdatePolicy.cs new file mode 100644 index 0000000..0bbdf5c --- /dev/null +++ b/src/DomainMapper.Abstractions/CollectionUpdatePolicy.cs @@ -0,0 +1,14 @@ +namespace DomainMapper.Abstractions; + +/// Defines how an existing target collection is updated. +public enum CollectionUpdatePolicy +{ + /// Replace the target member with a newly mapped collection. + Replace, + + /// Clear the existing target collection and add mapped source elements in source order. + ClearAndFill, + + /// Add mapped source elements to the existing target collection in source order. + Append, +} diff --git a/src/DomainMapper.Abstractions/MapCollectionAttribute.cs b/src/DomainMapper.Abstractions/MapCollectionAttribute.cs new file mode 100644 index 0000000..0c9d546 --- /dev/null +++ b/src/DomainMapper.Abstractions/MapCollectionAttribute.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; + +namespace DomainMapper.Abstractions; + +/// Configures an explicit mechanical update policy for one existing-target collection member. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +[Conditional("DOMAINMAPPER_ABSTRACTIONS_SCOPE_RUNTIME")] +public sealed class MapCollectionAttribute(string targetMember, CollectionUpdatePolicy policy) : Attribute +{ + /// The target collection property or field name. + public string TargetMember { get; } = targetMember; + + /// The generated collection update operation. + public CollectionUpdatePolicy Policy { get; } = policy; +} diff --git a/src/DomainMapper.Abstractions/MapReferenceTrackingAttribute.cs b/src/DomainMapper.Abstractions/MapReferenceTrackingAttribute.cs new file mode 100644 index 0000000..4105571 --- /dev/null +++ b/src/DomainMapper.Abstractions/MapReferenceTrackingAttribute.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; + +namespace DomainMapper.Abstractions; + +/// +/// Enables per-invocation source-reference preservation for a mapping root. +/// Reference identity is used; no domain or persistence key is inferred. +/// +[AttributeUsage(AttributeTargets.Method)] +[Conditional("DOMAINMAPPER_ABSTRACTIONS_SCOPE_RUNTIME")] +public sealed class MapReferenceTrackingAttribute : Attribute; diff --git a/src/DomainMapper.Abstractions/MapRegistryAttribute.cs b/src/DomainMapper.Abstractions/MapRegistryAttribute.cs new file mode 100644 index 0000000..722052c --- /dev/null +++ b/src/DomainMapper.Abstractions/MapRegistryAttribute.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace DomainMapper.Abstractions; + +/// +/// Generates closed-world runtime dispatch for eligible static mapping methods declared by this mapper. +/// +[AttributeUsage(AttributeTargets.Class)] +[Conditional("DOMAINMAPPER_ABSTRACTIONS_SCOPE_RUNTIME")] +public sealed class MapRegistryAttribute : Attribute; diff --git a/src/DomainMapper.Abstractions/MapRegistryDerivedAttribute.cs b/src/DomainMapper.Abstractions/MapRegistryDerivedAttribute.cs new file mode 100644 index 0000000..4babf04 --- /dev/null +++ b/src/DomainMapper.Abstractions/MapRegistryDerivedAttribute.cs @@ -0,0 +1,8 @@ +using System.Diagnostics; + +namespace DomainMapper.Abstractions; + +/// Opts one registry mapping into assignable derived-source dispatch. +[AttributeUsage(AttributeTargets.Method)] +[Conditional("DOMAINMAPPER_ABSTRACTIONS_SCOPE_RUNTIME")] +public sealed class MapRegistryDerivedAttribute : Attribute; diff --git a/src/DomainMapper.Projections/DomainMapper.Projections.csproj b/src/DomainMapper.Projections/DomainMapper.Projections.csproj new file mode 100644 index 0000000..609e6c7 --- /dev/null +++ b/src/DomainMapper.Projections/DomainMapper.Projections.csproj @@ -0,0 +1,22 @@ + + + true + DomainMapper.Projections + Provider-neutral expression-tree projections for DomainMapper. + README.md + logo.png + Mapper SourceGenerator ExpressionTree IQueryable + Apache-2.0 + https://github.com/skuirrels/DomainMapper + https://github.com/skuirrels/DomainMapper + true + git + true + T:System.Diagnostics.CodeAnalysis.NotNullWhenAttribute + + + + + + + diff --git a/src/DomainMapper.Projections/MapProjectionAttribute.cs b/src/DomainMapper.Projections/MapProjectionAttribute.cs new file mode 100644 index 0000000..7d42f11 --- /dev/null +++ b/src/DomainMapper.Projections/MapProjectionAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace DomainMapper.Projections; + +/// Declares a cached, provider-neutral projection for an eligible in-memory mapping contract. +[AttributeUsage(AttributeTargets.Method)] +[Conditional("DOMAINMAPPER_ABSTRACTIONS_SCOPE_RUNTIME")] +public sealed class MapProjectionAttribute(string mappingMethod) : Attribute +{ + /// The source mapping method whose compile-time contract is projected. + public string MappingMethod { get; } = mappingMethod; +} diff --git a/src/DomainMapper/AnalyzerReleases.Shipped.md b/src/DomainMapper/AnalyzerReleases.Shipped.md index 76d0d72..6a3d19d 100644 --- a/src/DomainMapper/AnalyzerReleases.Shipped.md +++ b/src/DomainMapper/AnalyzerReleases.Shipped.md @@ -1,5 +1,15 @@ ; Shipped analyzer releases +## Release 1.2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DMPR105 | DomainMapper | Error | Reference tracking requires a supported two-phase target construction shape +DMPR106 | DomainMapper | Error | Projection contracts reject operations outside the provider-neutral expression subset +DMPR107 | DomainMapper | Error | Closed-world registries reject duplicate or ambiguous declarations + ## Release 1.1.0 ### New Rules diff --git a/src/DomainMapper/DomainMapper.csproj b/src/DomainMapper/DomainMapper.csproj index a8a956e..486c0c0 100644 --- a/src/DomainMapper/DomainMapper.csproj +++ b/src/DomainMapper/DomainMapper.csproj @@ -1,7 +1,7 @@ true - 1.0.0 + 1.1.0 true false README.md diff --git a/src/DomainMapper/DomainMapperGenerator.cs b/src/DomainMapper/DomainMapperGenerator.cs index 6eb9755..33859ae 100644 --- a/src/DomainMapper/DomainMapperGenerator.cs +++ b/src/DomainMapper/DomainMapperGenerator.cs @@ -12,17 +12,24 @@ public sealed class DomainMapperGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - var mapperTypes = context.SyntaxProvider.ForAttributeWithMetadataName( - MapperAttribute, - static (node, _) => node is TypeDeclarationSyntax, - static (attributeContext, _) => (INamedTypeSymbol)attributeContext.TargetSymbol - ); + var mapperTypes = context + .SyntaxProvider.ForAttributeWithMetadataName( + MapperAttribute, + static (node, _) => node is TypeDeclarationSyntax, + static (attributeContext, _) => + MapperGenerationInput.Create( + (INamedTypeSymbol)attributeContext.TargetSymbol, + attributeContext.SemanticModel.Compilation + ) + ) + .WithComparer(MapperGenerationInputComparer.Instance) + .WithTrackingName("MapperContracts"); context.RegisterSourceOutput( - mapperTypes.Combine(context.CompilationProvider), + mapperTypes, static (productionContext, input) => { - var result = MapperCompiler.Compile(input.Left, input.Right, productionContext.CancellationToken); + var result = MapperCompiler.Compile(input.MapperType, input.Compilation, productionContext.CancellationToken); foreach (var diagnostic in result.Diagnostics) { productionContext.ReportDiagnostic(diagnostic); diff --git a/src/DomainMapper/Engine/MapperCompiler.cs b/src/DomainMapper/Engine/MapperCompiler.cs index 43ca615..4bd2ed5 100644 --- a/src/DomainMapper/Engine/MapperCompiler.cs +++ b/src/DomainMapper/Engine/MapperCompiler.cs @@ -14,6 +14,7 @@ internal sealed class MapperCompiler private const string IgnoreSourceMemberAttribute = "DomainMapper.Abstractions.IgnoreSourceMemberAttribute"; private const string IgnoreTargetMemberAttribute = "DomainMapper.Abstractions.IgnoreTargetMemberAttribute"; private const string IncludeMappingAttribute = "DomainMapper.Abstractions.IncludeMappingAttribute"; + private const string MapCollectionAttribute = "DomainMapper.Abstractions.MapCollectionAttribute"; private const string MapConditionAttribute = "DomainMapper.Abstractions.MapConditionAttribute"; private const string MapAfterAttribute = "DomainMapper.Abstractions.MapAfterAttribute"; private const string MapMemberAttribute = "DomainMapper.Abstractions.MapMemberAttribute"; @@ -21,9 +22,13 @@ internal sealed class MapperCompiler private const string MapNullAttribute = "DomainMapper.Abstractions.MapNullAttribute"; private const string MapNullSubstituteAttribute = "DomainMapper.Abstractions.MapNullSubstituteAttribute"; private const string MapOnlyTargetMembersAttribute = "DomainMapper.Abstractions.MapOnlyTargetMembersAttribute"; + private const string MapReferenceTrackingAttribute = "DomainMapper.Abstractions.MapReferenceTrackingAttribute"; + private const string MapRegistryAttribute = "DomainMapper.Abstractions.MapRegistryAttribute"; + private const string MapRegistryDerivedAttribute = "DomainMapper.Abstractions.MapRegistryDerivedAttribute"; private const string MapTargetMemberAttribute = "DomainMapper.Abstractions.MapTargetMemberAttribute"; private const string MapToFactoryAttribute = "DomainMapper.Abstractions.MapToFactoryAttribute"; private const string MappingCompletenessAttribute = "DomainMapper.Abstractions.MappingCompletenessAttribute"; + private const string MapProjectionAttribute = "DomainMapper.Projections.MapProjectionAttribute"; private static readonly DiagnosticDescriptor UnsupportedMethod = new( "DMPR100", @@ -70,6 +75,33 @@ internal sealed class MapperCompiler true ); + private static readonly DiagnosticDescriptor UnsupportedReferenceTracking = new( + "DMPR105", + "Reference tracking target is not supported", + "Mapping '{0}' cannot preserve references: {1}", + "DomainMapper", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidRegistry = new( + "DMPR107", + "Runtime registry is invalid", + "Mapper '{0}' runtime registry is invalid: {1}", + "DomainMapper", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidProjection = new( + "DMPR106", + "Projection contract is not supported", + "Projection '{0}' for member '{1}' cannot use {2}; {3}", + "DomainMapper", + DiagnosticSeverity.Error, + true + ); + private static readonly SymbolDisplayFormat TypeDisplayFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier @@ -87,22 +119,38 @@ internal sealed class MapperCompiler private readonly HashSet _activeDomainFactories = new(SymbolEqualityComparer.Default); private readonly Dictionary> _mappingMembers = new(SymbolEqualityComparer.Default); private readonly ImmutableArray _mappingMethods; + private readonly ImmutableArray _projectionMethods; private readonly IReadOnlyDictionary> _configurationHelpers; + private readonly List _supportMembers = []; + private readonly Dictionary _configurations = new(SymbolEqualityComparer.Default); + private readonly HashSet _successfulMappingMethods = new(SymbolEqualityComparer.Default); + private string? _referenceKeyName; private MapperCompiler(INamedTypeSymbol mapperType, Compilation compilation) { _mapperType = mapperType; _compilation = compilation; - _mappingMethods = mapperType + var partialMethods = mapperType .GetMembers() .OfType() .Where(x => x.IsPartialDefinition && x.PartialImplementationPart == null) .OrderBy(x => x.Locations.FirstOrDefault()?.SourceSpan.Start ?? int.MaxValue) .ToImmutableArray(); + _projectionMethods = partialMethods.Where(x => HasAttribute(x, MapProjectionAttribute)).ToImmutableArray(); + _mappingMethods = partialMethods.Where(x => !HasAttribute(x, MapProjectionAttribute)).ToImmutableArray(); foreach (var memberName in GetTypeHierarchy(mapperType).SelectMany(x => x.GetMembers()).Select(x => x.Name)) { _usedHelperNames.Add(memberName); } + for (var baseType = mapperType.BaseType; baseType != null; baseType = baseType.BaseType) + { + foreach ( + var memberName in baseType.GetMembers().Where(x => x.DeclaredAccessibility != Accessibility.Private).Select(x => x.Name) + ) + { + _usedHelperNames.Add(memberName); + } + } _configurationHelpers = IndexConfigurationHelpers(mapperType); } @@ -136,10 +184,491 @@ private MapperCompilation Build(CancellationToken cancellationToken) BuildHelperContract(_pendingHelpers.Dequeue()); } - var source = _rootContracts.Count == 0 ? null : EmitSource(); + BuildProjections(); + BuildRuntimeRegistry(); + + var source = _rootContracts.Count == 0 && _supportMembers.Count == 0 ? null : EmitSource(); return new MapperCompilation(BuildHintName(_mapperType), source, _diagnostics.ToImmutableArray()); } + [SuppressMessage( + "Maintainability", + "MA0051", + Justification = "Keeps closed-world registration validation and emitted dispatch ordering together." + )] + private void BuildRuntimeRegistry() + { + if (Attribute(_mapperType, MapRegistryAttribute) == null) + return; + + if (HasVisibleMapperMember("TryMapRuntime") || HasVisibleMapperMember("MapRuntime")) + { + _diagnostics.Add( + Diagnostic.Create( + InvalidRegistry, + _mapperType.Locations.FirstOrDefault(), + _mapperType.Name, + "generated method names TryMapRuntime and MapRuntime must be available" + ) + ); + return; + } + + var candidates = _mappingMethods + .Where(x => + x.IsStatic + && !x.ReturnsVoid + && x.TypeParameters.Length == 0 + && x.Parameters is [{ RefKind: RefKind.None }] + && _successfulMappingMethods.Contains(x) + ) + .ToArray(); + var duplicates = candidates + .GroupBy(x => $"{RuntimeSourceTypeName(x.Parameters[0].Type)}->{RuntimeTypeName(x.ReturnType)}", StringComparer.Ordinal) + .Where(x => x.Count() > 1) + .ToArray(); + if (duplicates.Length > 0) + { + foreach (var duplicate in duplicates) + { + _diagnostics.Add( + Diagnostic.Create( + InvalidRegistry, + _mapperType.Locations.FirstOrDefault(), + _mapperType.Name, + $"mapping pair '{duplicate.Key}' is registered more than once" + ) + ); + } + return; + } + + var derivedCandidates = candidates.Where(x => HasAttribute(x, MapRegistryDerivedAttribute)).ToArray(); + var invalidDerivedCandidate = derivedCandidates.FirstOrDefault(x => !x.Parameters[0].Type.IsReferenceType); + if (invalidDerivedCandidate != null) + { + _diagnostics.Add( + Diagnostic.Create( + InvalidRegistry, + invalidDerivedCandidate.Locations.FirstOrDefault(), + _mapperType.Name, + $"derived-source mapping '{invalidDerivedCandidate.Name}' requires a reference-type source" + ) + ); + return; + } + for (var left = 0; left < derivedCandidates.Length; left++) + { + for (var right = left + 1; right < derivedCandidates.Length; right++) + { + var first = derivedCandidates[left]; + var second = derivedCandidates[right]; + if (!RuntimeTypesEqual(first.ReturnType, second.ReturnType)) + continue; + var overlaps = RuntimeSourceTypesMayOverlap(first.Parameters[0].Type, second.Parameters[0].Type); + if (!overlaps) + continue; + _diagnostics.Add( + Diagnostic.Create( + InvalidRegistry, + _mapperType.Locations.FirstOrDefault(), + _mapperType.Name, + $"derived-source mappings '{first.Name}' and '{second.Name}' overlap for target '{second.ReturnType.ToDisplayString()}'" + ) + ); + return; + } + } + + var lines = new List(); + foreach ( + var method in candidates.OrderBy(x => HasAttribute(x, MapRegistryDerivedAttribute)).ThenBy(x => x.Name, StringComparer.Ordinal) + ) + { + var sourceType = TypeName(method.Parameters[0].Type); + var runtimeSourceType = RuntimeSourceTypeName(method.Parameters[0].Type); + var runtimeTargetType = RuntimeTypeName(method.ReturnType); + var sourceCheck = HasAttribute(method, MapRegistryDerivedAttribute) + ? $"source is {runtimeSourceType} typedSource" + : $"source.GetType() == typeof({runtimeSourceType})"; + var sourceArgument = HasAttribute(method, MapRegistryDerivedAttribute) ? "typedSource" : $"({sourceType})source"; + lines.Add( + $"if ({sourceCheck} && targetType == typeof({runtimeTargetType}))\n{{\n target = {Escape(method.Name)}({sourceArgument});\n return true;\n}}" + ); + } + lines.Add("target = null;\nreturn false;"); + var visibility = _mapperType.DeclaredAccessibility == Accessibility.Public ? "public" : "internal"; + _supportMembers.Add( + $"[global::System.CodeDom.Compiler.GeneratedCode(\"DomainMapper\", \"0.0.1.0\")]\n" + + $"{visibility} static bool TryMapRuntime(object source, global::System.Type targetType, out object? target)\n{{\n" + + " if (source is null) throw new global::System.ArgumentNullException(nameof(source));\n" + + " if (targetType is null) throw new global::System.ArgumentNullException(nameof(targetType));\n" + + Indent(string.Join("\n", lines)) + + "\n}" + ); + _supportMembers.Add( + $"[global::System.CodeDom.Compiler.GeneratedCode(\"DomainMapper\", \"0.0.1.0\")]\n" + + $"{visibility} static object? MapRuntime(object source, global::System.Type targetType)\n{{\n" + + " if (TryMapRuntime(source, targetType, out var target))\n return target!;\n" + + " throw new global::System.InvalidOperationException(\"No DomainMapper mapping is registered from '\" + source.GetType() + \"' to '\" + targetType + \"'.\");\n}" + ); + } + + [SuppressMessage( + "Maintainability", + "MA0051", + Justification = "Keeps projection declaration validation and cached member emission together." + )] + private void BuildProjections() + { + foreach (var projection in _projectionMethods) + { + if (!TryGetProjectionTypes(projection, out var sourceType, out var targetType)) + { + ReportInvalidProjection( + projection, + "", + "the declared method shape", + "Declare a parameterless method returning Expression>." + ); + continue; + } + if (!TryReadString(Attribute(projection, MapProjectionAttribute)!, 0, out var mappingName)) + { + ReportInvalidProjection(projection, "", "an invalid mapping reference", "Reference one mapping method by name."); + continue; + } + + var mappings = _mappingMethods + .Where(x => + NamesEqual(x.Name, mappingName) + && !x.ReturnsVoid + && x.Parameters.Length > 0 + && TypesEqual(x.Parameters[0].Type, sourceType) + && TypesEqual(x.ReturnType, targetType) + ) + .ToArray(); + if (mappings.Length != 1) + { + ReportInvalidProjection( + projection, + "", + "a missing or ambiguous mapping contract", + "Reference one successfully generated create mapping with matching source and target types." + ); + continue; + } + var mapping = mappings[0]; + if (!_successfulMappingMethods.Contains(mapping) || !_configurations.TryGetValue(mapping, out var configuration)) + { + ReportInvalidProjection( + projection, + "", + "an invalid mapping contract", + "Fix the referenced in-memory mapping before declaring its projection." + ); + continue; + } + if (!ValidateProjectionEligibility(projection, mapping, configuration)) + continue; + + var expression = BuildProjectionExpression( + sourceType, + targetType, + "source", + new MappingContext(mapping.TypeParameters, ImmutableArray.Empty, configuration), + new HashSet(StringComparer.Ordinal), + out var failureMember + ); + if (expression == null) + { + ReportInvalidProjection( + projection, + failureMember ?? "", + "an unsupported construction or conversion", + "Use constructor/member initialization and the documented pure conversion subset, or keep this as an in-memory mapping." + ); + continue; + } + + var holderName = ReserveMemberName( + $"__domainMapperProjection_{Sanitize(projection.Name)}_{StableHash(projection.ToDisplayString()):X8}Holder" + ); + _supportMembers.Add( + $"private static class {holderName}\n{{\n" + + "#if NET5_0_OR_GREATER\n" + + " [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"Expression-tree construction requires member metadata.\")]\n" + + "#endif\n" + + $" static {holderName}() {{ }}\n" + + $" internal static readonly {TypeName(projection.ReturnType)} Value = source => {expression};\n" + + "}" + ); + _rootContracts.Add( + new MappingContract( + projection.Name, + "#if NET5_0_OR_GREATER\n" + + "[global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"Expression-tree construction requires member metadata and is not supported by DomainMapper under trimming or native AOT.\")]\n" + + "#endif\n" + + BuildDeclaration(projection), + $"return {holderName}.Value;", + MappingShape.Create + ) + ); + } + } + + private bool ValidateProjectionEligibility(IMethodSymbol projection, IMethodSymbol mapping, MappingMethodConfiguration configuration) + { + var failures = new List<(string Member, string Operation, string Action)>(); + if (mapping.Parameters.Length != 1) + failures.Add(("", "additional mapping parameters", "Use an in-memory mapping for caller-supplied values.")); + if (ReadFactoryName(mapping) != null) + failures.Add(("", "a target factory", "Use a projection-safe constructor or member initializer.")); + if (configuration.CompletionHooks.Length > 0) + failures.Add(("", "completion hooks", "Keep completion hooks on the in-memory mapping only.")); + if (configuration.PreserveReferences) + failures.Add(("", "reference tracking", "Use reference tracking only for in-memory mappings.")); + if (configuration.MaximumDepth != null) + failures.Add(("", "bounded recursion", "Project an acyclic shape or use the in-memory mapping.")); + failures.AddRange( + configuration.Conditions.Keys.Select(x => + (x, "conditional assignment", "Express the condition in the consumer query or use the in-memory mapping.") + ) + ); + failures.AddRange( + configuration.ComputedMembers.Keys.Select(x => (x, "a mapper method call", "Bind a source path or use the in-memory mapping.")) + ); + failures.AddRange( + configuration.CollectionPolicies.Keys.Select(x => + (x, "existing-target collection mutation", "Collection mutation is not a projection operation.") + ) + ); + foreach (var failure in failures) + ReportInvalidProjection(projection, failure.Member, failure.Operation, failure.Action); + return failures.Count == 0; + } + + [SuppressMessage( + "Maintainability", + "MA0051", + Justification = "Keeps projection construction fail-closed in one recursive planning flow." + )] + private string? BuildProjectionExpression( + ITypeSymbol sourceType, + ITypeSymbol targetType, + string sourceExpression, + MappingContext context, + ISet visiting, + out string? failureMember + ) + { + failureMember = null; + if (TypesEqual(sourceType, targetType)) + return sourceExpression; + + if (targetType.IsReferenceType && targetType.NullableAnnotation == NullableAnnotation.Annotated) + { + var target = targetType.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + if (sourceType.IsReferenceType && sourceType.NullableAnnotation == NullableAnnotation.Annotated) + { + var source = sourceType.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + var mapped = BuildProjectionExpression(source, target, sourceExpression + "!", context, visiting, out failureMember); + return mapped == null ? null : $"{sourceExpression} == null ? null : {mapped}"; + } + return BuildProjectionExpression(sourceType, target, sourceExpression, context, visiting, out failureMember); + } + var conversion = _compilation.ClassifyConversion(sourceType, targetType); + if (conversion.Exists && conversion.IsImplicit && !conversion.IsUserDefined) + return sourceExpression; + if (IsNullable(sourceType)) + return null; + if (TryGetSequenceElement(sourceType, out _) || TryGetDictionaryTypes(sourceType, out _, out _)) + return null; + + var key = $"{TypeName(sourceType)}->{TypeName(targetType)}"; + if (!visiting.Add(key)) + return null; + try + { + if ( + targetType + is not INamedTypeSymbol { SpecialType: SpecialType.None, TypeKind: TypeKind.Class or TypeKind.Struct } namedTarget + || namedTarget.IsAbstract + ) + return null; + var configuration = RootConfiguration(context, sourceType, targetType); + foreach ( + var constructor in namedTarget + .InstanceConstructors.Where(IsAccessible) + .Where(x => !IsRecordCopyConstructor(x, namedTarget)) + .OrderByDescending(x => x.Parameters.Length) + ) + { + var arguments = new List(); + var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); + var valid = true; + foreach (var parameter in constructor.Parameters) + { + var argument = BuildProjectionMemberValue( + sourceType, + targetType, + sourceExpression, + parameter.Name, + parameter.Type, + context, + visiting, + out failureMember + ); + if (argument == null) + { + valid = false; + break; + } + arguments.Add(argument); + consumed.Add(parameter.Name); + } + if (!valid) + continue; + + var initializers = new List(); + foreach (var member in SettableTargetMembers(targetType, configuration)) + { + if (consumed.Contains(member.Name) || configuration?.IgnoredTargets.Contains(member.Name) == true) + continue; + var value = BuildProjectionMemberValue( + sourceType, + targetType, + sourceExpression, + member.Name, + member.Type, + context, + visiting, + out failureMember + ); + if (value == null) + { + if (configuration?.EnforceTarget == false && !member.IsRequired) + continue; + valid = false; + break; + } + initializers.Add($"{Escape(member.Name)} = {value}"); + } + if (!valid) + continue; + var initializer = initializers.Count == 0 ? string.Empty : $" {{ {string.Join(", ", initializers)} }}"; + return $"new {TypeName(targetType)}({string.Join(", ", arguments)}){initializer}"; + } + return null; + } + finally + { + visiting.Remove(key); + } + } + + private string? BuildProjectionMemberValue( + ITypeSymbol sourceType, + ITypeSymbol targetType, + string sourceExpression, + string targetMemberName, + ITypeSymbol targetMemberType, + MappingContext context, + ISet visiting, + out string? failureMember + ) + { + failureMember = targetMemberName; + var configuration = RootConfiguration(context, sourceType, targetType); + string sourceValue; + ITypeSymbol sourceValueType; + if (configuration?.Bindings.TryGetValue(targetMemberName, out var binding) == true) + { + sourceValueType = EffectivePathType(binding.SourceMembers); + sourceValue = BuildProjectionSourcePath(sourceExpression, binding.SourceMembers, sourceValueType); + } + else if (TryFindMember(ReadableMembers(sourceType), targetMemberName, out var sourceMember)) + { + sourceValue = $"{sourceExpression}.{Escape(sourceMember.Name)}"; + sourceValueType = sourceMember.Type; + } + else + { + return null; + } + + if (configuration?.NullSubstitutes.TryGetValue(targetMemberName, out var substitute) == true && IsNullable(sourceValueType)) + { + var mapped = BuildProjectionExpression( + NonNullableType(sourceValueType), + targetMemberType, + NonNullExpression(sourceValue, sourceValueType), + context, + visiting, + out failureMember + ); + return mapped == null ? null : $"{sourceValue} == null ? {substitute} : {mapped}"; + } + if (configuration?.NullBehaviors.TryGetValue(targetMemberName, out var behavior) == true && behavior != 0) + return null; + return BuildProjectionExpression(sourceValueType, targetMemberType, sourceValue, context, visiting, out failureMember); + } + + private static string BuildProjectionSourcePath(string sourceExpression, ImmutableArray path, ITypeSymbol effectiveType) + { + var expression = sourceExpression; + var nullablePrefixes = new List(); + ITypeSymbol? currentType = null; + foreach (var member in path) + { + if (currentType != null && IsNullable(currentType)) + { + nullablePrefixes.Add(expression); + expression = NonNullExpression(expression, currentType); + } + expression += "." + Escape(member.Name); + currentType = member.Type; + } + foreach (var prefix in nullablePrefixes.AsEnumerable().Reverse()) + expression = $"{prefix} == null ? default({TypeName(effectiveType)}) : {expression}"; + return expression; + } + + private static bool TryGetProjectionTypes(IMethodSymbol method, out ITypeSymbol sourceType, out ITypeSymbol targetType) + { + sourceType = null!; + targetType = null!; + if ( + !method.IsStatic + || method.Parameters.Length != 0 + || method.TypeParameters.Length != 0 + || method.ReturnType is not INamedTypeSymbol expression + ) + return false; + if ( + !string.Equals(expression.OriginalDefinition.MetadataName, "Expression`1", StringComparison.Ordinal) + || !string.Equals( + expression.OriginalDefinition.ContainingNamespace.ToDisplayString(), + "System.Linq.Expressions", + StringComparison.Ordinal + ) + ) + return false; + if ( + expression.TypeArguments[0] is not INamedTypeSymbol { DelegateInvokeMethod: { } invoke } delegateType + || !string.Equals(delegateType.OriginalDefinition.MetadataName, "Func`2", StringComparison.Ordinal) + || !string.Equals(delegateType.OriginalDefinition.ContainingNamespace.ToDisplayString(), "System", StringComparison.Ordinal) + || invoke.Parameters.Length != 1 + ) + return false; + sourceType = invoke.Parameters[0].Type; + targetType = invoke.ReturnType; + return true; + } + + private void ReportInvalidProjection(IMethodSymbol method, string member, string operation, string action) => + _diagnostics.Add(Diagnostic.Create(InvalidProjection, method.Locations.FirstOrDefault(), method.Name, member, operation, action)); + private ImmutableArray DiscoverMappingMethods() => _mappingMethods; [SuppressMessage("Maintainability", "MA0051", Justification = "Keeps the complete per-method configuration validation flow auditable.")] @@ -160,12 +689,27 @@ bool isUpdate var ignoredSources = ImmutableHashSet.CreateBuilder(comparer); var nullBehaviors = ImmutableDictionary.CreateBuilder(comparer); var nullSubstitutes = ImmutableDictionary.CreateBuilder(comparer); + var collectionPolicies = ImmutableDictionary.CreateBuilder(comparer); var computedMembers = ImmutableDictionary.CreateBuilder(comparer); var conditions = ImmutableDictionary.CreateBuilder(comparer); var completionHooks = ImmutableArray.CreateBuilder(); var completionHookMethods = new HashSet(SymbolEqualityComparer.Default); var sourceMembers = AllReadableMembers(sourceType); var targetMembers = GetAllMappingMembers(targetType).ToArray(); + var preserveReferences = HasAttribute(method, MapReferenceTrackingAttribute); + + if (preserveReferences && (isUpdate || !sourceType.IsReferenceType || !targetType.IsReferenceType)) + { + _diagnostics.Add( + Diagnostic.Create( + UnsupportedReferenceTracking, + method.Locations.FirstOrDefault(), + method.Name, + "tracking requires a create mapping between reference types" + ) + ); + valid = false; + } var completeness = 0; var completenessAttribute = Attribute(method, MappingCompletenessAttribute); @@ -186,22 +730,70 @@ bool isUpdate { if (maxDepthAttribute.ConstructorArguments is not [{ Value: int configuredDepth }] || configuredDepth <= 0) { - ReportInvalidConfiguration(method, "maximum mapping depth must be greater than zero"); + ReportInvalidConfiguration(method, "maximum mapping depth must be greater than zero"); + valid = false; + } + else + { + maximumDepth = configuredDepth; + var configuredBehavior = maxDepthAttribute + .NamedArguments.FirstOrDefault(x => string.Equals(x.Key, "ExhaustionBehavior", StringComparison.Ordinal)) + .Value.Value; + if (configuredBehavior is int behavior) + depthExhaustionBehavior = behavior; + if (depthExhaustionBehavior is < 0 or > 1) + { + ReportInvalidConfiguration(method, $"depth exhaustion behavior value '{depthExhaustionBehavior}' is not defined"); + valid = false; + } + } + } + + foreach (var attribute in Attributes(method, MapCollectionAttribute)) + { + if ( + !isUpdate + || !TryReadString(attribute, 0, out var targetName) + || attribute.ConstructorArguments.Length != 2 + || attribute.ConstructorArguments[1].Value is not int policy + || policy is < 0 or > 2 + || !TryFindMember(targetMembers, targetName, out var targetMember) + || !targetMember.CanRead + ) + { + ReportInvalidConfiguration( + method, + "a collection policy requires an existing-target mapping and a readable target collection member" + ); + valid = false; + continue; + } + + if (!TryGetSequenceElement(targetMember.Type, out _) && !TryGetDictionaryTypes(targetMember.Type, out _, out _)) + { + ReportInvalidConfiguration(method, $"collection policy target '{targetName}' is not a supported collection"); + valid = false; + continue; + } + if (policy == 0 && (!targetMember.CanWrite || targetMember.IsInitOnly)) + { + ReportInvalidConfiguration(method, $"Replace collection policy for '{targetName}' requires a writable target member"); valid = false; + continue; } - else + if (policy is 1 or 2 && !CanMutateCollection(targetMember.Type)) { - maximumDepth = configuredDepth; - var configuredBehavior = maxDepthAttribute - .NamedArguments.FirstOrDefault(x => string.Equals(x.Key, "ExhaustionBehavior", StringComparison.Ordinal)) - .Value.Value; - if (configuredBehavior is int behavior) - depthExhaustionBehavior = behavior; - if (depthExhaustionBehavior is < 0 or > 1) - { - ReportInvalidConfiguration(method, $"depth exhaustion behavior value '{depthExhaustionBehavior}' is not defined"); - valid = false; - } + ReportInvalidConfiguration( + method, + $"collection policy for '{targetName}' requires a mutable ICollection or IDictionary target" + ); + valid = false; + continue; + } + if (!collectionPolicies.TryAdd(targetName, policy)) + { + ReportInvalidConfiguration(method, $"target member '{targetName}' has more than one collection policy"); + valid = false; } } @@ -303,7 +895,13 @@ bool isUpdate } foreach (var memberName in ReadStringArray(onlyAttribute)) { - if (!TryFindMember(targetMembers, memberName, out var member) || !member.CanWrite || member.IsInitOnly) + if ( + !TryFindMember(targetMembers, memberName, out var member) + || ( + (!member.CanWrite || member.IsInitOnly) + && (!collectionPolicies.TryGetValue(memberName, out var policy) || policy is not (1 or 2)) + ) + ) { ReportInvalidConfiguration(method, $"allow-listed target member '{memberName}' is missing, ambiguous, or not writable"); valid = false; @@ -486,6 +1084,23 @@ bool isUpdate } } + foreach (var targetName in collectionPolicies.Keys) + { + if (ignoredTargets.Contains(targetName)) + { + ReportInvalidConfiguration(method, $"collection policy for target member '{targetName}' cannot be combined with an ignore"); + valid = false; + } + if (computedMembers.ContainsKey(targetName) || nullSubstitutes.ContainsKey(targetName)) + { + ReportInvalidConfiguration( + method, + $"collection policy for target member '{targetName}' cannot be combined with a computed member or null substitute" + ); + valid = false; + } + } + foreach (var targetName in nullBehaviors.Keys.Concat(nullSubstitutes.Keys)) { if (ignoredTargets.Contains(targetName) || computedMembers.ContainsKey(targetName)) @@ -544,7 +1159,9 @@ bool isUpdate conditions.ToImmutable(), completionHooks.ToImmutable(), maximumDepth, - depthExhaustionBehavior + depthExhaustionBehavior, + collectionPolicies.ToImmutable(), + preserveReferences ) : null; } @@ -563,10 +1180,12 @@ is IgnoreSourceMemberAttribute or IgnoreTargetMemberAttribute or IncludeMappingAttribute or MapMemberAttribute + or MapCollectionAttribute or MapMaxDepthAttribute or MapNullAttribute or MapNullSubstituteAttribute or MapOnlyTargetMembersAttribute + or MapReferenceTrackingAttribute or MappingCompletenessAttribute ) return true; @@ -595,9 +1214,12 @@ ITypeSymbol targetType ImmutableDictionary.Empty, ImmutableArray.Empty, null, - 0 + 0, + ImmutableDictionary.Empty, + false ); + [SuppressMessage("Maintainability", "MA0051", Justification = "Keeps root mapping mode selection and validation auditable.")] private void BuildRootContract(IMethodSymbol method) { if (method.ReturnsByRef || method.ReturnsByRefReadonly || method.Parameters.Any(x => x.RefKind == RefKind.Out)) @@ -623,11 +1245,50 @@ private void BuildRootContract(IMethodSymbol method) var configuration = BuildConfiguration(method, sourceParameter.Type, method.ReturnType, false); if (configuration == null) return; + _configurations[method] = configuration; var ambientValues = method.Parameters.Skip(1).Select(x => new MappingValue(x.Name, x.Type, Escape(x.Name))).ToImmutableArray(); var context = new MappingContext(method.TypeParameters, ambientValues, configuration); var factoryName = ReadFactoryName(method); IMethodSymbol? selectedFactory = null; + if (configuration.PreserveReferences) + { + if ( + IsNullable(sourceParameter.Type) + || factoryName != null + || !CanTrackObject(sourceParameter.Type, method.ReturnType, context) + ) + { + _diagnostics.Add( + Diagnostic.Create( + UnsupportedReferenceTracking, + method.Locations.FirstOrDefault(), + method.Name, + "tracking requires a non-null source and a target that can be allocated before its mapped members" + ) + ); + return; + } + + var trackedExpression = QueueObjectHelper(sourceParameter.Type, method.ReturnType, sourceExpression, context); + if (trackedExpression == null) + return; + if (!ValidateSourceCompleteness(configuration, null, null)) + return; + var trackedHooks = BuildCompletionHooks(configuration, sourceExpression, "target", context); + if (trackedHooks == null) + return; + var referenceKeyName = EnsureReferenceKey(); + var trackedBody = + $"var __references = new global::System.Collections.Generic.Dictionary<{referenceKeyName}, object>();\n" + + $"var target = {trackedExpression};\n" + + trackedHooks + + "return target;"; + _rootContracts.Add(new MappingContract(method.Name, BuildDeclaration(method), trackedBody, MappingShape.Create)); + _successfulMappingMethods.Add(method); + return; + } + var expression = factoryName == null ? BuildRootExpression(sourceParameter.Type, method.ReturnType, sourceExpression, context) @@ -661,6 +1322,7 @@ out selectedFactory guardedHooks = $"if ({sourceExpression} is not null && target is not null)\n{{\n{Indent(hooks.TrimEnd())}\n}}\n"; var body = $"var target = {expression};\n{guardedHooks}return target;"; _rootContracts.Add(new MappingContract(method.Name, BuildDeclaration(method), body, MappingShape.Create)); + _successfulMappingMethods.Add(method); } private string? BuildRootExpression(ITypeSymbol sourceType, ITypeSymbol targetType, string sourceExpression, MappingContext context) @@ -907,9 +1569,11 @@ private static bool MatchesRootType(ITypeSymbol candidate, ITypeSymbol configure string sourceExpression, string? targetExpression, string targetMemberName, - MappingContext context + MappingContext context, + out bool valid ) { + valid = true; var configuration = RootConfiguration(context, sourceType, targetType); if (configuration?.Conditions.TryGetValue(targetMemberName, out var condition) != true) return null; @@ -939,10 +1603,13 @@ MappingContext context context ); if (call == null) + { + valid = false; ReportInvalidConfiguration( configuration!.Method, $"condition method '{condition!.Name}' has an unsupported parameter contract" ); + } return call; } @@ -988,6 +1655,7 @@ private void BuildUpdateContract(IMethodSymbol method) var configuration = BuildConfiguration(method, source.Type, target.Type, true); if (configuration == null) return; + _configurations[method] = configuration; var context = new MappingContext(method.TypeParameters, ImmutableArray.Empty, configuration); if ( !TryBuildAssignments( @@ -1025,9 +1693,17 @@ out var assignments private void BuildHelperContract(MappingRequest request) { + if (request.Context.Configuration?.PreserveReferences == true) + { + BuildTrackedObjectHelper(request); + return; + } + var expression = BuildObjectCreation(request.SourceType, request.TargetType, "source", request.Context); if (expression == null) { + if (request.Context.Configuration != null) + _successfulMappingMethods.Remove(request.Context.Configuration.Method); _diagnostics.Add( Diagnostic.Create( CannotConstruct, @@ -1051,6 +1727,90 @@ private void BuildHelperContract(MappingRequest request) ); } + private void BuildTrackedObjectHelper(MappingRequest request) + { + if (!CanTrackObject(request.SourceType, request.TargetType, request.Context)) + { + _successfulMappingMethods.Remove(request.Context.Configuration!.Method); + _diagnostics.Add( + Diagnostic.Create( + UnsupportedReferenceTracking, + _mapperType.Locations.FirstOrDefault(), + request.Context.Configuration!.Method.Name, + $"target '{request.TargetType.ToDisplayString()}' cannot be allocated before its mapped members" + ) + ); + AddUnsupportedTrackedHelper(request); + return; + } + + if ( + !TryBuildAssignments( + request.SourceType, + request.TargetType, + "source", + "target", + new HashSet(StringComparer.OrdinalIgnoreCase), + false, + request.Context, + out var assignments + ) + ) + { + _successfulMappingMethods.Remove(request.Context.Configuration!.Method); + _diagnostics.Add( + Diagnostic.Create( + UnsupportedReferenceTracking, + _mapperType.Locations.FirstOrDefault(), + request.Context.Configuration!.Method.Name, + $"target '{request.TargetType.ToDisplayString()}' cannot be fully assigned after its tracked instance is allocated" + ) + ); + AddUnsupportedTrackedHelper(request); + return; + } + + var declaration = BuildHelperDeclaration(request.TargetType, request.MethodName, request.SourceType, request.Context); + var depthGuard = BuildDepthGuard(request.TargetType, request.Context); + var referenceKeyName = EnsureReferenceKey(); + var body = + $"var __referenceKey = new {referenceKeyName}(source, typeof({RuntimeTypeName(request.TargetType)}));\n" + + $"if (__references.TryGetValue(__referenceKey, out var __existing))\n{{\n return ({TypeName(request.TargetType)})__existing;\n}}\n" + + depthGuard + + $"var target = new {TypeName(request.TargetType)}();\n" + + "__references.Add(__referenceKey, target);\n" + + assignments + + (assignments.Length == 0 ? string.Empty : "\n") + + "return target;"; + _helperContracts.Add(new MappingContract(request.MethodName, declaration, body, MappingShape.Helper)); + } + + private void AddUnsupportedTrackedHelper(MappingRequest request) => + _helperContracts.Add( + new MappingContract( + request.MethodName, + BuildHelperDeclaration(request.TargetType, request.MethodName, request.SourceType, request.Context), + "throw new global::System.InvalidOperationException(\"Unsupported DomainMapper reference-tracking contract.\");", + MappingShape.Helper + ) + ); + + private bool CanTrackObject(ITypeSymbol sourceType, ITypeSymbol targetType, MappingContext context) + { + if ( + !sourceType.IsReferenceType + || targetType is not INamedTypeSymbol namedTarget + || !targetType.IsReferenceType + || namedTarget.IsAbstract + ) + return false; + if (!namedTarget.InstanceConstructors.Any(x => x.Parameters.Length == 0 && IsAccessible(x))) + return false; + if (RequiredFields(targetType).Count > 0 || SettableTargetMembers(targetType, context.Configuration).Any(x => x.IsInitOnly)) + return false; + return true; + } + private static string BuildDepthGuard(ITypeSymbol targetType, MappingContext context) { if (context.Configuration?.MaximumDepth == null) @@ -1223,6 +1983,11 @@ out var assignments return assignments.Length == 0 ? creation : new DeferredObjectCreation(creation, assignments).ToMarker(); } + [SuppressMessage( + "Maintainability", + "MA0051", + Justification = "Keeps member assignment and explicit mutation planning in one ordered flow." + )] private bool TryBuildAssignments( ITypeSymbol sourceType, ITypeSymbol targetType, @@ -1247,6 +2012,7 @@ out string assignments !consumedMembers.Contains(targetMember.Name) && HasConfiguredOrConventionValue(configuration, sourceMembers, targetMember.Name) && !writableMembers.Any(x => SymbolEqualityComparer.Default.Equals(x.Symbol, targetMember.Symbol)) + && CollectionPolicy(configuration, targetMember.Name) is not (1 or 2) ) { assignments = string.Empty; @@ -1255,7 +2021,11 @@ out string assignments } var lines = new List(); - foreach (var targetMember in writableMembers) + var assignmentMembers = writableMembers + .Concat(ReadableTargetMembers(targetType, configuration).Where(x => CollectionPolicy(configuration, x.Name) is 1 or 2)) + .GroupBy(x => x.Symbol, SymbolEqualityComparer.Default) + .Select(x => x.First()); + foreach (var targetMember in assignmentMembers) { if (consumedMembers.Contains(targetMember.Name)) continue; @@ -1264,6 +2034,27 @@ out string assignments if (configuration?.OnlyTargets != null && !configuration.OnlyTargets.Contains(targetMember.Name)) continue; + if (CollectionPolicy(configuration, targetMember.Name) is 1 or 2) + { + if ( + !TryBuildCollectionMutation( + sourceType, + targetType, + sourceExpression, + targetExpression, + targetMember, + context, + out var mutation + ) + ) + { + assignments = string.Empty; + return false; + } + lines.Add(mutation); + continue; + } + if ( !TryBuildMemberValue( sourceType, @@ -1291,8 +2082,14 @@ out var nullableSourceExpression sourceExpression, targetExpression, targetMember.Name, - context + context, + out var conditionValid ); + if (!conditionValid) + { + assignments = string.Empty; + return false; + } if (configuration?.NullBehaviors.TryGetValue(targetMember.Name, out var behavior) == true && behavior == 1) condition = condition == null @@ -1305,6 +2102,141 @@ out var nullableSourceExpression return !requireAssignment || lines.Count > 0; } + [SuppressMessage("Maintainability", "MA0051", Justification = "Keeps collection null, shape, and mutation policy validation together.")] + private bool TryBuildCollectionMutation( + ITypeSymbol sourceType, + ITypeSymbol targetType, + string sourceExpression, + string targetExpression, + MappingMember targetMember, + MappingContext context, + out string mutation + ) + { + mutation = string.Empty; + var configuration = context.Configuration!; + string sourceValue; + ITypeSymbol sourceValueType; + if (configuration.Bindings.TryGetValue(targetMember.Name, out var binding)) + { + sourceValue = BuildSourcePathExpression(sourceExpression, binding.SourceMembers); + sourceValueType = EffectivePathType(binding.SourceMembers); + } + else if (TryFindMember(ReadableMembers(sourceType), targetMember.Name, out var sourceMember)) + { + sourceValue = $"{sourceExpression}.{Escape(sourceMember.Name)}"; + sourceValueType = sourceMember.Type; + } + else + { + ReportInvalidConfiguration( + configuration.Method, + $"collection policy target '{targetMember.Name}' has no configured source collection" + ); + return false; + } + + var nonNullableSource = NonNullableType(sourceValueType); + var targetAccess = $"{targetExpression}.{Escape(targetMember.Name)}"; + var collectionVariable = "__collection_" + Sanitize(targetMember.Name); + var lines = new List(); + var policy = configuration.CollectionPolicies[targetMember.Name]; + var behavior = configuration.NullBehaviors.TryGetValue(targetMember.Name, out var configuredBehavior) ? configuredBehavior : 0; + var nullable = IsNullable(sourceValueType); + var sourceCollectionVariable = "__sourceCollection_" + Sanitize(targetMember.Name); + var collectionSource = nullable ? sourceCollectionVariable : sourceValue; + + if ( + TryGetDictionaryTypes(nonNullableSource, out var sourceKey, out var sourceValueTypeArgument) + && TryGetDictionaryTypes(targetMember.Type, out var targetKey, out var targetValue) + ) + { + var contract = FindGenericContract(targetMember.Type, "System.Collections.Generic.IDictionary"); + if (contract == null) + return false; + var helperContext = context.ForHelper(); + var key = ConvertExpression(sourceKey, targetKey, "item.Key", helperContext); + var value = ConvertExpression(sourceValueTypeArgument, targetValue, "item.Value", helperContext); + if (key == null || value == null) + return false; + lines.Add( + $"var {collectionVariable} = ({TypeName(contract)})({targetAccess} ?? throw new global::System.InvalidOperationException(\"Target collection '{Escape(targetMember.Name)}' cannot be null.\"));" + ); + if (policy == 1) + lines.Add($"{collectionVariable}.Clear();"); + lines.Add($"foreach (var item in {collectionSource})\n{{\n {collectionVariable}.Add({key}, {value});\n}}"); + } + else if ( + TryGetSequenceElement(nonNullableSource, out var sourceElement) + && TryGetSequenceElement(targetMember.Type, out var targetElement) + ) + { + var contract = FindGenericContract(targetMember.Type, "System.Collections.Generic.ICollection"); + if (contract == null) + return false; + var value = ConvertExpression(sourceElement, targetElement, "item", context.ForHelper()); + if (value == null) + return false; + lines.Add( + $"var {collectionVariable} = ({TypeName(contract)})({targetAccess} ?? throw new global::System.InvalidOperationException(\"Target collection '{Escape(targetMember.Name)}' cannot be null.\"));" + ); + if (policy == 1) + lines.Add($"{collectionVariable}.Clear();"); + lines.Add($"foreach (var item in {collectionSource})\n{{\n {collectionVariable}.Add({value});\n}}"); + } + else + { + ReportInvalidConfiguration( + configuration.Method, + $"collection policy target '{targetMember.Name}' has incompatible source and target collection shapes" + ); + return false; + } + + var body = string.Join("\n", lines); + if (nullable) + { + if (behavior == 2) + { + body = + $"if ({collectionSource} is null)\n{{\n throw new global::System.InvalidOperationException(\"Source collection for '{Escape(targetMember.Name)}' cannot be null.\");\n}}\n{body}"; + } + else if (behavior == 1 || policy != 1) + { + body = $"if ({collectionSource} is not null)\n{{\n{Indent(body)}\n}}"; + } + else + { + var collectionContract = FindGenericContract( + targetMember.Type, + "System.Collections.Generic.ICollection", + "System.Collections.Generic.IDictionary" + )!; + var clear = + $"(({TypeName(collectionContract)})({targetAccess} ?? throw new global::System.InvalidOperationException(\"Target collection '{Escape(targetMember.Name)}' cannot be null.\"))).Clear();"; + body = $"if ({collectionSource} is null)\n{{\n {clear}\n}}\nelse\n{{\n{Indent(string.Join("\n", lines))}\n}}"; + } + body = $"var {sourceCollectionVariable} = {sourceValue};\n{body}"; + } + + var condition = BuildConditionExpression( + sourceType, + targetType, + sourceExpression, + targetExpression, + targetMember.Name, + context, + out var conditionValid + ); + if (!conditionValid) + return false; + mutation = condition == null ? body : $"if ({condition})\n{{\n{Indent(body)}\n}}"; + return true; + } + + private static int? CollectionPolicy(MappingMethodConfiguration? configuration, string targetMemberName) => + configuration?.CollectionPolicies.TryGetValue(targetMemberName, out var policy) == true ? policy : null; + [SuppressMessage("Maintainability", "MA0051", Justification = "Keeps fail-closed construction planning in one flow.")] private bool TryBuildCreationPlan( ITypeSymbol sourceType, @@ -1386,7 +2318,21 @@ out _ return false; } - var condition = BuildConditionExpression(sourceType, targetType, sourceExpression, "target", targetMember.Name, context); + var condition = BuildConditionExpression( + sourceType, + targetType, + sourceExpression, + "target", + targetMember.Name, + context, + out var conditionValid + ); + if (!conditionValid) + { + initializer = string.Empty; + assignments = string.Empty; + return false; + } if (requiresInitializer && condition != null) { initializer = string.Empty; @@ -1492,6 +2438,11 @@ out _ return null; } + [SuppressMessage( + "Maintainability", + "MA0051", + Justification = "Keeps sequence target allocation and reference registration ordering together." + )] private string? BuildSequenceConversion( ITypeSymbol sourceType, ITypeSymbol targetType, @@ -1513,11 +2464,20 @@ MappingContext context var creation = targetType is IArrayTypeSymbol ? null : BuildSequenceCreation(targetType, targetElement, count); if (targetType is not IArrayTypeSymbol && creation == null) return null; + if (targetType is IArrayTypeSymbol && count == null && context.Configuration?.PreserveReferences == true) + return null; var key = BuildHelperKey(sourceType, targetType, context); var isNew = ReserveHelper(key, $"MapTo{SequenceName(targetType, targetElement)}", out var helperName); if (isNew) { + var referenceKeyName = helperContext.Configuration?.PreserveReferences == true ? EnsureReferenceKey() : null; + var trackLookup = + helperContext.Configuration?.PreserveReferences == true + ? $"var __referenceKey = new {referenceKeyName}(source, typeof({RuntimeTypeName(targetType)}));\nif (__references.TryGetValue(__referenceKey, out var __existing))\n{{\n return ({TypeName(targetType)})__existing;\n}}\n{BuildDepthGuard(targetType, helperContext)}" + : string.Empty; + var trackTarget = + helperContext.Configuration?.PreserveReferences == true ? "__references.Add(__referenceKey, target);\n" : string.Empty; if (targetType is IArrayTypeSymbol) { string body; @@ -1531,14 +2491,18 @@ MappingContext context else if (IndexExpression(sourceType, "source", "i") is { } indexedItem) { body = - $"var target = new {TypeName(targetElement)}[{count}];\n" + trackLookup + + $"var target = new {TypeName(targetElement)}[{count}];\n" + + trackTarget + $"for (var i = 0; i < {count}; i++)\n{{\n var item = {indexedItem};\n target[i] = {elementExpression};\n}}\n" + "return target;"; } else { body = - $"var target = new {TypeName(targetElement)}[{count}];\n" + trackLookup + + $"var target = new {TypeName(targetElement)}[{count}];\n" + + trackTarget + $"var index = 0;\nforeach (var item in {EnumerableExpression(sourceType, sourceElement, "source")})\n{{\n target[index++] = {elementExpression};\n}}\n" + "return target;"; } @@ -1559,7 +2523,12 @@ MappingContext context : $"foreach (var item in {EnumerableExpression(sourceType, sourceElement, "source")})\n{{\n target.Add({elementExpression});\n}}"; var declaration = BuildHelperDeclaration(targetType, helperName, sourceType, helperContext); _helperContracts.Add( - new MappingContract(helperName, declaration, $"var target = {creation};\n{iteration}\nreturn target;", MappingShape.Helper) + new MappingContract( + helperName, + declaration, + $"{trackLookup}var target = {creation};\n{trackTarget}{iteration}\nreturn target;", + MappingShape.Helper + ) ); } @@ -1592,8 +2561,17 @@ MappingContext context if (isNew) { var declaration = BuildHelperDeclaration(targetType, helperName, sourceType, helperContext); + var referenceKeyName = helperContext.Configuration?.PreserveReferences == true ? EnsureReferenceKey() : null; + var trackLookup = + helperContext.Configuration?.PreserveReferences == true + ? $"var __referenceKey = new {referenceKeyName}(source, typeof({RuntimeTypeName(targetType)}));\nif (__references.TryGetValue(__referenceKey, out var __existing))\n{{\n return ({TypeName(targetType)})__existing;\n}}\n{BuildDepthGuard(targetType, helperContext)}" + : string.Empty; + var trackTarget = + helperContext.Configuration?.PreserveReferences == true ? "__references.Add(__referenceKey, target);\n" : string.Empty; var body = - $"var target = new {creationType}({DictionaryCountExpression(sourceType, "source")});\n" + trackLookup + + $"var target = new {creationType}({DictionaryCountExpression(sourceType, "source")});\n" + + trackTarget + $"foreach (var item in {DictionaryExpression(sourceType, sourceKey, sourceValue, "source")})\n{{\n target[{keyExpression}] = {valueExpression};\n}}\nreturn target;"; _helperContracts.Add(new MappingContract(helperName, declaration, body, MappingShape.Helper)); } @@ -1624,8 +2602,10 @@ private static bool HasTargetConfiguration(MappingMethodConfiguration? configura || configuration.OnlyTargets != null || configuration.NullBehaviors.Count > 0 || configuration.NullSubstitutes.Count > 0 + || configuration.CollectionPolicies.Count > 0 || configuration.ComputedMembers.Count > 0 || configuration.Conditions.Count > 0 + || configuration.PreserveReferences || !configuration.EnforceTarget ); @@ -1716,7 +2696,7 @@ private bool CanConstructObject(ITypeSymbol sourceType, ITypeSymbol targetType, { var key = BuildHelperKey(sourceType, targetType, context); if (!visiting.Add(key)) - return context.Configuration?.MaximumDepth != null; + return context.Configuration?.MaximumDepth != null || context.Configuration?.PreserveReferences == true; try { @@ -1895,6 +2875,16 @@ private string EmitSource() writer.Line(); } + if (contracts.Length > 0 && _supportMembers.Count > 0) + writer.Line(); + for (var index = 0; index < _supportMembers.Count; index++) + { + foreach (var line in _supportMembers[index].Split('\n')) + writer.Line(line); + if (index < _supportMembers.Count - 1) + writer.Line(); + } + foreach (var _ in typeHierarchy) { writer.Unindent(); @@ -2667,6 +3657,7 @@ private static bool IsExplicitTargetMember(MappingMethodConfiguration? configura || configuration.Conditions.ContainsKey(memberName) || configuration.NullBehaviors.ContainsKey(memberName) || configuration.NullSubstitutes.ContainsKey(memberName) + || configuration.CollectionPolicies.ContainsKey(memberName) || configuration.OnlyTargets?.Contains(memberName) == true ); @@ -2889,6 +3880,22 @@ private static bool IsDictionaryType(INamedTypeSymbol type) || string.Equals(definition, "System.Collections.Generic.Dictionary", StringComparison.Ordinal); } + private static bool CanMutateCollection(ITypeSymbol type) + { + if (type is not INamedTypeSymbol named) + return false; + return named + .AllInterfaces.Append(named) + .Any(x => + string.Equals(x.OriginalDefinition.ToDisplayString(), "System.Collections.Generic.ICollection", StringComparison.Ordinal) + || string.Equals( + x.OriginalDefinition.ToDisplayString(), + "System.Collections.Generic.IDictionary", + StringComparison.Ordinal + ) + ); + } + private static INamedTypeSymbol? FindGenericContract(ITypeSymbol type, params string[] definitions) { if (type is not INamedTypeSymbol named) @@ -3023,6 +4030,39 @@ private bool ReserveHelper(string key, string baseName, out string helperName) return true; } + private string ReserveMemberName(string baseName) + { + var memberName = baseName; + var suffix = 2; + while (!_usedHelperNames.Add(memberName)) + { + memberName = $"{baseName}{suffix}"; + suffix++; + } + return memberName; + } + + private string EnsureReferenceKey() + { + if (_referenceKeyName != null) + return _referenceKeyName; + + _referenceKeyName = ReserveMemberName("__DomainMapperReferenceKey"); + + _supportMembers.Add( + $"private readonly struct {_referenceKeyName} : global::System.IEquatable<{_referenceKeyName}>\n" + + "{\n" + + " private readonly object _source;\n" + + " private readonly global::System.Type _targetType;\n" + + $" internal {_referenceKeyName}(object source, global::System.Type targetType) {{ _source = source; _targetType = targetType; }}\n" + + $" public bool Equals({_referenceKeyName} other) => global::System.Object.ReferenceEquals(_source, other._source) && _targetType == other._targetType;\n" + + $" public override bool Equals(object? value) => value is {_referenceKeyName} other && Equals(other);\n" + + " public override int GetHashCode() { unchecked { return (global::System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(_source) * 397) ^ _targetType.GetHashCode(); } }\n" + + "}" + ); + return _referenceKeyName; + } + private static IEnumerable GetAllMethods(INamedTypeSymbol type, string name) { for (var current = type; current != null; current = current.BaseType) @@ -3045,13 +4085,16 @@ private static string BuildHelperKey(ITypeSymbol sourceType, ITypeSymbol targetT ? "convention" : $"{context.Configuration.Method.ToDisplayString()}@{context.Configuration.Method.Locations.FirstOrDefault()?.SourceSpan.Start ?? -1}" + $"|depth-behavior:{context.Configuration.DepthExhaustionBehavior}"; - return $"{TypeName(sourceType)}->{TypeName(targetType)}|<{typeParameters}>{constraints}|{ambientValues}|depth:{depth}|{configurationIdentity}"; + var references = context.Configuration?.PreserveReferences == true ? "tracked" : "untracked"; + return $"{TypeName(sourceType)}->{TypeName(targetType)}|<{typeParameters}>{constraints}|{ambientValues}|depth:{depth}|references:{references}|{configurationIdentity}"; } - private static string BuildHelperDeclaration(ITypeSymbol targetType, string helperName, ITypeSymbol sourceType, MappingContext context) + private string BuildHelperDeclaration(ITypeSymbol targetType, string helperName, ITypeSymbol sourceType, MappingContext context) { var parameters = new List { $"{TypeName(sourceType)} source" }; parameters.AddRange(context.AmbientValues.Select((value, index) => $"{TypeName(value.Type)} __ambient{index}")); + if (context.Configuration?.PreserveReferences == true) + parameters.Add($"global::System.Collections.Generic.Dictionary<{EnsureReferenceKey()}, object> __references"); if (context.Configuration?.MaximumDepth != null) parameters.Add("int __depth"); return $"private static {TypeName(targetType)} {Escape(helperName)}{TypeParameters(context.MethodTypeParameters)}({string.Join(", ", parameters)}){ConstraintClauses(context.MethodTypeParameters)}"; @@ -3064,11 +4107,15 @@ private static string BuildHelperCall(string helperName, string sourceExpression ? string.Empty : $"<{string.Join(", ", context.MethodTypeParameters.Select(x => Escape(x.Name)))}>"; var arguments = new[] { sourceExpression }.Concat(context.AmbientValues.Select(x => x.Expression)); + if (context.Configuration?.PreserveReferences == true) + arguments = arguments.Append("__references"); if (context.Configuration?.MaximumDepth != null) { var depth = context.IsHelper ? "__depth - 1" - : (context.Configuration.MaximumDepth.Value - 1).ToString(CultureInfo.InvariantCulture); + : (context.Configuration.MaximumDepth.Value - (context.Configuration.PreserveReferences ? 0 : 1)).ToString( + CultureInfo.InvariantCulture + ); arguments = arguments.Append(depth); } return $"{Escape(helperName)}{typeArguments}({string.Join(", ", arguments)})"; @@ -3095,8 +4142,57 @@ private static uint StableHash(string value) private static bool TypesEqual(ITypeSymbol left, ITypeSymbol right) => SymbolEqualityComparer.IncludeNullability.Equals(left, right); + private bool HasVisibleMapperMember(string name) + { + for (var current = _mapperType; current != null; current = current.BaseType) + { + if ( + current + .GetMembers(name) + .Any(x => + SymbolEqualityComparer.Default.Equals(current, _mapperType) || x.DeclaredAccessibility != Accessibility.Private + ) + ) + return true; + } + return false; + } + + private bool RuntimeSourceTypesMayOverlap(ITypeSymbol first, ITypeSymbol second) + { + if (_compilation.ClassifyConversion(first, second).IsImplicit || _compilation.ClassifyConversion(second, first).IsImplicit) + return true; + + if (!first.IsReferenceType || !second.IsReferenceType) + return false; + if (first.TypeKind == TypeKind.Class && second.TypeKind == TypeKind.Class) + return false; + if (first is INamedTypeSymbol { IsSealed: true } || second is INamedTypeSymbol { IsSealed: true }) + return false; + + // Unrelated interfaces, or an open class/interface pair, can still be + // implemented by the same runtime type even without a conversion + // between the declared source types. + return true; + } + private static string TypeName(ITypeSymbol type) => type.ToDisplayString(TypeDisplayFormat); + private static string RuntimeTypeName(ITypeSymbol type) => TypeName(type.WithNullableAnnotation(NullableAnnotation.NotAnnotated)); + + private static bool RuntimeTypesEqual(ITypeSymbol first, ITypeSymbol second) => + SymbolEqualityComparer.Default.Equals( + first.WithNullableAnnotation(NullableAnnotation.NotAnnotated), + second.WithNullableAnnotation(NullableAnnotation.NotAnnotated) + ); + + private static string RuntimeSourceTypeName(ITypeSymbol type) => + RuntimeTypeName( + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type + ); + private static string AccessibilityText(Accessibility accessibility) => accessibility switch { diff --git a/src/DomainMapper/Engine/MapperGenerationInput.cs b/src/DomainMapper/Engine/MapperGenerationInput.cs new file mode 100644 index 0000000..f23f458 --- /dev/null +++ b/src/DomainMapper/Engine/MapperGenerationInput.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace DomainMapper.Engine; + +internal sealed class MapperGenerationInput +{ + private MapperGenerationInput(INamedTypeSymbol mapperType, Compilation compilation, ulong fingerprint) + { + MapperType = mapperType; + Compilation = compilation; + Fingerprint = fingerprint; + } + + public INamedTypeSymbol MapperType { get; } + + public Compilation Compilation { get; } + + public ulong Fingerprint { get; } + + public static MapperGenerationInput Create(INamedTypeSymbol mapperType, Compilation compilation) + { + var fingerprint = new FingerprintBuilder(); + fingerprint.Add(mapperType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + fingerprint.Add(compilation.AssemblyName ?? string.Empty); + fingerprint.Add(compilation.Options.OutputKind.ToString()); + if (compilation.Options is CSharpCompilationOptions compilationOptions) + { + fingerprint.Add(compilationOptions.NullableContextOptions.ToString()); + fingerprint.Add(compilationOptions.AllowUnsafe ? "unsafe" : "safe"); + fingerprint.Add(compilationOptions.CheckOverflow ? "checked" : "unchecked"); + } + var sourceTrees = new HashSet(); + for (var containingType = mapperType; containingType != null; containingType = containingType.ContainingType) + { + foreach ( + var syntax in containingType + .DeclaringSyntaxReferences.OrderBy(x => x.SyntaxTree.FilePath, StringComparer.Ordinal) + .ThenBy(x => x.Span.Start) + ) + AddSyntaxTree(syntax.SyntaxTree, ref fingerprint, sourceTrees); + } + + var visited = new HashSet(SymbolEqualityComparer.Default); + if (mapperType.BaseType != null) + AppendSourceType(mapperType.BaseType, ref fingerprint, visited, sourceTrees); + foreach (var method in mapperType.GetMembers().OfType()) + { + AppendSourceType(method.ReturnType, ref fingerprint, visited, sourceTrees); + foreach (var parameter in method.Parameters) + AppendSourceType(parameter.Type, ref fingerprint, visited, sourceTrees); + } + return new MapperGenerationInput(mapperType, compilation, fingerprint.Value); + } + + private static void AppendSourceType( + ITypeSymbol type, + ref FingerprintBuilder fingerprint, + ISet visited, + ISet sourceTrees + ) + { + if (type is IArrayTypeSymbol array) + { + AppendSourceType(array.ElementType, ref fingerprint, visited, sourceTrees); + return; + } + if (type is not INamedTypeSymbol named || !visited.Add(named)) + return; + foreach (var argument in named.TypeArguments) + AppendSourceType(argument, ref fingerprint, visited, sourceTrees); + if (!named.Locations.Any(x => x.IsInSource)) + { + fingerprint.Add(named.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + fingerprint.Add(named.ContainingAssembly.Identity.ToString()); + return; + } + + foreach ( + var syntax in named + .DeclaringSyntaxReferences.OrderBy(x => x.SyntaxTree.FilePath, StringComparer.Ordinal) + .ThenBy(x => x.Span.Start) + ) + AddSyntaxTree(syntax.SyntaxTree, ref fingerprint, sourceTrees); + + foreach (var member in named.GetMembers()) + { + switch (member) + { + case IPropertySymbol property when !property.IsStatic: + AppendSourceType(property.Type, ref fingerprint, visited, sourceTrees); + break; + case IFieldSymbol field when !field.IsStatic: + AppendSourceType(field.Type, ref fingerprint, visited, sourceTrees); + break; + case IMethodSymbol { MethodKind: MethodKind.Constructor } constructor: + foreach (var parameter in constructor.Parameters) + AppendSourceType(parameter.Type, ref fingerprint, visited, sourceTrees); + break; + } + } + if (named.BaseType != null) + AppendSourceType(named.BaseType, ref fingerprint, visited, sourceTrees); + } + + private static void AddSyntaxTree(SyntaxTree tree, ref FingerprintBuilder fingerprint, ISet sourceTrees) + { + if (!sourceTrees.Add(tree)) + return; + fingerprint.Add(tree.FilePath); + fingerprint.Add(tree.GetText().GetChecksum()); + if (tree.Options is CSharpParseOptions parseOptions) + { + fingerprint.Add(parseOptions.LanguageVersion.ToString()); + fingerprint.Add(parseOptions.DocumentationMode.ToString()); + fingerprint.Add(parseOptions.Kind.ToString()); + foreach (var symbol in parseOptions.PreprocessorSymbolNames.OrderBy(x => x, StringComparer.Ordinal)) + fingerprint.Add(symbol); + } + } + + private struct FingerprintBuilder + { + private const ulong Offset = 14695981039346656037; + private const ulong Prime = 1099511628211; + + private ulong _value; + + public readonly ulong Value => _value == 0 ? Offset : _value; + + public void Add(string value) + { + if (_value == 0) + _value = Offset; + foreach (var character in value) + { + _value ^= character; + _value *= Prime; + } + _value ^= 0xFF; + _value *= Prime; + } + + public void Add(IEnumerable value) + { + if (_value == 0) + _value = Offset; + foreach (var item in value) + { + _value ^= item; + _value *= Prime; + } + _value ^= 0xFE; + _value *= Prime; + } + } +} diff --git a/src/DomainMapper/Engine/MapperGenerationInputComparer.cs b/src/DomainMapper/Engine/MapperGenerationInputComparer.cs new file mode 100644 index 0000000..c9bbf18 --- /dev/null +++ b/src/DomainMapper/Engine/MapperGenerationInputComparer.cs @@ -0,0 +1,11 @@ +namespace DomainMapper.Engine; + +internal sealed class MapperGenerationInputComparer : IEqualityComparer +{ + public static MapperGenerationInputComparer Instance { get; } = new(); + + public bool Equals(MapperGenerationInput? x, MapperGenerationInput? y) => + ReferenceEquals(x, y) || x != null && y != null && x.Fingerprint == y.Fingerprint; + + public int GetHashCode(MapperGenerationInput obj) => obj.Fingerprint.GetHashCode(); +} diff --git a/src/DomainMapper/Engine/MappingMethodConfiguration.cs b/src/DomainMapper/Engine/MappingMethodConfiguration.cs index 9847f87..aa189b0 100644 --- a/src/DomainMapper/Engine/MappingMethodConfiguration.cs +++ b/src/DomainMapper/Engine/MappingMethodConfiguration.cs @@ -20,7 +20,9 @@ public MappingMethodConfiguration( ImmutableDictionary conditions, ImmutableArray completionHooks, int? maximumDepth, - int depthExhaustionBehavior + int depthExhaustionBehavior, + ImmutableDictionary collectionPolicies, + bool preserveReferences ) { Method = method; @@ -38,6 +40,8 @@ int depthExhaustionBehavior CompletionHooks = completionHooks; MaximumDepth = maximumDepth; DepthExhaustionBehavior = depthExhaustionBehavior; + CollectionPolicies = collectionPolicies; + PreserveReferences = preserveReferences; } public IMethodSymbol Method { get; } @@ -70,6 +74,10 @@ int depthExhaustionBehavior public int DepthExhaustionBehavior { get; } + public ImmutableDictionary CollectionPolicies { get; } + + public bool PreserveReferences { get; } + public bool EnforceTarget => Completeness is 0 or 2; public bool EnforceSource => Completeness is 1 or 2; diff --git a/test/DomainMapper.Tests/DomainMapper.Tests.csproj b/test/DomainMapper.Tests/DomainMapper.Tests.csproj index e2a41cb..5b39494 100644 --- a/test/DomainMapper.Tests/DomainMapper.Tests.csproj +++ b/test/DomainMapper.Tests/DomainMapper.Tests.csproj @@ -5,6 +5,7 @@ false + diff --git a/test/DomainMapper.Tests/Engine/AdvancedProductContractTests.cs b/test/DomainMapper.Tests/Engine/AdvancedProductContractTests.cs new file mode 100644 index 0000000..bc2d334 --- /dev/null +++ b/test/DomainMapper.Tests/Engine/AdvancedProductContractTests.cs @@ -0,0 +1,1094 @@ +using DomainMapper.Projections; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace DomainMapper.Tests.Engine; + +public sealed class AdvancedProductContractTests +{ + [Fact] + public void KeepsUnrelatedMapperOutputCachedAfterAnIsolatedContractEdit() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var mapperA = CSharpSyntaxTree.ParseText( + """ + using DomainMapper.Abstractions; + [DomainMapper] public static partial class MapperA { public static partial TargetA Map(SourceA source); } + public sealed record SourceA(int Value); + public sealed record TargetA(int Value); + """, + parseOptions, + "MapperA.cs" + ); + var mapperB = CSharpSyntaxTree.ParseText( + """ + using DomainMapper.Abstractions; + [DomainMapper] public static partial class MapperB { public static partial TargetB Map(SourceB source); } + public sealed record SourceB(int Value); + public sealed record TargetB(int Value); + """, + parseOptions, + "MapperB.cs" + ); + var compilation = GeneratorTestHarness.CreateCompilation([mapperA, mapperB]); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: parseOptions + ); + driver = driver.RunGenerators(compilation); + + var editedA = CSharpSyntaxTree.ParseText( + mapperA.GetText().ToString().Replace("int Value", "long Value"), + parseOptions, + "MapperA.cs" + ); + compilation = compilation.ReplaceSyntaxTree(mapperA, editedA); + driver = driver.RunGenerators(compilation); + + var reasons = driver + .GetRunResult() + .Results.Single() + .TrackedSteps["MapperContracts"] + .SelectMany(x => x.Outputs) + .Select(x => x.Reason) + .ToArray(); + reasons.ShouldContain(IncrementalStepRunReason.Modified); + reasons.ShouldContain(x => x == IncrementalStepRunReason.Cached || x == IncrementalStepRunReason.Unchanged); + } + + [Fact] + public void RegeneratesEachMapperAffectedByASharedContractEdit() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var contracts = CSharpSyntaxTree.ParseText( + "public sealed record Source(int Value); public sealed record Target(int Value);", + parseOptions, + "Contracts.cs" + ); + var mapperA = CSharpSyntaxTree.ParseText( + "using DomainMapper.Abstractions; [DomainMapper] public static partial class MapperA { public static partial Target Map(Source source); }", + parseOptions, + "MapperA.cs" + ); + var mapperB = CSharpSyntaxTree.ParseText( + "using DomainMapper.Abstractions; [DomainMapper] public static partial class MapperB { public static partial Target Map(Source source); }", + parseOptions, + "MapperB.cs" + ); + var compilation = GeneratorTestHarness.CreateCompilation([contracts, mapperA, mapperB]); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: parseOptions + ); + driver = driver.RunGenerators(compilation); + + var editedContracts = CSharpSyntaxTree.ParseText( + contracts.GetText().ToString().Replace("int Value", "long Value"), + parseOptions, + "Contracts.cs" + ); + driver = driver.RunGenerators(compilation.ReplaceSyntaxTree(contracts, editedContracts)); + + driver + .GetRunResult() + .Results.Single() + .TrackedSteps["MapperContracts"] + .SelectMany(x => x.Outputs) + .Select(x => x.Reason) + .ShouldAllBe(x => x == IncrementalStepRunReason.Modified); + } + + [Fact] + public void RegeneratesWhenAReferencedContractAssemblyIdentityChanges() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var mapper = CSharpSyntaxTree.ParseText( + "using Contracts; using DomainMapper.Abstractions; [DomainMapper] public static partial class Mapper { public static partial Target Map(Source source); }", + parseOptions, + "Mapper.cs" + ); + var contractsV1 = GeneratorTestHarness.CompileReference( + "Contracts", + """ + using System.Reflection; + [assembly: AssemblyVersion("1.0.0.0")] + namespace Contracts; + public sealed class Source { public int Value { get; set; } } + public sealed class Target { public int Value { get; set; } } + """ + ); + var contractsV2 = GeneratorTestHarness.CompileReference( + "Contracts", + """ + using System.Reflection; + [assembly: AssemblyVersion("2.0.0.0")] + namespace Contracts; + public sealed class Source { public long Value { get; set; } } + public sealed class Target { public long Value { get; set; } } + """ + ); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: parseOptions + ); + driver = driver.RunGenerators(GeneratorTestHarness.CreateCompilation([mapper], contractsV1)); + driver = driver.RunGenerators(GeneratorTestHarness.CreateCompilation([mapper], contractsV2)); + + driver + .GetRunResult() + .Results.Single() + .TrackedSteps["MapperContracts"] + .SelectMany(x => x.Outputs) + .ShouldHaveSingleItem() + .Reason.ShouldBe(IncrementalStepRunReason.Modified); + } + + [Fact] + public void RegeneratesWhenAContainingPartialTypeContractChanges() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var container = CSharpSyntaxTree.ParseText("public partial class Container { }", parseOptions, "Container.cs"); + var mapper = CSharpSyntaxTree.ParseText( + "using DomainMapper.Abstractions; public partial class Container { [DomainMapper] public static partial class Mapper { public static partial Target Map(Source source); } } public sealed record Source(int Value); public sealed record Target(int Value);", + parseOptions, + "Mapper.cs" + ); + var compilation = GeneratorTestHarness.CreateCompilation([container, mapper]); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: parseOptions + ); + driver = driver.RunGenerators(compilation); + + var editedContainer = CSharpSyntaxTree.ParseText( + "public partial class Container where T : class { }", + parseOptions, + "Container.cs" + ); + driver = driver.RunGenerators(compilation.ReplaceSyntaxTree(container, editedContainer)); + + driver + .GetRunResult() + .Results.Single() + .TrackedSteps["MapperContracts"] + .SelectMany(x => x.Outputs) + .ShouldHaveSingleItem() + .Reason.ShouldBe(IncrementalStepRunReason.Modified); + } + + [Fact] + public void RegeneratesWhenAnInheritedMapperMemberChangesHelperNaming() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var baseMapper = CSharpSyntaxTree.ParseText( + "public class MapperBase { protected static void Available() { } }", + parseOptions, + "MapperBase.cs" + ); + var mapper = CSharpSyntaxTree.ParseText( + "using DomainMapper.Abstractions; [DomainMapper] public partial class Mapper : MapperBase { public static partial Target Map(Source source); } public sealed record Source(ChildSource Child); public sealed record Target(ChildTarget Child); public sealed record ChildSource(int Value); public sealed record ChildTarget(int Value);", + parseOptions, + "Mapper.cs" + ); + var compilation = GeneratorTestHarness.CreateCompilation([baseMapper, mapper]); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new DomainMapperGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true), + parseOptions: parseOptions + ); + driver = driver.RunGenerators(compilation); + + var editedBaseMapper = CSharpSyntaxTree.ParseText( + "public class MapperBase { protected static void MapToChildTarget() { } }", + parseOptions, + "MapperBase.cs" + ); + driver = driver.RunGenerators(compilation.ReplaceSyntaxTree(baseMapper, editedBaseMapper)); + var result = driver.GetRunResult(); + + result + .Results.Single() + .TrackedSteps["MapperContracts"] + .SelectMany(x => x.Outputs) + .ShouldHaveSingleItem() + .Reason.ShouldBe(IncrementalStepRunReason.Modified); + result.GeneratedTrees.Single().GetText().ToString().ShouldContain("MapToChildTarget2"); + } + + [Fact] + public void AppliesExplicitClearAndFillAndAppendCollectionPolicies() + { + var result = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + + [DomainMapper] + public static partial class Mapper + { + [MapOnlyTargetMembers(nameof(Target.Items))] + [MapCollection(nameof(Target.Items), CollectionUpdatePolicy.ClearAndFill)] + public static partial void ReplaceItems(Source source, Target target); + + [MapOnlyTargetMembers(nameof(Target.Items))] + [MapCollection(nameof(Target.Items), CollectionUpdatePolicy.Append)] + public static partial void AppendItems(Source source, Target target); + + public static string Run() + { + var target = new Target(); + target.Items.Add(9); + ReplaceItems(new Source([1, 2]), target); + AppendItems(new Source([2, 3]), target); + return string.Join(",", target.Items); + } + } + + public sealed record Source(List Items); + public sealed class Target { public List Items { get; } = []; } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + result.Source.ShouldContain("__collection_Items.Clear();"); + result.Source.ShouldContain("__collection_Items.Add(item);"); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe("1,2,2,3"); + } + + [Fact] + public void AppliesDocumentedNullCollectionMutationBehavior() + { + var result = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapOnlyTargetMembers(nameof(Target.Cleared))] + [MapCollection(nameof(Target.Cleared), CollectionUpdatePolicy.ClearAndFill)] + [MapMember(nameof(Target.Cleared), nameof(Source.Items))] + public static partial void Clear(Source source, Target target); + + [MapOnlyTargetMembers(nameof(Target.Appended))] + [MapCollection(nameof(Target.Appended), CollectionUpdatePolicy.Append)] + [MapMember(nameof(Target.Appended), nameof(Source.Items))] + public static partial void Append(Source source, Target target); + + public static string Run() + { + var target = new Target(); + Clear(new Source(null), target); + Append(new Source(null), target); + return $"{target.Cleared.Count}|{string.Join(",", target.Appended)}"; + } + } + public sealed record Source(List? Items); + public sealed class Target + { + public List Cleared { get; } = [9]; + public List Appended { get; } = [8]; + } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe("0|8"); + } + + [Fact] + public void EvaluatesNullableCollectionSourcesOncePerMutation() + { + var result = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapOnlyTargetMembers(nameof(Target.Items))] + [MapCollection(nameof(Target.Items), CollectionUpdatePolicy.ClearAndFill)] + public static partial void Apply(Source source, Target target); + + public static int Run() + { + var source = new Source(); + Apply(source, new Target()); + return source.ReadCount; + } + } + public sealed class Source + { + public int ReadCount { get; private set; } + public List? Items + { + get + { + ReadCount++; + return [1, 2]; + } + } + } + public sealed class Target { public List Items { get; } = []; } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe(1); + } + + [Fact] + public void RejectsReplaceForAReadOnlyCollectionMember() + { + var result = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapCollection(nameof(Target.Items), CollectionUpdatePolicy.Replace)] + public static partial void Apply(Source source, Target target); + } + public sealed record Source(List Items); + public sealed class Target { public List Items { get; } = []; } + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR102" && x.GetMessage().Contains("requires a writable target")); + } + + [Fact] + public void PreservesSelfCyclesAndSharedReferencesPerInvocation() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + + public static bool Run() + { + var shared = new Source { Value = 2 }; + var root = new Source { Value = 1, Left = shared, Right = shared }; + root.Next = root; + var target = Map(root); + var second = Map(root); + return ReferenceEquals(target, target.Next) + && ReferenceEquals(target.Left, target.Right) + && !ReferenceEquals(target, second); + } + } + + public sealed class Source + { + public int Value { get; set; } + public Source? Next { get; set; } + public Source? Left { get; set; } + public Source? Right { get; set; } + } + public sealed class Target + { + public int Value { get; set; } + public Target? Next { get; set; } + public Target? Left { get; set; } + public Target? Right { get; set; } + } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + result.Source.ShouldContain("__DomainMapperReferenceKey"); + result.Source.ShouldContain("RuntimeHelpers.GetHashCode(_source)"); + result.Source.ShouldContain("__references.TryGetValue"); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void TracksOneSourceReferenceIndependentlyForEachTargetType() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + [MapMember(nameof(Target.First), nameof(Source.Shared))] + [MapMember(nameof(Target.Again), nameof(Source.Shared))] + [MapMember(nameof(Target.Second), nameof(Source.Shared))] + public static partial Target Map(Source source); + + public static bool Run() + { + var target = Map(new Source { Shared = new SharedSource { Value = 42 } }); + return target.First?.Value == 42 + && ReferenceEquals(target.First, target.Again) + && target.Second?.Value == 42; + } + } + + public sealed class Source { public SharedSource Shared { get; set; } = new(); } + public sealed class SharedSource { public int Value { get; set; } } + public sealed class Target + { + public FirstTarget? First { get; set; } + public FirstTarget? Again { get; set; } + public SecondTarget? Second { get; set; } + } + public sealed class FirstTarget { public int Value { get; set; } } + public sealed class SecondTarget { public int Value { get; set; } } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void PreservesParentChildCyclesThroughCollections() + { + var result = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + public static bool Run() + { + var parent = new Source { Value = 1 }; + var child = new Source { Value = 2, Parent = parent }; + parent.Children.Add(child); + var target = Map(parent); + return ReferenceEquals(target, target.Children[0].Parent); + } + } + public sealed class Source + { + public int Value { get; set; } + public Source? Parent { get; set; } + public List Children { get; set; } = []; + } + public sealed class Target + { + public int Value { get; set; } + public Target? Parent { get; set; } + public List Children { get; set; } = []; + } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void MapsDeepAcyclicAndNullableTrackedNodesDeterministically() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + public static bool Run() + { + var root = new Source { Value = 0 }; + var current = root; + for (var value = 1; value <= 128; value++) + { + current.Next = new Source { Value = value }; + current = current.Next; + } + var target = Map(root); + var count = 0; + for (var node = target; node != null; node = node.Next) + count++; + return count == 129 && current.Next is null; + } + } + public sealed class Source { public int Value { get; set; } public Source? Next { get; set; } } + public sealed class Target { public int Value { get; set; } public Target? Next { get; set; } } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void KeepsOrdinaryMappingsTrackerFreeAndRejectsUnsupportedNestedTrackingShapes() + { + var ordinary = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] public static partial class Mapper { public static partial Target Map(Source source); } + public sealed record Source(int Value); + public sealed record Target(int Value); + """ + ); + ordinary.Errors.ShouldBeEmpty(ordinary.Source); + ordinary.Source.ShouldNotContain("__references"); + ordinary.Source.ShouldNotContain("__DomainMapperReferenceKey"); + + var unsupported = GeneratorTestHarness.Generate( + """ + using System.Collections.Generic; + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + } + public sealed class Source { public IEnumerable Items { get; set; } = []; } + public sealed class Target { public Target[] Items { get; set; } = []; } + """ + ); + unsupported.Errors.ShouldHaveSingleItem().Id.ShouldBe("DMPR105"); + } + + [Fact] + public void ResolvesTrackedReferencesBeforeApplyingDepthToNewObjects() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + public static partial class Mapper + { + [MapReferenceTracking] + [MapMaxDepth(1)] + public static partial Target Map(Source source); + + public static bool Run() + { + var root = new Source(); + root.Self = root; + root.Next = new Source(); + var target = Map(root); + return ReferenceEquals(target, target.Self) && target.Next is null; + } + } + public sealed class Source { public Source? Self { get; set; } public Source? Next { get; set; } } + public sealed class Target { public Target? Self { get; set; } public Target? Next { get; set; } } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void KeepsGeneratedInfrastructureSafeAcrossConcurrentInvocations() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Linq.Expressions; + using System.Threading; + using System.Threading.Tasks; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + public static partial PlainTarget MapPlain(PlainSource source); + [MapProjection(nameof(MapPlain))] + public static partial Expression> Project(); + + public static bool Run() + { + var failures = 0; + Parallel.For(0, 256, value => + { + var source = new Source { Value = value }; + source.Next = source; + var direct = Map(source); + var runtime = (Target)MapRuntime(source, typeof(Target)); + if (!ReferenceEquals(direct, direct.Next) + || !ReferenceEquals(runtime, runtime.Next) + || !ReferenceEquals(Project(), Project())) + Interlocked.Increment(ref failures); + }); + return failures == 0; + } + } + public sealed class Source { public int Value { get; set; } public Source? Next { get; set; } } + public sealed class Target { public int Value { get; set; } public Target? Next { get; set; } } + public sealed record PlainSource(int Value); + public sealed record PlainTarget(int Value); + """, + projectionReference + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void GeneratesClosedWorldRuntimeDispatchWithoutReflection() + { + var result = GeneratorTestHarness.Generate( + """ + using System; + using DomainMapper.Abstractions; + + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + public static partial Target Map(Source source); + + public static string Run() + { + var mapped = (Target)MapRuntime(new Source(42), typeof(Target)); + var known = TryMapRuntime(new Source(7), typeof(Target), out var second); + var unknown = TryMapRuntime(new Source(1), typeof(string), out _); + return $"{mapped.Value}|{known}|{((Target?)second)?.Value}|{unknown}"; + } + } + public sealed record Source(int Value); + public sealed record Target(int Value); + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + result.Source.ShouldContain("source.GetType() == typeof(global::Source)"); + result.Source.ShouldNotContain("System.Reflection"); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe("42|True|7|False"); + } + + [Fact] + public void DispatchesOnlyDeclaredCollectionsAndOptedInDerivedSources() + { + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Collections.Generic; + using DomainMapper.Abstractions; + + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + public static partial List MapMany(List source); + + [MapRegistryDerived] + public static partial Target MapDerived(Source source); + + public static string Run() + { + var many = (List)MapRuntime(new List { new Source { Value = 4 } }, typeof(List)); + var derived = (Target)MapRuntime(new DerivedSource { Value = 7 }, typeof(Target)); + try + { + MapRuntime(new object(), typeof(Target)); + return "no-error"; + } + catch (InvalidOperationException error) + { + return $"{many[0].Value}|{derived.Value}|{error.Message.Contains("System.Object")}|{error.Message.Contains("Target")}"; + } + } + } + public class Source { public int Value { get; set; } } + public sealed class DerivedSource : Source { } + public sealed class Target { public int Value { get; set; } } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe("4|7|True|True"); + } + + [Fact] + public void RejectsDuplicateAndAmbiguousRuntimeRegistrations() + { + var duplicate = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + public static partial Target First(Source source); + public static partial Target Second(Source source); + } + public sealed record Source(int Value); + public sealed record Target(int Value); + """ + ); + duplicate.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("more than once")); + + var ambiguous = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + [MapRegistryDerived] public static partial Target MapBase(BaseSource source); + [MapRegistryDerived] public static partial Target MapDerived(DerivedSource source); + } + public class BaseSource { public int Value { get; set; } } + public sealed class DerivedSource : BaseSource { } + public sealed class Target { public int Value { get; set; } } + """ + ); + ambiguous.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("overlap")); + } + + [Fact] + public void RejectsRuntimeRegistrationsWhoseUnrelatedInterfacesCanOverlap() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + [MapRegistryDerived] public static partial Target MapFirst(IFirst source); + [MapRegistryDerived] public static partial Target? MapSecond(ISecond source); + } + public interface IFirst { int Value { get; } } + public interface ISecond { int Value { get; } } + public sealed record Target(int Value); + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("overlap")); + } + + [Fact] + public void RejectsRuntimeRegistryNamesInheritedFromABaseMapper() + { + var result = GeneratorTestHarness.Generate( + """ + using System; + using DomainMapper.Abstractions; + public class MapperBase + { + protected static object MapRuntime(object source, Type targetType) => source; + } + [DomainMapper] + [MapRegistry] + public partial class Mapper : MapperBase + { + public static partial Target Map(Source source); + } + public sealed record Source(int Value); + public sealed record Target(int Value); + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("must be available")); + } + + [Fact] + public void GeneratesRuntimeRegistrySyntaxForNullableReferenceAnnotations() + { + var result = GeneratorTestHarness.Generate( + """ + using System; + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + public static partial string? Map(string source); + public static partial int? MapNullable(int? source); + public static bool Run() => + TryMapRuntime("mapped", typeof(string), out var target) + && (string?)target == "mapped" + && TryMapRuntime(42, typeof(int?), out var nullableTarget) + && (int?)nullableTarget == 42; + } + """ + ); + + result.Errors.ShouldBeEmpty(result.Source); + result.Source.ShouldNotContain("typeof(string?)"); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } + + [Fact] + public void RejectsDerivedRuntimeDispatchForValueTypeSources() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + [MapRegistryDerived] + public static partial int Map(int source); + } + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("reference-type source")); + } + + [Fact] + public void RejectsRegistryPairsThatCollapseThroughNullableValueBoxing() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + public static partial int? MapValue(int source); + public static partial int? MapNullable(int? source); + } + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR107" && x.GetMessage().Contains("more than once")); + } + + [Fact] + public void ExcludesMappingsWhoseDeferredHelpersFailFromTheRuntimeRegistry() + { + var result = GeneratorTestHarness.Generate( + """ + using DomainMapper.Abstractions; + [DomainMapper] + [MapRegistry] + public static partial class Mapper + { + [MapReferenceTracking] + public static partial Target Map(Source source); + } + public sealed class Source { public ChildSource Child { get; set; } = new(); } + public sealed class ChildSource { public int Value { get; set; } } + public sealed class Target { public ChildTarget Child { get; set; } = new(); } + public sealed class ChildTarget { public int Value { get; } } + """ + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR105"); + result.Source.ShouldNotContain("target = Map("); + } + + [Fact] + public void GeneratesAndCachesAnInspectableTypedProjection() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Linq.Expressions; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + [DomainMapper] + public static partial class Mapper + { + [MapMember(nameof(Target.Description), nameof(Source.Warehouse) + "." + nameof(Warehouse.Description))] + public static partial Target Map(Source source); + + [MapProjection(nameof(Map))] + public static partial Expression> Project(); + + public static string Run() + { + var first = Project(); + var second = Project(); + var mapped = first.Compile()(new Source(42, null)); + return $"{ReferenceEquals(first, second)}|{mapped.Id}|{mapped.Description ?? "null"}"; + } + } + public sealed record Warehouse(string Description); + public sealed record Source(int Id, Warehouse? Warehouse); + public sealed record Target(int Id, string? Description); + """, + projectionReference + ); + + result.Errors.ShouldBeEmpty(result.Source); + result.Source.ShouldContain("internal static readonly global::System.Linq.Expressions.Expression"); + result.Source.ShouldContain("RequiresUnreferencedCode"); + result.Source.ShouldNotContain(".Compile()("); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBe("True|42|null"); + } + + [Fact] + public void RejectsProjectionOperationsThatWouldCauseRuntimeFallback() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Linq.Expressions; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + [DomainMapper] + public static partial class Mapper + { + public static partial Target Map(Source source); + + [MapAfter(nameof(Map))] + private static void Complete(Target target) { } + + [MapProjection(nameof(Map))] + public static partial Expression> Project(); + } + public sealed record Source(int Id); + public sealed record Target(int Id); + """, + projectionReference + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR106" && x.GetMessage().Contains("completion hooks")); + result.Source.ShouldNotContain("Expression> Project(); + } + public sealed record Source(int Id, int Unused); + public sealed record Target(int Id); + """, + projectionReference + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR103"); + result.Diagnostics.ShouldContain(x => x.Id == "DMPR106" && x.GetMessage().Contains("invalid mapping contract")); + result.Source.ShouldNotContain("__domainMapperProjection"); + } + + [Fact] + public void RejectsCustomDelegateProjectionDeclarations() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System.Linq.Expressions; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + public delegate Target CustomProjection(Source source); + [DomainMapper] + public static partial class Mapper + { + public static partial Target Map(Source source); + + [MapProjection(nameof(Map))] + public static partial Expression Project(); + } + public sealed record Source(int Id); + public sealed record Target(int Id); + """, + projectionReference + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR106" && x.GetMessage().Contains("declared method shape")); + result.Source.ShouldNotContain("__domainMapperProjection"); + } + + [Fact] + public void RejectsUserDefinedConversionsInProviderNeutralProjections() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Linq.Expressions; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + [DomainMapper] + public static partial class Mapper + { + public static partial Target Map(Source source); + + [MapProjection(nameof(Map))] + public static partial Expression> Project(); + } + public sealed record Source(WrappedInt Value); + public sealed record Target(int Value); + public sealed record WrappedInt(int Value) + { + public static implicit operator int(WrappedInt value) => value.Value; + } + """, + projectionReference + ); + + result.Diagnostics.ShouldContain(x => x.Id == "DMPR106" && x.GetMessage().Contains("unsupported construction")); + result.Source.ShouldNotContain("__domainMapperProjection"); + } + + [Fact] + public void GeneratesPureLiftedNullableConversionsInProjections() + { + var projectionReference = MetadataReference.CreateFromFile(typeof(MapProjectionAttribute).Assembly.Location); + var result = GeneratorTestHarness.Generate( + """ + using System; + using System.Linq.Expressions; + using DomainMapper.Abstractions; + using DomainMapper.Projections; + + [DomainMapper] + public static partial class Mapper + { + public static partial Target Map(Source source); + + [MapProjection(nameof(Map))] + public static partial Expression> Project(); + + public static bool Run() => Project().Compile()(new Source(42)).Value == 42L; + } + public sealed record Source(int? Value); + public sealed record Target(long? Value); + """, + projectionReference + ); + + result.Errors.ShouldBeEmpty(result.Source); + GeneratorTestHarness.InvokeStatic(result, "Mapper", "Run").ShouldBeTrue(); + } +} diff --git a/test/DomainMapper.Tests/Engine/GeneratorTestHarness.cs b/test/DomainMapper.Tests/Engine/GeneratorTestHarness.cs index 2fc99b4..07600db 100644 --- a/test/DomainMapper.Tests/Engine/GeneratorTestHarness.cs +++ b/test/DomainMapper.Tests/Engine/GeneratorTestHarness.cs @@ -33,6 +33,19 @@ public static GenerationResult Generate(string source, params MetadataReference[ return new GenerationResult(generatedSource, generatorDiagnostics, errors, warnings, generatedTrees.Length, outputCompilation); } + public static CSharpCompilation CreateCompilation( + IEnumerable syntaxTrees, + params MetadataReference[] additionalReferences + ) => + CSharpCompilation.Create( + $"DomainMapper.IncrementalTests.{Guid.NewGuid():N}", + syntaxTrees, + TrustedPlatformReferences() + .Add(MetadataReference.CreateFromFile(typeof(DomainMapperAttribute).Assembly.Location)) + .AddRange(additionalReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable) + ); + public static T InvokeStatic(GenerationResult result, string typeName, string methodName) { using var stream = new MemoryStream();