diff --git a/docs/examples/order-data-mapper-pattern.md b/docs/examples/order-data-mapper-pattern.md new file mode 100644 index 0000000..24db00f --- /dev/null +++ b/docs/examples/order-data-mapper-pattern.md @@ -0,0 +1,29 @@ +# Order Data Mapper Pattern + +This example maps an `OrderAggregate` domain model to an `OrderRow` persistence record and back again, then stores the row through the Repository pattern. + +## What It Demonstrates + +- fluent `DataMapper` construction +- generated mapper factory with `[GenerateDataMapper]` +- validation errors for invalid domain/data inputs +- repository integration after mapping +- `IServiceCollection` import through `AddOrderDataMapperDemo()` + +## Import + +```csharp +var services = new ServiceCollection(); +services.AddOrderDataMapperDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var workflow = provider.GetRequiredService(); +var summary = await workflow.RunAsync(); +``` + +The workflow uses `IDataMapper` so applications can replace the mapper with the generated factory, an audited fluent mapper, or a test double. + +## Source + +- `src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs` +- `test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 4760664..07ea32d 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -85,6 +85,9 @@ - name: Checkout Unit of Work Pattern href: checkout-unit-of-work-pattern.md +- name: Order Data Mapper Pattern + href: order-data-mapper-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/data-mapper.md b/docs/generators/data-mapper.md new file mode 100644 index 0000000..2ed6995 --- /dev/null +++ b/docs/generators/data-mapper.md @@ -0,0 +1,40 @@ +# Data Mapper Generator + +`DataMapperGenerator` emits a fluent `DataMapper` factory from two attributed projection methods. + +```csharp +[GenerateDataMapper(typeof(OrderAggregate), typeof(OrderRow), FactoryName = "CreateMapper")] +public static partial class GeneratedOrderDataMapper +{ + [DataMapperToData] + private static OrderRow ToData(OrderAggregate order) + => new(order.OrderId, order.CustomerId, order.Total, "PAID"); + + [DataMapperToDomain] + private static OrderAggregate ToDomain(OrderRow row) + => new(row.OrderId, row.BuyerId, row.TotalAmount, OrderState.Paid); +} +``` + +The generated method returns a normal runtime mapper: + +```csharp +DataMapper mapper = GeneratedOrderDataMapper.CreateMapper(); +``` + +## Diagnostics + +| ID | Severity | Message | +| --- | --- | --- | +| `PKMAP001` | Error | The host type must be partial. | +| `PKMAP002` | Error | Exactly one `[DataMapperToData]` and one `[DataMapperToDomain]` method are required. | +| `PKMAP003` | Error | Projection methods must be static, non-generic, return the target type, and accept one source parameter. | + +## DI Usage + +```csharp +services.AddSingleton>(_ => + GeneratedOrderDataMapper.CreateMapper()); +``` + +See [Order Data Mapper Pattern](../examples/order-data-mapper-pattern.md) for the full importable example. diff --git a/docs/generators/index.md b/docs/generators/index.md index 03aa7f3..7dd58b2 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -62,6 +62,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Repository**](repository.md) | In-memory repository factories from key selectors | `[GenerateRepository]` | | [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` | | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | +| [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index c466e9d..b1b6b2a 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -37,6 +37,9 @@ - name: Decorator href: decorator.md +- name: Data Mapper + href: data-mapper.md + - name: Dispatcher href: dispatcher.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 68701b1..0e3ec2b 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -69,6 +69,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | | Application Architecture | Unit of Work | `UnitOfWork` | Unit of Work generator | +| Application Architecture | Data Mapper | `DataMapper` | Data Mapper generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/data-mapper.md b/docs/patterns/application/data-mapper.md new file mode 100644 index 0000000..3b8c9ab --- /dev/null +++ b/docs/patterns/application/data-mapper.md @@ -0,0 +1,25 @@ +# Data Mapper + +Data Mapper keeps domain objects independent from persistence or transport records. Use it when a database row, API DTO, or integration shape should not leak into your domain model. + +## Fluent Path + +```csharp +var mapper = DataMapper.Create() + .MapToData(order => new OrderRow(order.OrderId, order.CustomerId, order.Total, "PAID")) + .MapToDomain(row => new OrderAggregate(row.OrderId, row.BuyerId, row.TotalAmount, OrderState.Paid)) + .ValidateDomain(order => string.IsNullOrWhiteSpace(order.OrderId) + ? new DataMapperError("order-id-required", "Order id is required.") + : null) + .Build(); +``` + +The mapper returns `DataMapperResult` so callers can distinguish successful mappings from validation failures without throwing for business validation. + +## Integration Notes + +- Register `IDataMapper` in `IServiceCollection` at the scope that matches the persistence boundary. +- Keep mapping rules deterministic and side-effect free; use repositories or gateways after mapping completes. +- Add validation hooks for invariants that must be true before crossing the domain/data boundary. + +See [Order Data Mapper Pattern](../../examples/order-data-mapper-pattern.md) for a repository-backed example. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 117bf08..5f9769d 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -337,6 +337,8 @@ href: application/repository.md - name: Unit of Work href: application/unit-of-work.md + - name: Data Mapper + href: application/data-mapper.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/DataMapping/DataMapper.cs b/src/PatternKit.Core/Application/DataMapping/DataMapper.cs new file mode 100644 index 0000000..ac86578 --- /dev/null +++ b/src/PatternKit.Core/Application/DataMapping/DataMapper.cs @@ -0,0 +1,192 @@ +namespace PatternKit.Application.DataMapping; + +/// +/// Maps between an isolated domain model and a persistence or transport data model. +/// +public interface IDataMapper +{ + /// Maps a domain model to its data representation. + ValueTask> ToDataAsync(TDomain domain, CancellationToken cancellationToken = default); + + /// Maps a data representation to its domain model. + ValueTask> ToDomainAsync(TData data, CancellationToken cancellationToken = default); +} + +/// +/// Fluent Data Mapper builder for production composition, examples, and tests. +/// +public sealed class DataMapper : IDataMapper +{ + private readonly Func _toData; + private readonly Func _toDomain; + private readonly IReadOnlyList> _domainValidators; + private readonly IReadOnlyList> _dataValidators; + + private DataMapper( + Func toData, + Func toDomain, + IReadOnlyList> domainValidators, + IReadOnlyList> dataValidators) + { + _toData = toData; + _toDomain = toDomain; + _domainValidators = domainValidators; + _dataValidators = dataValidators; + } + + /// Creates a Data Mapper builder. + public static Builder Create() => new(); + + /// + public ValueTask> ToDataAsync(TDomain domain, CancellationToken cancellationToken = default) + { + if (domain is null) + throw new ArgumentNullException(nameof(domain)); + + cancellationToken.ThrowIfCancellationRequested(); + var sourceErrors = Validate(_domainValidators, domain); + if (sourceErrors.Count > 0) + return new ValueTask>(DataMapperResult.Failed(sourceErrors)); + + var data = _toData(domain); + var mappedErrors = Validate(_dataValidators, data); + return new ValueTask>(mappedErrors.Count == 0 + ? DataMapperResult.Mapped(data) + : DataMapperResult.Failed(mappedErrors)); + } + + /// + public ValueTask> ToDomainAsync(TData data, CancellationToken cancellationToken = default) + { + if (data is null) + throw new ArgumentNullException(nameof(data)); + + cancellationToken.ThrowIfCancellationRequested(); + var sourceErrors = Validate(_dataValidators, data); + if (sourceErrors.Count > 0) + return new ValueTask>(DataMapperResult.Failed(sourceErrors)); + + var domain = _toDomain(data); + var mappedErrors = Validate(_domainValidators, domain); + return new ValueTask>(mappedErrors.Count == 0 + ? DataMapperResult.Mapped(domain) + : DataMapperResult.Failed(mappedErrors)); + } + + private static IReadOnlyList Validate( + IReadOnlyList> validators, + T value) + { + if (validators.Count == 0) + return Array.Empty(); + + var errors = new List(); + foreach (var validator in validators) + { + var error = validator(value); + if (error is not null) + errors.Add(error); + } + + return errors; + } + + /// Fluent builder for Data Mapper instances. + public sealed class Builder + { + private readonly List> _domainValidators = []; + private readonly List> _dataValidators = []; + private Func? _toData; + private Func? _toDomain; + + /// Configures the domain-to-data projection. + public Builder MapToData(Func mapper) + { + _toData = mapper ?? throw new ArgumentNullException(nameof(mapper)); + return this; + } + + /// Configures the data-to-domain projection. + public Builder MapToDomain(Func mapper) + { + _toDomain = mapper ?? throw new ArgumentNullException(nameof(mapper)); + return this; + } + + /// Adds validation that runs against domain models. + public Builder ValidateDomain(Func validator) + { + _domainValidators.Add(validator ?? throw new ArgumentNullException(nameof(validator))); + return this; + } + + /// Adds validation that runs against data models. + public Builder ValidateData(Func validator) + { + _dataValidators.Add(validator ?? throw new ArgumentNullException(nameof(validator))); + return this; + } + + /// Builds the mapper. + public DataMapper Build() + => new( + _toData ?? throw new InvalidOperationException("A domain-to-data mapper is required."), + _toDomain ?? throw new InvalidOperationException("A data-to-domain mapper is required."), + _domainValidators.ToArray(), + _dataValidators.ToArray()); + } +} + +/// Result returned by a Data Mapper operation. +public sealed class DataMapperResult +{ + private DataMapperResult(T? value, IReadOnlyList errors) + { + Value = value; + Errors = errors; + } + + /// The mapped value when the operation succeeds. + public T? Value { get; } + + /// Validation errors that prevented mapping. + public IReadOnlyList Errors { get; } + + /// Gets whether the mapping completed without validation errors. + public bool Succeeded => Errors.Count == 0; + + /// Creates a successful mapping result. + public static DataMapperResult Mapped(T value) + => new(value, Array.Empty()); + + /// Creates a failed mapping result. + public static DataMapperResult Failed(IReadOnlyList errors) + { + if (errors is null) + throw new ArgumentNullException(nameof(errors)); + if (errors.Count == 0) + throw new ArgumentException("At least one Data Mapper error is required.", nameof(errors)); + + return new(default, errors); + } +} + +/// Validation error returned by a Data Mapper. +public sealed class DataMapperError +{ + public DataMapperError(string code, string message) + { + Code = string.IsNullOrWhiteSpace(code) + ? throw new ArgumentException("Data Mapper error code is required.", nameof(code)) + : code; + Message = string.IsNullOrWhiteSpace(message) + ? throw new ArgumentException("Data Mapper error message is required.", nameof(message)) + : message; + } + + /// Stable validation code. + public string Code { get; } + + /// User-facing or log-facing validation message. + public string Message { get; } +} diff --git a/src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs b/src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs new file mode 100644 index 0000000..4e00bca --- /dev/null +++ b/src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.DataMapping; +using PatternKit.Application.Repository; +using PatternKit.Generators.DataMapping; + +namespace PatternKit.Examples.DataMapperDemo; + +/// Production-shaped Data Mapper example for keeping order domain models isolated from persistence rows. +public static class OrderDataMapperDemo +{ + public static async ValueTask RunFluentAsync() + { + var mapper = OrderDataMapperPolicies.CreateFluentMapper(); + return await MapStoreAndLoadAsync(mapper); + } + + public static async ValueTask RunGeneratedAsync() + { + var mapper = GeneratedOrderDataMapper.CreateMapper(); + return await MapStoreAndLoadAsync(mapper); + } + + internal static async ValueTask MapStoreAndLoadAsync(IDataMapper mapper) + { + var repository = InMemoryRepository.Create(static row => row.OrderId).Build(); + var order = new OrderAggregate("order-100", "customer-1", 125m, OrderState.Paid); + var data = await mapper.ToDataAsync(order); + if (!data.Succeeded) + return new(false, false, data.Errors.Count, string.Empty, string.Empty); + + _ = await repository.AddAsync(data.Value!); + var loaded = await repository.GetAsync("order-100"); + var mapped = await mapper.ToDomainAsync(loaded!); + + return new( + data.Succeeded, + mapped.Succeeded, + mapped.Errors.Count, + data.Value!.PaymentStatus, + mapped.Value?.CustomerId ?? string.Empty); + } + + public static async ValueTask RunValidationAsync() + { + var mapper = OrderDataMapperPolicies.CreateFluentMapper(); + var mapped = await mapper.ToDataAsync(new OrderAggregate("", "customer-1", 10m, OrderState.Pending)); + return new(mapped.Succeeded, false, mapped.Errors.Count, string.Empty, string.Empty); + } +} + +public sealed record OrderAggregate(string OrderId, string CustomerId, decimal Total, OrderState State); + +public enum OrderState +{ + Pending, + Paid +} + +public sealed record OrderRow(string OrderId, string BuyerId, decimal TotalAmount, string PaymentStatus); + +public sealed record OrderDataMapperSummary( + bool DataMapped, + bool DomainMapped, + int ValidationErrors, + string StoredStatus, + string LoadedCustomerId); + +public static class OrderDataMapperPolicies +{ + public static DataMapper CreateFluentMapper() + => DataMapper.Create() + .MapToData(static order => new OrderRow(order.OrderId, order.CustomerId, order.Total, ToStatus(order.State))) + .MapToDomain(static row => new OrderAggregate(row.OrderId, row.BuyerId, row.TotalAmount, ToState(row.PaymentStatus))) + .ValidateDomain(static order => string.IsNullOrWhiteSpace(order.OrderId) + ? new DataMapperError("order-id-required", "Order id is required before persistence mapping.") + : null) + .ValidateData(static row => string.IsNullOrWhiteSpace(row.OrderId) + ? new DataMapperError("order-id-required", "Order row id is required before domain mapping.") + : null) + .Build(); + + private static string ToStatus(OrderState state) + => state == OrderState.Paid ? "PAID" : "PENDING"; + + private static OrderState ToState(string status) + => string.Equals(status, "PAID", StringComparison.OrdinalIgnoreCase) ? OrderState.Paid : OrderState.Pending; +} + +public sealed class OrderDataMapperWorkflow +{ + private readonly IDataMapper _mapper; + + public OrderDataMapperWorkflow(IDataMapper mapper) + { + _mapper = mapper; + } + + public ValueTask RunAsync() + => OrderDataMapperDemo.MapStoreAndLoadAsync(_mapper); +} + +public sealed record OrderDataMapperDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderDataMapperServiceCollectionExtensions +{ + public static IServiceCollection AddOrderDataMapperDemo(this IServiceCollection services) + { + services.AddSingleton>(_ => OrderDataMapperPolicies.CreateFluentMapper()); + services.AddSingleton(); + services.AddSingleton(new OrderDataMapperDemoRunner(OrderDataMapperDemo.RunFluentAsync, OrderDataMapperDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateDataMapper(typeof(OrderAggregate), typeof(OrderRow), FactoryName = "CreateMapper")] +public static partial class GeneratedOrderDataMapper +{ + [DataMapperToData] + private static OrderRow ToData(OrderAggregate order) + => new(order.OrderId, order.CustomerId, order.Total, order.State == OrderState.Paid ? "PAID" : "PENDING"); + + [DataMapperToDomain] + private static OrderAggregate ToDomain(OrderRow row) + => new(row.OrderId, row.BuyerId, row.TotalAmount, string.Equals(row.PaymentStatus, "PAID", StringComparison.OrdinalIgnoreCase) ? OrderState.Paid : OrderState.Pending); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index cd8540f..d18e31c 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ using PatternKit.Examples.Chain; using PatternKit.Examples.Chain.ConfigDriven; using PatternKit.Examples.CircuitBreakerDemo; +using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; @@ -124,6 +125,7 @@ public sealed record GeneratedInterpreterRulesExample(Interpreter Registry, LoanApprovalService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow); +public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Runner, OrderDataMapperWorkflow Workflow); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -182,6 +184,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddLoanApprovalSpecificationsExample() .AddOrderRepositoryPatternExample() .AddCheckoutUnitOfWorkPatternExample() + .AddOrderDataMapperPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -532,6 +535,15 @@ public static IServiceCollection AddCheckoutUnitOfWorkPatternExample(this IServi return services.RegisterExample("Checkout Unit of Work Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderDataMapperPatternExample(this IServiceCollection services) + { + services.AddOrderDataMapperDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Data Mapper Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index feb9aef..7dfd8da 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -312,6 +312,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["UnitOfWork"], ["ordered commit boundary", "source-generated unit of work", "DI composition"]), + Descriptor( + "Order Data Mapper Pattern", + "src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs", + "test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs", + "docs/examples/order-data-mapper-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["DataMapper"], + ["domain/data isolation", "source-generated mapper factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index f5262a0..404fcb7 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -687,6 +687,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs", ["fluent commit boundary", "generated unit-of-work factory", "DI-importable checkout example"]), + Pattern("Data Mapper", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/data-mapper.md", + "src/PatternKit.Core/Application/DataMapping/DataMapper.cs", + "test/PatternKit.Tests/Application/DataMapping/DataMapperTests.cs", + "docs/generators/data-mapper.md", + "src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs", + "test/PatternKit.Generators.Tests/DataMapperGeneratorTests.cs", + null, + "docs/examples/order-data-mapper-pattern.md", + "src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs", + "test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs", + ["fluent domain/data mapper", "generated mapper factory", "DI-importable order persistence example"]), + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, "docs/patterns/application/anti-corruption-layer.md", "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", diff --git a/src/PatternKit.Generators.Abstractions/DataMapping/DataMapperAttributes.cs b/src/PatternKit.Generators.Abstractions/DataMapping/DataMapperAttributes.cs new file mode 100644 index 0000000..5829623 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/DataMapping/DataMapperAttributes.cs @@ -0,0 +1,29 @@ +namespace PatternKit.Generators.DataMapping; + +/// Requests a generated Data Mapper factory for a partial host type. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateDataMapperAttribute : Attribute +{ + public GenerateDataMapperAttribute(Type domainType, Type dataType) + { + DomainType = domainType ?? throw new ArgumentNullException(nameof(domainType)); + DataType = dataType ?? throw new ArgumentNullException(nameof(dataType)); + } + + /// Domain model type isolated from persistence details. + public Type DomainType { get; } + + /// Persistence or transport data model type. + public Type DataType { get; } + + /// Generated factory method name. + public string FactoryName { get; set; } = "Create"; +} + +/// Marks the domain-to-data projection method. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class DataMapperToDataAttribute : Attribute; + +/// Marks the data-to-domain projection method. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class DataMapperToDomainAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 140e88a..973a508 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -82,6 +82,9 @@ PKDEC003 | PatternKit.Generators.Decorator | Error | Name conflict for generated PKDEC004 | PatternKit.Generators.Decorator | Warning | Member is not accessible for decorator generation PKDEC005 | PatternKit.Generators.Decorator | Error | Generic contracts are not supported for decorator generation PKDEC006 | PatternKit.Generators.Decorator | Error | Nested types are not supported for decorator generation +PKMAP001 | PatternKit.Generators.DataMapping | Error | Data Mapper host must be partial. +PKMAP002 | PatternKit.Generators.DataMapping | Error | Data Mapper must declare exactly one projection in each direction. +PKMAP003 | PatternKit.Generators.DataMapping | Error | Data Mapper projection signature is invalid. PKPRO001 | PatternKit.Generators.Prototype | Error | Type marked with [Prototype] must be partial PKPRO002 | PatternKit.Generators.Prototype | Error | Cannot construct clone target (no supported clone construction path) PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture (mutable reference types) diff --git a/src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs b/src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs new file mode 100644 index 0000000..43fcc88 --- /dev/null +++ b/src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs @@ -0,0 +1,160 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.DataMapping; + +[Generator] +public sealed class DataMapperGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.DataMapping.GenerateDataMapperAttribute"; + private const string ToDataAttributeName = "PatternKit.Generators.DataMapping.DataMapperToDataAttribute"; + private const string ToDomainAttributeName = "PatternKit.Generators.DataMapping.DataMapperToDomainAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMAP001", + "Data Mapper host must be partial", + "Type '{0}' is marked with [GenerateDataMapper] but is not declared as partial", + "PatternKit.Generators.DataMapping", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingProjection = new( + "PKMAP002", + "Data Mapper projections are missing", + "Data Mapper '{0}' must declare exactly one [DataMapperToData] method and exactly one [DataMapperToDomain] method", + "PatternKit.Generators.DataMapping", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidProjection = new( + "PKMAP003", + "Data Mapper projection signature is invalid", + "Data Mapper projection '{0}' must be static and return the target type from one source parameter", + "PatternKit.Generators.DataMapping", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var domainType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var dataType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (domainType is null || dataType is null) + return; + + var toData = FindProjection(type, ToDataAttributeName); + var toDomain = FindProjection(type, ToDomainAttributeName); + if (toData.Length != 1 || toDomain.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingProjection, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsProjection(toData[0], domainType, dataType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidProjection, toData[0].Locations.FirstOrDefault(), toData[0].Name)); + return; + } + + if (!IsProjection(toDomain[0], dataType, domainType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidProjection, toDomain[0].Locations.FirstOrDefault(), toDomain[0].Name)); + return; + } + + context.AddSource($"{type.Name}.DataMapper.g.cs", SourceText.From( + GenerateSource(type, domainType, dataType, toData[0].Name, toDomain[0].Name, GetNamedString(attribute, "FactoryName") ?? "Create"), + Encoding.UTF8)); + } + + private static IMethodSymbol[] FindProjection(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol domainType, + INamedTypeSymbol dataType, + string toDataName, + string toDomainName, + string factoryName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var domainName = domainType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var dataName = dataType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.DataMapping.DataMapper<") + .Append(domainName).Append(", ").Append(dataName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.DataMapping.DataMapper<") + .Append(domainName).Append(", ").Append(dataName).AppendLine(">.Create()"); + sb.Append(" .MapToData(").Append(toDataName).AppendLine(")"); + sb.Append(" .MapToDomain(").Append(toDomainName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsProjection(IMethodSymbol method, INamedTypeSymbol sourceType, INamedTypeSymbol targetType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, sourceType) + && SymbolEqualityComparer.Default.Equals(method.ReturnType, targetType); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs b/test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs new file mode 100644 index 0000000..b513126 --- /dev/null +++ b/test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DataMapperDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.DataMapperDemo; + +[Feature("Order Data Mapper example")] +public sealed partial class OrderDataMapperDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated Data Mapper paths persist and reload orders")] + [Fact] + public Task Fluent_And_Generated_Data_Mapper_Paths_Persist_And_Reload_Orders() + => Given( + "fluent and generated order Data Mappers", + (Func>)(async () => new OrderDataMapperRuns( + await OrderDataMapperDemo.RunFluentAsync(), + await OrderDataMapperDemo.RunGeneratedAsync()))) + .Then("both mapping paths store rows and rehydrate domain orders", runs => + { + ScenarioExpect.True(runs.Fluent.DataMapped); + ScenarioExpect.True(runs.Fluent.DomainMapped); + ScenarioExpect.Equal("PAID", runs.Fluent.StoredStatus); + ScenarioExpect.Equal("customer-1", runs.Generated.LoadedCustomerId); + }) + .AssertPassed(); + + [Scenario("Data Mapper example exposes validation failures")] + [Fact] + public Task Data_Mapper_Example_Exposes_Validation_Failures() + => Given("an invalid domain order", OrderDataMapperDemo.RunValidationAsync) + .Then("the mapper returns validation errors instead of storing a row", summary => + { + ScenarioExpect.False(summary.DataMapped); + ScenarioExpect.Equal(1, summary.ValidationErrors); + }) + .AssertPassed(); + + [Scenario("Data Mapper example is importable through IServiceCollection")] + [Fact] + public Task Data_Mapper_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the order Data Mapper example", () => + { + var services = new ServiceCollection(); + services.AddOrderDataMapperDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("an importing application runs the workflow", provider => + { + using (provider) + return provider.GetRequiredService().RunAsync().AsTask().GetAwaiter().GetResult(); + }) + .Then("the workflow maps and loads the order", summary => + { + ScenarioExpect.True(summary.DataMapped); + ScenarioExpect.True(summary.DomainMapped); + ScenarioExpect.Equal("customer-1", summary.LoadedCustomerId); + }) + .AssertPassed(); + + private sealed record OrderDataMapperRuns( + OrderDataMapperSummary Fluent, + OrderDataMapperSummary Generated); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 44869f3..a8807d5 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -4,6 +4,7 @@ using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; using PatternKit.Examples.CircuitBreakerDemo; +using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; @@ -102,6 +103,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var specifications = provider.GetRequiredService(); var orderRepository = provider.GetRequiredService(); var unitOfWork = provider.GetRequiredService(); + var dataMapper = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -174,6 +176,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("generated specification registry approves prime loans", specifications.Service.Evaluate(PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo.CreatePrimeApplication()).Approved), ("repository example rejects duplicate order keys", orderRepository.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().DuplicateRejected), ("unit of work example commits checkout steps", unitOfWork.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().Committed), + ("data mapper example rehydrates stored orders", dataMapper.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().LoadedCustomerId == "customer-1"), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index bce126a..fdc4dd7 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -63,6 +63,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Specification", "Repository", "Unit of Work", + "Data Mapper", "Anti-Corruption Layer" ]; @@ -107,7 +108,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(6, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index b5f1c71..3efdffb 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -7,6 +7,7 @@ using PatternKit.Generators.Command; using PatternKit.Generators.Composite; using PatternKit.Generators.Composer; +using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; @@ -79,6 +80,9 @@ private enum TestTrigger { typeof(ComposeIgnoreAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDecoratorAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(DecoratorIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, + { typeof(GenerateDataMapperAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(DataMapperToDataAttribute), AttributeTargets.Method, false, false }, + { typeof(DataMapperToDomainAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateFacadeAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, true, false }, { typeof(FacadeExposeAttribute), AttributeTargets.Method, false, false }, { typeof(FacadeMapAttribute), AttributeTargets.Method, false, false }, @@ -246,6 +250,10 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() { FactoryName = "BuildRepository" }; + var dataMapper = new GenerateDataMapperAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildMapper" + }; ScenarioExpect.Equal(typeof(string), rateLimit.ResultType); ScenarioExpect.Equal("BuildSearchLimit", rateLimit.FactoryMethodName); @@ -256,9 +264,16 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal(typeof(string), repository.EntityType); ScenarioExpect.Equal(typeof(Guid), repository.KeyType); ScenarioExpect.Equal("BuildRepository", repository.FactoryName); + ScenarioExpect.Equal(typeof(string), dataMapper.DomainType); + ScenarioExpect.Equal(typeof(int), dataMapper.DataType); + ScenarioExpect.Equal("BuildMapper", dataMapper.FactoryName); ScenarioExpect.Throws(() => new GenerateRepositoryAttribute(null!, typeof(Guid))); ScenarioExpect.Throws(() => new GenerateRepositoryAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GenerateDataMapperAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateDataMapperAttribute(typeof(string), null!)); ScenarioExpect.IsType(new RepositoryKeySelectorAttribute()); + ScenarioExpect.IsType(new DataMapperToDataAttribute()); + ScenarioExpect.IsType(new DataMapperToDomainAttribute()); } [Scenario("Bulkhead Attributes Expose Defaults And Configuration")] diff --git a/test/PatternKit.Generators.Tests/DataMapperGeneratorTests.cs b/test/PatternKit.Generators.Tests/DataMapperGeneratorTests.cs new file mode 100644 index 0000000..4abc422 --- /dev/null +++ b/test/PatternKit.Generators.Tests/DataMapperGeneratorTests.cs @@ -0,0 +1,120 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.DataMapping; +using PatternKit.Generators.DataMapping; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Data Mapper generator")] +public sealed partial class DataMapperGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits mapper factory for valid projections")] + [Fact] + public Task Generator_Emits_Mapper_Factory_For_Valid_Projections() + => Given("a partial mapper declaration", () => Compile(""" + using PatternKit.Generators.DataMapping; + + namespace Demo; + + public sealed record DomainOrder(string Id, decimal Total); + public sealed record OrderRow(string OrderId, decimal Amount); + + [GenerateDataMapper(typeof(DomainOrder), typeof(OrderRow), FactoryName = "CreateMapper")] + public static partial class OrderDataMapper + { + [DataMapperToData] + private static OrderRow ToData(DomainOrder order) => new(order.Id, order.Total); + + [DataMapperToDomain] + private static DomainOrder ToDomain(OrderRow row) => new(row.OrderId, row.Amount); + } + """)) + .Then("generated source contains a fluent Data Mapper factory", result => + { + ScenarioExpect.Empty(result.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error)); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("CreateMapper()", source); + ScenarioExpect.Contains(".MapToData(ToData)", source); + ScenarioExpect.Contains(".MapToDomain(ToDomain)", source); + }) + .AssertPassed(); + + [Scenario("Generator reports non partial mapper hosts")] + [Fact] + public Task Generator_Reports_Non_Partial_Mapper_Hosts() + => Given("a non partial mapper declaration", () => Compile(""" + using PatternKit.Generators.DataMapping; + + public sealed record DomainOrder(string Id); + public sealed record OrderRow(string OrderId); + + [GenerateDataMapper(typeof(DomainOrder), typeof(OrderRow))] + public static class OrderDataMapper + { + [DataMapperToData] + private static OrderRow ToData(DomainOrder order) => new(order.Id); + + [DataMapperToDomain] + private static DomainOrder ToDomain(OrderRow row) => new(row.OrderId); + } + """)) + .Then("PKMAP001 is reported", result => + ScenarioExpect.Contains(result.Diagnostics, static diagnostic => diagnostic.Id == "PKMAP001")) + .AssertPassed(); + + [Scenario("Generator reports missing mapper projections")] + [Fact] + public Task Generator_Reports_Missing_Mapper_Projections() + => Given("a mapper declaration without projections", () => Compile(""" + using PatternKit.Generators.DataMapping; + + public sealed record DomainOrder(string Id); + public sealed record OrderRow(string OrderId); + + [GenerateDataMapper(typeof(DomainOrder), typeof(OrderRow))] + public static partial class OrderDataMapper; + """)) + .Then("PKMAP002 is reported", result => + ScenarioExpect.Contains(result.Diagnostics, static diagnostic => diagnostic.Id == "PKMAP002")) + .AssertPassed(); + + [Scenario("Generator reports invalid mapper projection signatures")] + [Fact] + public Task Generator_Reports_Invalid_Mapper_Projection_Signatures() + => Given("a mapper declaration with an invalid projection", () => Compile(""" + using PatternKit.Generators.DataMapping; + + public sealed record DomainOrder(string Id); + public sealed record OrderRow(string OrderId); + + [GenerateDataMapper(typeof(DomainOrder), typeof(OrderRow))] + public static partial class OrderDataMapper + { + [DataMapperToData] + private static string ToData(DomainOrder order) => order.Id; + + [DataMapperToDomain] + private static DomainOrder ToDomain(OrderRow row) => new(row.OrderId); + } + """)) + .Then("PKMAP003 is reported", result => + ScenarioExpect.Contains(result.Diagnostics, static diagnostic => diagnostic.Id == "PKMAP003")) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "DataMapperGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(DataMapper<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new DataMapperGenerator(), out var run, out _); + var result = run.Results.Single(); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources); +} diff --git a/test/PatternKit.Tests/Application/DataMapping/DataMapperTests.cs b/test/PatternKit.Tests/Application/DataMapping/DataMapperTests.cs new file mode 100644 index 0000000..8662c5e --- /dev/null +++ b/test/PatternKit.Tests/Application/DataMapping/DataMapperTests.cs @@ -0,0 +1,92 @@ +using PatternKit.Application.DataMapping; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.DataMapping; + +[Feature("Data Mapper")] +public sealed partial class DataMapperTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Data Mapper maps domain models and data records in both directions")] + [Fact] + public Task Data_Mapper_Maps_Domain_Models_And_Data_Records_In_Both_Directions() + => Given("a mapper between orders and persistence records", CreateMapper) + .When( + "mapping a domain order to data and back", + (Func, Task>)(async mapper => + { + var data = await mapper.ToDataAsync(new Order("order-100", 125m, true)); + var domain = await mapper.ToDomainAsync(data.Value!); + return new MappedOrderRoundTrip(data, domain); + })) + .Then("the data record is isolated from the domain shape", result => + { + ScenarioExpect.True(result.Data.Succeeded); + ScenarioExpect.Equal("order-100", result.Data.Value!.OrderId); + ScenarioExpect.Equal("Paid", result.Data.Value.Status); + }) + .And("the domain model round-trips through the mapper", result => + { + ScenarioExpect.True(result.Domain.Succeeded); + ScenarioExpect.Equal(125m, result.Domain.Value!.Total); + ScenarioExpect.True(result.Domain.Value.Paid); + }) + .AssertPassed(); + + [Scenario("Data Mapper returns validation errors before mapping invalid models")] + [Fact] + public Task Data_Mapper_Returns_Validation_Errors_Before_Mapping_Invalid_Models() + => Given("a mapper with domain validation", CreateMapper) + .When("mapping an invalid domain order", mapper => mapper.ToDataAsync(new Order("", 10m, false)).AsTask().GetAwaiter().GetResult()) + .Then("mapping fails with the validation error", result => + { + ScenarioExpect.False(result.Succeeded); + var error = ScenarioExpect.Single(result.Errors); + ScenarioExpect.Equal("order-id-required", error.Code); + }) + .AssertPassed(); + + [Scenario("Data Mapper builder requires both projections")] + [Fact] + public Task Data_Mapper_Builder_Requires_Both_Projections() + => Given("a mapper builder with only one projection", () => DataMapper.Create() + .MapToData(static order => new OrderRow(order.Id, order.Total, order.Paid ? "Paid" : "Pending"))) + .Then("building fails clearly", builder => + ScenarioExpect.Throws(() => builder.Build())) + .AssertPassed(); + + [Scenario("Data Mapper honors cancellation")] + [Fact] + public Task Data_Mapper_Honors_Cancellation() + => Given("a canceled token and mapper", () => + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + return new { Mapper = CreateMapper(), Token = cts.Token }; + }) + .Then("mapping observes cancellation", ctx => + ScenarioExpect.Throws(() => + ctx.Mapper.ToDataAsync(new Order("order-100", 10m, false), ctx.Token).AsTask().GetAwaiter().GetResult())) + .AssertPassed(); + + private static DataMapper CreateMapper() + => DataMapper.Create() + .MapToData(static order => new OrderRow(order.Id, order.Total, order.Paid ? "Paid" : "Pending")) + .MapToDomain(static row => new Order(row.OrderId, row.TotalAmount, row.Status == "Paid")) + .ValidateDomain(static order => string.IsNullOrWhiteSpace(order.Id) + ? new DataMapperError("order-id-required", "Order id is required.") + : null) + .ValidateData(static row => string.IsNullOrWhiteSpace(row.OrderId) + ? new DataMapperError("order-id-required", "Order row id is required.") + : null) + .Build(); + + private sealed record Order(string Id, decimal Total, bool Paid); + + private sealed record OrderRow(string OrderId, decimal TotalAmount, string Status); + + private sealed record MappedOrderRoundTrip( + DataMapperResult Data, + DataMapperResult Domain); +}