From 3560892e06dcbe5e698baa07610771bba2c241df Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 23:50:10 -0500 Subject: [PATCH] feat: add identity map pattern support --- docs/examples/order-identity-map-pattern.md | 26 +++ docs/examples/toc.yml | 3 + docs/generators/identity-map.md | 27 +++ docs/generators/index.md | 1 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/application/identity-map.md | 27 +++ docs/patterns/toc.yml | 2 + .../Application/IdentityMap/IdentityMap.cs | 154 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 ++ .../IdentityMapDemo/OrderIdentityMapDemo.cs | 77 +++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../IdentityMap/IdentityMapAttributes.cs | 20 +++ .../AnalyzerReleases.Unshipped.md | 3 + .../IdentityMap/IdentityMapGenerator.cs | 132 +++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 3 + .../OrderIdentityMapDemoTests.cs | 49 ++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 13 ++ .../IdentityMapGeneratorTests.cs | 89 ++++++++++ .../IdentityMap/IdentityMapTests.cs | 143 ++++++++++++++++ 22 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-identity-map-pattern.md create mode 100644 docs/generators/identity-map.md create mode 100644 docs/patterns/application/identity-map.md create mode 100644 src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs create mode 100644 src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/IdentityMap/IdentityMapAttributes.cs create mode 100644 src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/IdentityMap/IdentityMapTests.cs diff --git a/docs/examples/order-identity-map-pattern.md b/docs/examples/order-identity-map-pattern.md new file mode 100644 index 0000000..706d7ce --- /dev/null +++ b/docs/examples/order-identity-map-pattern.md @@ -0,0 +1,26 @@ +# Order Identity Map Pattern + +This example loads an order through a repository-backed workflow and uses an Identity Map to make repeated loads of the same key return the same object instance. + +## What It Demonstrates + +- fluent `IdentityMap` creation +- generated identity-map factory with `[GenerateIdentityMap]` +- duplicate key rejection +- request-scoped `IServiceCollection` registration +- repository integration for repeated loads + +## Import + +```csharp +var services = new ServiceCollection(); +services.AddOrderIdentityMapDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = workflow.Run(); +``` + +The registered `IIdentityMap` is scoped so each request or unit of work gets its own identity cache. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 07ea32d..ec03b0e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -88,6 +88,9 @@ - name: Order Data Mapper Pattern href: order-data-mapper-pattern.md +- name: Order Identity Map Pattern + href: order-identity-map-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/identity-map.md b/docs/generators/identity-map.md new file mode 100644 index 0000000..179418c --- /dev/null +++ b/docs/generators/identity-map.md @@ -0,0 +1,27 @@ +# Identity Map Generator + +`IdentityMapGenerator` emits a typed `IdentityMap` factory from a key selector. + +```csharp +[GenerateIdentityMap(typeof(TrackedOrder), typeof(string), FactoryName = "CreateMap")] +public static partial class GeneratedOrderIdentityMap +{ + [IdentityMapKeySelector] + private static string SelectKey(TrackedOrder order) => order.OrderId; +} +``` + +## Diagnostics + +| ID | Severity | Message | +| --- | --- | --- | +| `PKIM001` | Error | The host type must be partial. | +| `PKIM002` | Error | Exactly one `[IdentityMapKeySelector]` method is required. | +| `PKIM003` | Error | The key selector must be static, non-generic, return `TKey`, and accept one `TEntity`. | + +Register generated maps as scoped services when used with normal .NET hosts: + +```csharp +services.AddScoped>(_ => + GeneratedOrderIdentityMap.CreateMap()); +``` diff --git a/docs/generators/index.md b/docs/generators/index.md index 7dd58b2..853021d 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -63,6 +63,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | | [**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 b1b6b2a..ad443db 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -58,6 +58,9 @@ - name: Interpreter href: interpreter.md +- name: Identity Map + href: identity-map.md + - name: Iterator href: iterator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 0e3ec2b..29f5b6c 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -70,6 +70,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | Identity Map | `IdentityMap` | Identity Map generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/identity-map.md b/docs/patterns/application/identity-map.md new file mode 100644 index 0000000..2aaf1c2 --- /dev/null +++ b/docs/patterns/application/identity-map.md @@ -0,0 +1,27 @@ +# Identity Map + +Identity Map preserves object identity inside a request, unit of work, or other application scope. Use it when repeated loads of the same key should return the same object instance and duplicate tracked instances should be rejected. + +## Fluent Path + +```csharp +var map = IdentityMap.Create(order => order.OrderId) + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + +var order = map.GetOrAdd("order-100", key => repository.Load(key)); +var same = map.GetOrAdd("order-100", key => repository.Load(key)); +``` + +`same` is the same object reference as `order`. `Track` returns `IdentityMapResult` so duplicate-key conflicts are explicit. + +## DI Usage + +Register identity maps as scoped services: + +```csharp +services.AddScoped>(_ => + IdentityMap.Create(order => order.OrderId).Build()); +``` + +See [Order Identity Map Pattern](../../examples/order-identity-map-pattern.md). diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 5f9769d..e6dcfe5 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -339,6 +339,8 @@ href: application/unit-of-work.md - name: Data Mapper href: application/data-mapper.md + - name: Identity Map + href: application/identity-map.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs b/src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs new file mode 100644 index 0000000..e69fc2e --- /dev/null +++ b/src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs @@ -0,0 +1,154 @@ +namespace PatternKit.Application.IdentityMap; + +/// Request or unit-of-work scoped cache that preserves object identity by key. +public interface IIdentityMap + where TKey : notnull +{ + int Count { get; } + + TEntity? Get(TKey key); + + IdentityMapResult Track(TKey key, TEntity entity); + + TEntity GetOrAdd(TKey key, Func factory); + + bool Remove(TKey key); + + void Clear(); +} + +/// In-memory Identity Map for preserving object identity in a scope. +public sealed class IdentityMap : IIdentityMap + where TKey : notnull +{ + private readonly Dictionary _entities; + private readonly Func? _keySelector; + + private IdentityMap(Func? keySelector, IEqualityComparer? comparer) + { + _keySelector = keySelector; + _entities = new Dictionary(comparer); + } + + public int Count => _entities.Count; + + public static Builder Create(Func? keySelector = null) + => new(keySelector); + + public TEntity? Get(TKey key) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + _entities.TryGetValue(key, out var entity); + return entity; + } + + public IdentityMapResult Track(TEntity entity) + { + if (_keySelector is null) + throw new InvalidOperationException("A key selector is required to track entities without an explicit key."); + + return Track(_keySelector(entity), entity); + } + + public IdentityMapResult Track(TKey key, TEntity entity) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + if (entity is null) + throw new ArgumentNullException(nameof(entity)); + + if (_entities.TryGetValue(key, out var existing)) + { + return ReferenceEquals(existing, entity) + ? IdentityMapResult.Existing(existing) + : IdentityMapResult.Conflict(existing, "A different entity instance is already tracked for this key."); + } + + _entities.Add(key, entity); + return IdentityMapResult.Tracked(entity); + } + + public TEntity GetOrAdd(TKey key, Func factory) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + if (factory is null) + throw new ArgumentNullException(nameof(factory)); + + if (_entities.TryGetValue(key, out var existing)) + return existing; + + var created = factory(key); + _entities.Add(key, created); + return created; + } + + public bool Remove(TKey key) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + return _entities.Remove(key); + } + + public void Clear() => _entities.Clear(); + + public sealed class Builder + { + private readonly Func? _keySelector; + private IEqualityComparer? _comparer; + + internal Builder(Func? keySelector) + { + _keySelector = keySelector; + } + + public Builder UseComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + public IdentityMap Build() + => new(_keySelector, _comparer); + } +} + +/// Result returned when tracking an entity in an Identity Map. +public sealed class IdentityMapResult +{ + private IdentityMapResult(TEntity entity, IdentityMapStatus status, string? reason) + { + Entity = entity; + Status = status; + Reason = reason; + } + + public TEntity Entity { get; } + + public IdentityMapStatus Status { get; } + + public string? Reason { get; } + + public bool Succeeded => Status is IdentityMapStatus.Tracked or IdentityMapStatus.Existing; + + public static IdentityMapResult Tracked(TEntity entity) + => new(entity, IdentityMapStatus.Tracked, null); + + public static IdentityMapResult Existing(TEntity entity) + => new(entity, IdentityMapStatus.Existing, null); + + public static IdentityMapResult Conflict(TEntity entity, string reason) + => new(entity, IdentityMapStatus.Conflict, string.IsNullOrWhiteSpace(reason) + ? throw new ArgumentException("Identity Map conflict reason is required.", nameof(reason)) + : reason); +} + +public enum IdentityMapStatus +{ + Tracked, + Existing, + Conflict +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index d18e31c..d1be608 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; +using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.MementoDemo; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; @@ -126,6 +127,7 @@ public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -185,6 +187,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderRepositoryPatternExample() .AddCheckoutUnitOfWorkPatternExample() .AddOrderDataMapperPatternExample() + .AddOrderIdentityMapPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -544,6 +547,13 @@ public static IServiceCollection AddOrderDataMapperPatternExample(this IServiceC return services.RegisterExample("Order Data Mapper Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderIdentityMapPatternExample(this IServiceCollection services) + { + services.AddOrderIdentityMapDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Identity Map 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/IdentityMapDemo/OrderIdentityMapDemo.cs b/src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs new file mode 100644 index 0000000..eec342e --- /dev/null +++ b/src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.IdentityMap; +using PatternKit.Application.Repository; +using PatternKit.Generators.IdentityMap; + +namespace PatternKit.Examples.IdentityMapDemo; + +public static class OrderIdentityMapDemo +{ + public static OrderIdentityMapSummary RunFluent() + { + var map = OrderIdentityMapPolicies.CreateFluentMap(); + return LoadTwice(map); + } + + public static OrderIdentityMapSummary RunGenerated() + { + var map = GeneratedOrderIdentityMap.CreateMap(); + return LoadTwice(map); + } + + internal static OrderIdentityMapSummary LoadTwice(IIdentityMap map) + { + var repository = InMemoryRepository.Create(static order => order.OrderId).Build(); + _ = repository.AddAsync(new TrackedOrder("order-100", "customer-1", 125m)).AsTask().GetAwaiter().GetResult(); + + var first = map.GetOrAdd("order-100", key => repository.GetAsync(key).AsTask().GetAwaiter().GetResult()!); + var second = map.GetOrAdd("order-100", key => repository.GetAsync(key).AsTask().GetAwaiter().GetResult()!); + var conflict = map.Track("order-100", new TrackedOrder("order-100", "customer-1", 999m)); + + return new(ReferenceEquals(first, second), conflict.Status == IdentityMapStatus.Conflict, map.Count, second.Total); + } +} + +public sealed record TrackedOrder(string OrderId, string CustomerId, decimal Total); + +public sealed record OrderIdentityMapSummary(bool ReusedInstance, bool DuplicateRejected, int TrackedCount, decimal Total); + +public static class OrderIdentityMapPolicies +{ + public static IdentityMap CreateFluentMap() + => IdentityMap.Create(static order => order.OrderId) + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); +} + +public sealed class OrderIdentityMapWorkflow +{ + private readonly IIdentityMap _map; + + public OrderIdentityMapWorkflow(IIdentityMap map) + { + _map = map; + } + + public OrderIdentityMapSummary Run() => OrderIdentityMapDemo.LoadTwice(_map); +} + +public sealed record OrderIdentityMapDemoRunner(Func RunFluent, Func RunGenerated); + +public static class OrderIdentityMapServiceCollectionExtensions +{ + public static IServiceCollection AddOrderIdentityMapDemo(this IServiceCollection services) + { + services.AddScoped>(_ => OrderIdentityMapPolicies.CreateFluentMap()); + services.AddScoped(); + services.AddSingleton(new OrderIdentityMapDemoRunner(OrderIdentityMapDemo.RunFluent, OrderIdentityMapDemo.RunGenerated)); + return services; + } +} + +[GenerateIdentityMap(typeof(TrackedOrder), typeof(string), FactoryName = "CreateMap")] +public static partial class GeneratedOrderIdentityMap +{ + [IdentityMapKeySelector] + private static string SelectKey(TrackedOrder order) => order.OrderId; +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 7dfd8da..0a4f520 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -320,6 +320,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["DataMapper"], ["domain/data isolation", "source-generated mapper factory", "DI composition"]), + Descriptor( + "Order Identity Map Pattern", + "src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs", + "test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs", + "docs/examples/order-identity-map-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["IdentityMap"], + ["request-scoped identity reuse", "source-generated map 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 404fcb7..31e8160 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -700,6 +700,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs", ["fluent domain/data mapper", "generated mapper factory", "DI-importable order persistence example"]), + Pattern("Identity Map", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/identity-map.md", + "src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs", + "test/PatternKit.Tests/Application/IdentityMap/IdentityMapTests.cs", + "docs/generators/identity-map.md", + "src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs", + "test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs", + null, + "docs/examples/order-identity-map-pattern.md", + "src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs", + "test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs", + ["fluent scoped identity map", "generated identity-map factory", "DI-importable request scope 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/IdentityMap/IdentityMapAttributes.cs b/src/PatternKit.Generators.Abstractions/IdentityMap/IdentityMapAttributes.cs new file mode 100644 index 0000000..659c13a --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/IdentityMap/IdentityMapAttributes.cs @@ -0,0 +1,20 @@ +namespace PatternKit.Generators.IdentityMap; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateIdentityMapAttribute : Attribute +{ + public GenerateIdentityMapAttribute(Type entityType, Type keyType) + { + EntityType = entityType ?? throw new ArgumentNullException(nameof(entityType)); + KeyType = keyType ?? throw new ArgumentNullException(nameof(keyType)); + } + + public Type EntityType { get; } + + public Type KeyType { get; } + + public string FactoryName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class IdentityMapKeySelectorAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 973a508..d97aa4d 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -232,6 +232,9 @@ PKINT001 | PatternKit.Generators.Interpreter | Error | Interpreter host must be PKINT002 | PatternKit.Generators.Interpreter | Error | Interpreter must declare at least one rule. PKINT003 | PatternKit.Generators.Interpreter | Error | Interpreter rule signature is invalid. PKINT004 | PatternKit.Generators.Interpreter | Error | Interpreter rule declaration is duplicated. +PKIM001 | PatternKit.Generators.IdentityMap | Error | Identity Map host must be partial. +PKIM002 | PatternKit.Generators.IdentityMap | Error | Identity Map must declare exactly one key selector. +PKIM003 | PatternKit.Generators.IdentityMap | Error | Identity Map key selector signature is invalid. PKSPEC001 | PatternKit.Generators.Specification | Error | Specification registry host must be partial. PKSPEC002 | PatternKit.Generators.Specification | Error | Specification registry must declare at least one rule. PKSPEC003 | PatternKit.Generators.Specification | Error | Specification rule signature is invalid. diff --git a/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs b/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs new file mode 100644 index 0000000..5b1a4d2 --- /dev/null +++ b/src/PatternKit.Generators/IdentityMap/IdentityMapGenerator.cs @@ -0,0 +1,132 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.IdentityMap; + +[Generator] +public sealed class IdentityMapGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.IdentityMap.GenerateIdentityMapAttribute"; + private const string KeySelectorAttributeName = "PatternKit.Generators.IdentityMap.IdentityMapKeySelectorAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKIM001", "Identity Map host must be partial", + "Type '{0}' is marked with [GenerateIdentityMap] but is not declared as partial", + "PatternKit.Generators.IdentityMap", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingKeySelector = new( + "PKIM002", "Identity Map key selector is missing", + "Identity Map '{0}' must declare exactly one [IdentityMapKeySelector] method", + "PatternKit.Generators.IdentityMap", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidKeySelector = new( + "PKIM003", "Identity Map key selector signature is invalid", + "Identity Map key selector '{0}' must be static and return TKey from one TEntity parameter", + "PatternKit.Generators.IdentityMap", 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 entityType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var keyType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (entityType is null || keyType is null) + return; + + var selectors = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == KeySelectorAttributeName)) + .ToArray(); + if (selectors.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingKeySelector, node.Identifier.GetLocation(), type.Name)); + return; + } + + var selector = selectors[0]; + if (!IsKeySelector(selector, entityType, keyType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidKeySelector, selector.Locations.FirstOrDefault(), selector.Name)); + return; + } + + context.AddSource($"{type.Name}.IdentityMap.g.cs", SourceText.From( + GenerateSource(type, entityType, keyType, selector.Name, GetNamedString(attribute, "FactoryName") ?? "Create"), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol entityType, INamedTypeSymbol keyType, string selectorName, string factoryName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var entityName = entityType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var keyName = keyType.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.IdentityMap.IdentityMap<") + .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.IdentityMap.IdentityMap<") + .Append(entityName).Append(", ").Append(keyName).Append(">.Create(").Append(selectorName).AppendLine(").Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsKeySelector(IMethodSymbol method, INamedTypeSymbol entityType, INamedTypeSymbol keyType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, entityType) + && SymbolEqualityComparer.Default.Equals(method.ReturnType, keyType); + + 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/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index a8807d5..b38e192 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -6,6 +6,7 @@ using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.IdentityMapDemo; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PointOfSale; @@ -104,6 +105,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var orderRepository = provider.GetRequiredService(); var unitOfWork = provider.GetRequiredService(); var dataMapper = provider.GetRequiredService(); + var identityMap = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -177,6 +179,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("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"), + ("identity map example reuses loaded orders", identityMap.Runner.RunFluent().ReusedInstance), ("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/IdentityMapDemo/OrderIdentityMapDemoTests.cs b/test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs new file mode 100644 index 0000000..213d933 --- /dev/null +++ b/test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.IdentityMapDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.IdentityMapDemo; + +[Feature("Order Identity Map example")] +public sealed partial class OrderIdentityMapDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated Identity Maps reuse loaded orders")] + [Fact] + public Task Fluent_And_Generated_Identity_Maps_Reuse_Loaded_Orders() + => Given("fluent and generated identity maps", () => new + { + Fluent = OrderIdentityMapDemo.RunFluent(), + Generated = OrderIdentityMapDemo.RunGenerated() + }) + .Then("both paths reuse object identity and reject duplicates", runs => + { + ScenarioExpect.True(runs.Fluent.ReusedInstance); + ScenarioExpect.True(runs.Generated.ReusedInstance); + ScenarioExpect.True(runs.Generated.DuplicateRejected); + }) + .AssertPassed(); + + [Scenario("Identity Map example is scoped through IServiceCollection")] + [Fact] + public Task Identity_Map_Example_Is_Scoped_Through_IServiceCollection() + => Given("a provider importing the identity map example", () => + { + var services = new ServiceCollection(); + services.AddOrderIdentityMapDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a request scope uses the workflow", provider => + { + using (provider) + using (var scope = provider.CreateScope()) + return scope.ServiceProvider.GetRequiredService().Run(); + }) + .Then("the scoped map reuses the loaded order", summary => + { + ScenarioExpect.True(summary.ReusedInstance); + ScenarioExpect.Equal(1, summary.TrackedCount); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index fdc4dd7..7e9a4c3 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -64,6 +64,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Repository", "Unit of Work", "Data Mapper", + "Identity Map", "Anti-Corruption Layer" ]; @@ -108,7 +109,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(6, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(7, 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 3efdffb..7776301 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -12,6 +12,7 @@ using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; +using PatternKit.Generators.IdentityMap; using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; using PatternKit.Generators.Messaging; @@ -99,6 +100,8 @@ private enum TestTrigger { typeof(GenerateInterpreterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(InterpreterTerminalAttribute), AttributeTargets.Method, true, false }, { typeof(InterpreterNonTerminalAttribute), AttributeTargets.Method, true, false }, + { typeof(GenerateIdentityMapAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(IdentityMapKeySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(IteratorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(IteratorStepAttribute), AttributeTargets.Method, false, false }, { typeof(TraversalIteratorAttribute), AttributeTargets.Class, false, false }, @@ -254,6 +257,10 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() { FactoryName = "BuildMapper" }; + var identityMap = new GenerateIdentityMapAttribute(typeof(string), typeof(Guid)) + { + FactoryName = "BuildIdentityMap" + }; ScenarioExpect.Equal(typeof(string), rateLimit.ResultType); ScenarioExpect.Equal("BuildSearchLimit", rateLimit.FactoryMethodName); @@ -267,13 +274,19 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal(typeof(string), dataMapper.DomainType); ScenarioExpect.Equal(typeof(int), dataMapper.DataType); ScenarioExpect.Equal("BuildMapper", dataMapper.FactoryName); + ScenarioExpect.Equal(typeof(string), identityMap.EntityType); + ScenarioExpect.Equal(typeof(Guid), identityMap.KeyType); + ScenarioExpect.Equal("BuildIdentityMap", identityMap.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.Throws(() => new GenerateIdentityMapAttribute(null!, typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateIdentityMapAttribute(typeof(string), null!)); ScenarioExpect.IsType(new RepositoryKeySelectorAttribute()); ScenarioExpect.IsType(new DataMapperToDataAttribute()); ScenarioExpect.IsType(new DataMapperToDomainAttribute()); + ScenarioExpect.IsType(new IdentityMapKeySelectorAttribute()); } [Scenario("Bulkhead Attributes Expose Defaults And Configuration")] diff --git a/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs b/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs new file mode 100644 index 0000000..61860ae --- /dev/null +++ b/test/PatternKit.Generators.Tests/IdentityMapGeneratorTests.cs @@ -0,0 +1,89 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.IdentityMap; +using PatternKit.Generators.IdentityMap; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Identity Map generator")] +public sealed partial class IdentityMapGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits identity map factory")] + [Fact] + public Task Generator_Emits_Identity_Map_Factory() + => Given("a valid identity map declaration", () => Compile(""" + using PatternKit.Generators.IdentityMap; + namespace Demo; + public sealed record Order(string Id); + [GenerateIdentityMap(typeof(Order), typeof(string), FactoryName = "CreateMap")] + public static partial class OrderIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + """)) + .Then("generated source creates the map with the selector", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Contains("CreateMap()", ScenarioExpect.Single(result.GeneratedSources)); + ScenarioExpect.Contains("IdentityMap.Create(SelectKey).Build()", result.GeneratedSources[0]); + }) + .AssertPassed(); + + [Scenario("Generator emits internal struct factory in the global namespace")] + [Fact] + public Task Generator_Emits_Internal_Struct_Factory_In_The_Global_Namespace() + => Given("a valid internal struct declaration", () => Compile(""" + using PatternKit.Generators.IdentityMap; + public sealed record Order(string Id); + [GenerateIdentityMap(typeof(Order), typeof(string))] + internal partial struct OrderIdentityMap + { + [IdentityMapKeySelector] + private static string SelectKey(Order order) => order.Id; + } + """)) + .Then("generated source keeps the declaration shape and default factory name", result => + { + ScenarioExpect.Empty(result.Diagnostics); + ScenarioExpect.Contains("internal partial struct OrderIdentityMap", ScenarioExpect.Single(result.GeneratedSources)); + ScenarioExpect.Contains("Create()", result.GeneratedSources[0]); + ScenarioExpect.DoesNotContain("namespace", result.GeneratedSources[0]); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid identity map declarations")] + [Theory] + [InlineData("public static class OrderIdentityMap { [IdentityMapKeySelector] private static string SelectKey(Order order) => order.Id; }", "PKIM001")] + [InlineData("public static partial class OrderIdentityMap;", "PKIM002")] + [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static string SelectKey(Order order) => order.Id; [IdentityMapKeySelector] private static string SelectAlternate(Order order) => order.Id; }", "PKIM002")] + [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private static int SelectKey(Order order) => 1; }", "PKIM003")] + [InlineData("public static partial class OrderIdentityMap { [IdentityMapKeySelector] private string SelectKey(Order order) => order.Id; }", "PKIM003")] + public Task Generator_Reports_Invalid_Identity_Map_Declarations(string declaration, string diagnosticId) + => Given("an invalid identity map declaration", () => Compile($$""" + using PatternKit.Generators.IdentityMap; + public sealed record Order(string Id); + [GenerateIdentityMap(typeof(Order), typeof(string))] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "IdentityMapGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(IdentityMap<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new IdentityMapGenerator(), 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/IdentityMap/IdentityMapTests.cs b/test/PatternKit.Tests/Application/IdentityMap/IdentityMapTests.cs new file mode 100644 index 0000000..ed73e8d --- /dev/null +++ b/test/PatternKit.Tests/Application/IdentityMap/IdentityMapTests.cs @@ -0,0 +1,143 @@ +using PatternKit.Application.IdentityMap; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.IdentityMap; + +[Feature("Identity Map")] +public sealed partial class IdentityMapTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Identity Map reuses object instances for the same key")] + [Fact] + public Task Identity_Map_Reuses_Object_Instances_For_The_Same_Key() + => Given("an identity map", () => PatternKit.Application.IdentityMap.IdentityMap.Create(static order => order.Id).Build()) + .When("loading the same key twice", map => + { + var first = map.GetOrAdd("order-100", static id => new Order(id, 125m)); + var second = map.GetOrAdd("order-100", static id => new Order(id, 999m)); + return new { Map = map, First = first, Second = second }; + }) + .Then("the same instance is returned", result => + { + ScenarioExpect.True(ReferenceEquals(result.First, result.Second)); + ScenarioExpect.Equal(1, result.Map.Count); + ScenarioExpect.Equal(125m, result.Second.Total); + }) + .AssertPassed(); + + [Scenario("Identity Map rejects duplicate keys with different instances")] + [Fact] + public Task Identity_Map_Rejects_Duplicate_Keys_With_Different_Instances() + => Given("an identity map tracking one entity", () => + { + var map = PatternKit.Application.IdentityMap.IdentityMap.Create(static order => order.Id).Build(); + var tracked = map.Track(new Order("order-100", 125m)); + return new { Map = map, Tracked = tracked }; + }) + .When("a different instance with the same key is tracked", ctx => ctx.Map.Track(new Order("order-100", 200m))) + .Then("the duplicate is rejected", result => + { + ScenarioExpect.Equal(IdentityMapStatus.Conflict, result.Status); + ScenarioExpect.False(result.Succeeded); + }) + .AssertPassed(); + + [Scenario("Identity Map returns existing result when tracking the same instance again")] + [Fact] + public Task Identity_Map_Returns_Existing_Result_When_Tracking_The_Same_Instance_Again() + => Given("an identity map and an order instance", () => + { + var map = PatternKit.Application.IdentityMap.IdentityMap.Create(static order => order.Id).Build(); + var order = new Order("order-100", 125m); + var tracked = map.Track(order); + return new { Map = map, Order = order, Tracked = tracked }; + }) + .When("the same instance is tracked again", ctx => ctx.Map.Track(ctx.Order)) + .Then("the existing entity is returned as a successful result", result => + { + ScenarioExpect.Equal(IdentityMapStatus.Existing, result.Status); + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Null(result.Reason); + }) + .AssertPassed(); + + [Scenario("Identity Map reads tracked entities by key")] + [Fact] + public Task Identity_Map_Reads_Tracked_Entities_By_Key() + => Given("an identity map with a tracked entity", () => + { + var map = PatternKit.Application.IdentityMap.IdentityMap.Create().Build(); + var order = new Order("order-100", 125m); + var result = map.Track("order-100", order); + return new { Map = map, Order = order, Result = result }; + }) + .Then("the entity can be retrieved and the result reports a new track", ctx => + { + ScenarioExpect.Equal(IdentityMapStatus.Tracked, ctx.Result.Status); + ScenarioExpect.True(ctx.Result.Succeeded); + ScenarioExpect.Null(ctx.Result.Reason); + ScenarioExpect.True(ReferenceEquals(ctx.Order, ctx.Map.Get("order-100"))); + ScenarioExpect.Null(ctx.Map.Get("missing")); + }) + .AssertPassed(); + + [Scenario("Identity Map can remove and clear tracked entities")] + [Fact] + public Task Identity_Map_Can_Remove_And_Clear_Tracked_Entities() + => Given("an identity map with two entities", () => + { + var map = PatternKit.Application.IdentityMap.IdentityMap.Create().Build(); + map.Track("order-100", new Order("order-100", 125m)); + map.Track("order-101", new Order("order-101", 25m)); + return map; + }) + .Then("entities can be removed and cleared", map => + { + ScenarioExpect.True(map.Remove("order-100")); + ScenarioExpect.Equal(1, map.Count); + map.Clear(); + ScenarioExpect.Equal(0, map.Count); + }) + .AssertPassed(); + + [Scenario("Identity Map requires a selector for implicit tracking")] + [Fact] + public Task Identity_Map_Requires_A_Selector_For_Implicit_Tracking() + => Given("an identity map without a key selector", () => PatternKit.Application.IdentityMap.IdentityMap.Create().Build()) + .Then("implicit tracking is rejected", map => + ScenarioExpect.Throws(() => map.Track(new Order("order-100", 125m)))) + .AssertPassed(); + + [Scenario("Identity Map validates required arguments")] + [Fact] + public Task Identity_Map_Validates_Required_Arguments() + => Given("an identity map", () => PatternKit.Application.IdentityMap.IdentityMap.Create().Build()) + .Then("null arguments are rejected", map => + { + ScenarioExpect.Throws(() => map.Get(null!)); + ScenarioExpect.Throws(() => map.Track(null!, new Order("order-100", 125m))); + ScenarioExpect.Throws(() => map.Track("order-100", null!)); + ScenarioExpect.Throws(() => map.GetOrAdd(null!, static id => new Order(id, 125m))); + ScenarioExpect.Throws(() => map.GetOrAdd("order-100", null!)); + ScenarioExpect.Throws(() => map.Remove(null!)); + ScenarioExpect.Throws(() => PatternKit.Application.IdentityMap.IdentityMap.Create().UseComparer(null!)); + }) + .AssertPassed(); + + [Scenario("Identity Map conflict result requires a reason")] + [Fact] + public Task Identity_Map_Conflict_Result_Requires_A_Reason() + => Given("an order", () => new Order("order-100", 125m)) + .Then("blank conflict reasons are rejected", order => + { + ScenarioExpect.Throws(() => IdentityMapResult.Conflict(order, "")); + var conflict = IdentityMapResult.Conflict(order, "already tracked"); + ScenarioExpect.Equal(IdentityMapStatus.Conflict, conflict.Status); + ScenarioExpect.False(conflict.Succeeded); + ScenarioExpect.Equal("already tracked", conflict.Reason); + }) + .AssertPassed(); + + private sealed record Order(string Id, decimal Total); +}