From d6b713b97e9df35d678996fb11f5d5527aa1212a Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 22:21:36 -0500 Subject: [PATCH] feat: add repository pattern support --- docs/examples/order-repository-pattern.md | 19 ++ docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/repository.md | 26 +++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/application/repository.md | 35 ++++ docs/patterns/toc.yml | 2 + .../Application/Repository/Repository.cs | 185 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 12 ++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../RepositoryDemo/OrderRepositoryDemo.cs | 90 +++++++++ .../Repository/RepositoryAttributes.cs | 18 ++ .../AnalyzerReleases.Unshipped.md | 3 + .../Repository/RepositoryGenerator.cs | 146 ++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 3 + .../PatternKitPatternCatalogTests.cs | 3 +- .../OrderRepositoryDemoTests.cs | 57 ++++++ .../AbstractionsAttributeCoverageTests.cs | 13 ++ .../RepositoryGeneratorTests.cs | 114 +++++++++++ .../Application/Repository/RepositoryTests.cs | 83 ++++++++ 22 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-repository-pattern.md create mode 100644 docs/generators/repository.md create mode 100644 docs/patterns/application/repository.md create mode 100644 src/PatternKit.Core/Application/Repository/Repository.cs create mode 100644 src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Repository/RepositoryAttributes.cs create mode 100644 src/PatternKit.Generators/Repository/RepositoryGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/Repository/RepositoryTests.cs diff --git a/docs/examples/order-repository-pattern.md b/docs/examples/order-repository-pattern.md new file mode 100644 index 0000000..61a6aee --- /dev/null +++ b/docs/examples/order-repository-pattern.md @@ -0,0 +1,19 @@ +# Order Repository Pattern + +The order repository example models an application persistence boundary that stores orders, queries through a `Specification`, rejects duplicate keys, and can be imported through `IServiceCollection`. + +```csharp +var services = new ServiceCollection(); +services.AddOrderRepositoryDemo(); + +using var provider = services.BuildServiceProvider(); +var workflow = provider.GetRequiredService(); +var summary = await workflow.RunAsync(); +``` + +The example includes fluent and source-generated repository factories plus TinyBDD coverage for add/get/query, duplicate handling, and DI import. + +Files: + +- `src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs` +- `test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index ba63315..22a4406 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -79,6 +79,9 @@ - name: Loan Approval Specifications href: loan-approval-specifications.md +- name: Order Repository Pattern + href: order-repository-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index aaeb261..dd230ed 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -59,6 +59,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**State Machine**](state-machine.md) | Deterministic finite state machines | `[StateMachine]` | | [**Strategy**](strategy.md) | Predicate-based dispatch with fluent builder | `[GenerateStrategy]` | | [**Specification**](specification.md) | Named business-rule registries | `[GenerateSpecificationRegistry]` | +| [**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]` | | [**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/repository.md b/docs/generators/repository.md new file mode 100644 index 0000000..3886a95 --- /dev/null +++ b/docs/generators/repository.md @@ -0,0 +1,26 @@ +# Repository Generator + +`[GenerateRepository]` emits a factory for `InMemoryRepository` from a partial host and a key selector method. + +```csharp +using PatternKit.Generators.Repository; + +[GenerateRepository(typeof(OrderRecord), typeof(string), FactoryName = "CreateRepository")] +public static partial class GeneratedOrderRepository +{ + [RepositoryKeySelector] + private static string SelectKey(OrderRecord order) => order.OrderId; +} +``` + +## Diagnostics + +| ID | Meaning | +| --- | --- | +| `PKREP001` | The host type marked with `[GenerateRepository]` must be partial. | +| `PKREP002` | The host must declare exactly one `[RepositoryKeySelector]` method. | +| `PKREP003` | The key selector must be static and return `TKey` from one `TEntity` parameter. | + +## Example + +See [Order Repository Pattern](../examples/order-repository-pattern.md). diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a104e74..a885b3f 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -82,6 +82,9 @@ - name: Rate Limiting href: rate-limiting.md +- name: Repository + href: repository.md + - name: Retry href: retry.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 6fcc431..190645f 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -67,6 +67,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | +| Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/repository.md b/docs/patterns/application/repository.md new file mode 100644 index 0000000..6442665 --- /dev/null +++ b/docs/patterns/application/repository.md @@ -0,0 +1,35 @@ +# Repository + +Repository gives application code a collection-like persistence boundary around domain entities. PatternKit's runtime repository is async-first, supports specification filtering, and has deterministic mutation results for duplicate adds and missing updates. + +```csharp +var repository = InMemoryRepository + .Create(order => order.OrderId) + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); + +await repository.AddAsync(order, ct); +var pending = await repository.FindAsync(PendingOrderSpecification, ct); +``` + +Use `IRepository` at application boundaries and keep durable persistence application-owned. `InMemoryRepository` is useful for tests, examples, and embedded adapters. + +## Source Generator + +`[GenerateRepository]` emits an in-memory repository factory from a static key selector: + +```csharp +[GenerateRepository(typeof(OrderRecord), typeof(string), FactoryName = "CreateRepository")] +public static partial class GeneratedOrderRepository +{ + [RepositoryKeySelector] + private static string SelectKey(OrderRecord order) => order.OrderId; +} +``` + +See [Repository Generator](../../generators/repository.md). + +## Example + +- `src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs` +- `test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs` diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index a3e927b..a9901f9 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -333,6 +333,8 @@ items: - name: Anti-Corruption Layer href: application/anti-corruption-layer.md + - name: Repository + href: application/repository.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/Repository/Repository.cs b/src/PatternKit.Core/Application/Repository/Repository.cs new file mode 100644 index 0000000..577038f --- /dev/null +++ b/src/PatternKit.Core/Application/Repository/Repository.cs @@ -0,0 +1,185 @@ +using PatternKit.Application.Specification; + +namespace PatternKit.Application.Repository; + +/// +/// Async collection-like persistence boundary for domain entities. +/// +public interface IRepository + where TKey : notnull +{ + /// Adds a new entity and rejects duplicate keys. + ValueTask> AddAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Gets an entity by key. + ValueTask GetAsync(TKey key, CancellationToken cancellationToken = default); + + /// Lists all tracked entities. + ValueTask> ListAsync(CancellationToken cancellationToken = default); + + /// Lists entities matching a specification. + ValueTask> FindAsync(ISpecification specification, CancellationToken cancellationToken = default); + + /// Replaces an existing entity and rejects missing keys. + ValueTask> UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Removes an entity by key. + ValueTask RemoveAsync(TKey key, CancellationToken cancellationToken = default); +} + +/// In-memory repository implementation for tests, samples, and embedded applications. +public sealed class InMemoryRepository : IRepository + where TKey : notnull +{ + private readonly Dictionary _entities; + private readonly Func _keySelector; + + private InMemoryRepository(Func keySelector, IEqualityComparer? comparer) + { + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + _entities = new Dictionary(comparer); + } + + /// Creates an in-memory repository builder. + public static Builder Create(Func keySelector) + => new(keySelector); + + /// + public ValueTask> AddAsync(TEntity entity, CancellationToken cancellationToken = default) + { + if (entity is null) + throw new ArgumentNullException(nameof(entity)); + + cancellationToken.ThrowIfCancellationRequested(); + var key = _keySelector(entity); + if (_entities.ContainsKey(key)) + return new ValueTask>(RepositoryResult.Conflict(entity, $"Entity with key '{key}' already exists.")); + + _entities[key] = entity; + return new ValueTask>(RepositoryResult.Stored(entity)); + } + + /// + public ValueTask GetAsync(TKey key, CancellationToken cancellationToken = default) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + cancellationToken.ThrowIfCancellationRequested(); + _entities.TryGetValue(key, out var entity); + return new ValueTask(entity); + } + + /// + public ValueTask> ListAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask>(_entities.Values.ToArray()); + } + + /// + public ValueTask> FindAsync(ISpecification specification, CancellationToken cancellationToken = default) + { + if (specification is null) + throw new ArgumentNullException(nameof(specification)); + + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask>(_entities.Values.Where(specification.IsSatisfiedBy).ToArray()); + } + + /// + public ValueTask> UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + if (entity is null) + throw new ArgumentNullException(nameof(entity)); + + cancellationToken.ThrowIfCancellationRequested(); + var key = _keySelector(entity); + if (!_entities.ContainsKey(key)) + return new ValueTask>(RepositoryResult.Missing(entity, $"Entity with key '{key}' was not found.")); + + _entities[key] = entity; + return new ValueTask>(RepositoryResult.Stored(entity)); + } + + /// + public ValueTask RemoveAsync(TKey key, CancellationToken cancellationToken = default) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_entities.Remove(key)); + } + + /// Fluent builder for in-memory repositories. + public sealed class Builder + { + private readonly Func _keySelector; + private IEqualityComparer? _comparer; + + internal Builder(Func keySelector) + { + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + } + + /// Uses a custom key comparer. + public Builder UseComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + /// Builds the repository. + public InMemoryRepository Build() + => new(_keySelector, _comparer); + } +} + +/// Result returned by repository mutation operations. +public sealed class RepositoryResult +{ + private RepositoryResult(TEntity entity, RepositoryStatus status, string? reason) + { + Entity = entity; + Status = status; + Reason = reason; + } + + /// The entity supplied to the repository operation. + public TEntity Entity { get; } + + /// Operation status. + public RepositoryStatus Status { get; } + + /// Gets whether the entity was stored. + public bool Succeeded => Status == RepositoryStatus.Stored; + + /// Conflict or missing reason when the operation did not store the entity. + public string? Reason { get; } + + /// Creates a successful mutation result. + public static RepositoryResult Stored(TEntity entity) + => new(entity, RepositoryStatus.Stored, null); + + /// Creates a duplicate-key result. + public static RepositoryResult Conflict(TEntity entity, string reason) + => new(entity, RepositoryStatus.Conflict, ValidateReason(reason)); + + /// Creates a missing-entity result. + public static RepositoryResult Missing(TEntity entity, string reason) + => new(entity, RepositoryStatus.Missing, ValidateReason(reason)); + + private static string ValidateReason(string reason) + => string.IsNullOrWhiteSpace(reason) + ? throw new ArgumentException("Repository result reason is required.", nameof(reason)) + : reason; +} + +/// Repository mutation status. +public enum RepositoryStatus +{ + Stored, + Conflict, + Missing +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index a0c8c73..c3e53a2 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ using PatternKit.Examples.PrototypeDemo; using PatternKit.Examples.ProxyDemo; using PatternKit.Examples.RateLimitingDemo; +using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.RetryDemo; using PatternKit.Examples.Singleton; using PatternKit.Examples.SpecificationDemo; @@ -120,6 +121,7 @@ public sealed record ResilientCheckoutMailboxesExample(Func> RunAsync); public sealed record GeneratedInterpreterRulesExample(Interpreter Pricing, Interpreter Eligibility); public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); +public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow 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); @@ -176,6 +178,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddMessagingBackplaneFacadeExample() .AddGeneratedInterpreterRulesExample() .AddLoanApprovalSpecificationsExample() + .AddOrderRepositoryPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -508,6 +511,15 @@ public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServ return services.RegisterExample("Loan Approval Specifications", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddOrderRepositoryPatternExample(this IServiceCollection services) + { + services.AddOrderRepositoryDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Repository 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 ec9c89b..3885651 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -296,6 +296,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Specification"], ["composable business rules", "source-generated registry", "DI composition"]), + Descriptor( + "Order Repository Pattern", + "src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs", + "test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs", + "docs/examples/order-repository-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Repository"], + ["collection-like persistence boundary", "source-generated repository 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 1beaa31..059163f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -661,6 +661,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs", ["fluent specification composition", "generated specification registry", "DI-importable loan approval example"]), + Pattern("Repository", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/repository.md", + "src/PatternKit.Core/Application/Repository/Repository.cs", + "test/PatternKit.Tests/Application/Repository/RepositoryTests.cs", + "docs/generators/repository.md", + "src/PatternKit.Generators/Repository/RepositoryGenerator.cs", + "test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs", + null, + "docs/examples/order-repository-pattern.md", + "src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs", + "test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs", + ["fluent async repository", "generated repository 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.Examples/RepositoryDemo/OrderRepositoryDemo.cs b/src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs new file mode 100644 index 0000000..1acfa5e --- /dev/null +++ b/src/PatternKit.Examples/RepositoryDemo/OrderRepositoryDemo.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Repository; +using PatternKit.Application.Specification; +using PatternKit.Generators.Repository; + +namespace PatternKit.Examples.RepositoryDemo; + +/// Production-shaped repository example for order persistence boundaries. +public static class OrderRepositoryDemo +{ + public static async ValueTask RunFluentAsync() + { + var repository = OrderRepositoryPolicies.CreateFluentRepository(); + return await SeedAndQueryAsync(repository); + } + + public static async ValueTask RunGeneratedAsync() + { + var repository = GeneratedOrderRepository.CreateRepository(); + return await SeedAndQueryAsync(repository); + } + + internal static async ValueTask SeedAndQueryAsync(IRepository repository) + { + var pending = Specification.Where("pending", static order => order.Status == "Pending"); + + var first = await repository.AddAsync(new OrderRecord("order-100", "customer-1", "Pending", 125m)); + _ = await repository.AddAsync(new OrderRecord("order-101", "customer-2", "Closed", 25m)); + var duplicate = await repository.AddAsync(new OrderRecord("order-100", "customer-1", "Pending", 125m)); + var pendingOrders = await repository.FindAsync(pending); + var loaded = await repository.GetAsync("order-100"); + + return new OrderRepositorySummary( + first.Succeeded, + duplicate.Status == RepositoryStatus.Conflict, + pendingOrders.Count, + loaded?.CustomerId ?? string.Empty); + } +} + +public sealed record OrderRecord(string OrderId, string CustomerId, string Status, decimal Total); + +public sealed record OrderRepositorySummary( + bool Stored, + bool DuplicateRejected, + int PendingCount, + string LoadedCustomerId); + +public static class OrderRepositoryPolicies +{ + public static InMemoryRepository CreateFluentRepository() + => InMemoryRepository.Create(static order => order.OrderId) + .UseComparer(StringComparer.OrdinalIgnoreCase) + .Build(); +} + +public sealed class OrderRepositoryWorkflow +{ + private readonly IRepository _repository; + + public OrderRepositoryWorkflow(IRepository repository) + { + _repository = repository; + } + + public ValueTask RunAsync() + => OrderRepositoryDemo.SeedAndQueryAsync(_repository); +} + +public sealed record OrderRepositoryDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderRepositoryServiceCollectionExtensions +{ + public static IServiceCollection AddOrderRepositoryDemo(this IServiceCollection services) + { + services.AddSingleton>(_ => OrderRepositoryPolicies.CreateFluentRepository()); + services.AddSingleton(); + services.AddSingleton(new OrderRepositoryDemoRunner(OrderRepositoryDemo.RunFluentAsync, OrderRepositoryDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateRepository(typeof(OrderRecord), typeof(string), FactoryName = "CreateRepository")] +public static partial class GeneratedOrderRepository +{ + [RepositoryKeySelector] + private static string SelectKey(OrderRecord order) => order.OrderId; +} diff --git a/src/PatternKit.Generators.Abstractions/Repository/RepositoryAttributes.cs b/src/PatternKit.Generators.Abstractions/Repository/RepositoryAttributes.cs new file mode 100644 index 0000000..5f3b879 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Repository/RepositoryAttributes.cs @@ -0,0 +1,18 @@ +using System; + +namespace PatternKit.Generators.Repository; + +/// +/// Generates an in-memory repository factory for an entity and key type. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateRepositoryAttribute(Type entityType, Type keyType) : Attribute +{ + public Type EntityType { get; } = entityType ?? throw new ArgumentNullException(nameof(entityType)); + public Type KeyType { get; } = keyType ?? throw new ArgumentNullException(nameof(keyType)); + public string FactoryName { get; set; } = "Create"; +} + +/// Marks the static method used to select repository keys from entities. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class RepositoryKeySelectorAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 6c0fc32..6e6a55a 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -214,6 +214,9 @@ PKCB003 | PatternKit.Generators.CircuitBreaker | Error | Circuit breaker predica PKCB004 | PatternKit.Generators.CircuitBreaker | Error | Circuit breaker predicate declaration is duplicated. PKRLT001 | PatternKit.Generators.RateLimiting | Error | Rate-limit policy host must be partial. PKRLT002 | PatternKit.Generators.RateLimiting | Error | Rate-limit policy configuration is invalid. +PKREP001 | PatternKit.Generators.Repository | Error | Repository host must be partial. +PKREP002 | PatternKit.Generators.Repository | Error | Repository must declare exactly one key selector. +PKREP003 | PatternKit.Generators.Repository | Error | Repository key selector signature is invalid. PKAF001 | PatternKit.Generators.Factories | Error | Abstract factory host must be partial. PKAF002 | PatternKit.Generators.Factories | Error | Abstract factory must declare at least one product. PKAF003 | PatternKit.Generators.Factories | Error | Abstract factory product declaration is invalid. diff --git a/src/PatternKit.Generators/Repository/RepositoryGenerator.cs b/src/PatternKit.Generators/Repository/RepositoryGenerator.cs new file mode 100644 index 0000000..a4b6a01 --- /dev/null +++ b/src/PatternKit.Generators/Repository/RepositoryGenerator.cs @@ -0,0 +1,146 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Repository; + +[Generator] +public sealed class RepositoryGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.Repository.GenerateRepositoryAttribute"; + private const string KeySelectorAttributeName = "PatternKit.Generators.Repository.RepositoryKeySelectorAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKREP001", + "Repository host must be partial", + "Type '{0}' is marked with [GenerateRepository] but is not declared as partial", + "PatternKit.Generators.Repository", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingKeySelector = new( + "PKREP002", + "Repository key selector is missing", + "Repository '{0}' must declare exactly one [RepositoryKeySelector] method", + "PatternKit.Generators.Repository", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidKeySelector = new( + "PKREP003", + "Repository key selector signature is invalid", + "Repository key selector '{0}' must be static and return TKey from one TEntity parameter", + "PatternKit.Generators.Repository", + 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}.Repository.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.Repository.InMemoryRepository<") + .Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.Repository.InMemoryRepository<") + .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 691f5b0..32d8e22 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -10,6 +10,7 @@ using PatternKit.Examples.PointOfSale; using PatternKit.Examples.ProductionReadiness; using PatternKit.Examples.RateLimitingDemo; +using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.Strategies.Composed; using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; @@ -98,6 +99,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var checkout = provider.GetRequiredService(); var interpreter = provider.GetRequiredService(); var specifications = provider.GetRequiredService(); + var orderRepository = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -168,6 +170,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("generated interpreter computes tier discounts", interpreter.Pricing.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.TierDiscountRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 100m, CustomerTier = "Gold" }) == 10m), ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })), ("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), ("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 641c61b..15260cc 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -61,6 +61,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Rate Limiting", "CQRS", "Specification", + "Repository", "Anti-Corruption Layer" ]; @@ -105,7 +106,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(3, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(4, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs b/test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs new file mode 100644 index 0000000..a16981c --- /dev/null +++ b/test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.RepositoryDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.RepositoryDemo; + +[Feature("Order repository example")] +public sealed class OrderRepositoryDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent repository example stores queries and rejects duplicates")] + [Fact] + public Task Fluent_Repository_Example_Stores_Queries_And_Rejects_Duplicates() + => Given("the fluent order repository example", OrderRepositoryDemo.RunFluentAsync) + .Then("the repository behaves like a collection boundary", summary => + { + ScenarioExpect.True(summary.Stored); + ScenarioExpect.True(summary.DuplicateRejected); + ScenarioExpect.Equal(1, summary.PendingCount); + ScenarioExpect.Equal("customer-1", summary.LoadedCustomerId); + }) + .AssertPassed(); + + [Scenario("Generated repository example stores queries and rejects duplicates")] + [Fact] + public Task Generated_Repository_Example_Stores_Queries_And_Rejects_Duplicates() + => Given("the generated order repository example", OrderRepositoryDemo.RunGeneratedAsync) + .Then("the generated repository factory supports the same workflow", summary => + { + ScenarioExpect.True(summary.Stored); + ScenarioExpect.True(summary.DuplicateRejected); + ScenarioExpect.Equal(1, summary.PendingCount); + }) + .AssertPassed(); + + [Scenario("Repository example is importable through IServiceCollection")] + [Fact] + public Task Repository_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the order repository example", () => + { + var services = new ServiceCollection(); + services.AddOrderRepositoryDemo(); + 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 uses the registered repository", summary => + { + ScenarioExpect.True(summary.Stored); + ScenarioExpect.True(summary.DuplicateRejected); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 7046bc8..08ad26b 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -18,6 +18,7 @@ using PatternKit.Generators.Prototype; using PatternKit.Generators.Proxy; using PatternKit.Generators.RateLimiting; +using PatternKit.Generators.Repository; using PatternKit.Generators.Retry; using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; @@ -144,6 +145,8 @@ private enum TestTrigger { typeof(GenerateProxyAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, { typeof(GenerateRateLimitPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GenerateRepositoryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(RepositoryKeySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateRetryPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(RetryResultPredicateAttribute), AttributeTargets.Method, false, false }, { typeof(RetryExceptionPredicateAttribute), AttributeTargets.Method, false, false }, @@ -236,6 +239,10 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() PermitLimit = 10, WindowMilliseconds = 1000 }; + var repository = new GenerateRepositoryAttribute(typeof(string), typeof(Guid)) + { + FactoryName = "BuildRepository" + }; ScenarioExpect.Equal(typeof(string), rateLimit.ResultType); ScenarioExpect.Equal("BuildSearchLimit", rateLimit.FactoryMethodName); @@ -243,6 +250,12 @@ public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal(10, rateLimit.PermitLimit); ScenarioExpect.Equal(1000, rateLimit.WindowMilliseconds); ScenarioExpect.Throws(() => new GenerateRateLimitPolicyAttribute(null!)); + ScenarioExpect.Equal(typeof(string), repository.EntityType); + ScenarioExpect.Equal(typeof(Guid), repository.KeyType); + ScenarioExpect.Equal("BuildRepository", repository.FactoryName); + ScenarioExpect.Throws(() => new GenerateRepositoryAttribute(null!, typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateRepositoryAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new RepositoryKeySelectorAttribute()); } [Scenario("Bulkhead Attributes Expose Defaults And Configuration")] diff --git a/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs b/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs new file mode 100644 index 0000000..9fd327a --- /dev/null +++ b/test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs @@ -0,0 +1,114 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Application.Repository; +using PatternKit.Generators.Repository; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class RepositoryGeneratorTests +{ + [Scenario("Generates repository factory")] + [Fact] + public void GeneratesRepositoryFactory() + { + var source = """ + using PatternKit.Generators.Repository; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateRepository(typeof(Order), typeof(string), FactoryName = "Build")] + public static partial class OrderRepositoryFactory + { + [RepositoryKeySelector] + private static string SelectKey(Order order) => order.Id; + } + + public static class Demo + { + public static object Run() => OrderRepositoryFactory.Build(); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesRepositoryFactory)); + var gen = new RepositoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderRepositoryFactory.Repository.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build", text); + ScenarioExpect.Contains("InMemoryRepository.Create(SelectKey).Build()", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("Reports diagnostic for non partial repository")] + [Fact] + public void ReportsDiagnosticForNonPartialRepository() + { + var source = """ + using PatternKit.Generators.Repository; + public sealed record Order(string Id); + [GenerateRepository(typeof(Order), typeof(string))] + public static class OrderRepositoryFactory; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialRepository)); + var gen = new RepositoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKREP001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing repository key selector")] + [Fact] + public void ReportsDiagnosticForMissingRepositoryKeySelector() + { + var source = """ + using PatternKit.Generators.Repository; + public sealed record Order(string Id); + [GenerateRepository(typeof(Order), typeof(string))] + public static partial class OrderRepositoryFactory; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingRepositoryKeySelector)); + var gen = new RepositoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKREP002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid repository key selector")] + [Fact] + public void ReportsDiagnosticForInvalidRepositoryKeySelector() + { + var source = """ + using PatternKit.Generators.Repository; + public sealed record Order(string Id); + [GenerateRepository(typeof(Order), typeof(string))] + public static partial class OrderRepositoryFactory + { + [RepositoryKeySelector] + private static int SelectKey(Order order) => 1; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRepositoryKeySelector)); + var gen = new RepositoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKREP003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(InMemoryRepository<,>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Application/Repository/RepositoryTests.cs b/test/PatternKit.Tests/Application/Repository/RepositoryTests.cs new file mode 100644 index 0000000..de083ce --- /dev/null +++ b/test/PatternKit.Tests/Application/Repository/RepositoryTests.cs @@ -0,0 +1,83 @@ +using PatternKit.Application.Repository; +using PatternKit.Application.Specification; +using TinyBDD; + +namespace PatternKit.Tests.Application.Repository; + +public sealed class RepositoryTests +{ + [Scenario("Repository Adds Gets Lists And Filters Entities")] + [Fact] + public async Task Repository_Adds_Gets_Lists_And_Filters_Entities() + { + var repository = InMemoryRepository.Create(static order => order.Id).Build(); + var open = Specification.Where("open", static order => order.Status == "Open"); + + var stored = await repository.AddAsync(new Order("order-1", "Open", 125m)); + _ = await repository.AddAsync(new Order("order-2", "Closed", 25m)); + + var loaded = await repository.GetAsync("order-1"); + var all = await repository.ListAsync(); + var openOrders = await repository.FindAsync(open); + + ScenarioExpect.True(stored.Succeeded); + ScenarioExpect.Equal("order-1", loaded!.Id); + ScenarioExpect.Equal(2, all.Count); + var match = ScenarioExpect.Single(openOrders); + ScenarioExpect.Equal("order-1", match.Id); + } + + [Scenario("Repository RejectsDuplicateAddsAndMissingUpdates")] + [Fact] + public async Task Repository_RejectsDuplicateAddsAndMissingUpdates() + { + var repository = InMemoryRepository.Create(static order => order.Id).Build(); + var order = new Order("order-1", "Open", 125m); + _ = await repository.AddAsync(order); + + var duplicate = await repository.AddAsync(order); + var missing = await repository.UpdateAsync(new Order("missing", "Open", 10m)); + + ScenarioExpect.Equal(RepositoryStatus.Conflict, duplicate.Status); + ScenarioExpect.False(duplicate.Succeeded); + ScenarioExpect.Contains("already exists", duplicate.Reason!); + ScenarioExpect.Equal(RepositoryStatus.Missing, missing.Status); + ScenarioExpect.Contains("was not found", missing.Reason!); + } + + [Scenario("Repository UpdatesAndRemovesExistingEntities")] + [Fact] + public async Task Repository_UpdatesAndRemovesExistingEntities() + { + var repository = InMemoryRepository.Create(static order => order.Id).Build(); + _ = await repository.AddAsync(new Order("order-1", "Open", 125m)); + + var updated = await repository.UpdateAsync(new Order("order-1", "Closed", 125m)); + var loaded = await repository.GetAsync("order-1"); + var removed = await repository.RemoveAsync("order-1"); + var afterRemove = await repository.GetAsync("order-1"); + + ScenarioExpect.True(updated.Succeeded); + ScenarioExpect.Equal("Closed", loaded!.Status); + ScenarioExpect.True(removed); + ScenarioExpect.Null(afterRemove); + } + + [Scenario("Repository ValidatesInputsAndObservesCancellation")] + [Fact] + public async Task Repository_ValidatesInputsAndObservesCancellation() + { + ScenarioExpect.Throws(() => InMemoryRepository.Create(null!)); + ScenarioExpect.Throws(() => InMemoryRepository.Create(static order => order.Id).UseComparer(null!)); + + var repository = InMemoryRepository.Create(static order => order.Id).Build(); + using var source = new CancellationTokenSource(); + source.Cancel(); + + await ScenarioExpect.ThrowsAsync(async () => await repository.AddAsync(null!)); + await ScenarioExpect.ThrowsAsync(async () => await repository.FindAsync(null!)); + await ScenarioExpect.ThrowsAsync(async () => await repository.ListAsync(source.Token)); + } + + private sealed record Order(string Id, string Status, decimal Total); +}