diff --git a/CHANGELOG.md b/CHANGELOG.md index 331c676..bc9d82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2025-05-01 + +### Added + +- Complete redesign of the workflow feature API + - New `Group` method that groups activities with an optional condition + - New `WithContext` method for isolated contexts with their own local state + - New `Detach` method for executing activities without merging their results back + - New `Parallel` method for parallel execution of multiple groups + - New `ParallelDetached` method for parallel execution of detached activities +- Better support for nullable conditions - all new methods accept nullable condition +- Clear separation of merged and non-merged execution paths +- Improved naming consistency across the API + +### Changed + +- **BREAKING CHANGE**: Reorganized internal class structure + - Added feature-specific namespaces and folders + - Created a consistent `IWorkflowFeature` interface for all features +- **BREAKING CHANGE**: Renamed `Branch` to `Group` for better clarity +- **BREAKING CHANGE**: Renamed `BranchWithLocalPayload` to `WithContext` to better express intention + +### Deprecated + +- The old `Branch` method is now marked as obsolete and will be removed in a future version +- The old `BranchWithLocalPayload` method is now marked as obsolete and will be removed in a future version + +### Compatibility + +- All existing code using the deprecated methods will continue to work, but will show deprecation warnings +- To migrate, replace: + + ```csharp + .Branch(condition, branch => branch.Do(...)) + ``` + + With: + + ```csharp + .Group(condition, group => group.Do(...)) + ``` + + And replace: + + ```csharp + .BranchWithLocalPayload(condition, factory, branch => branch.Do(...)) + ``` + + With: + + ```csharp + .WithContext(condition, factory, context => context.Do(...)) + ``` + ## [2.2.0] - 2025-04-25 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index e941de7..a8250fe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,7 @@ A .NET library for building robust, functional workflows and processing pipelines. - 2.2.0 + 3.0.0 true diff --git a/README.md b/README.md index b284bc8..9108603 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,19 @@ [![NuGet Version](https://img.shields.io/nuget/v/Zooper.Bee.svg)](https://www.nuget.org/packages/Zooper.Bee/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you to build type-safe, declarative business workflows with robust error handling. +A flexible and powerful workflow library for .NET that allows you to define complex business processes with a fluent API. -## Key Features +## Overview -- **Fluent Builder API**: Create workflows with an intuitive, chainable syntax -- **Type-safe**: Leverage C#'s static typing for error-resistant workflows -- **Functional Style**: Uses an Either monad pattern for clear success/failure handling -- **Composable**: Build complex workflows from simple, reusable components -- **Comprehensive**: Support for validations, conditional activities, branches, and finally blocks -- **Isolated Branches**: Create branches with their own isolated local payload types -- **Async-first**: First-class support for async/await operations -- **Testable**: Workflows built with Zooper.Bee are easy to unit test -- **No Dependencies**: Minimal external dependencies (only uses Zooper.Fox) +Zooper.Bee lets you create workflows that process requests and produce either successful results or meaningful errors. The library uses a builder pattern to construct workflows with various execution patterns including sequential, conditional, parallel, and detached operations. + +## Key Concepts + +- **Workflow**: A sequence of operations that process a request to produce a result or error +- **Request**: The input data to the workflow +- **Payload**: Data that passes through and gets modified by workflow activities +- **Success**: The successful result of the workflow +- **Error**: The error result if the workflow fails ## Installation @@ -25,182 +25,267 @@ Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you t dotnet add package Zooper.Bee ``` -## Quick Start - -Here's a basic example of creating and executing a workflow: +## Getting Started ```csharp -// Define your models -public record OrderRequest(string CustomerId, decimal Amount); -public record OrderPayload(OrderRequest Request, Guid OrderId, bool IsProcessed); -public record OrderConfirmation(Guid OrderId, DateTime ProcessedAt); -public record OrderError(string Code, string Message); - -// Create a workflow -var workflow = new WorkflowBuilder( - // Initial payload factory - request => new OrderPayload(request, Guid.NewGuid(), false), - // Result selector - payload => new OrderConfirmation(payload.OrderId, DateTime.UtcNow)) - - // Add validations - .Validate(request => - string.IsNullOrEmpty(request.CustomerId) - ? Option.Some(new OrderError("INVALID_CUSTOMER", "Customer ID required")) - : Option.None()) - - // Add activities - .Do(ProcessPayment) - .Do(UpdateInventory) - - // Add conditional activities - .DoIf( - payload => payload.Request.Amount > 1000, - ApplyHighValueDiscount) - - // Add finally activities - .Finally(LogOrderProcessing) - - // Build the workflow - .Build(); +// Define a simple workflow +var workflow = new WorkflowBuilder( + // Factory function that creates the initial payload from the request + request => new Payload { Data = request.Data }, -// Execute the workflow -var result = await workflow.Execute(new OrderRequest("CUST123", 299.99)); + // Selector function that creates the success result from the final payload + payload => new SuccessResult { ProcessedData = payload.Data } +) +.Validate(request => +{ + // Validate the request + if (string.IsNullOrEmpty(request.Data)) + return Option.Some(new ErrorResult { Message = "Data is required" }); + + return Option.None; +}) +.Do(payload => +{ + // Process the payload + payload.Data = payload.Data.ToUpper(); + return Either.FromRight(payload); +}) +.Build(); -// Handle the result +// Execute the workflow +var result = await workflow.Execute(new Request { Data = "hello world" }, CancellationToken.None); if (result.IsRight) { - var confirmation = result.Right; - Console.WriteLine($"Order {confirmation.OrderId} processed at {confirmation.ProcessedAt}"); + Console.WriteLine($"Success: {result.Right.ProcessedData}"); // Output: Success: HELLO WORLD } else { - var error = result.Left; - Console.WriteLine($"Error: {error.Code} - {error.Message}"); + Console.WriteLine($"Error: {result.Left.Message}"); } ``` -## Core Concepts - -### Workflow +## Building Workflows -A workflow represents a sequence of operations that processes a request and produces either a success result or an error. It's created using the `WorkflowBuilder`. +### Basic Operations -### WorkflowBuilder +#### Validation -The builder provides a fluent API for constructing workflows: +Validates the incoming request before processing begins. ```csharp -var workflow = new WorkflowBuilder( - contextFactory, // Function that creates the initial payload from the request - resultSelector) // Function that creates the success result from the final payload - .Validate(...) // Add validations - .Do(...) // Add activities - .DoIf(...) // Add conditional activities - .Branch(...) // Add branching logic - .BranchWithLocalPayload(...) // Add branch with its own isolated payload type - .Finally(...) // Add finally activities - .Build(); // Build the workflow +// Asynchronous validation +.Validate(async (request, cancellationToken) => +{ + var isValid = await ValidateAsync(request, cancellationToken); + return isValid ? Option.None : Option.Some(new ErrorResult()); +}) + +// Synchronous validation +.Validate(request => +{ + var isValid = Validate(request); + return isValid ? Option.None : Option.Some(new ErrorResult()); +}) ``` -### Validations +#### Activities -Validations check if the request is valid before processing begins. They return an `Option`: +Activities are the building blocks of a workflow. They process the payload and can produce either a success (with modified payload) or an error. ```csharp -.Validate(request => - string.IsNullOrEmpty(request.CustomerId) - ? Option.Some(new OrderError("INVALID_CUSTOMER", "Customer ID required")) - : Option.None()) +// Asynchronous activity +.Do(async (payload, cancellationToken) => +{ + var result = await ProcessAsync(payload, cancellationToken); + return Either.FromRight(result); +}) + +// Synchronous activity +.Do(payload => +{ + var result = Process(payload); + return Either.FromRight(result); +}) + +// Multiple activities +.DoAll( + payload => DoFirstThing(payload), + payload => DoSecondThing(payload), + payload => DoThirdThing(payload) +) ``` -### Activities +#### Conditional Activities -Activities are the primary building blocks of workflows. They process the payload and return either a success or failure result: +Activities that only execute if a condition is met. ```csharp -private static Either ProcessPayment(OrderPayload payload) -{ - // Process payment logic - - if (successful) - { - var updatedPayload = payload with { IsProcessed = true }; - return Either.FromRight(updatedPayload); - } - else +.DoIf( + payload => payload.ShouldProcess, // Condition + payload => { - return Either.FromLeft( - new OrderError("PAYMENT_FAILED", "Failed to process payment")); + // Activity that only executes if the condition is true + payload.Data = Process(payload.Data); + return Either.FromRight(payload); } -} +) ``` -### Conditional Activities +### Advanced Features + +#### Groups -Execute activities only when specific conditions are met: +Organize related activities into logical groups. Groups can have conditions and always merge their results back to the main workflow. ```csharp -.DoIf( - payload => payload.Request.Amount > 1000, // Condition - ApplyHighValueDiscount) // Activity +.Group( + payload => payload.ShouldProcessGroup, // Optional condition + group => group + .Do(payload => FirstActivity(payload)) + .Do(payload => SecondActivity(payload)) + .Do(payload => ThirdActivity(payload)) +) +``` + +#### Contexts with Local State + +Create a context with local state that is accessible to all activities within the context. This helps encapsulate related operations. + +```csharp +.WithContext( + null, // No condition, always execute + payload => new LocalState { Counter = 0 }, // Create local state + context => context + .Do((payload, state) => + { + state.Counter++; + return (payload, state); + }) + .Do((payload, state) => + { + payload.Result = $"Counted to {state.Counter}"; + return (payload, state); + }) +) ``` -### Branches +#### Parallel Execution -Create branches for more complex conditional logic: +Execute multiple groups of activities in parallel and merge the results. ```csharp -.Branch(payload => payload.IsExpressShipping) - .Do(CalculateExpressShippingFee) - .Do(PrioritizeOrder) - .EndBranch() -.Branch(payload => !payload.IsExpressShipping) - .Do(CalculateStandardShippingFee) - .EndBranch() +.Parallel( + null, // No condition, always execute + parallel => parallel + .Group(group => group + .Do(payload => { payload.Result1 = "Result 1"; return payload; }) + ) + .Group(group => group + .Do(payload => { payload.Result2 = "Result 2"; return payload; }) + ) +) ``` -### Branches with Local Payload +#### Detached Execution -Create isolated branches with their own local payload type that doesn't affect the main workflow payload: +Execute activities in the background without waiting for their completion. Results from detached activities are not merged back into the main workflow. ```csharp -.BranchWithLocalPayload( - // Condition - payload => payload.NeedsCustomization, - - // Local payload factory - mainPayload => new CustomizationPayload( - AvailableOptions: new[] { "Engraving", "Gift Wrap" }, - SelectedOptions: new string[0], - CustomizationCost: 0m - ), - - // Branch configuration - branch => branch - .Do((mainPayload, localPayload) => { - // Activity can access and modify both payloads - var selectedOption = "Engraving"; - - var updatedLocalPayload = localPayload with { - SelectedOptions = new[] { selectedOption }, - CustomizationCost = 10.00m - }; - - var updatedMainPayload = mainPayload with { - FinalPrice = mainPayload.Price + updatedLocalPayload.CustomizationCost - }; - - return Either.FromRight( - (updatedMainPayload, updatedLocalPayload)); +.Detach( + null, // No condition, always execute + detached => detached + .Do(payload => + { + // This runs in the background + LogActivity(payload); + return payload; }) ) ``` -### Finally Blocks +#### Parallel Detached Execution + +Execute multiple groups of detached activities in parallel without waiting for completion. + +```csharp +.ParallelDetached( + null, // No condition, always execute + parallelDetached => parallelDetached + .Detached(detached => detached + .Do(payload => { LogActivity1(payload); return payload; }) + ) + .Detached(detached => detached + .Do(payload => { LogActivity2(payload); return payload; }) + ) +) +``` + +#### Finally Block + +Activities that always execute, even if the workflow fails. + +```csharp +.Finally(payload => +{ + // Cleanup or logging + CleanupResources(payload); + return Either.FromRight(payload); +}) +``` + +## Advanced Patterns -Activities that execute regardless of workflow success or failure: +### Error Handling +```csharp +.Do(payload => +{ + try + { + var result = RiskyOperation(payload); + return Either.FromRight(result); + } + catch (Exception ex) + { + return Either.FromLeft(new ErrorResult { Message = ex.Message }); + } +}) ``` +### Conditional Branching + +Use conditions to determine which path to take in a workflow. + +```csharp +.Group( + payload => payload.Type == "TypeA", + group => group + .Do(payload => ProcessTypeA(payload)) +) +.Group( + payload => payload.Type == "TypeB", + group => group + .Do(payload => ProcessTypeB(payload)) +) ``` + +## Performance Considerations + +- Use `Parallel` for CPU-bound operations that can benefit from parallel execution +- Use `Detach` for I/O operations that don't affect the main workflow +- Be mindful of resource contention in parallel operations +- Consider using `WithContext` to maintain state between related activities + +## Best Practices + +1. Keep activities small and focused on a single responsibility +2. Use descriptive names for your workflow methods +3. Group related activities together +4. Handle errors at appropriate levels +5. Use `Finally` for cleanup operations +6. Validate requests early to fail fast +7. Use contextual state to avoid passing too many parameters + +## License + +MIT License (Copyright details here) diff --git a/Zooper.Bee.Example/BranchingExample.cs b/Zooper.Bee.Example/BranchingExample.cs index 3ed59fa..be76edc 100644 --- a/Zooper.Bee.Example/BranchingExample.cs +++ b/Zooper.Bee.Example/BranchingExample.cs @@ -1,74 +1,79 @@ -using System; -using System.Threading.Tasks; -using Zooper.Bee; using Zooper.Fox; namespace Zooper.Bee.Example; public class BranchingExample { - // Request models - public record UserRegistrationRequest( - string Username, + // Request model + public record RegistrationRequest( string Email, + string Password, bool IsVipMember); // Success model - public record RegistrationResult( - string Username, + public record RegistrationSuccess( + Guid UserId, string Email, - string AccountType, - bool WelcomeEmailSent, - bool VipBenefitsActivated); + bool IsVipMember, + string? WelcomeMessage); // Error model public record RegistrationError(string Code, string Message); - // Payload model + // Registration payload model public record RegistrationPayload( - string Username, + Guid UserId, string Email, + string Password, bool IsVipMember, - bool IsRegistered = false, - bool WelcomeEmailSent = false, - bool VipBenefitsActivated = false, - string AccountType = "Standard"); + string? WelcomeMessage = null); public static async Task RunExample() { - Console.WriteLine("\n=== Workflow Branching Example ===\n"); + Console.WriteLine("\n=== Workflow Grouping Example ===\n"); // Create sample requests - var standardUser = new UserRegistrationRequest("john_doe", "john@example.com", false); - var vipUser = new UserRegistrationRequest("jane_smith", "jane@example.com", true); + var standardUserRequest = new RegistrationRequest("user@example.com", "Password123!", false); + var vipUserRequest = new RegistrationRequest("vip@example.com", "VIPPassword123!", true); + var invalidEmailRequest = new RegistrationRequest("invalid-email", "Password123!", false); // Build the registration workflow var workflow = CreateRegistrationWorkflow(); - // Process the standard user - Console.WriteLine("Processing standard user registration:"); - await ProcessRegistration(workflow, standardUser); + // Process standard user registration + Console.WriteLine("Registering standard user:"); + await ProcessRegistration(workflow, standardUserRequest); + + Console.WriteLine(); + + // Process VIP user registration + Console.WriteLine("Registering VIP user:"); + await ProcessRegistration(workflow, vipUserRequest); Console.WriteLine(); - // Process the VIP user - Console.WriteLine("Processing VIP user registration:"); - await ProcessRegistration(workflow, vipUser); + // Process invalid registration + Console.WriteLine("Attempting to register user with invalid email:"); + await ProcessRegistration(workflow, invalidEmailRequest); } private static async Task ProcessRegistration( - Workflow workflow, - UserRegistrationRequest request) + Workflow workflow, + RegistrationRequest request) { var result = await workflow.Execute(request); if (result.IsRight) { - var registration = result.Right; - Console.WriteLine($"Registration successful for {registration.Username}"); - Console.WriteLine($"Account Type: {registration.AccountType}"); - Console.WriteLine($"Welcome Email Sent: {registration.WelcomeEmailSent}"); - Console.WriteLine($"VIP Benefits Activated: {registration.VipBenefitsActivated}"); + var success = result.Right; + Console.WriteLine($"Registration successful for {success.Email}"); + Console.WriteLine($"User ID: {success.UserId}"); + Console.WriteLine($"VIP Member: {success.IsVipMember}"); + + if (success.WelcomeMessage != null) + { + Console.WriteLine($"Welcome message: {success.WelcomeMessage}"); + } } else { @@ -77,22 +82,22 @@ private static async Task ProcessRegistration( } } - private static Workflow CreateRegistrationWorkflow() + private static Workflow CreateRegistrationWorkflow() { - return new WorkflowBuilder( + return new WorkflowBuilder( // Create initial payload from request request => new RegistrationPayload( - request.Username, + Guid.NewGuid(), // Generate a new unique ID request.Email, + request.Password, request.IsVipMember), // Create result from final payload - payload => new RegistrationResult( - payload.Username, + payload => new RegistrationSuccess( + payload.UserId, payload.Email, - payload.AccountType, - payload.WelcomeEmailSent, - payload.VipBenefitsActivated) + payload.IsVipMember, + payload.WelcomeMessage) ) // Validate email format .Validate(request => @@ -100,7 +105,7 @@ private static Workflow.Some( - new RegistrationError("INVALID_EMAIL", "Email address is not in a valid format")); + new RegistrationError("INVALID_EMAIL", "Email must contain @ symbol")); } return Option.None(); @@ -108,58 +113,53 @@ private static Workflow { - Console.WriteLine($"Registering user {payload.Username}..."); + Console.WriteLine($"Registering user with email: {payload.Email}"); - // Simulate registration - return Either.FromRight( - payload with { IsRegistered = true }); + // In a real app, this would save the user to a database + return Either.FromRight(payload); }) - // Branch the workflow based on membership type - .Branch( + // Conditional group for VIP members + .Group( + // Condition: only execute for VIP members payload => payload.IsVipMember, - branch => branch - .Do(payload => - { - Console.WriteLine("Activating VIP benefits..."); - return Either.FromRight( - payload with - { - VipBenefitsActivated = true, - AccountType = "VIP" - }); - }) + // Configure the group with VIP-specific activities + group => group .Do(payload => { - Console.WriteLine("Setting up premium support access..."); + Console.WriteLine("Activating VIP benefits..."); + // In a real app, this would activate VIP-specific features return Either.FromRight(payload); }) - ) - // Branch for standard users - .Branch( - payload => !payload.IsVipMember, - branch => branch .Do(payload => { - Console.WriteLine("Setting up standard account features..."); + Console.WriteLine("Sending VIP welcome package notification..."); - return Either.FromRight(payload); + // Update the welcome message for VIP users + return Either.FromRight( + payload with { WelcomeMessage = "Welcome to our VIP program! Your welcome package is on the way." }); }) ) // Send welcome email to all users .Do(payload => { - Console.WriteLine($"Sending welcome email to {payload.Email}..."); + Console.WriteLine($"Sending welcome email to: {payload.Email}"); - // Simulate sending email - return Either.FromRight( - payload with { WelcomeEmailSent = true }); + // Only set a default welcome message if one hasn't been set (for non-VIP users) + if (payload.WelcomeMessage == null) + { + payload = payload with { WelcomeMessage = "Welcome to our platform!" }; + } + + return Either.FromRight(payload); }) - // Finally log the registration + // Log the registration .Finally(payload => { - Console.WriteLine($"Logging registration of {payload.Username} ({payload.AccountType} account)"); + Console.WriteLine($"Logging registration for user: {payload.Email} (ID: {payload.UserId})"); + + // Return the unmodified payload to satisfy the lambda return type return Either.FromRight(payload); }) .Build(); diff --git a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs b/Zooper.Bee.Example/ContextLocalPayloadExample.cs similarity index 92% rename from Zooper.Bee.Example/BranchWithLocalPayloadExample.cs rename to Zooper.Bee.Example/ContextLocalPayloadExample.cs index bf09688..e65e054 100644 --- a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs +++ b/Zooper.Bee.Example/ContextLocalPayloadExample.cs @@ -1,11 +1,8 @@ -using System; -using System.Threading.Tasks; -using Zooper.Bee; using Zooper.Fox; namespace Zooper.Bee.Example; -public class BranchWithLocalPayloadExample +public class ContextLocalPayloadExample { // Request models public record OrderRequest( @@ -33,7 +30,7 @@ public record OrderPayload( decimal TotalAmount = 0, string? ShippingTrackingNumber = null); - // Local payload for shipping branch + // Local payload for shipping context public record ShippingPayload( string CustomerAddress, decimal ShippingCost, @@ -43,7 +40,7 @@ public record ShippingPayload( public static async Task RunExample() { - Console.WriteLine("\n=== Workflow Branch With Local Payload Example ===\n"); + Console.WriteLine("\n=== Workflow With Context Local Payload Example ===\n"); // Create sample requests var standardOrder = new OrderRequest(2001, "Alice Johnson", 75.00m, false); @@ -129,9 +126,9 @@ private static Workflow CreateOrder return Either.FromRight( payload with { TotalAmount = payload.OrderAmount }); }) - // Branch with local payload for shipping-specific processing - .BranchWithLocalPayload( - // Only enter this branch if shipping is needed + // Use specialized context for shipping-specific processing + .WithContext( + // Only enter this context if shipping is needed payload => payload.NeedsShipping, // Create the local shipping payload @@ -141,8 +138,8 @@ private static Workflow CreateOrder PackagingCost: 2.75m, InsuranceCost: 5.00m), - // Configure the branch with shipping-specific activities - branch => branch + // Configure the context with shipping-specific activities + context => context // First shipping activity - calculate shipping costs .Do((mainPayload, shippingPayload) => { diff --git a/Zooper.Bee.Example/ParallelExecutionExample.cs b/Zooper.Bee.Example/ParallelExecutionExample.cs new file mode 100644 index 0000000..07fa77d --- /dev/null +++ b/Zooper.Bee.Example/ParallelExecutionExample.cs @@ -0,0 +1,231 @@ +using Zooper.Fox; + +namespace Zooper.Bee.Example; + +public class ParallelExecutionExample +{ + // Request model + public record DataProcessingRequest(string DataId, string[] Segments, bool NotifyOnCompletion); + + // Success model + public record DataProcessingResult(string DataId, int ProcessedSegments, DateTime CompletedAt); + + // Error model + public record DataProcessingError(string Code, string Message); + + // Main payload model + public record DataProcessingPayload( + string DataId, + string[] Segments, + bool NotifyOnCompletion, + int ProcessedSegments = 0, + bool Validated = false, + DateTime? CompletedAt = null); + + public static async Task RunExample() + { + Console.WriteLine("\n=== Parallel Execution Example ===\n"); + + // Create a sample request + var request = new DataProcessingRequest( + "DATA-12345", + new[] { "Segment1", "Segment2", "Segment3", "Segment4" }, + true + ); + + // Build the workflows + var parallelWorkflow = CreateParallelWorkflow(); + var parallelDetachedWorkflow = CreateParallelDetachedWorkflow(); + + // Process with parallel execution + Console.WriteLine("Processing with parallel execution:"); + await ProcessData(parallelWorkflow, request); + + Console.WriteLine(); + + // Process with parallel detached execution + Console.WriteLine("Processing with parallel detached execution:"); + await ProcessData(parallelDetachedWorkflow, request); + } + + private static async Task ProcessData( + Workflow workflow, + DataProcessingRequest request) + { + var result = await workflow.Execute(request); + + if (result.IsRight) + { + var success = result.Right; + Console.WriteLine($"Data {success.DataId} processed successfully"); + Console.WriteLine($"Processed {success.ProcessedSegments} segments"); + Console.WriteLine($"Completed at: {success.CompletedAt}"); + } + else + { + var error = result.Left; + Console.WriteLine($"Data processing failed: [{error.Code}] {error.Message}"); + } + } + + private static Workflow CreateParallelWorkflow() + { + return new WorkflowBuilder( + // Create initial payload from request + request => new DataProcessingPayload( + request.DataId, + request.Segments, + request.NotifyOnCompletion), + + // Create result from final payload + payload => new DataProcessingResult( + payload.DataId, + payload.ProcessedSegments, + payload.CompletedAt ?? DateTime.UtcNow) + ) + .Do(payload => + { + Console.WriteLine($"Preparing to process data {payload.DataId}..."); + + // Validate data + return Either.FromRight( + payload with { Validated = true }); + }) + // Use parallel execution to process segments in parallel + .Parallel( + // Configure parallel execution groups + parallel => parallel + // First parallel group - process first half of segments + .Group( + // Create a group for the first half + group => group + .Do(payload => + { + var halfwayPoint = payload.Segments.Length / 2; + var firstHalf = payload.Segments[..halfwayPoint]; + + Console.WriteLine($"Processing first half ({firstHalf.Length} segments) in parallel..."); + // Simulate processing time + Task.Delay(500).GetAwaiter().GetResult(); + + return Either.FromRight( + payload with { ProcessedSegments = payload.ProcessedSegments + firstHalf.Length }); + }) + ) + // Second parallel group - process second half of segments + .Group( + // Create a group for the second half + group => group + .Do(payload => + { + var halfwayPoint = payload.Segments.Length / 2; + var secondHalf = payload.Segments[halfwayPoint..]; + + Console.WriteLine($"Processing second half ({secondHalf.Length} segments) in parallel..."); + // Simulate processing time + Task.Delay(300).GetAwaiter().GetResult(); + + return Either.FromRight( + payload with { ProcessedSegments = payload.ProcessedSegments + secondHalf.Length }); + }) + ) + ) + // Finalize the processing + .Do(payload => + { + Console.WriteLine($"Finalizing data processing for {payload.DataId}..."); + var completedAt = DateTime.UtcNow; + + return Either.FromRight( + payload with { CompletedAt = completedAt }); + }) + // Send notification if requested + .DoIf( + payload => payload.NotifyOnCompletion, + payload => + { + Console.WriteLine($"Sending completion notification for data {payload.DataId}..."); + return Either.FromRight(payload); + } + ) + .Build(); + } + + private static Workflow CreateParallelDetachedWorkflow() + { + return new WorkflowBuilder( + // Create initial payload from request + request => new DataProcessingPayload( + request.DataId, + request.Segments, + request.NotifyOnCompletion), + + // Create result from final payload + payload => new DataProcessingResult( + payload.DataId, + payload.ProcessedSegments, + payload.CompletedAt ?? DateTime.UtcNow) + ) + .Do(payload => + { + Console.WriteLine($"Preparing to process data {payload.DataId} with detached parallel execution..."); + + // Since detached execution doesn't wait for results, we'll count all segments as processed + // in the main workflow + return Either.FromRight( + payload with + { + Validated = true, + ProcessedSegments = payload.Segments.Length + }); + }) + // Use parallel detached execution for background tasks + .ParallelDetached( + // Configure parallel detached execution groups + parallelDetached => parallelDetached + // First background task - log processing start + .Detached( + group => group + .Do(payload => + { + Console.WriteLine($"BACKGROUND: Logging processing start for {payload.DataId}..."); + // Simulate logging delay + Task.Delay(200).GetAwaiter().GetResult(); + Console.WriteLine($"BACKGROUND: Logging completed for {payload.DataId}"); + + return Either.FromRight(payload); + }) + ) + // Second background task - generate analytics + .Detached( + group => group + .Do(payload => + { + Console.WriteLine($"BACKGROUND: Generating analytics for {payload.DataId}..."); + // Simulate analytics generation + Task.Delay(1000).GetAwaiter().GetResult(); + Console.WriteLine($"BACKGROUND: Analytics completed for {payload.DataId}"); + + return Either.FromRight(payload); + }) + ) + ) + // Finalize the processing (this runs immediately, not waiting for detached tasks) + .Do(payload => + { + Console.WriteLine($"Main workflow: Finalizing data processing for {payload.DataId}..."); + var completedAt = DateTime.UtcNow; + + return Either.FromRight( + payload with { CompletedAt = completedAt }); + }) + // Wait briefly to allow background tasks to make progress before example ends + .Finally(payload => + { + // Just a small delay so we can see some background task output + Task.Delay(500).GetAwaiter().GetResult(); + return Either.FromRight(payload); + }) + .Build(); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Example/ParameterlessWorkflowExample.cs b/Zooper.Bee.Example/ParameterlessWorkflowExample.cs new file mode 100644 index 0000000..d28e749 --- /dev/null +++ b/Zooper.Bee.Example/ParameterlessWorkflowExample.cs @@ -0,0 +1,148 @@ +using Zooper.Fox; + +namespace Zooper.Bee.Example; + +public class ParameterlessWorkflowExample +{ + // Success model + public record ProcessingResult(DateTime ProcessedAt, string Status); + + // Error model + public record ProcessingError(string Code, string Message); + + // Payload model + public record ProcessingPayload( + DateTime StartedAt, + bool IsCompleted = false, + string Status = "Pending"); + + public static async Task RunExample() + { + Console.WriteLine("\n=== Parameterless Workflow Example ===\n"); + + Console.WriteLine("Example 1: Using WorkflowBuilderFactory.Create"); + await RunExampleWithFactory(); + + Console.WriteLine("\nExample 2: Using Unit type directly"); + await RunExampleWithUnit(); + + Console.WriteLine("\nExample 3: Using extension method for execution"); + await RunExampleWithExtension(); + } + + private static async Task RunExampleWithFactory() + { + // Create a workflow that doesn't need input parameters + var workflow = WorkflowBuilderFactory.CreateWorkflow( + // Initial payload factory - no parameters needed + () => new ProcessingPayload(StartedAt: DateTime.UtcNow), + + // Result selector - convert final payload to success result + payload => new ProcessingResult(DateTime.UtcNow, payload.Status), + + // Configure the workflow + builder => builder + .Do(payload => + { + Console.WriteLine("Processing step 1..."); + return Either.FromRight( + payload with { Status = "Step 1 completed" }); + }) + .Do(payload => + { + Console.WriteLine("Processing step 2..."); + return Either.FromRight( + payload with { Status = "Step 2 completed", IsCompleted = true }); + }) + ); + + // Execute without parameters + var result = await workflow.Execute(); + + if (result.IsRight) + { + Console.WriteLine($"Workflow completed successfully: {result.Right.Status}"); + Console.WriteLine($"Processed at: {result.Right.ProcessedAt}"); + } + else + { + Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}"); + } + } + + private static async Task RunExampleWithUnit() + { + // Create a workflow with Unit type as request + var workflow = new WorkflowBuilder( + // Use Unit parameter (ignored) + _ => new ProcessingPayload(StartedAt: DateTime.UtcNow), + + // Result selector + payload => new ProcessingResult(DateTime.UtcNow, payload.Status) + ) + .Do(payload => + { + Console.WriteLine("Executing task A..."); + return Either.FromRight( + payload with { Status = "Task A completed" }); + }) + .Do(payload => + { + Console.WriteLine("Executing task B..."); + return Either.FromRight( + payload with { Status = "Task B completed", IsCompleted = true }); + }) + .Build(); + + // Execute with Unit.Value + var result = await workflow.Execute(Unit.Value); + + if (result.IsRight) + { + Console.WriteLine($"Workflow completed successfully: {result.Right.Status}"); + Console.WriteLine($"Processed at: {result.Right.ProcessedAt}"); + } + else + { + Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}"); + } + } + + private static async Task RunExampleWithExtension() + { + // Create a workflow with Unit type as request + var workflow = new WorkflowBuilder( + // Use Unit parameter (ignored) + _ => new ProcessingPayload(StartedAt: DateTime.UtcNow), + + // Result selector + payload => new ProcessingResult(DateTime.UtcNow, payload.Status) + ) + .Do(payload => + { + Console.WriteLine("Running process X..."); + return Either.FromRight( + payload with { Status = "Process X completed" }); + }) + .Do(payload => + { + Console.WriteLine("Running process Y..."); + return Either.FromRight( + payload with { Status = "Process Y completed", IsCompleted = true }); + }) + .Build(); + + // Execute using the extension method (no parameters) + var result = await workflow.Execute(); + + if (result.IsRight) + { + Console.WriteLine($"Workflow completed successfully: {result.Right.Status}"); + Console.WriteLine($"Processed at: {result.Right.ProcessedAt}"); + } + else + { + Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}"); + } + } +} \ No newline at end of file diff --git a/Zooper.Bee.Example/Program.cs b/Zooper.Bee.Example/Program.cs index 6703e2b..333c484 100644 --- a/Zooper.Bee.Example/Program.cs +++ b/Zooper.Bee.Example/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Threading.Tasks; -using Zooper.Bee; -using Zooper.Fox; +using Zooper.Fox; namespace Zooper.Bee.Example; @@ -44,7 +41,13 @@ public static async Task Main() await BranchingExample.RunExample(); // Run the branch with local payload example - await BranchWithLocalPayloadExample.RunExample(); + await ContextLocalPayloadExample.RunExample(); + + // Run the parallel execution example + await ParallelExecutionExample.RunExample(); + + // Run the parameterless workflow example + await ParameterlessWorkflowExample.RunExample(); } private static async Task ProcessOrder(OrderRequest request) diff --git a/Zooper.Bee.Tests/BranchTests.cs b/Zooper.Bee.Tests/BranchTests.cs index 9cf3f7c..406f65a 100644 --- a/Zooper.Bee.Tests/BranchTests.cs +++ b/Zooper.Bee.Tests/BranchTests.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -37,7 +36,7 @@ public async Task Branch_ExecutesWhenConditionIsTrue() payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") ) .Do(payload => Either.FromRight(payload)) - .Branch( + .Group( // Condition: Category is Premium payload => payload.Category == "Premium", branch => branch @@ -51,7 +50,7 @@ public async Task Branch_ExecutesWhenConditionIsTrue() return Either.FromRight(processed); }) ) - .Branch( + .Group( // Condition: Category is Standard payload => payload.Category == "Standard", branch => branch @@ -92,7 +91,7 @@ public async Task Branch_SkipsWhenConditionIsFalse() ) .Do(payload => Either.FromRight( payload with { ProcessingResult = "Initial Processing" })) - .Branch( + .Group( // Condition: Category is Premium and Value is over 1000 payload => payload.Category == "Premium" && payload.Value > 1000, branch => branch @@ -122,7 +121,7 @@ public async Task Branch_UnconditionalBranch_AlwaysExecutes() request => new TestPayload(request.Name, request.Value, request.Category), payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") ) - .Branch( + .Group( branch => branch .Do(payload => Either.FromRight( payload with { ProcessingResult = "Always Processed" })) @@ -149,21 +148,21 @@ public async Task Branch_MultipleBranches_CorrectlyExecutes() ) .Do(payload => Either.FromRight( payload with { ProcessingResult = "Initial" })) - .Branch( + .Group( // First branch - based on Category payload => payload.Category == "Premium", branch => branch .Do(payload => Either.FromRight( payload with { ProcessingResult = payload.ProcessingResult + " + Premium" })) ) - .Branch( + .Group( // Second branch - based on Value payload => payload.Value > 75, branch => branch .Do(payload => Either.FromRight( payload with { ProcessingResult = payload.ProcessingResult + " + High Value" })) ) - .Branch( + .Group( // Third branch - always executes branch => branch .Do(payload => Either.FromRight( @@ -190,7 +189,7 @@ public async Task Branch_WithError_StopsExecutionAndReturnsError() request => new TestPayload(request.Name, request.Value, request.Category), payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") ) - .Branch( + .Group( payload => payload.Category == "Premium", branch => branch .Do(payload => @@ -204,7 +203,7 @@ public async Task Branch_WithError_StopsExecutionAndReturnsError() payload with { ProcessingResult = "Premium Processing" }); }) ) - .Branch( + .Group( branch => branch .Do(payload => Either.FromRight( payload with { ProcessingResult = "Final Processing" })) @@ -230,7 +229,7 @@ public async Task Branch_WithMultipleActivities_ExecutesAllInOrder() request => new TestPayload(request.Name, request.Value, request.Category), payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed") ) - .Branch( + .Group( payload => true, branch => branch .Do(payload => Either.FromRight( diff --git a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs index 2febd21..122d59c 100644 --- a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs +++ b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs @@ -6,7 +6,7 @@ namespace Zooper.Bee.Tests; -public class BranchWithLocalPayloadTests +public class ContextTests { #region Test Models // Request model @@ -19,30 +19,27 @@ private record ProductPayload( decimal Price, bool NeedsCustomProcessing, string? ProcessingResult = null, - string? CustomizationDetails = null, decimal FinalPrice = 0); - // Local payload for customization branch + // Local payload for customization context private record CustomizationPayload( string[] AvailableOptions, string[] SelectedOptions, - decimal CustomizationCost, - string CustomizationDetails); + decimal CustomizationCost); // Success result model private record ProductResult( int Id, string Name, decimal FinalPrice, - string? ProcessingResult, - string? CustomizationDetails); + string? ProcessingResult); // Error model private record ProductError(string Code, string Message); #endregion [Fact] - public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() + public async Task WithContext_ExecutesWhenConditionIsTrue() { // Arrange var workflow = new WorkflowBuilder( @@ -58,8 +55,7 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, - payload.CustomizationDetails) + payload.ProcessingResult) ) .Do(payload => { @@ -70,8 +66,8 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() FinalPrice = payload.Price }); }) - // Branch with local payload for products that need customization - .BranchWithLocalPayload( + // Context with local payload for products that need customization + .WithContext( // Condition: Product needs custom processing payload => payload.NeedsCustomProcessing, @@ -79,38 +75,21 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() payload => new CustomizationPayload( AvailableOptions: new[] { "Engraving", "Gift Wrap", "Extended Warranty" }, SelectedOptions: new[] { "Engraving", "Gift Wrap" }, - CustomizationCost: 25.99m, - CustomizationDetails: "Custom initialized" + CustomizationCost: 25.99m ), - // Branch configuration - branch => branch - // First customization activity - process options - .Do((mainPayload, localPayload) => - { - // Process the selected options - string optionsProcessed = string.Join(", ", localPayload.SelectedOptions); - - // Update both payloads - var updatedLocalPayload = localPayload with - { - CustomizationDetails = $"Options: {optionsProcessed}" - }; - - return Either.FromRight( - (mainPayload, updatedLocalPayload)); - }) - // Second customization activity - apply costs and finalize customization + // Context configuration + context => context + // Apply customization costs .Do((mainPayload, localPayload) => { // Calculate total price decimal totalPrice = mainPayload.Price + localPayload.CustomizationCost; - // Update both payloads + // Update the main payload with customization results var updatedMainPayload = mainPayload with { FinalPrice = totalPrice, - CustomizationDetails = localPayload.CustomizationDetails, ProcessingResult = $"{mainPayload.ProcessingResult} with customization" }; @@ -128,88 +107,82 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue() var standardResult = await workflow.Execute(standardProduct); // Assert - // Custom product should go through customization customResult.IsRight.Should().BeTrue(); customResult.Right.FinalPrice.Should().Be(125.98m); // 99.99 + 25.99 customResult.Right.ProcessingResult.Should().Be("Standard processing complete with customization"); - customResult.Right.CustomizationDetails.Should().Be("Options: Engraving, Gift Wrap"); // Standard product should not go through customization standardResult.IsRight.Should().BeTrue(); standardResult.Right.FinalPrice.Should().Be(49.99m); // Just base price standardResult.Right.ProcessingResult.Should().Be("Standard processing complete"); - standardResult.Right.CustomizationDetails.Should().BeNull(); } [Fact] - public async Task BranchWithLocalPayload_LocalPayloadIsolated_NotAffectedByOtherActivities() + public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities() { // Arrange var workflow = new WorkflowBuilder( request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), payload => new ProductResult( - payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, payload.CustomizationDetails) + payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult) ) .Do(payload => Either.FromRight(payload with { ProcessingResult = "Initial processing", FinalPrice = payload.Price })) - .BranchWithLocalPayload( - // Condition + // First context + .WithContext( + // Always execute payload => true, - // Create local payload + // Create local payload for first context _ => new CustomizationPayload( AvailableOptions: new[] { "Option1", "Option2" }, SelectedOptions: new[] { "Option1" }, - CustomizationCost: 10.00m, - CustomizationDetails: "Branch 1 customization" + CustomizationCost: 10.00m ), - // Branch configuration - branch => branch + // Configure first context + context => context .Do((mainPayload, localPayload) => { var updatedMainPayload = mainPayload with { - ProcessingResult = mainPayload.ProcessingResult + " -> Branch 1", - FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, - CustomizationDetails = localPayload.CustomizationDetails + ProcessingResult = mainPayload.ProcessingResult + " -> Context 1", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost }; return Either.FromRight( (updatedMainPayload, localPayload)); }) ) - // Another main activity that changes the main payload but shouldn't affect the next branch's local payload + // Main activity that changes the main payload but shouldn't affect the next context's local payload .Do(payload => Either.FromRight(payload with { ProcessingResult = payload.ProcessingResult + " -> Main activity" })) - .BranchWithLocalPayload( - // Second branch + // Second context - should have its own isolated local payload + .WithContext( + // Always execute payload => true, // Create a different local payload _ => new CustomizationPayload( AvailableOptions: new[] { "OptionA", "OptionB" }, SelectedOptions: new[] { "OptionA", "OptionB" }, - CustomizationCost: 20.00m, - CustomizationDetails: "Branch 2 customization" + CustomizationCost: 20.00m ), - // Branch configuration - branch => branch + // Configure second context + context => context .Do((mainPayload, localPayload) => { var updatedMainPayload = mainPayload with { - ProcessingResult = mainPayload.ProcessingResult + " -> Branch 2", - FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, - CustomizationDetails = mainPayload.CustomizationDetails + " + " + localPayload.CustomizationDetails + ProcessingResult = mainPayload.ProcessingResult + " -> Context 2", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost }; return Either.FromRight( @@ -218,131 +191,144 @@ public async Task BranchWithLocalPayload_LocalPayloadIsolated_NotAffectedByOther ) .Build(); - var request = new ProductRequest(1001, "Test Product", 100.00m, true); - // Act - var result = await workflow.Execute(request); + var result = await workflow.Execute(new ProductRequest(1, "Test Product", 100.00m, false)); // Assert result.IsRight.Should().BeTrue(); - result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Branch 1 -> Branch 2"); - result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20 - result.Right.CustomizationDetails.Should().Be("Branch 1 customization + Branch 2 customization"); + result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2"); + result.Right.FinalPrice.Should().Be(130.00m); // Base (100) + Context 1 (10) + Context 2 (20) } [Fact] - public async Task BranchWithLocalPayload_ErrorInBranch_StopsExecutionAndReturnsError() + public async Task WithContext_ErrorInBranch_StopsExecutionAndReturnsError() { // Arrange var workflow = new WorkflowBuilder( request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), payload => new ProductResult( - payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, payload.CustomizationDetails) + payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult) ) - .Do(payload => Either.FromRight( - payload with { ProcessingResult = "Initial processing" })) - .BranchWithLocalPayload( + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = "Initial processing", + FinalPrice = payload.Price + })) + .WithContext( + // Condition payload => payload.NeedsCustomProcessing, - _ => new CustomizationPayload( - AvailableOptions: new string[0], - SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error - CustomizationCost: 10.00m, - CustomizationDetails: "Should fail" + + // Create local payload + payload => new CustomizationPayload( + AvailableOptions: new[] { "Option1", "Option2" }, + SelectedOptions: new[] { "Option1" }, + CustomizationCost: payload.Price * 0.10m // 10% surcharge ), - branch => branch + + // Configure context + context => context .Do((mainPayload, localPayload) => { - // Validate selected options are available - foreach (var option in localPayload.SelectedOptions) + // Simulate error if price is too high + if (mainPayload.Price + localPayload.CustomizationCost > 150) { - if (Array.IndexOf(localPayload.AvailableOptions, option) < 0) - { - return Either.FromLeft( - new ProductError("INVALID_OPTION", $"Option '{option}' is not available")); - } + return Either.FromLeft( + new ProductError("PRICE_TOO_HIGH", "Product with customization exceeds price limit")); } + var updatedMainPayload = mainPayload with + { + ProcessingResult = "Customization applied", + FinalPrice = mainPayload.Price + localPayload.CustomizationCost + }; + return Either.FromRight( - (mainPayload, localPayload)); + (updatedMainPayload, localPayload)); }) ) - .Do(payload => Either.FromRight( - payload with { ProcessingResult = payload.ProcessingResult + " -> Final processing" })) + // This activity should not execute if the context returns an error + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = payload.ProcessingResult + " -> Final processing" + })) .Build(); - var request = new ProductRequest(1001, "Test Product", 100.00m, true); - // Act - var result = await workflow.Execute(request); + var expensiveProduct = new ProductRequest(1, "Expensive Product", 150.00m, true); + var result = await workflow.Execute(expensiveProduct); // Assert result.IsLeft.Should().BeTrue(); - result.Left.Code.Should().Be("INVALID_OPTION"); - result.Left.Message.Should().Be("Option 'Unavailable Option' is not available"); + result.Left.Code.Should().Be("PRICE_TOO_HIGH"); + result.Left.Message.Should().Be("Product with customization exceeds price limit"); } [Fact] - public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLocalPayload() + public async Task WithContext_MultipleActivitiesInSameBranch_ShareLocalPayload() { // Arrange var workflow = new WorkflowBuilder( request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), payload => new ProductResult( - payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, payload.CustomizationDetails) + payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult) ) - .BranchWithLocalPayload( - _ => true, + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = "Initial processing", + FinalPrice = payload.Price + })) + .WithContext( + // Always execute + payload => true, + + // Create local payload _ => new CustomizationPayload( AvailableOptions: new[] { "Option1", "Option2", "Option3" }, - SelectedOptions: new string[0], // Start with no selected options - CustomizationCost: 0m, // Start with no cost - CustomizationDetails: "Start" + SelectedOptions: Array.Empty(), // Start with no selections + CustomizationCost: 0 // Start with no cost ), - branch => branch - // First activity - select Option1 + + // Configure context with multiple activities that share local payload + context => context + // First activity selects options .Do((mainPayload, localPayload) => { - var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; - Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); - updatedOptions[updatedOptions.Length - 1] = "Option1"; + // Add options based on product price + var selectedOptions = mainPayload.Price > 100 + ? new[] { "Option1", "Option2" } + : new[] { "Option1" }; var updatedLocalPayload = localPayload with { - SelectedOptions = updatedOptions, - CustomizationCost = localPayload.CustomizationCost + 10m, - CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option1" + SelectedOptions = selectedOptions }; return Either.FromRight( (mainPayload, updatedLocalPayload)); }) - // Second activity - select Option2 + // Second activity calculates cost based on selections .Do((mainPayload, localPayload) => { - var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; - Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); - updatedOptions[updatedOptions.Length - 1] = "Option2"; + // Calculate cost based on selected options + decimal cost = localPayload.SelectedOptions.Length * 15.00m; var updatedLocalPayload = localPayload with { - SelectedOptions = updatedOptions, - CustomizationCost = localPayload.CustomizationCost + 15m, - CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option2" + CustomizationCost = cost }; return Either.FromRight( (mainPayload, updatedLocalPayload)); }) - // Third activity - finalize and update main payload + // Third activity applies the customization to the main payload .Do((mainPayload, localPayload) => { + string optionsDescription = string.Join(", ", localPayload.SelectedOptions); + var updatedMainPayload = mainPayload with { - FinalPrice = mainPayload.Price + localPayload.CustomizationCost, - CustomizationDetails = localPayload.CustomizationDetails, - ProcessingResult = $"Processed with {localPayload.SelectedOptions.Length} options" + ProcessingResult = $"Customized with options: {optionsDescription}", + FinalPrice = mainPayload.Price + localPayload.CustomizationCost }; return Either.FromRight( @@ -351,44 +337,49 @@ public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLoc ) .Build(); - var request = new ProductRequest(1001, "Test Product", 100.00m, true); - // Act - var result = await workflow.Execute(request); + var expensiveProduct = new ProductRequest(1, "Expensive Product", 150.00m, true); + var cheapProduct = new ProductRequest(2, "Cheap Product", 50.00m, true); + + var expensiveResult = await workflow.Execute(expensiveProduct); + var cheapResult = await workflow.Execute(cheapProduct); // Assert - result.IsRight.Should().BeTrue(); - result.Right.FinalPrice.Should().Be(125.00m); // 100 + 10 + 15 - result.Right.ProcessingResult.Should().Be("Processed with 2 options"); - result.Right.CustomizationDetails.Should().Be("Start -> Added Option1 -> Added Option2"); + expensiveResult.IsRight.Should().BeTrue(); + expensiveResult.Right.ProcessingResult.Should().Be("Customized with options: Option1, Option2"); + expensiveResult.Right.FinalPrice.Should().Be(180.00m); // 150 + (2 options * 15) + + cheapResult.IsRight.Should().BeTrue(); + cheapResult.Right.ProcessingResult.Should().Be("Customized with options: Option1"); + cheapResult.Right.FinalPrice.Should().Be(65.00m); // 50 + (1 option * 15) } [Fact] - public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes() + public async Task WithContext_UnconditionalBranch_AlwaysExecutes() { // Arrange var workflow = new WorkflowBuilder( request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), payload => new ProductResult( - payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, payload.CustomizationDetails) + payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult) ) - .BranchWithLocalPayload( - // Local payload factory only + // Use WithContext without condition (which means it always executes) + .WithContext( + // Create local payload _ => new CustomizationPayload( - AvailableOptions: new[] { "Default Option" }, - SelectedOptions: new[] { "Default Option" }, - CustomizationCost: 5.00m, - CustomizationDetails: "Default customization" + AvailableOptions: new[] { "Standard Option" }, + SelectedOptions: new[] { "Standard Option" }, + CustomizationCost: 5.00m ), - branch => branch + + // Configure context + context => context .Do((mainPayload, localPayload) => { var updatedMainPayload = mainPayload with { - FinalPrice = mainPayload.Price + localPayload.CustomizationCost, - CustomizationDetails = localPayload.CustomizationDetails, - ProcessingResult = "Processed with default customization" + ProcessingResult = "Standard processing applied", + FinalPrice = mainPayload.Price + localPayload.CustomizationCost }; return Either.FromRight( @@ -397,61 +388,13 @@ public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes() ) .Build(); - var request = new ProductRequest(1001, "Test Product", 100.00m, false); - - // Act - var result = await workflow.Execute(request); - - // Assert - result.IsRight.Should().BeTrue(); - result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5 - result.Right.ProcessingResult.Should().Be("Processed with default customization"); - result.Right.CustomizationDetails.Should().Be("Default customization"); - } - - [Fact] - public async Task BranchWithLocalPayload_UnconditionalBranchFluentApi_AlwaysExecutes() - { - // Arrange - var workflow = new WorkflowBuilder( - request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), - payload => new ProductResult( - payload.Id, payload.Name, payload.FinalPrice, - payload.ProcessingResult, payload.CustomizationDetails) - ) - .BranchWithLocalPayload( - // Local payload factory only (no condition parameter) - _ => new CustomizationPayload( - AvailableOptions: new[] { "Default Option" }, - SelectedOptions: new[] { "Default Option" }, - CustomizationCost: 5.00m, - CustomizationDetails: "Default customization (fluent API)" - ), - // Use callback pattern instead of fluent API - branch => branch.Do((mainPayload, localPayload) => - { - var updatedMainPayload = mainPayload with - { - FinalPrice = mainPayload.Price + localPayload.CustomizationCost, - CustomizationDetails = localPayload.CustomizationDetails, - ProcessingResult = "Processed with fluent API" - }; - - return Either.FromRight( - (updatedMainPayload, localPayload)); - }) - ) - .Build(); - - var request = new ProductRequest(1001, "Test Product", 100.00m, false); - // Act - var result = await workflow.Execute(request); + var product = new ProductRequest(1, "Test Product", 100.00m, false); + var result = await workflow.Execute(product); // Assert result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Standard processing applied"); result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5 - result.Right.ProcessingResult.Should().Be("Processed with fluent API"); - result.Right.CustomizationDetails.Should().Be("Default customization (fluent API)"); } } \ No newline at end of file diff --git a/Zooper.Bee.Tests/DetachedExecutionTests.cs b/Zooper.Bee.Tests/DetachedExecutionTests.cs new file mode 100644 index 0000000..4446dfb --- /dev/null +++ b/Zooper.Bee.Tests/DetachedExecutionTests.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class DetachedExecutionTests +{ + #region Test Models + // Request model + private record NotificationRequest(string UserId, string Message, bool IsUrgent); + + // Main payload model + private record NotificationPayload( + string UserId, + string Message, + bool IsUrgent, + bool IsProcessed = false, + string Status = "Pending"); + + // Success result model + private record NotificationResult(string UserId, string Status); + + // Error model + private record NotificationError(string Code, string Message); + #endregion + + [Fact] + public async Task Detached_ExecutesInBackground_DoesNotAffectMainWorkflow() + { + // Arrange + var backgroundTaskCompleted = new TaskCompletionSource(); + var syncObj = new object(); + var backgroundTaskRan = false; + + var workflow = new WorkflowBuilder( + request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent), + payload => new NotificationResult(payload.UserId, payload.Status) + ) + .Do(payload => + { + // Main workflow processing + return Either.FromRight( + payload with + { + IsProcessed = true, + Status = "Processed" + }); + }) + // Detached execution that won't affect the main workflow result + .Detach( + detached => detached + .Do(payload => + { + try + { + // This task runs in the background + lock (syncObj) + { + backgroundTaskRan = true; + } + + // Simulate some work + Thread.Sleep(100); + + // In a real application, this might send an email or log to a database + Console.WriteLine($"Background notification sent to: {payload.UserId}"); + + // This Status change should NOT affect the main workflow result + backgroundTaskCompleted.SetResult(true); + return Either.FromRight( + payload with { Status = "Background task executed" }); + } + catch (Exception ex) + { + backgroundTaskCompleted.SetException(ex); + throw; + } + }) + ) + .Finally(payload => + { + // Add a small delay to allow the background task to start + Thread.Sleep(200); + return Either.FromRight(payload); + }) + .Build(); + + var request = new NotificationRequest("user-123", "Test message", false); + + // Act + var result = await workflow.Execute(request); + + // Wait for the background task to complete or timeout after 2 seconds + var timeoutTask = Task.Delay(2000); + var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask); + var timedOut = completedTask == timeoutTask; + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Status.Should().Be("Processed"); // Should have the status from the main workflow + + // Verify the background task ran + lock (syncObj) + { + backgroundTaskRan.Should().BeTrue(); + } + + timedOut.Should().BeFalse("Background task timed out"); + } + + [Fact] + public async Task Detached_WithCondition_OnlyExecutesWhenConditionIsTrue() + { + // Arrange + var urgentTaskCompleted = new TaskCompletionSource(); + var regularTaskCompleted = new TaskCompletionSource(); + var syncObj = new object(); + var urgentTaskRan = false; + var regularTaskRan = false; + + var workflow = new WorkflowBuilder( + request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent), + payload => new NotificationResult(payload.UserId, payload.Status) + ) + .Do(payload => + { + // Main workflow processing + return Either.FromRight( + payload with + { + IsProcessed = true, + Status = "Processed" + }); + }) + // Conditional detached execution for urgent notifications + .Detach( + // Only execute for urgent notifications + payload => payload.IsUrgent, + detached => detached + .Do(payload => + { + try + { + lock (syncObj) + { + urgentTaskRan = true; + } + + // Simulate some work + Thread.Sleep(100); + + Console.WriteLine($"URGENT notification sent to: {payload.UserId}"); + + urgentTaskCompleted.SetResult(true); + return Either.FromRight(payload); + } + catch (Exception ex) + { + urgentTaskCompleted.SetException(ex); + throw; + } + }) + ) + // Unconditional detached execution for all notifications + .Detach( + detached => detached + .Do(payload => + { + try + { + lock (syncObj) + { + regularTaskRan = true; + } + + // Simulate some work + Thread.Sleep(100); + + Console.WriteLine($"Regular notification processing for: {payload.UserId}"); + + regularTaskCompleted.SetResult(true); + return Either.FromRight(payload); + } + catch (Exception ex) + { + regularTaskCompleted.SetException(ex); + throw; + } + }) + ) + .Finally(payload => + { + // Add a small delay to allow the background task to start + Thread.Sleep(200); + return Either.FromRight(payload); + }) + .Build(); + + // Act & Assert for urgent request + var urgentRequest = new NotificationRequest("user-urgent", "Urgent message", true); + var urgentResult = await workflow.Execute(urgentRequest); + + // Wait for the background tasks to complete or timeout + var timeoutTask = Task.Delay(2000); + await Task.WhenAny( + Task.WhenAll(urgentTaskCompleted.Task, regularTaskCompleted.Task), + timeoutTask); + + urgentResult.IsRight.Should().BeTrue(); + urgentResult.Right.Status.Should().Be("Processed"); + + lock (syncObj) + { + urgentTaskRan.Should().BeTrue(); // Urgent task should run for urgent requests + regularTaskRan.Should().BeTrue(); // Regular task should run for all requests + } + + // Reset for next test + urgentTaskCompleted = new TaskCompletionSource(); + regularTaskCompleted = new TaskCompletionSource(); + lock (syncObj) + { + urgentTaskRan = false; + regularTaskRan = false; + } + + // Act & Assert for regular request + var regularRequest = new NotificationRequest("user-regular", "Regular message", false); + var regularResult = await workflow.Execute(regularRequest); + + // Wait for the background tasks to complete or timeout + timeoutTask = Task.Delay(2000); + await Task.WhenAny(regularTaskCompleted.Task, timeoutTask); + + regularResult.IsRight.Should().BeTrue(); + regularResult.Right.Status.Should().Be("Processed"); + + lock (syncObj) + { + urgentTaskRan.Should().BeFalse(); // Urgent task should NOT run for regular requests + regularTaskRan.Should().BeTrue(); // Regular task should run for all requests + } + } + + [Fact] + public async Task Detached_WithMultipleActivities_ExecutesAllInOrder() + { + // Arrange + var detachedTasksCompleted = new TaskCompletionSource(); + var syncObj = new object(); + var executionOrder = new List(); + + var workflow = new WorkflowBuilder( + request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent), + payload => new NotificationResult(payload.UserId, payload.Status) + ) + .Do(payload => + { + // Main workflow processing + lock (syncObj) + { + executionOrder.Add("Main"); + } + + return Either.FromRight( + payload with { IsProcessed = true, Status = "Processed" }); + }) + // Detached execution with multiple activities + .Detach( + detached => detached + .Do(payload => + { + try + { + // First detached activity + lock (syncObj) + { + executionOrder.Add("Detached1"); + } + + // Simulate some work + Thread.Sleep(50); + + return Either.FromRight(payload); + } + catch (Exception) + { + detachedTasksCompleted.SetException(new Exception("Failed in Detached1")); + throw; + } + }) + .Do(payload => + { + try + { + // Second detached activity - should run after the first one + lock (syncObj) + { + executionOrder.Add("Detached2"); + } + + // Simulate some work + Thread.Sleep(50); + + return Either.FromRight(payload); + } + catch (Exception) + { + detachedTasksCompleted.SetException(new Exception("Failed in Detached2")); + throw; + } + }) + .Do(payload => + { + try + { + // Third detached activity - should run after the second one + lock (syncObj) + { + executionOrder.Add("Detached3"); + } + + // Notify that all detached tasks completed + detachedTasksCompleted.SetResult(true); + + return Either.FromRight(payload); + } + catch (Exception ex) + { + detachedTasksCompleted.SetException(ex); + throw; + } + }) + ) + .Finally(payload => + { + // Add a small delay to allow the background task to start + Thread.Sleep(200); + return Either.FromRight(payload); + }) + .Build(); + + var request = new NotificationRequest("user-123", "Test message", false); + + // Act + var result = await workflow.Execute(request); + + // Wait for the detached tasks to complete or timeout after 2 seconds + var timeoutTask = Task.Delay(2000); + var completedTask = await Task.WhenAny(detachedTasksCompleted.Task, timeoutTask); + var timedOut = completedTask == timeoutTask; + + // Assert + result.IsRight.Should().BeTrue(); + timedOut.Should().BeFalse("Detached tasks timed out"); + + // Lock to access shared state + List capturedOrder; + lock (syncObj) + { + capturedOrder = new List(executionOrder); + } + + // Check that the main activity executed first + capturedOrder[0].Should().Be("Main"); + + // Check that the detached activities executed in order relative to each other + // We need to find the indices of each detached activity + int detached1Index = capturedOrder.IndexOf("Detached1"); + int detached2Index = capturedOrder.IndexOf("Detached2"); + int detached3Index = capturedOrder.IndexOf("Detached3"); + + // All detached activities should be found + detached1Index.Should().BeGreaterThan(0, "Detached1 should have executed"); + detached2Index.Should().BeGreaterThan(0, "Detached2 should have executed"); + detached3Index.Should().BeGreaterThan(0, "Detached3 should have executed"); + + // Check the order + detached1Index.Should().BeLessThan(detached2Index, "Detached1 should execute before Detached2"); + detached2Index.Should().BeLessThan(detached3Index, "Detached2 should execute before Detached3"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/ParallelExecutionTests.cs b/Zooper.Bee.Tests/ParallelExecutionTests.cs new file mode 100644 index 0000000..84ccd48 --- /dev/null +++ b/Zooper.Bee.Tests/ParallelExecutionTests.cs @@ -0,0 +1,403 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class ParallelExecutionTests +{ + #region Test Models + // Request model + private record TestRequest(string Id, int[] Values); + + // Main payload model + private record TestPayload( + string Id, + int[] Values, + int Sum = 0, + int Product = 0, + bool IsProcessed = false); + + // Success result model + private record TestSuccess(string Id, int Sum, int Product, bool IsProcessed); + + // Error model + private record TestError(string Code, string Message); + #endregion + + [Fact] + public async Task Parallel_ExecutesGroupsInParallel_CombinesResults() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Id, request.Values), + payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed) + ) + .Do(payload => Either.FromRight( + payload with { IsProcessed = true })) + .Parallel( + parallel => parallel + // First parallel group - calculate sum + .Group( + group => group + .Do(payload => + { + int sum = 0; + foreach (var value in payload.Values) + { + sum += value; + } + return Either.FromRight( + payload with { Sum = sum }); + }) + ) + // Second parallel group - calculate product + .Group( + group => group + .Do(payload => + { + int product = 1; + foreach (var value in payload.Values) + { + product *= value; + } + return Either.FromRight( + payload with { Product = product }); + }) + ) + ) + .Build(); + + var request = new TestRequest("test-123", new[] { 2, 3, 5 }); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Sum.Should().Be(10); // 2 + 3 + 5 + result.Right.Product.Should().Be(30); // 2 * 3 * 5 + result.Right.IsProcessed.Should().BeTrue(); + } + + [Fact] + public async Task Parallel_WithConditionalGroups_OnlyExecutesMatchingGroups() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Id, request.Values), + payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed) + ) + .Parallel( + parallel => parallel + // Group that only runs when the ID starts with "sum-" + .Group( + // Condition + payload => payload.Id.StartsWith("sum-"), + group => group + .Do(payload => + { + int sum = 0; + foreach (var value in payload.Values) + { + sum += value; + } + return Either.FromRight( + payload with { Sum = sum }); + }) + ) + // Group that only runs when the ID starts with "product-" + .Group( + // Condition + payload => payload.Id.StartsWith("product-"), + group => group + .Do(payload => + { + int product = 1; + foreach (var value in payload.Values) + { + product *= value; + } + return Either.FromRight( + payload with { Product = product }); + }) + ) + // Group that always runs + .Group( + group => group + .Do(payload => Either.FromRight( + payload with { IsProcessed = true })) + ) + ) + .Build(); + + var sumRequest = new TestRequest("sum-123", new[] { 2, 3, 5 }); + var productRequest = new TestRequest("product-456", new[] { 2, 3, 5 }); + var otherRequest = new TestRequest("other-789", new[] { 2, 3, 5 }); + + // Act + var sumResult = await workflow.Execute(sumRequest); + var productResult = await workflow.Execute(productRequest); + var otherResult = await workflow.Execute(otherRequest); + + // Assert + sumResult.IsRight.Should().BeTrue(); + sumResult.Right.Sum.Should().Be(10); // 2 + 3 + 5 + sumResult.Right.Product.Should().Be(0); // Not calculated + sumResult.Right.IsProcessed.Should().BeTrue(); + + productResult.IsRight.Should().BeTrue(); + productResult.Right.Sum.Should().Be(0); // Not calculated + productResult.Right.Product.Should().Be(30); // 2 * 3 * 5 + productResult.Right.IsProcessed.Should().BeTrue(); + + otherResult.IsRight.Should().BeTrue(); + otherResult.Right.Sum.Should().Be(0); // Not calculated + otherResult.Right.Product.Should().Be(0); // Not calculated + otherResult.Right.IsProcessed.Should().BeTrue(); // Only the unconditional group ran + } + + [Fact] + public async Task Parallel_ErrorInOneGroup_StopsExecutionAndReturnsError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Id, request.Values), + payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed) + ) + .Parallel( + parallel => parallel + // First group - calculate sum (will succeed) + .Group( + group => group + .Do(payload => + { + int sum = 0; + foreach (var value in payload.Values) + { + sum += value; + } + return Either.FromRight( + payload with { Sum = sum }); + }) + ) + // Second group - will fail if values contain zero + .Group( + group => group + .Do(payload => + { + foreach (var value in payload.Values) + { + if (value == 0) + { + return Either.FromLeft( + new TestError("ZERO_VALUE", "Cannot process values containing zero")); + } + } + + int product = 1; + foreach (var value in payload.Values) + { + product *= value; + } + return Either.FromRight( + payload with { Product = product }); + }) + ) + ) + .Build(); + + var validRequest = new TestRequest("valid", new[] { 2, 3, 5 }); + var invalidRequest = new TestRequest("invalid", new[] { 2, 0, 5 }); + + // Act + var validResult = await workflow.Execute(validRequest); + var invalidResult = await workflow.Execute(invalidRequest); + + // Assert + validResult.IsRight.Should().BeTrue(); + validResult.Right.Sum.Should().Be(10); + validResult.Right.Product.Should().Be(30); + + invalidResult.IsLeft.Should().BeTrue(); + invalidResult.Left.Code.Should().Be("ZERO_VALUE"); + invalidResult.Left.Message.Should().Be("Cannot process values containing zero"); + } + + [Fact] + public async Task ParallelDetached_DetachedGroupsDoNotAffectResult() + { + // Arrange + var backgroundTaskCompleted = new TaskCompletionSource(); + var syncObj = new object(); + var backgroundTaskRan = false; + + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Id, request.Values), + payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed) + ) + .Do(payload => + { + int sum = 0; + foreach (var value in payload.Values) + { + sum += value; + } + return Either.FromRight( + payload with { Sum = sum, IsProcessed = true }); + }) + .ParallelDetached( + parallelDetached => parallelDetached + .Detached( + detachedGroup => detachedGroup + .Do(payload => + { + try + { + // This is a detached task, its changes should not affect the main workflow + lock (syncObj) + { + backgroundTaskRan = true; + } + + // Simulate some work + Thread.Sleep(100); + + // This modification to Product should NOT be reflected in the final result + int product = 1; + foreach (var value in payload.Values) + { + product *= value; + } + + backgroundTaskCompleted.SetResult(true); + return Either.FromRight( + payload with { Product = product }); + } + catch (Exception ex) + { + backgroundTaskCompleted.SetException(ex); + throw; + } + }) + ) + ) + .Finally(payload => + { + // Add a small delay to allow the background task to start + Thread.Sleep(200); + return Either.FromRight(payload); + }) + .Build(); + + var request = new TestRequest("test-123", new[] { 2, 3, 5 }); + + // Act + var result = await workflow.Execute(request); + + // Wait for the background task to complete or timeout after 2 seconds + var timeoutTask = Task.Delay(2000); + var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask); + var timedOut = completedTask == timeoutTask; + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Sum.Should().Be(10); // 2 + 3 + 5 + result.Right.Product.Should().Be(0); // Should NOT be updated by detached group + result.Right.IsProcessed.Should().BeTrue(); + + // Verify that the background task did run (or was at least started) + lock (syncObj) + { + backgroundTaskRan.Should().BeTrue(); + } + + timedOut.Should().BeFalse("Background task timed out"); + } + + [Fact] + public async Task ParallelDetached_ErrorInDetachedGroup_DoesNotAffectMainWorkflow() + { + // Arrange + var backgroundTaskCompleted = new TaskCompletionSource(); + var syncObj = new object(); + var backgroundTaskRan = false; + + var workflow = new WorkflowBuilder( + request => new TestPayload(request.Id, request.Values), + payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed) + ) + .Do(payload => + { + int sum = 0; + foreach (var value in payload.Values) + { + sum += value; + } + return Either.FromRight( + payload with { Sum = sum, IsProcessed = true }); + }) + .ParallelDetached( + parallelDetached => parallelDetached + .Detached( + detachedGroup => detachedGroup + .Do(payload => + { + try + { + lock (syncObj) + { + backgroundTaskRan = true; + } + + // Simulate some work + Thread.Sleep(100); + + // This error should NOT affect the main workflow + backgroundTaskCompleted.SetResult(true); + return Either.FromLeft( + new TestError("BACKGROUND_ERROR", "This error occurs in background")); + } + catch (Exception ex) + { + backgroundTaskCompleted.SetException(ex); + throw; + } + }) + ) + ) + .Finally(payload => + { + // Add a small delay to allow the background task to start + Thread.Sleep(200); + return Either.FromRight(payload); + }) + .Build(); + + var request = new TestRequest("test-123", new[] { 2, 3, 5 }); + + // Act + var result = await workflow.Execute(request); + + // Wait for the background task to complete or timeout after 2 seconds + var timeoutTask = Task.Delay(2000); + var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask); + var timedOut = completedTask == timeoutTask; + + // Assert + result.IsRight.Should().BeTrue(); // Main workflow should succeed + result.Right.Sum.Should().Be(10); + result.Right.IsProcessed.Should().BeTrue(); + + // Verify that the background task did run + lock (syncObj) + { + backgroundTaskRan.Should().BeTrue(); + } + + timedOut.Should().BeFalse("Background task timed out"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs b/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs new file mode 100644 index 0000000..0c92d92 --- /dev/null +++ b/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class ParameterlessWorkflowTests +{ + #region Test Models + // Payload model for tests + private record TestPayload(DateTime StartTime, string Status = "Waiting"); + + // Success result model + private record TestSuccess(string Status, bool IsComplete); + + // Error model + private record TestError(string Code, string Message); + #endregion + + [Fact] + public async Task ParameterlessWorkflow_UsingUnitType_CanBeExecuted() + { + // Arrange + var workflow = new WorkflowBuilder( + // Convert Unit to initial payload + _ => new TestPayload(DateTime.UtcNow), + + // Convert final payload to success result + payload => new TestSuccess(payload.Status, true) + ) + .Do(payload => Either.FromRight( + payload with { Status = "Processing" })) + .Do(payload => Either.FromRight( + payload with { Status = "Completed" })) + .Build(); + + // Act + var result = await workflow.Execute(Unit.Value); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Status.Should().Be("Completed"); + result.Right.IsComplete.Should().BeTrue(); + } + + [Fact] + public async Task ParameterlessWorkflow_UsingFactory_CanBeExecuted() + { + // Arrange + var workflow = WorkflowBuilderFactory.CreateWorkflow( + // Initial payload factory + () => new TestPayload(DateTime.UtcNow), + + // Result selector + payload => new TestSuccess(payload.Status, true), + + // Configure the workflow + builder => builder + .Do(payload => Either.FromRight( + payload with { Status = "Processing" })) + .Do(payload => Either.FromRight( + payload with { Status = "Completed" })) + ); + + // Act + var result = await workflow.Execute(); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Status.Should().Be("Completed"); + result.Right.IsComplete.Should().BeTrue(); + } + + [Fact] + public async Task ParameterlessWorkflow_UsingExtensionMethod_CanBeExecuted() + { + // Arrange + var workflow = new WorkflowBuilder( + _ => new TestPayload(DateTime.UtcNow), + payload => new TestSuccess(payload.Status, true) + ) + .Do(payload => Either.FromRight( + payload with { Status = "Processing" })) + .Do(payload => Either.FromRight( + payload with { Status = "Completed" })) + .Build(); + + // Act - using extension method (no parameters) + var result = await workflow.Execute(); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.Status.Should().Be("Completed"); + result.Right.IsComplete.Should().BeTrue(); + } + + [Fact] + public async Task ParameterlessWorkflow_WithError_ReturnsError() + { + // Arrange + var workflow = WorkflowBuilderFactory.Create( + () => new TestPayload(DateTime.UtcNow), + payload => new TestSuccess(payload.Status, true) + ) + .Do(payload => Either.FromRight( + payload with { Status = "Processing" })) + .Do(payload => + { + // Simulate an error in the workflow + return Either.FromLeft( + new TestError("PROCESSING_FAILED", "Failed to complete processing")); + }) + .Build(); + + // Act + var result = await workflow.Execute(); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("PROCESSING_FAILED"); + result.Left.Message.Should().Be("Failed to complete processing"); + } +} \ No newline at end of file diff --git a/Zooper.Bee.Tests/WorkflowInternalsTests.cs b/Zooper.Bee.Tests/WorkflowInternalsTests.cs index 3f74035..b0f8bde 100644 --- a/Zooper.Bee.Tests/WorkflowInternalsTests.cs +++ b/Zooper.Bee.Tests/WorkflowInternalsTests.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -33,7 +32,7 @@ public async Task DynamicBranchExecution_ConditionTrue_ExecutesActivities() ) .Do(payload => Either.FromRight( payload with { Result = "Initial processing" })) - .BranchWithLocalPayload( + .WithContext( // Condition - always true payload => true, @@ -76,7 +75,7 @@ public async Task DynamicBranchExecution_ConditionFalse_SkipsActivities() ) .Do(payload => Either.FromRight( payload with { Result = "Initial processing" })) - .BranchWithLocalPayload( + .WithContext( // Condition - always false payload => false, @@ -116,7 +115,7 @@ public async Task DynamicBranchExecution_ActivityReturnsError_PropagatesError() request => new TestPayload(request.Name, request.Value), payload => new TestSuccess(payload.Result ?? "No result") ) - .BranchWithLocalPayload( + .WithContext( // Condition - always true payload => true, @@ -152,7 +151,7 @@ public async Task DynamicBranchExecution_MultipleActivities_ExecutesInOrder() request => new TestPayload(request.Name, request.Value), payload => new TestSuccess(payload.Result ?? "No result") ) - .BranchWithLocalPayload( + .WithContext( // Condition - always true payload => true, @@ -216,7 +215,7 @@ public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently() .Do(payload => Either.FromRight( payload with { Result = "Start" })) // First branch with first local payload type - .BranchWithLocalPayload( + .WithContext( payload => true, payload => new TestLocalPayload("Branch 1 data"), branch => branch @@ -232,7 +231,7 @@ public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently() }) ) // Second branch with the same local payload type - .BranchWithLocalPayload( + .WithContext( payload => payload.Value > 0, payload => new TestLocalPayload("Branch 2 data"), branch => branch diff --git a/Zooper.Bee.Tests/WorkflowTests.cs b/Zooper.Bee.Tests/WorkflowTests.cs index 40177d2..fc28439 100644 --- a/Zooper.Bee.Tests/WorkflowTests.cs +++ b/Zooper.Bee.Tests/WorkflowTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Xunit; diff --git a/Zooper.Bee.Tests/WorkflowWithContextTests.cs b/Zooper.Bee.Tests/WorkflowWithContextTests.cs new file mode 100644 index 0000000..93cf514 --- /dev/null +++ b/Zooper.Bee.Tests/WorkflowWithContextTests.cs @@ -0,0 +1,457 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using Zooper.Fox; + +namespace Zooper.Bee.Tests; + +public class WorkflowWithContextTests +{ + #region Test Models + // Request model + private record ProductRequest(int Id, string Name, decimal Price, bool NeedsCustomProcessing); + + // Main workflow payload model + private record ProductPayload( + int Id, + string Name, + decimal Price, + bool NeedsCustomProcessing, + string? ProcessingResult = null, + string? CustomizationDetails = null, + decimal FinalPrice = 0); + + // Local payload for customization context + private record CustomizationPayload( + string[] AvailableOptions, + string[] SelectedOptions, + decimal CustomizationCost, + string CustomizationDetails); + + // Success result model + private record ProductResult( + int Id, + string Name, + decimal FinalPrice, + string? ProcessingResult, + string? CustomizationDetails); + + // Error model + private record ProductError(string Code, string Message); + #endregion + + [Fact] + public async Task WithContext_ExecutesWhenConditionIsTrue() + { + // Arrange + var workflow = new WorkflowBuilder( + // Create the main payload from the request + request => new ProductPayload( + request.Id, + request.Name, + request.Price, + request.NeedsCustomProcessing), + + // Create the result from the final payload + payload => new ProductResult( + payload.Id, + payload.Name, + payload.FinalPrice, + payload.ProcessingResult, + payload.CustomizationDetails) + ) + .Do(payload => + { + // Initial processing + return Either.FromRight(payload with + { + ProcessingResult = "Standard processing complete", + FinalPrice = payload.Price + }); + }) + // Context with local payload for products that need customization + .WithContext( + // Condition: Product needs custom processing + payload => payload.NeedsCustomProcessing, + + // Create the local customization payload + payload => new CustomizationPayload( + AvailableOptions: new[] { "Engraving", "Gift Wrap", "Extended Warranty" }, + SelectedOptions: new[] { "Engraving", "Gift Wrap" }, + CustomizationCost: 25.99m, + CustomizationDetails: "Custom initialized" + ), + + // Context configuration + context => context + // First customization activity - process options + .Do((mainPayload, localPayload) => + { + // Process the selected options + string optionsProcessed = string.Join(", ", localPayload.SelectedOptions); + + // Update both payloads + var updatedLocalPayload = localPayload with + { + CustomizationDetails = $"Options: {optionsProcessed}" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Second customization activity - apply costs and finalize customization + .Do((mainPayload, localPayload) => + { + // Calculate total price + decimal totalPrice = mainPayload.Price + localPayload.CustomizationCost; + + // Update both payloads + var updatedMainPayload = mainPayload with + { + FinalPrice = totalPrice, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = $"{mainPayload.ProcessingResult} with customization" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var customizableProduct = new ProductRequest(1001, "Custom Widget", 99.99m, true); + var standardProduct = new ProductRequest(1002, "Standard Widget", 49.99m, false); + + // Act + var customResult = await workflow.Execute(customizableProduct); + var standardResult = await workflow.Execute(standardProduct); + + // Assert + + // Custom product should go through customization + customResult.IsRight.Should().BeTrue(); + customResult.Right.FinalPrice.Should().Be(125.98m); // 99.99 + 25.99 + customResult.Right.ProcessingResult.Should().Be("Standard processing complete with customization"); + customResult.Right.CustomizationDetails.Should().Be("Options: Engraving, Gift Wrap"); + + // Standard product should not go through customization + standardResult.IsRight.Should().BeTrue(); + standardResult.Right.FinalPrice.Should().Be(49.99m); // Just base price + standardResult.Right.ProcessingResult.Should().Be("Standard processing complete"); + standardResult.Right.CustomizationDetails.Should().BeNull(); + } + + [Fact] + public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = "Initial processing", + FinalPrice = payload.Price + })) + .WithContext( + // Condition + payload => true, + + // Create local payload + _ => new CustomizationPayload( + AvailableOptions: new[] { "Option1", "Option2" }, + SelectedOptions: new[] { "Option1" }, + CustomizationCost: 10.00m, + CustomizationDetails: "Context 1 customization" + ), + + // Context configuration + context => context + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + ProcessingResult = mainPayload.ProcessingResult + " -> Context 1", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + // Another main activity that changes the main payload but shouldn't affect the next context's local payload + .Do(payload => Either.FromRight(payload with + { + ProcessingResult = payload.ProcessingResult + " -> Main activity" + })) + .WithContext( + // Second context + payload => true, + + // Create a different local payload + _ => new CustomizationPayload( + AvailableOptions: new[] { "OptionA", "OptionB" }, + SelectedOptions: new[] { "OptionA", "OptionB" }, + CustomizationCost: 20.00m, + CustomizationDetails: "Context 2 customization" + ), + + // Context configuration + context => context + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + ProcessingResult = mainPayload.ProcessingResult + " -> Context 2", + FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost, + CustomizationDetails = mainPayload.CustomizationDetails + " + " + localPayload.CustomizationDetails + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2"); + result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20 + result.Right.CustomizationDetails.Should().Be("Context 1 customization + Context 2 customization"); + } + + [Fact] + public async Task WithContext_ErrorInContext_StopsExecutionAndReturnsError() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = "Initial processing" })) + .WithContext( + payload => payload.NeedsCustomProcessing, + _ => new CustomizationPayload( + AvailableOptions: new string[0], + SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error + CustomizationCost: 10.00m, + CustomizationDetails: "Should fail" + ), + context => context + .Do((mainPayload, localPayload) => + { + // Validate selected options are available + foreach (var option in localPayload.SelectedOptions) + { + if (Array.IndexOf(localPayload.AvailableOptions, option) < 0) + { + return Either.FromLeft( + new ProductError("INVALID_OPTION", $"Option '{option}' is not available")); + } + } + + return Either.FromRight( + (mainPayload, localPayload)); + }) + ) + .Do(payload => Either.FromRight( + payload with { ProcessingResult = payload.ProcessingResult + " -> Final processing" })) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsLeft.Should().BeTrue(); + result.Left.Code.Should().Be("INVALID_OPTION"); + result.Left.Message.Should().Be("Option 'Unavailable Option' is not available"); + } + + [Fact] + public async Task WithContext_MultipleActivitiesInSameContext_ShareLocalPayload() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .WithContext( + _ => true, + _ => new CustomizationPayload( + AvailableOptions: new[] { "Option1", "Option2", "Option3" }, + SelectedOptions: new string[0], // Start with no selected options + CustomizationCost: 0m, // Start with no cost + CustomizationDetails: "Start" + ), + context => context + // First activity - select Option1 + .Do((mainPayload, localPayload) => + { + var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; + Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); + updatedOptions[updatedOptions.Length - 1] = "Option1"; + + var updatedLocalPayload = localPayload with + { + SelectedOptions = updatedOptions, + CustomizationCost = localPayload.CustomizationCost + 10m, + CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option1" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Second activity - select Option2 + .Do((mainPayload, localPayload) => + { + var updatedOptions = new string[localPayload.SelectedOptions.Length + 1]; + Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length); + updatedOptions[updatedOptions.Length - 1] = "Option2"; + + var updatedLocalPayload = localPayload with + { + SelectedOptions = updatedOptions, + CustomizationCost = localPayload.CustomizationCost + 15m, + CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option2" + }; + + return Either.FromRight( + (mainPayload, updatedLocalPayload)); + }) + // Third activity - finalize and update main payload + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + FinalPrice = mainPayload.Price + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = $"Processed with {localPayload.SelectedOptions.Length} options" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, true); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.FinalPrice.Should().Be(125.00m); // 100 + 10 + 15 + result.Right.ProcessingResult.Should().Be("Processed with 2 options"); + result.Right.CustomizationDetails.Should().Be("Start -> Added Option1 -> Added Option2"); + } + + [Fact] + public async Task WithContext_UnconditionalContext_AlwaysExecutes() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .WithContext( + // Local payload factory only + _ => new CustomizationPayload( + AvailableOptions: new[] { "Default Option" }, + SelectedOptions: new[] { "Default Option" }, + CustomizationCost: 5.00m, + CustomizationDetails: "Default customization" + ), + context => context + .Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + FinalPrice = mainPayload.Price + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = "Processed with default customization" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, false); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5 + result.Right.ProcessingResult.Should().Be("Processed with default customization"); + result.Right.CustomizationDetails.Should().Be("Default customization"); + } + + [Fact] + public async Task WithContext_UnconditionalContextFluentApi_AlwaysExecutes() + { + // Arrange + var workflow = new WorkflowBuilder( + request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing), + payload => new ProductResult( + payload.Id, payload.Name, payload.FinalPrice, + payload.ProcessingResult, payload.CustomizationDetails) + ) + .WithContext( + // Local payload factory only (no condition parameter) + _ => new CustomizationPayload( + AvailableOptions: new[] { "Default Option" }, + SelectedOptions: new[] { "Default Option" }, + CustomizationCost: 5.00m, + CustomizationDetails: "Default customization (fluent API)" + ), + // Use callback pattern instead of fluent API + context => context.Do((mainPayload, localPayload) => + { + var updatedMainPayload = mainPayload with + { + FinalPrice = mainPayload.Price + localPayload.CustomizationCost, + CustomizationDetails = localPayload.CustomizationDetails, + ProcessingResult = "Processed with fluent API" + }; + + return Either.FromRight( + (updatedMainPayload, localPayload)); + }) + ) + .Build(); + + var request = new ProductRequest(1001, "Test Product", 100.00m, false); + + // Act + var result = await workflow.Execute(request); + + // Assert + result.IsRight.Should().BeTrue(); + result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5 + result.Right.ProcessingResult.Should().Be("Processed with fluent API"); + result.Right.CustomizationDetails.Should().Be("Default customization (fluent API)"); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Context/Context.cs b/Zooper.Bee/Features/Context/Context.cs new file mode 100644 index 0000000..c5442fa --- /dev/null +++ b/Zooper.Bee/Features/Context/Context.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Zooper.Bee.Features.Context; + +/// +/// Represents a context in the workflow with its own local state and an optional condition. +/// +/// Type of the main workflow payload +/// Type of the local context state +/// Type of the error +internal sealed class Context : IWorkflowFeature +{ + /// + /// The condition that determines if this context should execute. + /// + public Func? Condition { get; } + + /// + /// Contexts always merge back into the main workflow. + /// + public bool ShouldMerge => true; + + /// + /// The factory function that creates the local state from the main payload. + /// + public Func LocalStateFactory { get; } + + /// + /// The list of activities in this context that operate on both the main and local states. + /// + public List> Activities { get; } = new(); + + /// + /// Creates a new context with an optional condition. + /// + /// The condition that determines if this context should execute. If null, the context always executes. + /// The factory function that creates the local state + public Context(Func? condition, Func localStateFactory) + { + Condition = condition; + LocalStateFactory = localStateFactory ?? throw new ArgumentNullException(nameof(localStateFactory)); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Context/ContextActivity.cs b/Zooper.Bee/Features/Context/ContextActivity.cs new file mode 100644 index 0000000..72d1c21 --- /dev/null +++ b/Zooper.Bee/Features/Context/ContextActivity.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Fox; + +namespace Zooper.Bee.Features.Context; + +/// +/// Represents an activity in a context that operates on both the main workflow payload and a local state. +/// +/// Type of the main workflow payload +/// Type of the local context state +/// Type of the error +internal sealed class ContextActivity +{ + private readonly Func>> _activity; + private readonly string? _name; + + /// + /// Creates a new context activity. + /// + /// The activity function that operates on both the main payload and local state + /// Optional name for the activity + public ContextActivity( + Func>> activity, + string? name = null) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + _name = name; + } + + /// + /// Executes the activity with the provided payloads. + /// + /// The main workflow payload + /// The local context state + /// Cancellation token + /// Either an error or the updated payload and state + public Task> Execute( + TPayload mainPayload, + TLocalState localState, + CancellationToken token) + { + return _activity(mainPayload, localState, token); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Context/ContextBuilder.cs b/Zooper.Bee/Features/Context/ContextBuilder.cs new file mode 100644 index 0000000..fea807a --- /dev/null +++ b/Zooper.Bee/Features/Context/ContextBuilder.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Fox; + +namespace Zooper.Bee.Features.Context; + +/// +/// Builder for a context with a local state that enables a fluent API for adding activities. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the local context state +/// The type of the success result +/// The type of the error result +public sealed class ContextBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly Context _context; + + internal ContextBuilder( + WorkflowBuilder workflow, + Context context) + { + _workflow = workflow; + _context = context; + } + + /// + /// Adds an activity to the context that operates on both the main payload and local state. + /// + /// The activity to add + /// The context builder for fluent chaining + public ContextBuilder Do( + Func>> activity) + { + _context.Activities.Add(new ContextActivity(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the context that operates on both the main payload and local state. + /// + /// The activity to add + /// The context builder for fluent chaining + public ContextBuilder Do( + Func> activity) + { + _context.Activities.Add(new ContextActivity( + (mainPayload, localState, _) => Task.FromResult(activity(mainPayload, localState)) + )); + return this; + } + + /// + /// Adds multiple activities to the context. + /// + /// The activities to add + /// The context builder for fluent chaining + public ContextBuilder DoAll( + params Func>>[] activities) + { + foreach (var activity in activities) + { + _context.Activities.Add(new ContextActivity(activity)); + } + return this; + } + + /// + /// Adds multiple synchronous activities to the context. + /// + /// The activities to add + /// The context builder for fluent chaining + public ContextBuilder DoAll( + params Func>[] activities) + { + foreach (var activity in activities) + { + _context.Activities.Add(new ContextActivity( + (mainPayload, localState, _) => Task.FromResult(activity(mainPayload, localState)) + )); + } + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Detached/Detached.cs b/Zooper.Bee/Features/Detached/Detached.cs new file mode 100644 index 0000000..1a9fa44 --- /dev/null +++ b/Zooper.Bee/Features/Detached/Detached.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Zooper.Bee.Internal; + +namespace Zooper.Bee.Features.Detached; + +/// +/// Represents a detached group of activities in the workflow that doesn't merge back. +/// +/// Type of the main workflow payload +/// Type of the error +internal sealed class Detached : IWorkflowFeature +{ + /// + /// The condition that determines if this detached group should execute. + /// + public Func? Condition { get; } + + /// + /// Detached groups never merge back into the main workflow. + /// + public bool ShouldMerge => false; + + /// + /// The list of activities in this detached group. + /// + public List> Activities { get; } = new(); + + /// + /// Creates a new detached group with an optional condition. + /// + /// The condition that determines if this detached group should execute. If null, the group always executes. + public Detached(Func? condition = null) + { + Condition = condition; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Detached/DetachedBuilder.cs b/Zooper.Bee/Features/Detached/DetachedBuilder.cs new file mode 100644 index 0000000..bf395b2 --- /dev/null +++ b/Zooper.Bee/Features/Detached/DetachedBuilder.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Internal; +using Zooper.Fox; + +namespace Zooper.Bee.Features.Detached; + +/// +/// Builder for a detached group that enables a fluent API for adding activities. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the success result +/// The type of the error result +public sealed class DetachedBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly Detached _detached; + + internal DetachedBuilder( + WorkflowBuilder workflow, + Detached detached) + { + _workflow = workflow; + _detached = detached; + } + + /// + /// Adds an activity to the detached group. + /// + /// The activity to add + /// The detached builder for fluent chaining + public DetachedBuilder Do( + Func>> activity) + { + _detached.Activities.Add(new WorkflowActivity(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the detached group. + /// + /// The activity to add + /// The detached builder for fluent chaining + public DetachedBuilder Do( + Func> activity) + { + _detached.Activities.Add(new WorkflowActivity( + (payload, _) => Task.FromResult(activity(payload)) + )); + return this; + } + + /// + /// Adds multiple activities to the detached group. + /// + /// The activities to add + /// The detached builder for fluent chaining + public DetachedBuilder DoAll( + params Func>>[] activities) + { + foreach (var activity in activities) + { + _detached.Activities.Add(new WorkflowActivity(activity)); + } + return this; + } + + /// + /// Adds multiple synchronous activities to the detached group. + /// + /// The activities to add + /// The detached builder for fluent chaining + public DetachedBuilder DoAll( + params Func>[] activities) + { + foreach (var activity in activities) + { + _detached.Activities.Add(new WorkflowActivity( + (payload, _) => Task.FromResult(activity(payload)) + )); + } + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Group/Group.cs b/Zooper.Bee/Features/Group/Group.cs new file mode 100644 index 0000000..8c2d225 --- /dev/null +++ b/Zooper.Bee/Features/Group/Group.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Zooper.Bee.Internal; + +namespace Zooper.Bee.Features.Group; + +/// +/// Represents a group of activities in the workflow with an optional condition. +/// +/// Type of the main workflow payload +/// Type of the error +internal sealed class Group : IWorkflowFeature +{ + /// + /// The condition that determines if this group should execute. + /// + public Func? Condition { get; } + + /// + /// Groups always merge back into the main workflow. + /// + public bool ShouldMerge => true; + + /// + /// The list of activities in this group. + /// + public List> Activities { get; } = new(); + + /// + /// Creates a new group with an optional condition. + /// + /// The condition that determines if this group should execute. If null, the group always executes. + public Group(Func? condition = null) + { + Condition = condition; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Group/GroupBuilder.cs b/Zooper.Bee/Features/Group/GroupBuilder.cs new file mode 100644 index 0000000..c81e983 --- /dev/null +++ b/Zooper.Bee/Features/Group/GroupBuilder.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Internal; +using Zooper.Fox; + +namespace Zooper.Bee.Features.Group; + +/// +/// Builder for a group that enables a fluent API for adding activities. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the success result +/// The type of the error result +public sealed class GroupBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly Group _group; + + internal GroupBuilder( + WorkflowBuilder workflow, + Group group) + { + _workflow = workflow; + _group = group; + } + + /// + /// Adds an activity to the group. + /// + /// The activity to add + /// The group builder for fluent chaining + public GroupBuilder Do( + Func>> activity) + { + _group.Activities.Add(new WorkflowActivity(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the group. + /// + /// The activity to add + /// The group builder for fluent chaining + public GroupBuilder Do( + Func> activity) + { + _group.Activities.Add(new WorkflowActivity( + (payload, _) => Task.FromResult(activity(payload)) + )); + return this; + } + + /// + /// Adds multiple activities to the group. + /// + /// The activities to add + /// The group builder for fluent chaining + public GroupBuilder DoAll( + params Func>>[] activities) + { + foreach (var activity in activities) + { + _group.Activities.Add(new WorkflowActivity(activity)); + } + return this; + } + + /// + /// Adds multiple synchronous activities to the group. + /// + /// The activities to add + /// The group builder for fluent chaining + public GroupBuilder DoAll( + params Func>[] activities) + { + foreach (var activity in activities) + { + _group.Activities.Add(new WorkflowActivity( + (payload, _) => Task.FromResult(activity(payload)) + )); + } + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/IWorkflowFeature.cs b/Zooper.Bee/Features/IWorkflowFeature.cs new file mode 100644 index 0000000..b6e6a8c --- /dev/null +++ b/Zooper.Bee/Features/IWorkflowFeature.cs @@ -0,0 +1,21 @@ +using System; + +namespace Zooper.Bee.Features; + +/// +/// Base interface for all workflow features. +/// +/// The type of the main workflow payload +/// The type of the error +public interface IWorkflowFeature +{ + /// + /// Gets the condition that determines if this feature should execute. + /// + Func? Condition { get; } + + /// + /// Whether this feature should merge back into the main workflow. + /// + bool ShouldMerge { get; } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Parallel/Parallel.cs b/Zooper.Bee/Features/Parallel/Parallel.cs new file mode 100644 index 0000000..10bafe6 --- /dev/null +++ b/Zooper.Bee/Features/Parallel/Parallel.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Zooper.Bee.Features.Group; + +namespace Zooper.Bee.Features.Parallel; + +/// +/// Represents a parallel execution of multiple groups in the workflow. +/// +/// Type of the main workflow payload +/// Type of the error +internal sealed class Parallel : IWorkflowFeature +{ + /// + /// Parallel execution can have a condition, but typically runs unconditionally. + /// + public Func? Condition { get; } + + /// + /// Parallel execution always merges back into the main workflow. + /// + public bool ShouldMerge => true; + + /// + /// The list of groups to execute in parallel. + /// + public List> Groups { get; } = new(); + + /// + /// Creates a new parallel execution group with an optional condition. + /// + /// The condition that determines if this parallel group should execute. If null, it always executes. + public Parallel(Func? condition = null) + { + Condition = condition; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Parallel/ParallelBuilder.cs b/Zooper.Bee/Features/Parallel/ParallelBuilder.cs new file mode 100644 index 0000000..df1b33c --- /dev/null +++ b/Zooper.Bee/Features/Parallel/ParallelBuilder.cs @@ -0,0 +1,61 @@ +using System; +using Zooper.Bee.Features.Group; + +namespace Zooper.Bee.Features.Parallel; + +/// +/// Builder for configuring parallel execution of multiple groups. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the success result +/// The type of the error result +public sealed class ParallelBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly Parallel _parallel; + + internal ParallelBuilder( + WorkflowBuilder workflow, + Parallel parallel) + { + _workflow = workflow; + _parallel = parallel; + } + + /// + /// Adds a group to be executed in parallel. + /// + /// The configuration for the group + /// The parallel builder for fluent chaining + public ParallelBuilder Group( + Action> groupConfiguration) + { + var group = new Group(); + _parallel.Groups.Add(group); + + var groupBuilder = new GroupBuilder(_workflow, group); + groupConfiguration(groupBuilder); + + return this; + } + + /// + /// Adds a conditional group to be executed in parallel. + /// + /// The condition that determines if the group should execute + /// The configuration for the group + /// The parallel builder for fluent chaining + public ParallelBuilder Group( + Func condition, + Action> groupConfiguration) + { + var group = new Group(condition); + _parallel.Groups.Add(group); + + var groupBuilder = new GroupBuilder(_workflow, group); + groupConfiguration(groupBuilder); + + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Parallel/ParallelDetached.cs b/Zooper.Bee/Features/Parallel/ParallelDetached.cs new file mode 100644 index 0000000..8c4bba4 --- /dev/null +++ b/Zooper.Bee/Features/Parallel/ParallelDetached.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Zooper.Bee.Features.Detached; + +namespace Zooper.Bee.Features.Parallel; + +/// +/// Represents a parallel execution of multiple detached groups in the workflow that don't merge back. +/// +/// Type of the main workflow payload +/// Type of the error +internal sealed class ParallelDetached : IWorkflowFeature +{ + /// + /// Parallel detached execution can have a condition, but typically runs unconditionally. + /// + public Func? Condition { get; } + + /// + /// Parallel detached execution never merges back into the main workflow. + /// + public bool ShouldMerge => false; + + /// + /// The list of detached groups to execute in parallel. + /// + public List> DetachedGroups { get; } = new(); + + /// + /// Creates a new parallel detached execution group with an optional condition. + /// + /// The condition that determines if this parallel detached group should execute. If null, it always executes. + public ParallelDetached(Func? condition = null) + { + Condition = condition; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs b/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs new file mode 100644 index 0000000..e30487c --- /dev/null +++ b/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs @@ -0,0 +1,61 @@ +using System; +using Zooper.Bee.Features.Detached; + +namespace Zooper.Bee.Features.Parallel; + +/// +/// Builder for configuring parallel execution of multiple detached groups. +/// +/// The type of the request input +/// The type of the main workflow payload +/// The type of the success result +/// The type of the error result +public sealed class ParallelDetachedBuilder +{ + private readonly WorkflowBuilder _workflow; + private readonly ParallelDetached _parallelDetached; + + internal ParallelDetachedBuilder( + WorkflowBuilder workflow, + ParallelDetached parallelDetached) + { + _workflow = workflow; + _parallelDetached = parallelDetached; + } + + /// + /// Adds a detached group to be executed in parallel. + /// + /// The configuration for the detached group + /// The parallel detached builder for fluent chaining + public ParallelDetachedBuilder Detached( + Action> detachedConfiguration) + { + var detached = new Detached(); + _parallelDetached.DetachedGroups.Add(detached); + + var detachedBuilder = new DetachedBuilder(_workflow, detached); + detachedConfiguration(detachedBuilder); + + return this; + } + + /// + /// Adds a conditional detached group to be executed in parallel. + /// + /// The condition that determines if the detached group should execute + /// The configuration for the detached group + /// The parallel detached builder for fluent chaining + public ParallelDetachedBuilder Detached( + Func condition, + Action> detachedConfiguration) + { + var detached = new Detached(condition); + _parallelDetached.DetachedGroups.Add(detached); + + var detachedBuilder = new DetachedBuilder(_workflow, detached); + detachedConfiguration(detachedBuilder); + + return this; + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/ContextExecutor.cs b/Zooper.Bee/Internal/Executors/ContextExecutor.cs new file mode 100644 index 0000000..350a4c6 --- /dev/null +++ b/Zooper.Bee/Internal/Executors/ContextExecutor.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Executor for Context features with support for any local state type +/// +/// The type of the workflow payload +/// The type of the error +internal class ContextExecutor : IFeatureExecutor +{ + /// + public bool CanExecute(Features.IWorkflowFeature feature) + { + if (feature == null) + { + return false; + } + + var featureType = feature.GetType(); + if (featureType == null) + { + return false; + } + + return featureType.IsGenericType && + featureType.GetGenericTypeDefinition() == typeof(Features.Context.Context<,,>); + } + + /// + public async Task> Execute( + Features.IWorkflowFeature feature, + TPayload payload, + CancellationToken cancellationToken) + { + if (feature == null) + { + return Either.FromRight(payload); + } + + // Skip if the condition is false + if (feature.Condition != null && !feature.Condition(payload)) + { + return Either.FromRight(payload); + } + + // Use reflection to call the appropriate method based on the feature's generic type parameters + try + { + var featureType = feature.GetType(); + if (featureType == null) + { + return Either.FromRight(payload); + } + + var typeArgs = featureType.GetGenericArguments(); + if (typeArgs == null || typeArgs.Length < 2) + { + return Either.FromRight(payload); + } + + var localStateType = typeArgs[1]; + if (localStateType == null) + { + return Either.FromRight(payload); + } + + // Get the generic method and make it specific to the local state type + var method = GetType().GetMethod(nameof(ExecuteTyped), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (method == null) + { + throw new InvalidOperationException($"Method {nameof(ExecuteTyped)} not found."); + } + + var genericMethod = method.MakeGenericMethod(localStateType); + if (genericMethod == null) + { + return Either.FromRight(payload); + } + + // Ensure payload is not null before passing to the method + payload ??= default!; // Use default value if null + + // Invoke the method with the right generic parameter + var result = genericMethod.Invoke(this, new object[] { feature, payload, cancellationToken }); + return result == null + ? throw new InvalidOperationException("Method invocation returned null.") + : await (Task>)result; + } + catch (Exception) + { + // If any reflection-related exception occurs, return the payload unchanged + return Either.FromRight(payload); + } + } + + /// + /// Executes a context with a specific local state type + /// + /// The type of the local state + /// The context feature + /// The current workflow payload + /// The cancellation token + /// Either the error or the modified payload + private async Task> ExecuteTyped( + Features.IWorkflowFeature feature, + TPayload payload, + CancellationToken cancellationToken) + { + var context = feature as Features.Context.Context; + if (context == null) + { + return Either.FromRight(payload); + } + + // Check if local state factory is null + if (context.LocalStateFactory == null) + { + return Either.FromRight(payload); + } + + // Create the local state + TLocalState? localState; + try + { + localState = context.LocalStateFactory(payload); + } + catch (Exception) + { + // If we can't create the local state, return the payload unchanged + return Either.FromRight(payload); + } + + // Check if activities collection is null + if (context.Activities == null) + { + return Either.FromRight(payload); + } + + // Execute the context activities + foreach (var activity in context.Activities) + { + // Skip null activities + if (activity == null) + { + continue; + } + + var activityResult = await activity.Execute(payload, localState, cancellationToken); + if (activityResult == null) + { + // Skip if the activity result is null + continue; + } + + if (activityResult.IsLeft) + { + return Either.FromLeft(activityResult.Left); + } + + // Update both payload and local state + if (activityResult.Right.MainPayload != null) + { + payload = activityResult.Right.MainPayload; + } + if (activityResult.Right.LocalState != null) + { + localState = activityResult.Right.LocalState; + } + } + + return Either.FromRight(payload); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/DetachedExecutor.cs b/Zooper.Bee/Internal/Executors/DetachedExecutor.cs new file mode 100644 index 0000000..810268d --- /dev/null +++ b/Zooper.Bee/Internal/Executors/DetachedExecutor.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features.Detached; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Executor for Detached features +/// +/// The type of the workflow payload +/// The type of the error +internal class DetachedExecutor : FeatureExecutorBase> +{ + /// + protected override Task> ExecuteTyped( + Detached feature, + TPayload payload, + CancellationToken cancellationToken) + { + // Start detached activities but don't wait for them or use their results + var detachedPayload = payload; + + // Check if activities collection is null + if (feature.Activities == null) + { + return Task.FromResult(Either.FromRight(payload)); + } + + // Disable the warning about not awaiting the Task.Run +#pragma warning disable CS4014 + Task.Run(async () => + { + foreach (var activity in feature.Activities) + { + // Skip null activities + if (activity == null) + { + continue; + } + + var activityResult = await activity.Execute(detachedPayload, cancellationToken); + if (activityResult == null) + { + continue; + } + + if (activityResult.IsLeft) + { + // Log or handle error if needed + break; + } + + if (activityResult.Right != null) + { + detachedPayload = activityResult.Right; + } + } + }, cancellationToken); +#pragma warning restore CS4014 + + // Return original payload since detached execution doesn't affect the main flow + return Task.FromResult(Either.FromRight(payload)); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/FeatureExecutorBase.cs b/Zooper.Bee/Internal/Executors/FeatureExecutorBase.cs new file mode 100644 index 0000000..0162b22 --- /dev/null +++ b/Zooper.Bee/Internal/Executors/FeatureExecutorBase.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Base class for feature executors +/// +/// The type of the workflow payload +/// The type of the error +/// The specific type of feature this executor handles +internal abstract class FeatureExecutorBase : IFeatureExecutor + where TFeature : IWorkflowFeature +{ + /// + public bool CanExecute(IWorkflowFeature feature) + { + return feature is TFeature; + } + + /// + public async Task> Execute( + IWorkflowFeature feature, + TPayload payload, + CancellationToken cancellationToken) + { + if (feature is not TFeature typedFeature) + { + return Either.FromRight(payload); + } + + return await ExecuteTyped(typedFeature, payload, cancellationToken); + } + + /// + /// Executes the typed feature + /// + /// The typed feature to execute + /// The current workflow payload + /// The cancellation token + /// Either the error or the modified payload + protected abstract Task> ExecuteTyped( + TFeature feature, + TPayload payload, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/FeatureExecutorFactory.cs b/Zooper.Bee/Internal/Executors/FeatureExecutorFactory.cs new file mode 100644 index 0000000..0f6c1bc --- /dev/null +++ b/Zooper.Bee/Internal/Executors/FeatureExecutorFactory.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Factory for creating feature executors +/// +/// The type of the workflow payload +/// The type of the error +internal class FeatureExecutorFactory +{ + private readonly IList> _executors; + + /// + /// Initializes a new instance of the class. + /// + public FeatureExecutorFactory() + { + _executors = new List> + { + new GroupExecutor(), + new DetachedExecutor(), + new ParallelExecutor(), + new ParallelDetachedExecutor(), + new ContextExecutor(), + // Add other executors here as they are implemented + }; + } + + /// + /// Executes the given feature + /// + /// The feature to execute + /// The current workflow payload + /// The cancellation token + /// Either the error or the modified payload + public async Task> ExecuteFeature( + IWorkflowFeature feature, + TPayload payload, + CancellationToken cancellationToken) + { + // Skip null features + if (feature == null) + { + return Either.FromRight(payload); + } + + // Skip if the condition is false + if (feature.Condition != null && !feature.Condition(payload)) + { + return Either.FromRight(payload); + } + + // Find an executor that can handle this feature + var executor = _executors.FirstOrDefault(e => e.CanExecute(feature)); + if (executor == null) + { + throw new InvalidOperationException($"No executor found for feature type {feature.GetType().Name}"); + } + + // Execute the feature + return await executor.Execute(feature, payload, cancellationToken); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/GroupExecutor.cs b/Zooper.Bee/Internal/Executors/GroupExecutor.cs new file mode 100644 index 0000000..25e2b5b --- /dev/null +++ b/Zooper.Bee/Internal/Executors/GroupExecutor.cs @@ -0,0 +1,64 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features.Group; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Executor for Group features +/// +/// The type of the workflow payload +/// The type of the error +internal class GroupExecutor : FeatureExecutorBase> +{ + /// + protected override async Task> ExecuteTyped( + Group feature, + TPayload payload, + CancellationToken cancellationToken) + { + var currentPayload = payload; + + // Check if activities collection is null + if (feature.Activities == null) + { + return Either.FromRight(currentPayload); + } + + foreach (var activity in feature.Activities) + { + // Skip null activities + if (activity == null) + { + continue; + } + + var activityResult = await activity.Execute(currentPayload, cancellationToken); + if (activityResult == null) + { + // Skip if the activity result is null + continue; + } + + if (activityResult.IsLeft) + { + // Check if Left is null + if (activityResult.Left == null) + { + continue; + } + return Either.FromLeft(activityResult.Left); + } + + // Check if Right is null + if (activityResult.Right == null) + { + continue; + } + currentPayload = activityResult.Right; + } + + return Either.FromRight(currentPayload); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/IFeatureExecutor.cs b/Zooper.Bee/Internal/Executors/IFeatureExecutor.cs new file mode 100644 index 0000000..e6dd4e7 --- /dev/null +++ b/Zooper.Bee/Internal/Executors/IFeatureExecutor.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Interface for executing workflow features +/// +/// The type of the workflow payload +/// The type of the error +internal interface IFeatureExecutor +{ + /// + /// Determines if this executor can handle the given feature + /// + /// The feature to check + /// True if this executor can handle the feature, false otherwise + bool CanExecute(IWorkflowFeature feature); + + /// + /// Executes the feature with the given payload + /// + /// The feature to execute + /// The current workflow payload + /// The cancellation token + /// Either the error or the modified payload + Task> Execute( + IWorkflowFeature feature, + TPayload payload, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/ParallelDetachedExecutor.cs b/Zooper.Bee/Internal/Executors/ParallelDetachedExecutor.cs new file mode 100644 index 0000000..ce4389f --- /dev/null +++ b/Zooper.Bee/Internal/Executors/ParallelDetachedExecutor.cs @@ -0,0 +1,81 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features.Parallel; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Executor for ParallelDetached features +/// +/// The type of the workflow payload +/// The type of the error +internal class ParallelDetachedExecutor : FeatureExecutorBase> +{ + /// + protected override Task> ExecuteTyped( + ParallelDetached feature, + TPayload payload, + CancellationToken cancellationToken) + { + // Start detached groups in parallel but don't wait for them or use their results + var detachedPayload = payload; + + // Check if detached groups collection is null + if (feature.DetachedGroups == null) + { + return Task.FromResult(Either.FromRight(payload)); + } + + foreach (var detachedGroup in feature.DetachedGroups) + { + // Skip null groups + if (detachedGroup == null) + { + continue; + } + + // Skip if the condition is false + if (detachedGroup.Condition != null && !detachedGroup.Condition(detachedPayload)) + { + continue; + } + + // Start each detached group in its own task +#pragma warning disable CS4014 + Task.Run(async () => + { + var localPayload = detachedPayload; + foreach (var activity in detachedGroup.Activities) + { + // Skip null activities + if (activity == null) + { + continue; + } + + var activityResult = await activity.Execute(localPayload, cancellationToken); + if (activityResult == null) + { + continue; + } + + if (activityResult.IsLeft) + { + // Log or handle error if needed + break; + } + + if (activityResult.Right != null) + { + localPayload = activityResult.Right; + } + } + }, cancellationToken); +#pragma warning restore CS4014 + } + + // Return original payload since parallel detached execution doesn't affect the main flow + return Task.FromResult(Either.FromRight(payload)); + } +} \ No newline at end of file diff --git a/Zooper.Bee/Internal/Executors/ParallelExecutor.cs b/Zooper.Bee/Internal/Executors/ParallelExecutor.cs new file mode 100644 index 0000000..777ac13 --- /dev/null +++ b/Zooper.Bee/Internal/Executors/ParallelExecutor.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features.Parallel; +using Zooper.Fox; + +namespace Zooper.Bee.Internal.Executors; + +/// +/// Executor for Parallel features +/// +/// The type of the workflow payload +/// The type of the error +internal class ParallelExecutor : FeatureExecutorBase> +{ + /// + protected override async Task> ExecuteTyped( + Parallel feature, + TPayload payload, + CancellationToken cancellationToken) + { + // Execute groups in parallel and merge results + var tasks = new List>>(); + + // Check if groups collection is null + if (feature.Groups == null) + { + return Either.FromRight(payload); + } + + foreach (var parallelGroup in feature.Groups) + { + // Skip null groups + if (parallelGroup == null) + { + continue; + } + + // Skip if the condition is false + if (parallelGroup.Condition != null && !parallelGroup.Condition(payload)) + { + continue; + } + + tasks.Add(ExecuteGroupActivities(parallelGroup.Activities, payload, cancellationToken)); + } + + if (tasks.Count == 0) + { + // No groups to execute + return Either.FromRight(payload); + } + + // Wait for all tasks and process results + var results = await Task.WhenAll(tasks); + + // If any task returned an error, return that error + foreach (var result in results) + { + // Skip null results + if (result == null) + { + continue; + } + + if (result.IsLeft) + { + // Check if Left is null + if (result.Left == null) + { + continue; + } + return Either.FromLeft(result.Left); + } + } + + // Create a merged result from all parallel executions + var mergedPayload = payload; + + // Apply each result to the merged payload + // This uses reflection to copy over non-default property values + foreach (var result in results) + { + var source = result.Right; + + // Skip if source is null + if (source == null) + { + continue; + } + + var sourceType = source.GetType(); + if (sourceType == null) + { + continue; + } + + var targetType = mergedPayload?.GetType(); + if (targetType == null) + { + continue; + } + + // Get all properties from the payload type + var properties = sourceType.GetProperties(); + if (properties == null || properties.Length == 0) + { + continue; + } + + foreach (var property in properties) + { + // Skip if property is null + if (property == null) + { + continue; + } + + // Skip if property type is null + if (property.PropertyType == null) + { + continue; + } + + object? sourceValue = null; + try + { + sourceValue = property.GetValue(source); + } + catch (Exception) + { + // Skip if we can't get the value + continue; + } + + object? defaultValue = null; + try + { + defaultValue = property.PropertyType.IsValueType ? + Activator.CreateInstance(property.PropertyType) : null; + } + catch (Exception) + { + // If we can't create default, use null as default + } + + // Only copy non-default values (like Sum, Product, etc.) + if (sourceValue != null && (defaultValue == null || !sourceValue.Equals(defaultValue))) + { + // Ensure the property can be written to and the merged payload is not null + if (property.CanWrite && mergedPayload != null) + { + try + { + property.SetValue(mergedPayload, sourceValue); + } + catch (Exception) + { + // Skip if we can't set the value + } + } + } + } + } + + return Either.FromRight(mergedPayload); + } + + // Helper method to execute a group's activities + private async Task> ExecuteGroupActivities( + List> activities, + TPayload payload, + CancellationToken cancellationToken) + { + // Check if activities is null + if (activities == null) + { + return Either.FromRight(payload); + } + + var currentPayload = payload; + + foreach (var activity in activities) + { + // Skip null activities + if (activity == null) + { + continue; + } + + var activityResult = await activity.Execute(currentPayload, cancellationToken); + if (activityResult == null) + { + // Skip if the activity result is null + continue; + } + + if (activityResult.IsLeft) + { + // Check if Left is null + if (activityResult.Left == null) + { + continue; + } + return Either.FromLeft(activityResult.Left); + } + + // Check if Right is null + if (activityResult.Right == null) + { + continue; + } + currentPayload = activityResult.Right; + } + + return Either.FromRight(currentPayload); + } +} \ No newline at end of file diff --git a/Zooper.Bee/WorkflowBuilder.cs b/Zooper.Bee/WorkflowBuilder.cs index 332ac1b..c6835f6 100644 --- a/Zooper.Bee/WorkflowBuilder.cs +++ b/Zooper.Bee/WorkflowBuilder.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Zooper.Bee.Features; using Zooper.Bee.Internal; +using Zooper.Bee.Internal.Executors; using Zooper.Fox; namespace Zooper.Bee; @@ -24,12 +26,15 @@ public sealed class WorkflowBuilder private readonly Func _contextFactory; private readonly Func _resultSelector; - private readonly List> _validations = []; - private readonly List> _activities = []; - private readonly List> _conditionalActivities = []; - private readonly List> _finallyActivities = []; - private readonly List> _branches = []; - private readonly List _branchesWithLocalPayload = []; + private readonly List> _validations = new(); + private readonly List> _activities = new(); + private readonly List> _conditionalActivities = new(); + private readonly List> _finallyActivities = new(); + private readonly List> _branches = new(); + private readonly List _branchesWithLocalPayload = new(); + + // Collections for new features + private readonly List> _features = new(); /// /// Initializes a new instance of the class. @@ -179,6 +184,7 @@ public WorkflowBuilder DoIf( /// /// The condition to evaluate /// A branch builder that allows adding activities to the branch + [Obsolete("Use Group() method instead. This method will be removed in a future version.")] public BranchBuilder Branch(Func condition) { var branch = new Branch(condition); @@ -192,6 +198,7 @@ public BranchBuilder Branch(FuncThe condition to evaluate /// An action that configures the branch /// The workflow builder to continue the workflow definition + [Obsolete("Use Group() method instead. This method will be removed in a future version.")] public WorkflowBuilder Branch( Func condition, Action> branchConfiguration) @@ -204,46 +211,43 @@ public WorkflowBuilder Branch( } /// - /// Creates a branch in the workflow that always executes. - /// This is a convenience method for organizing related activities. + /// Creates an unconditional branch in the workflow. (Always executes) /// /// An action that configures the branch /// The workflow builder to continue the workflow definition + [Obsolete("Use Group() method instead. This method will be removed in a future version.")] public WorkflowBuilder Branch( Action> branchConfiguration) { - // Create a branch with a condition that always returns true - var branch = new Branch(_ => true); - _branches.Add(branch); - var branchBuilder = new BranchBuilder(this, branch); - branchConfiguration(branchBuilder); - return this; + return Branch(_ => true, branchConfiguration); } /// - /// Adds an activity to the finally block that will always execute, even if the workflow fails. + /// Creates a group of activities in the workflow with an optional condition. /// - /// The activity to execute - /// The builder instance for method chaining - public WorkflowBuilder Finally( - Func>> activity) + /// The condition to evaluate. If null, the group always executes. + /// An action that configures the group + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Group( + Func? condition, + Action> groupConfiguration) { - _finallyActivities.Add(new WorkflowActivity(activity)); + var group = new Features.Group.Group(condition); + _features.Add(group); + var groupBuilder = new Features.Group.GroupBuilder(this, group); + groupConfiguration(groupBuilder); return this; } /// - /// Adds a synchronous activity to the finally block that will always execute, even if the workflow fails. + /// Creates a group of activities in the workflow that always executes. /// - /// The activity to execute - /// The builder instance for method chaining - public WorkflowBuilder Finally( - Func> activity) + /// An action that configures the group + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Group( + Action> groupConfiguration) { - _finallyActivities.Add(new WorkflowActivity( - (payload, _) => Task.FromResult(activity(payload)) - )); - return this; + return Group(null, groupConfiguration); } /// @@ -253,6 +257,7 @@ public WorkflowBuilder Finally( /// The condition to evaluate /// The factory function that creates the local payload /// A branch builder that allows adding activities to the branch + [Obsolete("Use WithContext() method instead. This method will be removed in a future version.")] public BranchWithLocalPayloadBuilder BranchWithLocalPayload( Func condition, Func localPayloadFactory) @@ -270,6 +275,7 @@ public BranchWithLocalPayloadBuilderThe factory function that creates the local payload /// An action that configures the branch /// The workflow builder to continue the workflow definition + [Obsolete("Use WithContext() method instead. This method will be removed in a future version.")] public WorkflowBuilder BranchWithLocalPayload( Func condition, Func localPayloadFactory, @@ -289,6 +295,7 @@ public WorkflowBuilder BranchWithLocalPayl /// The type of the local branch payload /// The factory function that creates the local payload /// A branch builder that allows adding activities to the branch + [Obsolete("Use WithContext() method instead. This method will be removed in a future version.")] public BranchWithLocalPayloadBuilder BranchWithLocalPayload( Func localPayloadFactory) { @@ -305,15 +312,161 @@ public BranchWithLocalPayloadBuilderThe factory function that creates the local payload /// An action that configures the branch /// The workflow builder to continue the workflow definition + [Obsolete("Use WithContext() method instead. This method will be removed in a future version.")] public WorkflowBuilder BranchWithLocalPayload( Func localPayloadFactory, Action> branchConfiguration) { - // Create a branch with a condition that always returns true - var branch = new BranchWithLocalPayload(_ => true, localPayloadFactory); - _branchesWithLocalPayload.Add(branch); - var branchBuilder = new BranchWithLocalPayloadBuilder(this, branch); - branchConfiguration(branchBuilder); + return BranchWithLocalPayload(_ => true, localPayloadFactory, branchConfiguration); + } + + /// + /// Creates a context with local state in the workflow and an optional condition. + /// + /// The type of the local context state + /// The condition to evaluate. If null, the context always executes. + /// The factory function that creates the local state + /// An action that configures the context + /// The workflow builder to continue the workflow definition + public WorkflowBuilder WithContext( + Func? condition, + Func localStateFactory, + Action> contextConfiguration) + { + var context = new Features.Context.Context(condition, localStateFactory); + _features.Add(context); + var contextBuilder = new Features.Context.ContextBuilder(this, context); + contextConfiguration(contextBuilder); + return this; + } + + /// + /// Creates a context with local state in the workflow that always executes. + /// + /// The type of the local context state + /// The factory function that creates the local state + /// An action that configures the context + /// The workflow builder to continue the workflow definition + public WorkflowBuilder WithContext( + Func localStateFactory, + Action> contextConfiguration) + { + return WithContext(null, localStateFactory, contextConfiguration); + } + + /// + /// Creates a detached group of activities in the workflow with an optional condition. + /// Detached groups don't merge their results back into the main workflow. + /// + /// The condition to evaluate. If null, the detached group always executes. + /// An action that configures the detached group + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Detach( + Func? condition, + Action> detachedConfiguration) + { + var detached = new Features.Detached.Detached(condition); + _features.Add(detached); + var detachedBuilder = new Features.Detached.DetachedBuilder(this, detached); + detachedConfiguration(detachedBuilder); + return this; + } + + /// + /// Creates a detached group of activities in the workflow that always executes. + /// Detached groups don't merge their results back into the main workflow. + /// + /// An action that configures the detached group + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Detach( + Action> detachedConfiguration) + { + return Detach(null, detachedConfiguration); + } + + /// + /// Creates a parallel execution of multiple groups with an optional condition. + /// All groups execute in parallel and their results are merged back into the main workflow. + /// + /// The condition to evaluate. If null, the parallel execution always occurs. + /// An action that configures the parallel execution + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Parallel( + Func? condition, + Action> parallelConfiguration) + { + var parallel = new Features.Parallel.Parallel(condition); + _features.Add(parallel); + var parallelBuilder = new Features.Parallel.ParallelBuilder(this, parallel); + parallelConfiguration(parallelBuilder); + return this; + } + + /// + /// Creates a parallel execution of multiple groups that always executes. + /// All groups execute in parallel and their results are merged back into the main workflow. + /// + /// An action that configures the parallel execution + /// The workflow builder to continue the workflow definition + public WorkflowBuilder Parallel( + Action> parallelConfiguration) + { + return Parallel(null, parallelConfiguration); + } + + /// + /// Creates a parallel execution of multiple detached groups with an optional condition. + /// All detached groups execute in parallel and their results are NOT merged back. + /// + /// The condition to evaluate. If null, the parallel detached execution always occurs. + /// An action that configures the parallel detached execution + /// The workflow builder to continue the workflow definition + public WorkflowBuilder ParallelDetached( + Func? condition, + Action> parallelDetachedConfiguration) + { + var parallelDetached = new Features.Parallel.ParallelDetached(condition); + _features.Add(parallelDetached); + var parallelDetachedBuilder = new Features.Parallel.ParallelDetachedBuilder(this, parallelDetached); + parallelDetachedConfiguration(parallelDetachedBuilder); + return this; + } + + /// + /// Creates a parallel execution of multiple detached groups that always executes. + /// All detached groups execute in parallel and their results are NOT merged back. + /// + /// An action that configures the parallel detached execution + /// The workflow builder to continue the workflow definition + public WorkflowBuilder ParallelDetached( + Action> parallelDetachedConfiguration) + { + return ParallelDetached(null, parallelDetachedConfiguration); + } + + /// + /// Adds an activity to the finally block that will always execute, even if the workflow fails. + /// + /// The activity to execute + /// The builder instance for method chaining + public WorkflowBuilder Finally( + Func>> activity) + { + _finallyActivities.Add(new WorkflowActivity(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the finally block that will always execute, even if the workflow fails. + /// + /// The activity to execute + /// The builder instance for method chaining + public WorkflowBuilder Finally( + Func> activity) + { + _finallyActivities.Add(new WorkflowActivity( + (payload, _) => Task.FromResult(activity(payload)) + )); return this; } @@ -329,41 +482,110 @@ public Workflow Build() // Run validations foreach (var validation in _validations) { + // Skip null validations + if (validation == null) + { + continue; + } + var validationResult = await validation.Validate(request, cancellationToken); if (validationResult.IsSome) { - return Either.FromLeft(validationResult.Value); + var errorValue = validationResult.Value; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); } } // Create initial payload var payload = _contextFactory(request); + // Skip if payload is null + if (payload == null) + { + // Return a default success with default payload + return Either.FromRight(_resultSelector(default!)); + } + // Execute main activities try { foreach (var activity in _activities) { + // Skip null activities + if (activity == null) + { + continue; + } + var activityResult = await activity.Execute(payload, cancellationToken); + if (activityResult == null) + { + continue; + } + if (activityResult.IsLeft) { - return Either.FromLeft(activityResult.Left); + var errorValue = activityResult.Left; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); } + // Skip if result is null + if (activityResult.Right == null) + { + continue; + } payload = activityResult.Right; } // Execute conditional activities foreach (var conditionalActivity in _conditionalActivities) { + // Skip null conditional activities + if (conditionalActivity == null) + { + continue; + } + if (conditionalActivity.ShouldExecute(payload)) { + // Skip if activity is null + if (conditionalActivity.Activity == null) + { + continue; + } + var activityResult = await conditionalActivity.Activity.Execute(payload, cancellationToken); + if (activityResult == null) + { + continue; + } + if (activityResult.IsLeft) { - return Either.FromLeft(activityResult.Left); + var errorValue = activityResult.Left; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); } + // Skip if result is null + if (activityResult.Right == null) + { + continue; + } payload = activityResult.Right; } } @@ -371,35 +593,144 @@ public Workflow Build() // Execute branches foreach (var branch in _branches) { + // Skip null branches + if (branch == null) + { + continue; + } + + // Skip if condition is null + if (branch.Condition == null) + { + continue; + } + if (branch.Condition(payload)) { + // Skip if activities collection is null + if (branch.Activities == null) + { + continue; + } + foreach (var activity in branch.Activities) { + // Skip null activities + if (activity == null) + { + continue; + } + var activityResult = await activity.Execute(payload, cancellationToken); + if (activityResult == null) + { + continue; + } + if (activityResult.IsLeft) { - return Either.FromLeft(activityResult.Left); + var errorValue = activityResult.Left; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); } + // Skip if result is null + if (activityResult.Right == null) + { + continue; + } payload = activityResult.Right; } } } - // Execute branches with local payloads + // Execute branches with local payload foreach (var branchObj in _branchesWithLocalPayload) { + // Skip null branch objects + if (branchObj == null) + { + continue; + } + var branchResult = await ExecuteBranchWithLocalPayloadDynamic(branchObj, payload, cancellationToken); + if (branchResult == null) + { + continue; + } + if (branchResult.IsLeft) { - return Either.FromLeft(branchResult.Left); + var errorValue = branchResult.Left; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); } + // Skip if result is null + if (branchResult.Right == null) + { + continue; + } payload = branchResult.Right; } + // Execute workflow features (Group, WithContext, Detach, Parallel, etc.) + var featureExecutorFactory = new FeatureExecutorFactory(); + foreach (var feature in _features) + { + // Skip null features + if (feature == null) + { + continue; + } + + // Execute the feature + var featureResult = await featureExecutorFactory.ExecuteFeature(feature, payload, cancellationToken); + if (featureResult == null) + { + continue; + } + + if (featureResult.IsLeft) + { + var errorValue = featureResult.Left; + // Skip if error value is null + if (errorValue == null) + { + continue; + } + return Either.FromLeft(errorValue); + } + + if (feature.ShouldMerge) + { + // Skip if result is null + if (featureResult.Right == null) + { + continue; + } + payload = featureResult.Right; + } + } + // Create success result var success = _resultSelector(payload); + + // Skip if success result is null + if (success == null) + { + // Return an empty success result + return Either.FromRight(default!); + } + return Either.FromRight(success); } finally @@ -407,6 +738,18 @@ public Workflow Build() // Execute finally activities foreach (var finallyActivity in _finallyActivities) { + // Skip null finally activities + if (finallyActivity == null) + { + continue; + } + + // Skip if payload is null + if (payload == null) + { + continue; + } + // Ignore errors from finally activities _ = await finallyActivity.Execute(payload, cancellationToken); } @@ -421,22 +764,71 @@ private async Task> ExecuteBranchWithLocalPayloadDynami TPayload payload, CancellationToken cancellationToken) { + // Skip if branch object is null + if (branchObj == null) + { + return Either.FromRight(payload); + } + // Use reflection to call the appropriate generic method var branchType = branchObj.GetType(); - if (branchType.IsGenericType && - branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) + + // Skip if branch type is null + if (branchType == null) { - var typeArgs = branchType.GetGenericArguments(); - var localPayloadType = typeArgs[1]; + return Either.FromRight(payload); + } - // Get the generic method and make it specific to the local payload type - var method = GetType().GetMethod(nameof(ExecuteBranchWithLocalPayload), - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var genericMethod = method!.MakeGenericMethod(localPayloadType); + try + { + if (branchType.IsGenericType && + branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) + { + var typeArgs = branchType.GetGenericArguments(); + if (typeArgs == null || typeArgs.Length < 2) + { + return Either.FromRight(payload); + } - // Invoke the method with the right generic parameter - return (Either)await (Task>) - genericMethod.Invoke(this, new[] { branchObj, payload, cancellationToken })!; + var localPayloadType = typeArgs[1]; + if (localPayloadType == null) + { + return Either.FromRight(payload); + } + + // Get the generic method and make it specific to the local payload type + var method = GetType().GetMethod(nameof(ExecuteBranchWithLocalPayload), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + // Check if method is null before using it + if (method == null) + { + throw new InvalidOperationException($"Method {nameof(ExecuteBranchWithLocalPayload)} not found."); + } + + var genericMethod = method.MakeGenericMethod(localPayloadType); + if (genericMethod == null) + { + return Either.FromRight(payload); + } + + // Ensure payload is not null before passing to the method + if (payload == null) + { + payload = default!; // Use default value if null + } + + // Invoke the method with the right generic parameter + var result = genericMethod.Invoke(this, new object[] { branchObj, payload, cancellationToken }); + return result == null + ? throw new InvalidOperationException("Method invocation returned null.") + : await (Task>)result; + } + } + catch (Exception) + { + // If any reflection-related exception occurs, return the payload unchanged + return Either.FromRight(payload); } // If branch type isn't recognized, just return the payload unchanged @@ -449,25 +841,77 @@ private async Task> ExecuteBranchWithLocalPayload.FromRight(payload); + } + + // Check if condition is null + if (branch.Condition == null) + { + return Either.FromRight(payload); + } + if (!branch.Condition(payload)) { return Either.FromRight(payload); } + // Check if local payload factory is null + if (branch.LocalPayloadFactory == null) + { + return Either.FromRight(payload); + } + // Create the local payload - var localPayload = branch.LocalPayloadFactory(payload); + TLocalPayload? localPayload; + try + { + localPayload = branch.LocalPayloadFactory(payload); + } + catch (Exception) + { + // If we can't create the local payload, return the payload unchanged + return Either.FromRight(payload); + } + + // Check if activities collection is null + if (branch.Activities == null) + { + return Either.FromRight(payload); + } // Execute the branch activities foreach (var activity in branch.Activities) { + // Skip null activities + if (activity == null) + { + continue; + } + var activityResult = await activity.Execute(payload, localPayload, cancellationToken); + if (activityResult == null) + { + // Skip if the activity result is null + continue; + } + if (activityResult.IsLeft) { return Either.FromLeft(activityResult.Left); } // Update both payloads - (payload, localPayload) = activityResult.Right; + if (activityResult.Right.Item1 != null) + { + payload = activityResult.Right.Item1; + } + if (activityResult.Right.Item2 != null) + { + localPayload = activityResult.Right.Item2; + } } return Either.FromRight(payload); diff --git a/Zooper.Bee/WorkflowBuilderFactory.cs b/Zooper.Bee/WorkflowBuilderFactory.cs new file mode 100644 index 0000000..00aa103 --- /dev/null +++ b/Zooper.Bee/WorkflowBuilderFactory.cs @@ -0,0 +1,51 @@ +using System; +using Zooper.Fox; + +namespace Zooper.Bee; + +/// +/// Provides factory methods for creating workflows without requiring a request parameter. +/// +public static class WorkflowBuilderFactory +{ + /// + /// Creates a new workflow builder that doesn't require a request parameter. + /// + /// The type of payload that will be used throughout the workflow + /// The type of the success result + /// The type of the error result + /// A factory function that creates the initial payload + /// A function that creates the success result from the final payload + /// A workflow builder instance + public static WorkflowBuilder Create( + Func payloadFactory, + Func resultSelector) + { + return new WorkflowBuilder( + _ => payloadFactory(), + resultSelector); + } + + /// + /// Creates a new workflow that doesn't require a request parameter. + /// + /// The type of payload that will be used throughout the workflow + /// The type of the success result + /// The type of the error result + /// A factory function that creates the initial payload + /// A function that creates the success result from the final payload + /// An action that configures the workflow + /// A workflow instance + public static Workflow CreateWorkflow( + Func payloadFactory, + Func resultSelector, + Action> configure) + { + var builder = new WorkflowBuilder( + _ => payloadFactory(), + resultSelector); + + configure(builder); + return builder.Build(); + } +} \ No newline at end of file diff --git a/Zooper.Bee/WorkflowExtensions.cs b/Zooper.Bee/WorkflowExtensions.cs new file mode 100644 index 0000000..091effa --- /dev/null +++ b/Zooper.Bee/WorkflowExtensions.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using Zooper.Fox; + +namespace Zooper.Bee; + +/// +/// Extension methods for the Workflow class. +/// +public static class WorkflowExtensions +{ + /// + /// Executes a workflow that doesn't require a request parameter. + /// + /// The type of the success result + /// The type of the error result + /// The workflow to execute + /// The result of the workflow execution + public static Task> Execute( + this Workflow workflow) + { + return workflow.Execute(Unit.Value); + } + + /// + /// Executes a workflow that doesn't require a request parameter. + /// + /// The type of the success result + /// The type of the error result + /// The workflow to execute + /// A cancellation token to observe while waiting for the task to complete + /// The result of the workflow execution + public static Task> Execute( + this Workflow workflow, + CancellationToken cancellationToken) + { + return workflow.Execute(Unit.Value, cancellationToken); + } +} \ No newline at end of file