From 5e013942369b4a16282266abd7a28d858fa23d3e Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 21 May 2026 01:49:52 -0500 Subject: [PATCH] feat: add table data gateway pattern support --- .../order-table-data-gateway-pattern.md | 28 +++ docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/table-data-gateway.md | 26 +++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + .../application/table-data-gateway.md | 24 +++ docs/patterns/toc.yml | 2 + .../TableDataGateway/TableDataGateway.cs | 174 ++++++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../OrderTableDataGatewayDemo.cs | 80 ++++++++ .../TableDataGatewayAttributes.cs | 24 +++ .../AnalyzerReleases.Unshipped.md | 3 + .../TableDataGatewayGenerator.cs | 144 +++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 3 + .../PatternKitPatternCatalogTests.cs | 3 +- .../OrderTableDataGatewayDemoTests.cs | 55 ++++++ .../AbstractionsAttributeCoverageTests.cs | 15 ++ .../TableDataGatewayGeneratorTests.cs | 65 +++++++ .../TableDataGateway/TableDataGatewayTests.cs | 63 +++++++ 22 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-table-data-gateway-pattern.md create mode 100644 docs/generators/table-data-gateway.md create mode 100644 docs/patterns/application/table-data-gateway.md create mode 100644 src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs create mode 100644 src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/TableDataGateway/TableDataGatewayAttributes.cs create mode 100644 src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/TableDataGateway/TableDataGatewayTests.cs diff --git a/docs/examples/order-table-data-gateway-pattern.md b/docs/examples/order-table-data-gateway-pattern.md new file mode 100644 index 0000000..b7e1f01 --- /dev/null +++ b/docs/examples/order-table-data-gateway-pattern.md @@ -0,0 +1,28 @@ +# Order Table Data Gateway Pattern + +This production-shaped example shows a row-oriented order table gateway. + +It demonstrates: + +- fluent `InMemoryTableDataGateway` construction +- generated gateway factory with `[GenerateTableDataGateway]` +- row insert, update, query, and delete behavior +- scoped `ITableDataGateway` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddOrderTableDataGatewayDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.CloseAsync("order-100", "customer-1", 125m); +``` + +The registered gateway is scoped so importing applications can compose it with request-scoped storage adapters, database sessions, tenant services, or transaction boundaries. + +Files: + +- `src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs` +- `test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index e337414..78ec0ae 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -100,6 +100,9 @@ - name: Order Domain Event Pattern href: order-domain-event-pattern.md +- name: Order Table Data Gateway Pattern + href: order-table-data-gateway-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 5596e03..cce20e4 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -67,6 +67,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | | [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` | +| [**Table Data Gateway**](table-data-gateway.md) | Row gateway factories from key selectors | `[GenerateTableDataGateway]` | | [**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/table-data-gateway.md b/docs/generators/table-data-gateway.md new file mode 100644 index 0000000..770ae8f --- /dev/null +++ b/docs/generators/table-data-gateway.md @@ -0,0 +1,26 @@ +# Table Data Gateway Generator + +`GenerateTableDataGatewayAttribute` creates a typed `InMemoryTableDataGateway` factory from a key selector method. + +```csharp +[GenerateTableDataGateway(typeof(OrderTableRow), typeof(string), FactoryName = "CreateGateway", TableName = "orders")] +public static partial class GeneratedOrderTableGateway +{ + [TableGatewayKeySelector] + private static string SelectKey(OrderTableRow row) => row.OrderId; +} +``` + +The generated factory is equivalent to: + +```csharp +InMemoryTableDataGateway + .Create("orders", SelectKey) + .Build(); +``` + +Diagnostics: + +- `PKTDG001`: host type must be partial. +- `PKTDG002`: exactly one `[TableGatewayKeySelector]` method is required. +- `PKTDG003`: key selector must be static and return `TKey` from one `TRow` parameter. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index b31da79..de5490d 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -108,6 +108,9 @@ - name: State Machine href: state-machine.md +- name: Table Data Gateway + href: table-data-gateway.md + - name: Strategy href: strategy.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 3ee6fb7..1489859 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -74,6 +74,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Transaction Script | `TransactionScript` | Transaction Script generator | | Application Architecture | Service Layer | `IServiceOperation` and `ServiceLayerOperation` | Service Layer generator | | Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher` | Domain Event generator | +| Application Architecture | Table Data Gateway | `ITableDataGateway` and `InMemoryTableDataGateway` | Table Data Gateway generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/table-data-gateway.md b/docs/patterns/application/table-data-gateway.md new file mode 100644 index 0000000..aa6762d --- /dev/null +++ b/docs/patterns/application/table-data-gateway.md @@ -0,0 +1,24 @@ +# Table Data Gateway + +Table Data Gateway models row-level access to a single table or table-like persistence boundary. Use it when an application needs simple row CRUD and query operations without mapping rows into richer domain objects. + +PatternKit provides `ITableDataGateway` and `InMemoryTableDataGateway` in `PatternKit.Application.TableDataGateway`. + +```csharp +var gateway = InMemoryTableDataGateway + .Create("orders", row => row.OrderId) + .Build(); + +await gateway.InsertAsync(new OrderTableRow("order-100", "customer-1", "Pending", 125m)); +await gateway.UpdateAsync(new OrderTableRow("order-100", "customer-1", "Closed", 125m)); +var closed = await gateway.QueryAsync(row => row.Status == "Closed"); +``` + +The gateway returns `TableGatewayResult` for mutations so callers can distinguish inserted, updated, deleted, conflict, and missing rows. + +Use the source-generated path when the table row type and key selector are stable. Register `ITableDataGateway` as scoped when the gateway is composed with request-scoped storage, transaction, or tenant services. + +See also: + +- [Table Data Gateway generator](../../generators/table-data-gateway.md) +- [Order Table Data Gateway example](../../examples/order-table-data-gateway-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index ac0f228..cc317ce 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -347,6 +347,8 @@ href: application/service-layer.md - name: Domain Event href: application/domain-event.md + - name: Table Data Gateway + href: application/table-data-gateway.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs b/src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs new file mode 100644 index 0000000..20fb03e --- /dev/null +++ b/src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs @@ -0,0 +1,174 @@ +namespace PatternKit.Application.TableDataGateway; + +/// Row-oriented gateway for one table or table-like persistence boundary. +public interface ITableDataGateway + where TKey : notnull +{ + string TableName { get; } + + ValueTask> InsertAsync(TRow row, CancellationToken cancellationToken = default); + + ValueTask GetAsync(TKey key, CancellationToken cancellationToken = default); + + ValueTask> ListAsync(CancellationToken cancellationToken = default); + + ValueTask> QueryAsync(Func predicate, CancellationToken cancellationToken = default); + + ValueTask> UpdateAsync(TRow row, CancellationToken cancellationToken = default); + + ValueTask> DeleteAsync(TKey key, CancellationToken cancellationToken = default); +} + +/// In-memory Table Data Gateway for samples, tests, and embedded applications. +public sealed class InMemoryTableDataGateway : ITableDataGateway + where TKey : notnull +{ + private readonly Dictionary _rows; + private readonly Func _keySelector; + + private InMemoryTableDataGateway(string tableName, Func keySelector, IEqualityComparer? comparer) + { + TableName = tableName; + _keySelector = keySelector; + _rows = new Dictionary(comparer); + } + + public string TableName { get; } + + public static Builder Create(string tableName, Func keySelector) + => new(tableName, keySelector); + + public ValueTask> InsertAsync(TRow row, CancellationToken cancellationToken = default) + { + if (row is null) + throw new ArgumentNullException(nameof(row)); + + cancellationToken.ThrowIfCancellationRequested(); + var key = _keySelector(row); + if (_rows.ContainsKey(key)) + return new(TableGatewayResult.Conflict(row, $"Row with key '{key}' already exists in '{TableName}'.")); + + _rows[key] = row; + return new(TableGatewayResult.Inserted(row)); + } + + public ValueTask GetAsync(TKey key, CancellationToken cancellationToken = default) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + cancellationToken.ThrowIfCancellationRequested(); + _rows.TryGetValue(key, out var row); + return new(row); + } + + public ValueTask> ListAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return new((IReadOnlyList)_rows.Values.ToArray()); + } + + public ValueTask> QueryAsync(Func predicate, CancellationToken cancellationToken = default) + { + if (predicate is null) + throw new ArgumentNullException(nameof(predicate)); + + cancellationToken.ThrowIfCancellationRequested(); + return new((IReadOnlyList)_rows.Values.Where(predicate).ToArray()); + } + + public ValueTask> UpdateAsync(TRow row, CancellationToken cancellationToken = default) + { + if (row is null) + throw new ArgumentNullException(nameof(row)); + + cancellationToken.ThrowIfCancellationRequested(); + var key = _keySelector(row); + if (!_rows.ContainsKey(key)) + return new(TableGatewayResult.Missing(row, $"Row with key '{key}' was not found in '{TableName}'.")); + + _rows[key] = row; + return new(TableGatewayResult.Updated(row)); + } + + public ValueTask> DeleteAsync(TKey key, CancellationToken cancellationToken = default) + { + if (key is null) + throw new ArgumentNullException(nameof(key)); + + cancellationToken.ThrowIfCancellationRequested(); + if (!_rows.TryGetValue(key, out var row)) + return new(TableGatewayResult.Missing(default, $"Row with key '{key}' was not found in '{TableName}'.")); + + _rows.Remove(key); + return new(TableGatewayResult.Deleted(row)); + } + + public sealed class Builder + { + private readonly string _tableName; + private readonly Func _keySelector; + private IEqualityComparer? _comparer; + + internal Builder(string tableName, Func keySelector) + { + _tableName = string.IsNullOrWhiteSpace(tableName) + ? throw new ArgumentException("Table Data Gateway table name is required.", nameof(tableName)) + : tableName; + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + } + + public Builder UseComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + public InMemoryTableDataGateway Build() + => new(_tableName, _keySelector, _comparer); + } +} + +/// Result returned by Table Data Gateway mutation operations. +public sealed class TableGatewayResult +{ + private TableGatewayResult(TRow? row, TableGatewayStatus status, string? reason) + { + Row = row; + Status = status; + Reason = reason; + } + + public TRow? Row { get; } + + public TableGatewayStatus Status { get; } + + public string? Reason { get; } + + public bool Succeeded => Status is TableGatewayStatus.Inserted or TableGatewayStatus.Updated or TableGatewayStatus.Deleted; + + public static TableGatewayResult Inserted(TRow row) => new(row, TableGatewayStatus.Inserted, null); + + public static TableGatewayResult Updated(TRow row) => new(row, TableGatewayStatus.Updated, null); + + public static TableGatewayResult Deleted(TRow row) => new(row, TableGatewayStatus.Deleted, null); + + public static TableGatewayResult Conflict(TRow row, string reason) => new(row, TableGatewayStatus.Conflict, Validate(reason)); + + public static TableGatewayResult Missing(TRow? row, string reason) => new(row, TableGatewayStatus.Missing, Validate(reason)); + + private static string Validate(string reason) + => string.IsNullOrWhiteSpace(reason) + ? throw new ArgumentException("Table Data Gateway result reason is required.", nameof(reason)) + : reason; +} + +/// Mutation status for Table Data Gateway operations. +public enum TableGatewayStatus +{ + Inserted, + Updated, + Deleted, + Conflict, + Missing +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index e25af5e..a8b9aa3 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -47,6 +47,7 @@ using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.Strategies.Coercion; using PatternKit.Examples.Strategies.Composed; +using PatternKit.Examples.TableDataGatewayDemo; using PatternKit.Examples.TemplateDemo; using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; @@ -134,6 +135,7 @@ public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner R public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); +public sealed record OrderTableDataGatewayPatternExample(OrderTableDataGatewayDemoRunner Runner); 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); @@ -197,6 +199,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderTransactionScriptPatternExample() .AddCustomerServiceLayerPatternExample() .AddOrderDomainEventPatternExample() + .AddOrderTableDataGatewayPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -584,6 +587,13 @@ public static IServiceCollection AddOrderDomainEventPatternExample(this IService return services.RegisterExample("Order Domain Event Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderTableDataGatewayPatternExample(this IServiceCollection services) + { + services.AddOrderTableDataGatewayDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Table Data Gateway 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 4b2f93d..326d190 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -352,6 +352,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["DomainEvent"], ["aggregate event dispatch", "source-generated dispatcher factory", "DI composition"]), + Descriptor( + "Order Table Data Gateway Pattern", + "src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs", + "test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs", + "docs/examples/order-table-data-gateway-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["TableDataGateway"], + ["row-oriented table boundary", "source-generated gateway 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 66a9a70..8a485cc 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -752,6 +752,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs", ["fluent domain event dispatcher", "generated handler registry", "DI-importable projection and audit workflow"]), + Pattern("Table Data Gateway", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/table-data-gateway.md", + "src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs", + "test/PatternKit.Tests/Application/TableDataGateway/TableDataGatewayTests.cs", + "docs/generators/table-data-gateway.md", + "src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs", + "test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs", + null, + "docs/examples/order-table-data-gateway-pattern.md", + "src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs", + "test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs", + ["fluent row gateway", "generated table gateway factory", "DI-importable row workflow"]), + 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/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs b/src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs new file mode 100644 index 0000000..c7a4ba0 --- /dev/null +++ b/src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.TableDataGateway; +using PatternKit.Generators.TableDataGateway; + +namespace PatternKit.Examples.TableDataGatewayDemo; + +public static class OrderTableDataGatewayDemo +{ + public static async ValueTask RunFluentAsync() + { + var gateway = OrderTableGatewayPolicies.CreateFluentGateway(); + return await RunScenarioAsync(gateway, "order-100"); + } + + public static async ValueTask RunGeneratedAsync() + => await RunScenarioAsync(GeneratedOrderTableGateway.CreateGateway(), "order-200"); + + private static async ValueTask RunScenarioAsync(ITableDataGateway gateway, string orderId) + { + _ = await gateway.InsertAsync(new OrderTableRow(orderId, "customer-1", "Pending", 125m)); + _ = await gateway.UpdateAsync(new OrderTableRow(orderId, "customer-1", "Closed", 125m)); + var closed = await gateway.QueryAsync(static row => row.Status == "Closed"); + return new(gateway.TableName, closed.Count, ScenarioExpectId(closed)); + } + + private static string ScenarioExpectId(IReadOnlyList rows) + => rows.Count == 0 ? "" : rows[0].OrderId; +} + +public sealed record OrderTableRow(string OrderId, string CustomerId, string Status, decimal Total); + +public sealed record OrderTableGatewaySummary(string TableName, int ClosedOrderCount, string FirstClosedOrderId); + +public static class OrderTableGatewayPolicies +{ + public static InMemoryTableDataGateway CreateFluentGateway() + => InMemoryTableDataGateway.Create("orders", static row => row.OrderId).Build(); +} + +public sealed class OrderTableGatewayWorkflow +{ + private readonly ITableDataGateway _gateway; + + public OrderTableGatewayWorkflow(ITableDataGateway gateway) + { + _gateway = gateway; + } + + public async ValueTask CloseAsync(string orderId, string customerId, decimal total, CancellationToken cancellationToken = default) + { + _ = await _gateway.InsertAsync(new OrderTableRow(orderId, customerId, "Pending", total), cancellationToken).ConfigureAwait(false); + _ = await _gateway.UpdateAsync(new OrderTableRow(orderId, customerId, "Closed", total), cancellationToken).ConfigureAwait(false); + var closed = await _gateway.QueryAsync(static row => row.Status == "Closed", cancellationToken).ConfigureAwait(false); + return new(_gateway.TableName, closed.Count, closed.Count == 0 ? "" : closed[0].OrderId); + } +} + +public sealed record OrderTableDataGatewayDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderTableDataGatewayServiceCollectionExtensions +{ + public static IServiceCollection AddOrderTableDataGatewayDemo(this IServiceCollection services) + { + services.AddScoped>(_ => OrderTableGatewayPolicies.CreateFluentGateway()); + services.AddScoped(); + services.AddSingleton(new OrderTableDataGatewayDemoRunner( + OrderTableDataGatewayDemo.RunFluentAsync, + OrderTableDataGatewayDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateTableDataGateway(typeof(OrderTableRow), typeof(string), FactoryName = "CreateGateway", TableName = "orders")] +public static partial class GeneratedOrderTableGateway +{ + [TableGatewayKeySelector] + private static string SelectKey(OrderTableRow row) => row.OrderId; +} diff --git a/src/PatternKit.Generators.Abstractions/TableDataGateway/TableDataGatewayAttributes.cs b/src/PatternKit.Generators.Abstractions/TableDataGateway/TableDataGatewayAttributes.cs new file mode 100644 index 0000000..525fff0 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/TableDataGateway/TableDataGatewayAttributes.cs @@ -0,0 +1,24 @@ +namespace PatternKit.Generators.TableDataGateway; + +/// Generates an in-memory Table Data Gateway factory from a key selector. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateTableDataGatewayAttribute : Attribute +{ + public GenerateTableDataGatewayAttribute(Type rowType, Type keyType) + { + RowType = rowType ?? throw new ArgumentNullException(nameof(rowType)); + KeyType = keyType ?? throw new ArgumentNullException(nameof(keyType)); + } + + public Type RowType { get; } + + public Type KeyType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string TableName { get; set; } = ""; +} + +/// Marks the key selector method for a generated Table Data Gateway. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class TableGatewayKeySelectorAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index f5ce2fb..744162e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -252,6 +252,9 @@ PKSPEC001 | PatternKit.Generators.Specification | Error | Specification registry PKSPEC002 | PatternKit.Generators.Specification | Error | Specification registry must declare at least one rule. PKSPEC003 | PatternKit.Generators.Specification | Error | Specification rule signature is invalid. PKSPEC004 | PatternKit.Generators.Specification | Error | Specification rule declaration is duplicated. +PKTDG001 | PatternKit.Generators.TableDataGateway | Error | Table Data Gateway host must be partial. +PKTDG002 | PatternKit.Generators.TableDataGateway | Error | Table Data Gateway must declare exactly one key selector. +PKTDG003 | PatternKit.Generators.TableDataGateway | Error | Table Data Gateway key selector signature is invalid. PKRET001 | PatternKit.Generators.Retry | Error | Retry policy host must be partial. PKRET002 | PatternKit.Generators.Retry | Error | Retry policy configuration is invalid. PKRET003 | PatternKit.Generators.Retry | Error | Retry predicate signature is invalid. diff --git a/src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs b/src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs new file mode 100644 index 0000000..3062941 --- /dev/null +++ b/src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs @@ -0,0 +1,144 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.TableDataGateway; + +[Generator] +public sealed class TableDataGatewayGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.TableDataGateway.GenerateTableDataGatewayAttribute"; + private const string KeySelectorAttributeName = "PatternKit.Generators.TableDataGateway.TableGatewayKeySelectorAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKTDG001", "Table Data Gateway host must be partial", + "Type '{0}' is marked with [GenerateTableDataGateway] but is not declared as partial", + "PatternKit.Generators.TableDataGateway", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingKeySelector = new( + "PKTDG002", "Table Data Gateway key selector is missing", + "Table Data Gateway '{0}' must declare exactly one [TableGatewayKeySelector] method", + "PatternKit.Generators.TableDataGateway", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidKeySelector = new( + "PKTDG003", "Table Data Gateway key selector signature is invalid", + "Table Data Gateway key selector '{0}' must be static and return TKey from one TRow parameter", + "PatternKit.Generators.TableDataGateway", 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 rowType = 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 (rowType 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, rowType, keyType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidKeySelector, selector.Locations.FirstOrDefault(), selector.Name)); + return; + } + + var tableName = GetNamedString(attribute, "TableName"); + if (string.IsNullOrWhiteSpace(tableName)) + tableName = type.Name; + + context.AddSource($"{type.Name}.TableDataGateway.g.cs", SourceText.From( + GenerateSource(type, rowType, keyType, selector.Name, GetNamedString(attribute, "FactoryName") ?? "Create", tableName!), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol rowType, + INamedTypeSymbol keyType, + string selectorName, + string factoryName, + string tableName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var rowName = rowType.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.TableDataGateway.InMemoryTableDataGateway<") + .Append(rowName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.TableDataGateway.InMemoryTableDataGateway<") + .Append(rowName).Append(", ").Append(keyName).Append(">.Create(\"").Append(Escape(tableName)).Append("\", ").Append(selectorName).AppendLine(").Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsKeySelector(IMethodSymbol method, INamedTypeSymbol rowType, INamedTypeSymbol keyType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, rowType) + && 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 Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + 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 71af81b..18f4250 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -16,6 +16,7 @@ using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.Strategies.Composed; +using PatternKit.Examples.TableDataGatewayDemo; using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; @@ -112,6 +113,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var transactionScript = provider.GetRequiredService(); var serviceLayer = provider.GetRequiredService(); var domainEvents = provider.GetRequiredService(); + var tableGateway = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -189,6 +191,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("transaction script example submits orders", transactionScript.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Submitted), ("service layer example registers customers", serviceLayer.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Registered), ("domain event example dispatches order events", domainEvents.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Dispatched), + ("table data gateway example queries order rows", tableGateway.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().ClosedOrderCount == 1), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 794907e..39ef9ca 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -68,6 +68,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Transaction Script", "Service Layer", "Domain Event", + "Table Data Gateway", "Anti-Corruption Layer" ]; @@ -112,7 +113,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(10, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(11, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs b/test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs new file mode 100644 index 0000000..dca1246 --- /dev/null +++ b/test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.TableDataGatewayDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.TableDataGatewayDemo; + +[Feature("Order Table Data Gateway demo")] +public sealed partial class OrderTableDataGatewayDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Order Table Data Gateway demo manages order rows")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Order_Table_Data_Gateway_Demo_Manages_Order_Rows(bool sourceGenerated) + => Given("the order table data gateway demo", () => sourceGenerated) + .When("the selected path runs", (Func>)(async generated => + generated + ? await OrderTableDataGatewayDemo.RunGeneratedAsync() + : await OrderTableDataGatewayDemo.RunFluentAsync())) + .Then("the gateway queries closed orders", summary => + { + ScenarioExpect.Equal("orders", summary.TableName); + ScenarioExpect.Equal(1, summary.ClosedOrderCount); + ScenarioExpect.False(string.IsNullOrWhiteSpace(summary.FirstClosedOrderId)); + }) + .AssertPassed(); + + [Scenario("Order Table Data Gateway demo is importable through IServiceCollection")] + [Fact] + public Task Order_Table_Data_Gateway_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service provider with the order table data gateway demo", () => + { + var services = new ServiceCollection(); + services.AddOrderTableDataGatewayDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a scoped workflow closes an order row", (Func>)(async provider => + { + using (provider) + using (var scope = provider.CreateScope()) + { + var workflow = scope.ServiceProvider.GetRequiredService(); + return await workflow.CloseAsync("order-300", "customer-3", 50m); + } + })) + .Then("the imported gateway handles the row workflow", summary => + { + ScenarioExpect.Equal("orders", summary.TableName); + ScenarioExpect.Equal("order-300", summary.FirstClosedOrderId); + ScenarioExpect.Equal(1, summary.ClosedOrderCount); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index e836c04..26a985b 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -27,6 +27,7 @@ using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; using PatternKit.Generators.State; +using PatternKit.Generators.TableDataGateway; using PatternKit.Generators.Template; using PatternKit.Generators.TransactionScript; using PatternKit.Generators.UnitOfWork; @@ -172,6 +173,8 @@ private enum TestTrigger { typeof(StateGuardAttribute), AttributeTargets.Method, true, false }, { typeof(StateEntryAttribute), AttributeTargets.Method, true, false }, { typeof(StateExitAttribute), AttributeTargets.Method, true, false }, + { typeof(GenerateTableDataGatewayAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(TableGatewayKeySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(TemplateAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(TemplateStepAttribute), AttributeTargets.Method, false, false }, { typeof(TemplateHookAttribute), AttributeTargets.Method, false, false }, @@ -1046,6 +1049,11 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() DispatcherName = "order-events" }; var domainEventHandler = new DomainEventHandlerAttribute(typeof(string), 20); + var tableGateway = new GenerateTableDataGatewayAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildOrderTable", + TableName = "orders" + }; ScenarioExpect.Equal(typeof(TestState), stateMachine.StateType); ScenarioExpect.Equal(typeof(TestTrigger), stateMachine.TriggerType); @@ -1093,6 +1101,10 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("order-events", domainEvents.DispatcherName); ScenarioExpect.Equal(typeof(string), domainEventHandler.EventType); ScenarioExpect.Equal(20, domainEventHandler.Order); + ScenarioExpect.Equal(typeof(string), tableGateway.RowType); + ScenarioExpect.Equal(typeof(int), tableGateway.KeyType); + ScenarioExpect.Equal("BuildOrderTable", tableGateway.FactoryName); + ScenarioExpect.Equal("orders", tableGateway.TableName); ScenarioExpect.Throws(() => new UnitOfWorkStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(typeof(string), null!)); @@ -1102,6 +1114,9 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("code", "", 1)); ScenarioExpect.Throws(() => new GenerateDomainEventDispatcherAttribute(null!)); ScenarioExpect.Throws(() => new DomainEventHandlerAttribute(null!, 1)); + ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new TableGatewayKeySelectorAttribute()); ScenarioExpect.IsType(new TransactionScriptHandlerAttribute()); ScenarioExpect.IsType(new TransactionScriptValidatorAttribute()); ScenarioExpect.IsType(new ServiceLayerHandlerAttribute()); diff --git a/test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs b/test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs new file mode 100644 index 0000000..70d4646 --- /dev/null +++ b/test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs @@ -0,0 +1,65 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.TableDataGateway; +using PatternKit.Generators.TableDataGateway; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Table Data Gateway generator")] +public sealed partial class TableDataGatewayGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits table data gateway factory")] + [Fact] + public Task Generator_Emits_Table_Data_Gateway_Factory() + => Given("a valid table data gateway declaration", () => Compile(""" + using PatternKit.Generators.TableDataGateway; + namespace Demo; + public sealed record OrderRow(string OrderId); + [GenerateTableDataGateway(typeof(OrderRow), typeof(string), FactoryName = "Build", TableName = "orders")] + public static partial class OrderTableGateway + { + [TableGatewayKeySelector] + private static string SelectKey(OrderRow row) => row.OrderId; + } + """)) + .Then("generated source creates the gateway", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Create(\"orders\", SelectKey).Build()", source); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid table data gateway declarations")] + [Theory] + [InlineData("public static class OrderTableGateway { [TableGatewayKeySelector] private static string SelectKey(OrderRow row) => row.OrderId; }", "PKTDG001")] + [InlineData("public static partial class OrderTableGateway;", "PKTDG002")] + [InlineData("public static partial class OrderTableGateway { [TableGatewayKeySelector] private static string One(OrderRow row) => row.OrderId; [TableGatewayKeySelector] private static string Two(OrderRow row) => row.OrderId; }", "PKTDG002")] + [InlineData("public static partial class OrderTableGateway { [TableGatewayKeySelector] private static int SelectKey(OrderRow row) => 1; }", "PKTDG003")] + public Task Generator_Reports_Invalid_Table_Data_Gateway_Declarations(string declaration, string diagnosticId) + => Given("an invalid table data gateway declaration", () => Compile($$""" + using PatternKit.Generators.TableDataGateway; + public sealed record OrderRow(string OrderId); + [GenerateTableDataGateway(typeof(OrderRow), 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, + "TableDataGatewayGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(InMemoryTableDataGateway<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new TableDataGatewayGenerator(), 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/TableDataGateway/TableDataGatewayTests.cs b/test/PatternKit.Tests/Application/TableDataGateway/TableDataGatewayTests.cs new file mode 100644 index 0000000..bf7b47a --- /dev/null +++ b/test/PatternKit.Tests/Application/TableDataGateway/TableDataGatewayTests.cs @@ -0,0 +1,63 @@ +using PatternKit.Application.TableDataGateway; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.TableDataGateway; + +[Feature("Table Data Gateway")] +public sealed partial class TableDataGatewayTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Table Data Gateway stores queries updates and deletes rows")] + [Fact] + public Task Table_Data_Gateway_Stores_Queries_Updates_And_Deletes_Rows() + => Given("an orders table gateway", () => InMemoryTableDataGateway.Create("orders", static row => row.OrderId).Build()) + .When("rows are inserted updated queried and deleted", (Func, ValueTask>)(async gateway => + { + var insert = await gateway.InsertAsync(new OrderRow("order-100", "Pending", 125m)); + var duplicate = await gateway.InsertAsync(new OrderRow("order-100", "Pending", 125m)); + var update = await gateway.UpdateAsync(new OrderRow("order-100", "Closed", 125m)); + var closed = await gateway.QueryAsync(static row => row.Status == "Closed"); + var delete = await gateway.DeleteAsync("order-100"); + var missing = await gateway.GetAsync("order-100"); + return new(insert, duplicate, update, closed, delete, missing); + })) + .Then("the table gateway enforces row keys and mutations", result => + { + ScenarioExpect.Equal(TableGatewayStatus.Inserted, result.Insert.Status); + ScenarioExpect.Equal(TableGatewayStatus.Conflict, result.Duplicate.Status); + ScenarioExpect.Equal(TableGatewayStatus.Updated, result.Update.Status); + ScenarioExpect.Equal("Closed", ScenarioExpect.Single(result.ClosedRows).Status); + ScenarioExpect.Equal(TableGatewayStatus.Deleted, result.Delete.Status); + ScenarioExpect.Null(result.Missing); + }) + .AssertPassed(); + + [Scenario("Table Data Gateway validates required configuration")] + [Fact] + public Task Table_Data_Gateway_Validates_Required_Configuration() + => Given("table gateway builders", () => true) + .Then("invalid arguments are rejected", _ => + { + ScenarioExpect.Throws(() => InMemoryTableDataGateway.Create("", static row => row.OrderId)); + ScenarioExpect.Throws(() => InMemoryTableDataGateway.Create("orders", null!)); + ScenarioExpect.Throws(() => InMemoryTableDataGateway.Create("orders", static row => row.OrderId).UseComparer(null!)); + var gateway = InMemoryTableDataGateway.Create("orders", static row => row.OrderId).Build(); + ScenarioExpect.Throws(() => gateway.InsertAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => gateway.GetAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => gateway.QueryAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => TableGatewayResult.Conflict(new OrderRow("order-1", "Pending", 10m), "")); + ScenarioExpect.Throws(() => TableGatewayResult.Missing(null, "")); + }) + .AssertPassed(); + + private sealed record OrderRow(string OrderId, string Status, decimal Total); + + private sealed record TableGatewayScenario( + TableGatewayResult Insert, + TableGatewayResult Duplicate, + TableGatewayResult Update, + IReadOnlyList ClosedRows, + TableGatewayResult Delete, + OrderRow? Missing); +}