diff --git a/docs/examples/order-transaction-script-pattern.md b/docs/examples/order-transaction-script-pattern.md new file mode 100644 index 0000000..19f8a37 --- /dev/null +++ b/docs/examples/order-transaction-script-pattern.md @@ -0,0 +1,28 @@ +# Order Transaction Script Pattern + +This production-shaped example shows a submit-order application operation as a Transaction Script. + +It demonstrates: + +- fluent `TransactionScript` construction +- generated script factory with `[GenerateTransactionScript]` +- repository and unit-of-work coordination inside the script handler +- scoped `ITransactionScript` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddOrderTransactionScriptDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.SubmitAsync(new SubmitOrderRequest("order-100", "customer-1", 125m)); +``` + +The registered script is scoped so importing applications can safely compose it with request-scoped repositories, units of work, database sessions, or tenant services. + +Files: + +- `src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs` +- `test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index ec03b0e..e9d63dc 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -91,6 +91,9 @@ - name: Order Identity Map Pattern href: order-identity-map-pattern.md +- name: Order Transaction Script Pattern + href: order-transaction-script-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 853021d..cb987f9 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -64,6 +64,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | +| [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index ad443db..c32195c 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -114,6 +114,9 @@ - name: Unit of Work href: unit-of-work.md +- name: Transaction Script + href: transaction-script.md + - name: Visitor Generator href: visitor-generator.md diff --git a/docs/generators/transaction-script.md b/docs/generators/transaction-script.md new file mode 100644 index 0000000..97a76ec --- /dev/null +++ b/docs/generators/transaction-script.md @@ -0,0 +1,36 @@ +# Transaction Script Generator + +`GenerateTransactionScriptAttribute` creates a typed `TransactionScript` factory from a static partial host. + +```csharp +[GenerateTransactionScript(typeof(SubmitOrderRequest), typeof(SubmitOrderReceipt), FactoryName = "CreateScript", ScriptName = "submit-order")] +public static partial class GeneratedSubmitOrderScript +{ + [TransactionScriptValidator] + private static IEnumerable Validate(SubmitOrderRequest request) + => request.Total <= 0m + ? [new TransactionScriptError("total", "Order total must be positive.")] + : []; + + [TransactionScriptHandler] + private static ValueTask Handle(SubmitOrderRequest request, CancellationToken cancellationToken) + => new(new SubmitOrderReceipt(request.OrderId, request.Total)); +} +``` + +The generated factory is equivalent to: + +```csharp +TransactionScript + .Create("submit-order") + .Validate(Validate) + .Execute(Handle) + .Build(); +``` + +Diagnostics: + +- `PKTS001`: host type must be partial. +- `PKTS002`: exactly one `[TransactionScriptHandler]` method is required. +- `PKTS003`: handler must be static and return `ValueTask` from `(TRequest, CancellationToken)`. +- `PKTS004`: validator must be a single static method returning `IEnumerable` from `TRequest`. diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 29f5b6c..2225885 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -71,6 +71,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Unit of Work | `UnitOfWork` | Unit of Work generator | | Application Architecture | Data Mapper | `DataMapper` | Data Mapper generator | | Application Architecture | Identity Map | `IdentityMap` | Identity Map generator | +| Application Architecture | Transaction Script | `TransactionScript` | Transaction Script generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/transaction-script.md b/docs/patterns/application/transaction-script.md new file mode 100644 index 0000000..f6003a3 --- /dev/null +++ b/docs/patterns/application/transaction-script.md @@ -0,0 +1,30 @@ +# Transaction Script + +Transaction Script models one application operation as an explicit workflow: validate input, coordinate persistence or integrations, and return a typed result. Use it when the business transaction is procedural and does not need a rich domain object to own the behavior. + +PatternKit provides `TransactionScript` in `PatternKit.Application.TransactionScript`. + +```csharp +var script = TransactionScript + .Create("submit-order") + .Validate(request => request.Total <= 0m + ? [new TransactionScriptError("total", "Order total must be positive.")] + : []) + .Execute(async (request, ct) => + { + await repository.AddAsync(new SubmittedOrder(request.OrderId, request.CustomerId, request.Total), ct); + return new SubmitOrderReceipt(request.OrderId, request.Total); + }) + .Build(); + +var result = await script.ExecuteAsync(request, cancellationToken); +``` + +The runtime path returns `TransactionScriptResult` so callers can distinguish completed, rejected, and failed executions without manual assertions or exception-only control flow. + +Use the source-generated path when the script handler and validator are stable application code. Register `ITransactionScript` as scoped when the script depends on repositories, unit-of-work state, or request-scoped infrastructure. + +See also: + +- [Transaction Script generator](../../generators/transaction-script.md) +- [Order Transaction Script example](../../examples/order-transaction-script-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index e6dcfe5..282c063 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -341,6 +341,8 @@ href: application/data-mapper.md - name: Identity Map href: application/identity-map.md + - name: Transaction Script + href: application/transaction-script.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs b/src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs new file mode 100644 index 0000000..2ce49cf --- /dev/null +++ b/src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs @@ -0,0 +1,151 @@ +namespace PatternKit.Application.TransactionScript; + +/// Application service operation that executes one request workflow as an explicit transaction script. +public interface ITransactionScript +{ + string Name { get; } + + ValueTask> ExecuteAsync(TRequest request, CancellationToken cancellationToken = default); +} + +/// Fluent Transaction Script implementation for request/application workflows. +public sealed class TransactionScript : ITransactionScript +{ + private readonly Func> _validator; + private readonly Func> _handler; + + private TransactionScript( + string name, + Func> validator, + Func> handler) + { + Name = name; + _validator = validator; + _handler = handler; + } + + public string Name { get; } + + public static Builder Create(string name) + => new(name); + + public async ValueTask> ExecuteAsync(TRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + cancellationToken.ThrowIfCancellationRequested(); + var errors = (_validator(request) ?? Array.Empty()) + .Where(static error => error is not null) + .ToArray(); + if (errors.Length > 0) + return TransactionScriptResult.Rejected(errors); + + try + { + var response = await _handler(request, cancellationToken).ConfigureAwait(false); + return TransactionScriptResult.Completed(response); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return TransactionScriptResult.Failed(ex); + } + } + + public sealed class Builder + { + private readonly string _name; + private Func> _validator = static _ => Array.Empty(); + private Func>? _handler; + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Transaction script name is required.", nameof(name)) + : name; + } + + public Builder Validate(Func> validator) + { + _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + return this; + } + + public Builder Execute(Func> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public TransactionScript Build() + => new(_name, _validator, _handler ?? throw new InvalidOperationException("Transaction script handler is required.")); + } +} + +/// Result returned by a Transaction Script execution. +public sealed class TransactionScriptResult +{ + private TransactionScriptResult( + TResponse? response, + TransactionScriptStatus status, + IReadOnlyList errors, + Exception? exception) + { + Response = response; + Status = status; + Errors = errors; + Exception = exception; + } + + public TResponse? Response { get; } + + public TransactionScriptStatus Status { get; } + + public IReadOnlyList Errors { get; } + + public Exception? Exception { get; } + + public bool Succeeded => Status == TransactionScriptStatus.Completed; + + public static TransactionScriptResult Completed(TResponse response) + => new(response, TransactionScriptStatus.Completed, Array.Empty(), null); + + public static TransactionScriptResult Rejected(IReadOnlyList errors) + { + if (errors is null) + throw new ArgumentNullException(nameof(errors)); + if (errors.Count == 0) + throw new ArgumentException("Transaction script rejection requires at least one error.", nameof(errors)); + + return new(default, TransactionScriptStatus.Rejected, errors, null); + } + + public static TransactionScriptResult Failed(Exception exception) + => new(default, TransactionScriptStatus.Failed, Array.Empty(), exception ?? throw new ArgumentNullException(nameof(exception))); +} + +/// Execution status for a Transaction Script. +public enum TransactionScriptStatus +{ + Completed, + Rejected, + Failed +} + +/// Validation or precondition failure reported before a Transaction Script handler runs. +public sealed class TransactionScriptError +{ + public TransactionScriptError(string code, string message) + { + Code = string.IsNullOrWhiteSpace(code) + ? throw new ArgumentException("Transaction script error code is required.", nameof(code)) + : code; + Message = string.IsNullOrWhiteSpace(message) + ? throw new ArgumentException("Transaction script error message is required.", nameof(message)) + : message; + } + + public string Code { get; } + + public string Message { get; } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index d1be608..cb9d790 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ using PatternKit.Examples.Strategies.Coercion; using PatternKit.Examples.Strategies.Composed; using PatternKit.Examples.TemplateDemo; +using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; using PatternKit.Examples.VisitorDemo; using PatternKit.Messaging.Routing; @@ -128,6 +129,7 @@ public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Run public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow); public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Runner, OrderDataMapperWorkflow Workflow); public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner); +public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner 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); @@ -188,6 +190,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCheckoutUnitOfWorkPatternExample() .AddOrderDataMapperPatternExample() .AddOrderIdentityMapPatternExample() + .AddOrderTransactionScriptPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -554,6 +557,13 @@ public static IServiceCollection AddOrderIdentityMapPatternExample(this IService return services.RegisterExample("Order Identity Map Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderTransactionScriptPatternExample(this IServiceCollection services) + { + services.AddOrderTransactionScriptDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Transaction Script 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 0a4f520..a566845 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -328,6 +328,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["IdentityMap"], ["request-scoped identity reuse", "source-generated map factory", "DI composition"]), + Descriptor( + "Order Transaction Script Pattern", + "src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs", + "test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs", + "docs/examples/order-transaction-script-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["TransactionScript"], + ["explicit application workflow", "source-generated script 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 31e8160..611f400 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -713,6 +713,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/IdentityMapDemo/OrderIdentityMapDemoTests.cs", ["fluent scoped identity map", "generated identity-map factory", "DI-importable request scope example"]), + Pattern("Transaction Script", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/transaction-script.md", + "src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs", + "test/PatternKit.Tests/Application/TransactionScript/TransactionScriptTests.cs", + "docs/generators/transaction-script.md", + "src/PatternKit.Generators/TransactionScript/TransactionScriptGenerator.cs", + "test/PatternKit.Generators.Tests/TransactionScriptGeneratorTests.cs", + null, + "docs/examples/order-transaction-script-pattern.md", + "src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs", + "test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs", + ["fluent application workflow", "generated script factory", "DI-importable service operation"]), + 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/TransactionScriptDemo/OrderTransactionScriptDemo.cs b/src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs new file mode 100644 index 0000000..7ccc292 --- /dev/null +++ b/src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Repository; +using PatternKit.Application.TransactionScript; +using PatternKit.Application.UnitOfWork; +using PatternKit.Generators.TransactionScript; + +namespace PatternKit.Examples.TransactionScriptDemo; + +public static class OrderTransactionScriptDemo +{ + public static async ValueTask RunFluentAsync() + { + var repository = InMemoryRepository.Create(static order => order.OrderId).Build(); + var script = OrderTransactionScriptPolicies.CreateFluentScript(repository); + var result = await script.ExecuteAsync(new SubmitOrderRequest("order-100", "customer-10", 125m)); + return new(result.Succeeded, result.Response?.OrderId ?? "", (await repository.ListAsync()).Count); + } + + public static async ValueTask RunGeneratedAsync() + { + GeneratedSubmitOrderScript.Repository = InMemoryRepository.Create(static order => order.OrderId).Build(); + var result = await GeneratedSubmitOrderScript.CreateScript().ExecuteAsync(new SubmitOrderRequest("order-200", "customer-20", 75m)); + return new(result.Succeeded, result.Response?.OrderId ?? "", (await GeneratedSubmitOrderScript.Repository.ListAsync()).Count); + } +} + +public sealed record SubmitOrderRequest(string OrderId, string CustomerId, decimal Total); + +public sealed record SubmitOrderReceipt(string OrderId, decimal Total); + +public sealed record SubmittedOrder(string OrderId, string CustomerId, decimal Total); + +public sealed record OrderTransactionScriptSummary(bool Submitted, string OrderId, int RepositoryCount); + +public static class OrderTransactionScriptPolicies +{ + public static TransactionScript CreateFluentScript(IRepository repository) + { + if (repository is null) + throw new ArgumentNullException(nameof(repository)); + + return TransactionScript.Create("submit-order") + .Validate(static request => request.Total <= 0m + ? [new TransactionScriptError("total", "Order total must be positive.")] + : []) + .Execute(async (request, cancellationToken) => + { + var unit = UnitOfWork.Create() + .Enlist("persist-order", async ct => + { + var result = await repository.AddAsync(new SubmittedOrder(request.OrderId, request.CustomerId, request.Total), ct).ConfigureAwait(false); + if (!result.Succeeded) + throw new InvalidOperationException(result.Reason); + }) + .Build(); + + var commit = await unit.CommitAsync(cancellationToken).ConfigureAwait(false); + if (!commit.Committed) + throw commit.Exception ?? new InvalidOperationException("Order transaction failed."); + + return new SubmitOrderReceipt(request.OrderId, request.Total); + }) + .Build(); + } +} + +public sealed class OrderTransactionScriptWorkflow +{ + private readonly ITransactionScript _script; + + public OrderTransactionScriptWorkflow(ITransactionScript script) + { + _script = script; + } + + public async ValueTask SubmitAsync(SubmitOrderRequest request, CancellationToken cancellationToken = default) + { + var result = await _script.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + return new(result.Succeeded, result.Response?.OrderId ?? "", result.Succeeded ? 1 : 0); + } +} + +public sealed record OrderTransactionScriptDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderTransactionScriptServiceCollectionExtensions +{ + public static IServiceCollection AddOrderTransactionScriptDemo(this IServiceCollection services) + { + services.AddScoped>(_ => InMemoryRepository.Create(static order => order.OrderId).Build()); + services.AddScoped>(sp => + OrderTransactionScriptPolicies.CreateFluentScript(sp.GetRequiredService>())); + services.AddScoped(); + services.AddSingleton(new OrderTransactionScriptDemoRunner( + OrderTransactionScriptDemo.RunFluentAsync, + OrderTransactionScriptDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateTransactionScript(typeof(SubmitOrderRequest), typeof(SubmitOrderReceipt), FactoryName = "CreateScript", ScriptName = "submit-order")] +public static partial class GeneratedSubmitOrderScript +{ + public static IRepository Repository { get; set; } = + InMemoryRepository.Create(static order => order.OrderId).Build(); + + [TransactionScriptValidator] + private static IEnumerable Validate(SubmitOrderRequest request) + => request.Total <= 0m + ? [new TransactionScriptError("total", "Order total must be positive.")] + : []; + + [TransactionScriptHandler] + private static async ValueTask Handle(SubmitOrderRequest request, CancellationToken cancellationToken) + { + var result = await Repository.AddAsync(new SubmittedOrder(request.OrderId, request.CustomerId, request.Total), cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + throw new InvalidOperationException(result.Reason); + + return new SubmitOrderReceipt(request.OrderId, request.Total); + } +} diff --git a/src/PatternKit.Generators.Abstractions/TransactionScript/TransactionScriptAttributes.cs b/src/PatternKit.Generators.Abstractions/TransactionScript/TransactionScriptAttributes.cs new file mode 100644 index 0000000..5e4912d --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/TransactionScript/TransactionScriptAttributes.cs @@ -0,0 +1,28 @@ +namespace PatternKit.Generators.TransactionScript; + +/// Generates a Transaction Script factory from attributed handler and validator methods. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateTransactionScriptAttribute : Attribute +{ + public GenerateTransactionScriptAttribute(Type requestType, Type responseType) + { + RequestType = requestType ?? throw new ArgumentNullException(nameof(requestType)); + ResponseType = responseType ?? throw new ArgumentNullException(nameof(responseType)); + } + + public Type RequestType { get; } + + public Type ResponseType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string ScriptName { get; set; } = ""; +} + +/// Marks the handler method for a generated Transaction Script. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class TransactionScriptHandlerAttribute : Attribute; + +/// Marks the optional validator method for a generated Transaction Script. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class TransactionScriptValidatorAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d97aa4d..59d1328 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -63,6 +63,10 @@ PKTMP004 | PatternKit.Generators.Template | Error | TemplateGenerator PKTMP005 | PatternKit.Generators.Template | Error | TemplateGenerator PKTMP007 | PatternKit.Generators.Template | Warning | TemplateGenerator PKTMP008 | PatternKit.Generators.Template | Error | TemplateGenerator +PKTS001 | PatternKit.Generators.TransactionScript | Error | Transaction Script host must be partial. +PKTS002 | PatternKit.Generators.TransactionScript | Error | Transaction Script must declare exactly one handler. +PKTS003 | PatternKit.Generators.TransactionScript | Error | Transaction Script handler signature is invalid. +PKTS004 | PatternKit.Generators.TransactionScript | Error | Transaction Script validator signature is invalid. PKUOW001 | PatternKit.Generators.UnitOfWork | Error | Unit of work host must be partial. PKUOW002 | PatternKit.Generators.UnitOfWork | Error | Unit of work must declare at least one step. PKUOW003 | PatternKit.Generators.UnitOfWork | Error | Unit of work step signature is invalid. diff --git a/src/PatternKit.Generators/TransactionScript/TransactionScriptGenerator.cs b/src/PatternKit.Generators/TransactionScript/TransactionScriptGenerator.cs new file mode 100644 index 0000000..f39022a --- /dev/null +++ b/src/PatternKit.Generators/TransactionScript/TransactionScriptGenerator.cs @@ -0,0 +1,179 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.TransactionScript; + +[Generator] +public sealed class TransactionScriptGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.TransactionScript.GenerateTransactionScriptAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.TransactionScript.TransactionScriptHandlerAttribute"; + private const string ValidatorAttributeName = "PatternKit.Generators.TransactionScript.TransactionScriptValidatorAttribute"; + private const string ErrorTypeName = "PatternKit.Application.TransactionScript.TransactionScriptError"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKTS001", "Transaction Script host must be partial", + "Type '{0}' is marked with [GenerateTransactionScript] but is not declared as partial", + "PatternKit.Generators.TransactionScript", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKTS002", "Transaction Script handler is missing", + "Transaction Script '{0}' must declare exactly one [TransactionScriptHandler] method", + "PatternKit.Generators.TransactionScript", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKTS003", "Transaction Script handler signature is invalid", + "Transaction Script handler '{0}' must be static and return ValueTask from TRequest and CancellationToken parameters", + "PatternKit.Generators.TransactionScript", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidValidator = new( + "PKTS004", "Transaction Script validator signature is invalid", + "Transaction Script validator must be a single static method returning IEnumerable from one TRequest parameter", + "PatternKit.Generators.TransactionScript", 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 requestType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var responseType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (requestType is null || responseType is null) + return; + + var handlers = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == HandlerAttributeName)) + .ToArray(); + if (handlers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var handler = handlers[0]; + if (!IsHandler(handler, requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + + var validators = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == ValidatorAttributeName)) + .ToArray(); + if (validators.Length > 1 || (validators.Length == 1 && !IsValidator(validators[0], requestType))) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidValidator, validators.FirstOrDefault()?.Locations.FirstOrDefault() ?? node.Identifier.GetLocation())); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var scriptName = GetNamedString(attribute, "ScriptName"); + if (string.IsNullOrWhiteSpace(scriptName)) + scriptName = type.Name; + + context.AddSource($"{type.Name}.TransactionScript.g.cs", SourceText.From( + GenerateSource(type, requestType, responseType, handler.Name, validators.FirstOrDefault()?.Name, factoryName, scriptName!), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + string handlerName, + string? validatorName, + string factoryName, + string scriptName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var requestName = requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var responseName = responseType.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.TransactionScript.TransactionScript<") + .Append(requestName).Append(", ").Append(responseName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.TransactionScript.TransactionScript<") + .Append(requestName).Append(", ").Append(responseName).Append(">.Create(\"").Append(Escape(scriptName)).Append("\")"); + if (validatorName is not null) + sb.AppendLine().Append(" .Validate(").Append(validatorName).Append(')'); + sb.AppendLine().Append(" .Execute(").Append(handlerName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType is INamedTypeSymbol returnType + && returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.Tasks.ValueTask<" + responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">" + && method.Parameters.Length == 2 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType) + && method.Parameters[1].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; + + private static bool IsValidator(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType) + && method.ReturnType is INamedTypeSymbol returnType + && (returnType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Collections.Generic.IEnumerable" + || returnType.AllInterfaces.Any(static i => i.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Collections.Generic.IEnumerable")) + && returnType.TypeArguments.Length == 1 + && returnType.TypeArguments[0].ToDisplayString() == ErrorTypeName; + + 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 b38e192..09fdcb5 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -14,6 +14,7 @@ using PatternKit.Examples.RateLimitingDemo; using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.Strategies.Composed; +using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; @@ -106,6 +107,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var unitOfWork = provider.GetRequiredService(); var dataMapper = provider.GetRequiredService(); var identityMap = provider.GetRequiredService(); + var transactionScript = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -180,6 +182,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("unit of work example commits checkout steps", unitOfWork.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().Committed), ("data mapper example rehydrates stored orders", dataMapper.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().LoadedCustomerId == "customer-1"), ("identity map example reuses loaded orders", identityMap.Runner.RunFluent().ReusedInstance), + ("transaction script example submits orders", transactionScript.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Submitted), ("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 7e9a4c3..62f8bef 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -65,6 +65,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Unit of Work", "Data Mapper", "Identity Map", + "Transaction Script", "Anti-Corruption Layer" ]; @@ -109,7 +110,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(7, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(8, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs b/test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs new file mode 100644 index 0000000..8860a22 --- /dev/null +++ b/test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.TransactionScriptDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.TransactionScriptDemo; + +[Feature("Order Transaction Script demo")] +public sealed partial class OrderTransactionScriptDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated transaction scripts submit orders")] + [Fact] + public Task Fluent_And_Generated_Transaction_Scripts_Submit_Orders() + => Given("the order transaction script demo", () => true) + .When("fluent and generated scripts run", (Func>)(async _ => new OrderTransactionScriptResults( + await OrderTransactionScriptDemo.RunFluentAsync(), + await OrderTransactionScriptDemo.RunGeneratedAsync()))) + .Then("orders are submitted and persisted", result => + { + ScenarioExpect.True(result.Fluent.Submitted); + ScenarioExpect.True(result.Generated.Submitted); + ScenarioExpect.Equal(1, result.Fluent.RepositoryCount); + ScenarioExpect.Equal(1, result.Generated.RepositoryCount); + }) + .AssertPassed(); + + [Scenario("Transaction Script demo registers with IServiceCollection")] + [Fact] + public Task Transaction_Script_Demo_Registers_With_IServiceCollection() + => Given("a service collection with the transaction script demo", () => + { + var services = new ServiceCollection(); + services.AddOrderTransactionScriptDemo(); + return services.BuildServiceProvider(); + }) + .When("the scoped workflow submits an order", (Func>)(async provider => + { + using var scope = provider.CreateScope(); + var workflow = scope.ServiceProvider.GetRequiredService(); + return await workflow.SubmitAsync(new SubmitOrderRequest("order-300", "customer-30", 45m)); + })) + .Then("the workflow uses the registered script", summary => + { + ScenarioExpect.True(summary.Submitted); + ScenarioExpect.Equal("order-300", summary.OrderId); + ScenarioExpect.Equal(1, summary.RepositoryCount); + }) + .AssertPassed(); + + private sealed record OrderTransactionScriptResults( + OrderTransactionScriptSummary Fluent, + OrderTransactionScriptSummary Generated); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 7776301..0da9a21 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -26,6 +26,7 @@ using PatternKit.Generators.Specification; using PatternKit.Generators.State; using PatternKit.Generators.Template; +using PatternKit.Generators.TransactionScript; using PatternKit.Generators.UnitOfWork; using PatternKit.Generators.Visitors; using PatternKit.Generators; @@ -170,6 +171,9 @@ private enum TestTrigger { typeof(TemplateAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(TemplateStepAttribute), AttributeTargets.Method, false, false }, { typeof(TemplateHookAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateTransactionScriptAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(TransactionScriptHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(TransactionScriptValidatorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateUnitOfWorkAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(UnitOfWorkStepAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateVisitorAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, false, false } @@ -1018,6 +1022,11 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() { RollbackMethodName = "UndoPersist" }; + var transactionScript = new GenerateTransactionScriptAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildSubmitOrder", + ScriptName = "submit-order" + }; ScenarioExpect.Equal(typeof(TestState), stateMachine.StateType); ScenarioExpect.Equal(typeof(TestTrigger), stateMachine.TriggerType); @@ -1049,7 +1058,15 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("persist", unitOfWorkStep.Name); ScenarioExpect.Equal(20, unitOfWorkStep.Order); ScenarioExpect.Equal("UndoPersist", unitOfWorkStep.RollbackMethodName); + ScenarioExpect.Equal(typeof(string), transactionScript.RequestType); + ScenarioExpect.Equal(typeof(int), transactionScript.ResponseType); + ScenarioExpect.Equal("BuildSubmitOrder", transactionScript.FactoryName); + ScenarioExpect.Equal("submit-order", transactionScript.ScriptName); ScenarioExpect.Throws(() => new UnitOfWorkStepAttribute("", 1)); + ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new TransactionScriptHandlerAttribute()); + ScenarioExpect.IsType(new TransactionScriptValidatorAttribute()); AssertEnumValues(StateMachineInvalidTriggerPolicy.Throw, StateMachineInvalidTriggerPolicy.Ignore, StateMachineInvalidTriggerPolicy.ReturnFalse); AssertEnumValues(StateMachineGuardFailurePolicy.Throw, StateMachineGuardFailurePolicy.Ignore, StateMachineGuardFailurePolicy.ReturnFalse); AssertEnumValues(HookPoint.BeforeAll, HookPoint.AfterAll, HookPoint.OnError); diff --git a/test/PatternKit.Generators.Tests/TransactionScriptGeneratorTests.cs b/test/PatternKit.Generators.Tests/TransactionScriptGeneratorTests.cs new file mode 100644 index 0000000..4506dbe --- /dev/null +++ b/test/PatternKit.Generators.Tests/TransactionScriptGeneratorTests.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.TransactionScript; +using PatternKit.Generators.TransactionScript; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Transaction Script generator")] +public sealed partial class TransactionScriptGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits transaction script factory")] + [Fact] + public Task Generator_Emits_Transaction_Script_Factory() + => Given("a valid transaction script declaration", () => Compile(""" + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.TransactionScript; + using PatternKit.Generators.TransactionScript; + namespace Demo; + public sealed record SubmitOrder(string OrderId); + public sealed record OrderReceipt(string OrderId); + [GenerateTransactionScript(typeof(SubmitOrder), typeof(OrderReceipt), FactoryName = "Build", ScriptName = "submit-order")] + public static partial class SubmitOrderScript + { + [TransactionScriptValidator] + private static IEnumerable Validate(SubmitOrder request) => []; + [TransactionScriptHandler] + private static ValueTask Handle(SubmitOrder request, CancellationToken cancellationToken) => new(new OrderReceipt(request.OrderId)); + } + """)) + .Then("generated source creates the script with validator and handler", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Create(\"submit-order\")", source); + ScenarioExpect.Contains(".Validate(Validate)", source); + ScenarioExpect.Contains(".Execute(Handle)", source); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid transaction script declarations")] + [Theory] + [InlineData("public static class SubmitOrderScript { [TransactionScriptHandler] private static ValueTask Handle(SubmitOrder request, CancellationToken cancellationToken) => new(new OrderReceipt(request.OrderId)); }", "PKTS001")] + [InlineData("public static partial class SubmitOrderScript;", "PKTS002")] + [InlineData("public static partial class SubmitOrderScript { [TransactionScriptHandler] private static ValueTask One(SubmitOrder request, CancellationToken cancellationToken) => new(new OrderReceipt(request.OrderId)); [TransactionScriptHandler] private static ValueTask Two(SubmitOrder request, CancellationToken cancellationToken) => new(new OrderReceipt(request.OrderId)); }", "PKTS002")] + [InlineData("public static partial class SubmitOrderScript { [TransactionScriptHandler] private static OrderReceipt Handle(SubmitOrder request) => new(request.OrderId); }", "PKTS003")] + [InlineData("public static partial class SubmitOrderScript { [TransactionScriptValidator] private static string Validate(SubmitOrder request) => \"invalid\"; [TransactionScriptHandler] private static ValueTask Handle(SubmitOrder request, CancellationToken cancellationToken) => new(new OrderReceipt(request.OrderId)); }", "PKTS004")] + public Task Generator_Reports_Invalid_Transaction_Script_Declarations(string declaration, string diagnosticId) + => Given("an invalid transaction script declaration", () => Compile($$""" + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Application.TransactionScript; + using PatternKit.Generators.TransactionScript; + public sealed record SubmitOrder(string OrderId); + public sealed record OrderReceipt(string OrderId); + [GenerateTransactionScript(typeof(SubmitOrder), typeof(OrderReceipt))] + {{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, + "TransactionScriptGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(TransactionScript<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new TransactionScriptGenerator(), 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/TransactionScript/TransactionScriptTests.cs b/test/PatternKit.Tests/Application/TransactionScript/TransactionScriptTests.cs new file mode 100644 index 0000000..7441598 --- /dev/null +++ b/test/PatternKit.Tests/Application/TransactionScript/TransactionScriptTests.cs @@ -0,0 +1,119 @@ +using PatternKit.Application.TransactionScript; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.TransactionScript; + +[Feature("Transaction Script")] +public sealed partial class TransactionScriptTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Transaction Script validates and executes a request workflow")] + [Fact] + public Task Transaction_Script_Validates_And_Executes_A_Request_Workflow() + => Given("a transaction script", () => TransactionScript.Create("submit-order") + .Validate(static request => request.Total <= 0m + ? [new TransactionScriptError("total", "Total must be positive.")] + : []) + .Execute(static (request, _) => new ValueTask(new OrderReceipt(request.OrderId, request.Total))) + .Build()) + .When("a valid request is executed", (Func, ValueTask>>)(async script => + await script.ExecuteAsync(new SubmitOrder("order-100", 125m)))) + .Then("the response is completed", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal(TransactionScriptStatus.Completed, result.Status); + ScenarioExpect.Equal("order-100", result.Response!.OrderId); + ScenarioExpect.Empty(result.Errors); + }) + .AssertPassed(); + + [Scenario("Transaction Script rejects invalid requests before the handler runs")] + [Fact] + public Task Transaction_Script_Rejects_Invalid_Requests_Before_The_Handler_Runs() + => Given("a transaction script with validation", () => + { + var handled = false; + var script = TransactionScript.Create("submit-order") + .Validate(static _ => [new TransactionScriptError("invalid", "Order is invalid.")]) + .Execute((request, _) => + { + handled = true; + return new ValueTask(new OrderReceipt(request.OrderId, request.Total)); + }) + .Build(); + return new SubmitOrderScriptContext(script, () => handled); + }) + .When("an invalid request is executed", (Func>)(async ctx => + new RejectedSubmitOrderResult(await ctx.Script.ExecuteAsync(new SubmitOrder("order-100", 0m)), ctx.WasHandled))) + .Then("the handler is skipped", ctx => + { + ScenarioExpect.Equal(TransactionScriptStatus.Rejected, ctx.Result.Status); + ScenarioExpect.False(ctx.Result.Succeeded); + ScenarioExpect.False(ctx.WasHandled()); + ScenarioExpect.Equal("invalid", ScenarioExpect.Single(ctx.Result.Errors).Code); + }) + .AssertPassed(); + + [Scenario("Transaction Script reports handled failures")] + [Fact] + public Task Transaction_Script_Reports_Handled_Failures() + => Given("a transaction script with a failing handler", () => TransactionScript.Create("submit-order") + .Execute(static (_, _) => throw new InvalidOperationException("database unavailable")) + .Build()) + .When("the request is executed", (Func, ValueTask>>)(async script => + await script.ExecuteAsync(new SubmitOrder("order-100", 125m)))) + .Then("the failure is returned", result => + { + ScenarioExpect.Equal(TransactionScriptStatus.Failed, result.Status); + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.IsType(result.Exception); + }) + .AssertPassed(); + + [Scenario("Transaction Script validates required configuration")] + [Fact] + public Task Transaction_Script_Validates_Required_Configuration() + => Given("transaction script builders", () => true) + .Then("invalid arguments are rejected", _ => + { + ScenarioExpect.Throws(() => TransactionScript.Create("")); + ScenarioExpect.Throws(() => TransactionScript.Create("submit").Validate(null!)); + ScenarioExpect.Throws(() => TransactionScript.Create("submit").Execute(null!)); + ScenarioExpect.Throws(() => TransactionScript.Create("submit").Build()); + ScenarioExpect.Throws(() => TransactionScript.Create("submit") + .Execute(static (request, _) => new ValueTask(new OrderReceipt(request.OrderId, request.Total))) + .Build() + .ExecuteAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => TransactionScriptResult.Rejected(Array.Empty())); + ScenarioExpect.Throws(() => TransactionScriptResult.Rejected(null!)); + ScenarioExpect.Throws(() => TransactionScriptResult.Failed(null!)); + ScenarioExpect.Throws(() => new TransactionScriptError("", "message")); + ScenarioExpect.Throws(() => new TransactionScriptError("code", "")); + }) + .AssertPassed(); + + [Scenario("Transaction Script treats null validator output as no errors")] + [Fact] + public Task Transaction_Script_Treats_Null_Validator_Output_As_No_Errors() + => Given("a transaction script with a null-returning validator", () => TransactionScript.Create("submit") + .Validate(static _ => null!) + .Execute(static (request, _) => new ValueTask(new OrderReceipt(request.OrderId, request.Total))) + .Build()) + .When("the request is executed", (Func, ValueTask>>)(async script => + await script.ExecuteAsync(new SubmitOrder("order-100", 125m)))) + .Then("the handler still completes", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal(TransactionScriptStatus.Completed, result.Status); + }) + .AssertPassed(); + + private sealed record SubmitOrder(string OrderId, decimal Total); + + private sealed record OrderReceipt(string OrderId, decimal Total); + + private sealed record SubmitOrderScriptContext(ITransactionScript Script, Func WasHandled); + + private sealed record RejectedSubmitOrderResult(TransactionScriptResult Result, Func WasHandled); +}