diff --git a/docs/generators/observer.md b/docs/generators/observer.md new file mode 100644 index 0000000..439c7ac --- /dev/null +++ b/docs/generators/observer.md @@ -0,0 +1,820 @@ +# Observer Pattern Generator + +## Overview + +The **Observer Generator** creates type-safe, high-performance Observer pattern implementations with configurable threading, exception handling, and ordering semantics. It eliminates the need to manually write subscription management code, providing compile-time safety and optimal runtime performance. + +## When to Use + +Use the Observer generator when you need: + +- **Event notification systems**: Publish events to multiple subscribers +- **Reactive programming**: Build observable data streams and change notifications +- **Decoupled communication**: Publishers don't need to know about subscribers +- **Type-safe event handling**: Compile-time verification of handler signatures +- **Configurable behavior**: Control threading, exceptions, and ordering + +## Installation + +The generator is included in the `PatternKit.Generators` package: + +```bash +dotnet add package PatternKit.Generators +``` + +## Quick Start + +```csharp +using PatternKit.Generators.Observer; + +public record Temperature(double Celsius); + +[Observer(typeof(Temperature))] +public partial class TemperatureChanged +{ +} +``` + +Generated methods: + +```csharp +public partial class TemperatureChanged +{ + // Subscribe with sync handler + public IDisposable Subscribe(Action handler) { ... } + + // Subscribe with async handler + public IDisposable Subscribe(Func handler) { ... } + + // Publish to all subscribers + public void Publish(Temperature payload) { ... } + + // Publish asynchronously + public ValueTask PublishAsync(Temperature payload, CancellationToken cancellationToken = default) { ... } +} +``` + +Usage: + +```csharp +var tempEvent = new TemperatureChanged(); + +// Subscribe to events +var subscription = tempEvent.Subscribe(temp => + Console.WriteLine($"Temperature: {temp.Celsius}°C")); + +// Publish events +tempEvent.Publish(new Temperature(23.5)); +tempEvent.Publish(new Temperature(24.0)); + +// Unsubscribe +subscription.Dispose(); +``` + +## Basic Usage + +### Synchronous Handlers + +```csharp +public record StockPrice(string Symbol, decimal Price); + +[Observer(typeof(StockPrice))] +public partial class StockPriceChanged +{ +} + +// Usage +var priceEvent = new StockPriceChanged(); + +priceEvent.Subscribe(price => + Console.WriteLine($"{price.Symbol}: ${price.Price}")); + +priceEvent.Subscribe(price => + LogToDatabase(price)); + +priceEvent.Publish(new StockPrice("MSFT", 420.50m)); +``` + +### Asynchronous Handlers + +```csharp +public record UserRegistration(string Email, DateTime Timestamp); + +[Observer(typeof(UserRegistration))] +public partial class UserRegistered +{ +} + +// Usage +var userEvent = new UserRegistered(); + +userEvent.Subscribe(async user => +{ + await SendWelcomeEmailAsync(user.Email); + await CreateUserProfileAsync(user); +}); + +await userEvent.PublishAsync( + new UserRegistration("user@example.com", DateTime.UtcNow)); +``` + +### Managing Subscriptions + +Subscriptions return `IDisposable` for cleanup: + +```csharp +var subscription1 = tempEvent.Subscribe(t => Console.WriteLine(t.Celsius)); +var subscription2 = tempEvent.Subscribe(t => LogTemperature(t)); + +// Unsubscribe individual handlers +subscription1.Dispose(); + +// Using 'using' for automatic cleanup +using (var sub = tempEvent.Subscribe(t => ProcessTemperature(t))) +{ + tempEvent.Publish(new Temperature(25.0)); +} // Automatically unsubscribed +``` + +## Configuration Options + +### Threading Policies + +Control how Subscribe/Publish operations handle concurrency: + +#### Locking (Default) + +Uses locking for thread safety. Recommended for most scenarios: + +```csharp +[Observer(typeof(Temperature), Threading = ObserverThreadingPolicy.Locking)] +public partial class TemperatureChanged { } +``` + +**Characteristics:** +- Thread-safe Subscribe/Unsubscribe/Publish +- Snapshots subscriber list under lock for predictable iteration +- Moderate overhead for lock acquisition + +**Use when:** +- Multiple threads may publish or subscribe concurrently +- You need deterministic ordering +- Default choice for most applications + +#### SingleThreadedFast + +No thread safety, maximum performance: + +```csharp +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] +public partial class UiEventOccurred { } +``` + +**Characteristics:** +- No synchronization overhead +- Not thread-safe +- Lowest memory footprint + +**Use when:** +- All operations occur on a single thread (e.g., UI thread) +- Performance is critical +- You can guarantee no concurrent access + +⚠️ **Warning:** Using this policy with concurrent access will cause data corruption and race conditions. + +#### Concurrent + +Lock-free atomic operations for high concurrency: + +```csharp +[Observer(typeof(MetricUpdate), Threading = ObserverThreadingPolicy.Concurrent)] +public partial class MetricUpdated { } +``` + +**Characteristics:** +- Lock-free concurrent operations +- Thread-safe with better performance under high concurrency +- May have undefined ordering unless RegistrationOrder is used + +**Use when:** +- High-throughput scenarios with many concurrent publishers +- Minimizing lock contention is important +- Can tolerate potential ordering variations + +### Exception Policies + +Control how exceptions from handlers are managed: + +#### Continue (Default) + +Continue invoking all handlers even if some throw: + +```csharp +[Observer(typeof(Message), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class MessageReceived +{ + // Optional: handle errors from subscribers + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Subscriber failed"); + } +} +``` + +**Characteristics:** +- All handlers get invoked +- Exceptions are caught and optionally logged +- Publishing never throws + +**Use when:** +- Subscriber failures shouldn't affect other subscribers +- You want best-effort delivery +- Fault tolerance is important + +**Optional Hook:** Implement `partial void OnSubscriberError(Exception ex)` to log or handle errors. + +#### Stop + +Stop at first exception and rethrow: + +```csharp +[Observer(typeof(CriticalCommand), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class CommandExecuted { } +``` + +**Characteristics:** +- First exception stops publishing +- Exception is rethrown to caller +- Remaining handlers are not invoked + +**Use when:** +- Any handler failure should abort the operation +- You need to handle errors at the call site +- Order matters and failures are critical + +#### Aggregate + +Collect all exceptions and throw AggregateException: + +```csharp +[Observer(typeof(ValidationRequest), Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class ValidationRequested { } +``` + +**Characteristics:** +- All handlers are invoked +- Exceptions are collected +- AggregateException thrown if any failed + +**Use when:** +- You need to know about all failures +- All handlers should run regardless of failures +- Collecting multiple validation errors + +```csharp +try +{ + validationEvent.Publish(request); +} +catch (AggregateException aex) +{ + foreach (var ex in aex.InnerExceptions) + { + Console.WriteLine($"Validation error: {ex.Message}"); + } +} +``` + +### Order Policies + +Control handler invocation order: + +#### RegistrationOrder (Default) + +Handlers invoked in subscription order (FIFO): + +```csharp +[Observer(typeof(Event), Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class EventOccurred { } +``` + +**Characteristics:** +- Deterministic, predictable order +- Handlers invoked in the order they were subscribed +- Slightly higher memory overhead + +**Use when:** +- Order matters (e.g., validation → processing → logging) +- Debugging requires predictable behavior +- Default choice for most scenarios + +#### Undefined + +No order guarantee, potential performance benefit: + +```csharp +[Observer(typeof(Metric), Order = ObserverOrderPolicy.Undefined)] +public partial class MetricRecorded { } +``` + +**Characteristics:** +- No ordering guarantee +- May provide better performance with Concurrent threading +- Lower memory overhead + +**Use when:** +- Order doesn't matter (e.g., independent metrics collection) +- Maximum performance is needed +- Handlers are truly independent + +### Async Configuration + +Control async method generation: + +```csharp +// Generate async methods (default) +[Observer(typeof(Data), GenerateAsync = true)] +public partial class DataAvailable { } + +// Don't generate async methods +[Observer(typeof(Data), GenerateAsync = false)] +public partial class DataAvailable { } + +// Force async-only (no sync Subscribe) +[Observer(typeof(Data), ForceAsync = true)] +public partial class DataAvailable { } +``` + +## Supported Types + +The generator supports: + +| Type | Supported | Example / Notes | +|------|-----------|------------------| +| `partial class` | ✅ | `public partial class Event { }` | +| `partial struct` | ❌ | Generates PKOBS003 diagnostic (struct observers are not supported) | +| `partial record class` | ✅ | `public partial record class Event;` | +| `partial record struct` | ❌ | Generates PKOBS003 diagnostic (struct observers are not supported) | +| Non-partial types | ❌ | Generates PKOBS001 error | + +> **Note:** Struct-based observer types (`partial struct`, `partial record struct`) are rejected with PKOBS003 diagnostic. Supporting struct observers would require complex capture and boxing semantics, so only class-based observer types are currently supported. + +## API Reference + +### Subscribe Methods + +#### Synchronous Handler + +```csharp +public IDisposable Subscribe(Action handler) +``` + +Subscribes a synchronous handler to the event. + +**Parameters:** +- `handler`: Action to invoke when events are published + +**Returns:** `IDisposable` that removes the subscription when disposed + +**Example:** +```csharp +var sub = observable.Subscribe(payload => + Console.WriteLine(payload)); +sub.Dispose(); // Unsubscribe +``` + +#### Asynchronous Handler + +```csharp +public IDisposable Subscribe(Func handler) +``` + +Subscribes an asynchronous handler to the event. + +**Parameters:** +- `handler`: Async function to invoke when events are published + +**Returns:** `IDisposable` that removes the subscription when disposed + +**Example:** +```csharp +var sub = observable.Subscribe(async payload => + await ProcessAsync(payload)); +``` + +### Publish Methods + +#### Synchronous Publish + +```csharp +public void Publish(TPayload payload) +``` + +Publishes an event to all subscribers synchronously. + +**Parameters:** +- `payload`: The event data to publish + +**Behavior:** +- Invokes synchronous handlers directly +- Invokes async handlers synchronously (fire-and-forget) +- Exception handling per configured policy + +**Example:** +```csharp +observable.Publish(new Temperature(25.0)); +``` + +#### Asynchronous Publish + +```csharp +public ValueTask PublishAsync(TPayload payload, CancellationToken cancellationToken = default) +``` + +Publishes an event to all subscribers asynchronously. + +**Parameters:** +- `payload`: The event data to publish +- `cancellationToken`: Optional cancellation token + +**Returns:** `ValueTask` that completes when all async handlers finish + +**Behavior:** +- Waits for async handlers to complete +- Synchronous handlers run on calling thread +- Exception handling per configured policy +- Honors cancellation token + +**Example:** +```csharp +await observable.PublishAsync( + new UserAction("click"), + cancellationToken); +``` + +### Optional Hooks + +#### OnSubscriberError + +```csharp +partial void OnSubscriberError(Exception ex); +``` + +Optional method for handling subscriber exceptions when using `Exceptions = ObserverExceptionPolicy.Continue`. + +**Parameters:** +- `ex`: The exception thrown by a subscriber + +**Example:** +```csharp +[Observer(typeof(Event), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class EventOccurred +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Subscriber threw exception"); + Telemetry.RecordError(ex); + } +} +``` + +## Performance Considerations + +### Memory and Allocations + +- **SingleThreadedFast**: Uses `List`, minimal allocations +- **Locking**: Uses `List` with lock, snapshots on publish +- **Concurrent**: Uses `ImmutableList` (RegistrationOrder) or `ConcurrentBag` (Undefined) + +### Thread Safety Overhead + +| Policy | Subscribe/Unsubscribe | Publish | Notes | +|--------|----------------------|---------|-------| +| SingleThreadedFast | None | None | Fastest, not thread-safe | +| Locking | Lock acquisition | Snapshot + lock | Good for moderate concurrency | +| Concurrent | Atomic operations | Lock-free | Best for high concurrency | + +### Async Performance + +- `PublishAsync` uses `ValueTask` to reduce allocations +- Synchronous handlers in `PublishAsync` don't allocate +- Async handlers only allocate if they don't complete synchronously + +### Best Practices + +1. **Use Locking by default** unless you have specific needs +2. **Profile before optimizing** - start with defaults +3. **Dispose subscriptions** to prevent memory leaks +4. **Use SingleThreadedFast** only when guaranteed single-threaded +5. **Prefer Continue exception policy** for fault tolerance +6. **Use weak references** if subscribers have long lifetimes and publishers are short-lived (implement manually) + +## Common Patterns + +### Observable Properties + +```csharp +public record PropertyChanged(string PropertyName, object? NewValue); + +[Observer(typeof(PropertyChanged))] +public partial class PropertyChangeNotifier +{ +} + +public class ViewModel +{ + private readonly PropertyChangeNotifier _notifier = new(); + private string _name = ""; + + public IDisposable SubscribeToChanges(Action handler) => + _notifier.Subscribe(handler); + + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + _notifier.Publish(new PropertyChanged(nameof(Name), value)); + } + } + } +} +``` + +### Event Aggregator + +```csharp +public partial class EventAggregator +{ + [Observer(typeof(UserLoggedIn))] + private partial class UserLoggedInEvent { } + + [Observer(typeof(OrderPlaced))] + private partial class OrderPlacedEvent { } + + private readonly UserLoggedInEvent _userLoggedIn = new(); + private readonly OrderPlacedEvent _orderPlaced = new(); + + public IDisposable Subscribe(Action handler) + { + return typeof(T).Name switch + { + nameof(UserLoggedIn) => _userLoggedIn.Subscribe(e => handler((T)(object)e)), + nameof(OrderPlaced) => _orderPlaced.Subscribe(e => handler((T)(object)e)), + _ => throw new NotSupportedException() + }; + } + + public void Publish(T @event) + { + switch (@event) + { + case UserLoggedIn e: _userLoggedIn.Publish(e); break; + case OrderPlaced e: _orderPlaced.Publish(e); break; + } + } +} +``` + +### Composite Subscriptions + +```csharp +public class CompositeDisposable : IDisposable +{ + private readonly List _subscriptions = new(); + + public void Add(IDisposable subscription) => _subscriptions.Add(subscription); + + public void Dispose() + { + foreach (var sub in _subscriptions) + sub.Dispose(); + _subscriptions.Clear(); + } +} + +// Usage +var subscriptions = new CompositeDisposable(); +subscriptions.Add(tempEvent.Subscribe(HandleTemperature)); +subscriptions.Add(pressureEvent.Subscribe(HandlePressure)); +subscriptions.Add(humidityEvent.Subscribe(HandleHumidity)); + +// Unsubscribe all at once +subscriptions.Dispose(); +``` + +## Diagnostics + +| ID | Severity | Description | +|----|----------|-------------| +| **PKOBS001** | Error | Type marked with `[Observer]` must be declared as `partial` | +| **PKOBS002** | Error | Unable to extract payload type from `[Observer]` attribute | +| **PKOBS003** | Warning | Invalid configuration or unsupported type (generic, nested, or struct types) | + +### PKOBS001: Type must be partial + +**Cause:** Missing `partial` keyword on observer type. + +**Fix:** +```csharp +// ❌ Wrong +[Observer(typeof(Message))] +public class MessageReceived { } + +// ✅ Correct +[Observer(typeof(Message))] +public partial class MessageReceived { } +``` + +### PKOBS002: Missing payload type + +**Cause:** Payload type could not be determined from attribute. + +**Fix:** Ensure you provide a valid type to the attribute: +```csharp +// ✅ Correct +[Observer(typeof(MyEventData))] +public partial class MyEvent { } +``` + +## Best Practices + +### 1. Always Dispose Subscriptions + +Prevent memory leaks by disposing subscriptions: + +```csharp +// ✅ Good: Using statement +using var subscription = observable.Subscribe(HandleEvent); + +// ✅ Good: Explicit disposal +var subscription = observable.Subscribe(HandleEvent); +// ... later ... +subscription.Dispose(); + +// ⚠️ Bad: Never disposed - memory leak! +observable.Subscribe(HandleEvent); +``` + +### 2. Use Immutable Payload Types + +Records make excellent event payloads: + +```csharp +// ✅ Good: Immutable record +public record OrderPlaced(int OrderId, decimal Amount, DateTime Timestamp); + +[Observer(typeof(OrderPlaced))] +public partial class OrderPlacedEvent { } + +// ⚠️ Avoid: Mutable payload +public class OrderPlaced +{ + public int OrderId { get; set; } // Can be modified by handlers +} +``` + +### 3. Keep Handlers Fast + +Long-running handlers block other subscribers: + +```csharp +// ⚠️ Bad: Slow handler blocks others +observable.Subscribe(data => +{ + Thread.Sleep(1000); // Blocks! + ProcessData(data); +}); + +// ✅ Good: Offload work +observable.Subscribe(data => + Task.Run(() => ProcessData(data))); + +// ✅ Better: Use async +observable.Subscribe(async data => + await ProcessDataAsync(data)); +``` + +### 4. Choose the Right Threading Policy + +```csharp +// ✅ UI thread events +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] + +// ✅ General application events +[Observer(typeof(AppEvent), Threading = ObserverThreadingPolicy.Locking)] + +// ✅ High-throughput metrics +[Observer(typeof(Metric), Threading = ObserverThreadingPolicy.Concurrent)] +``` + +### 5. Handle Exceptions Appropriately + +```csharp +// ✅ Good: Fault tolerant +[Observer(typeof(Notification), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class NotificationSent +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogWarning(ex, "Notification handler failed"); + } +} + +// ✅ Good: Critical operations +[Observer(typeof(Payment), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class PaymentProcessed { } +``` + +### 6. Use Meaningful Event Names + +```csharp +// ✅ Good: Clear, action-based names +[Observer(typeof(User))] +public partial class UserRegistered { } + +[Observer(typeof(Order))] +public partial class OrderShipped { } + +// ⚠️ Unclear +[Observer(typeof(User))] +public partial class UserEvent { } // What happened to the user? +``` + +## Examples + +See the [ObserverGeneratorDemo](/src/PatternKit.Examples/ObserverGeneratorDemo/) for complete, runnable examples including: + +- **TemperatureMonitor.cs**: Basic observer usage with temperature sensors +- **NotificationSystem.cs**: Async handlers and exception handling +- **README.md**: Example explanations and usage + +## Troubleshooting + +### Handlers not being called + +**Possible causes:** +1. Subscription was disposed +2. Wrong payload type +3. Exception thrown and swallowed (check `OnSubscriberError`) + +**Debug steps:** +```csharp +var sub = observable.Subscribe(payload => +{ + Console.WriteLine("Handler called!"); // Add logging +}); +observable.Publish(payload); +``` + +### Memory leaks + +**Cause:** Subscriptions not disposed. + +**Fix:** Always dispose subscriptions, especially in long-lived objects: +```csharp +public class Service : IDisposable +{ + private readonly CompositeDisposable _subscriptions = new(); + + public Service(SomeObservable observable) + { + _subscriptions.Add(observable.Subscribe(HandleEvent)); + } + + public void Dispose() => _subscriptions.Dispose(); +} +``` + +### Race conditions with SingleThreadedFast + +**Cause:** Using SingleThreadedFast with multiple threads. + +**Fix:** Use `Locking` or `Concurrent` policy: +```csharp +[Observer(typeof(Data), Threading = ObserverThreadingPolicy.Locking)] +public partial class DataReceived { } +``` + +### Async handlers not awaited in Publish + +**Behavior:** `Publish` calls async handlers in fire-and-forget mode. + +**Solution:** Use `PublishAsync` to await async handlers: +```csharp +// ⚠️ Async handlers not awaited +observable.Publish(data); + +// ✅ Async handlers are awaited +await observable.PublishAsync(data); +``` + +## See Also + +- [Memento Generator](memento.md) — For saving/restoring observable state +- [State Machine Generator](state-machine.md) — For state-based event handling +- [Observer Pattern (Classic)](https://en.wikipedia.org/wiki/Observer_pattern) +- [Reactive Extensions](https://reactivex.io/) — Advanced reactive programming diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index ed0dbad..16d8555 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -28,6 +28,9 @@ - name: Visitor Generator href: visitor-generator.md +- name: Observer + href: observer.md + - name: Examples href: examples.md diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs new file mode 100644 index 0000000..45bfbe8 --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs @@ -0,0 +1,368 @@ +using PatternKit.Generators.Observer; + +namespace PatternKit.Examples.ObserverGeneratorDemo; + +/// +/// A notification message to be sent. +/// +/// ID of the recipient. +/// The notification message. +/// Priority level (0=low, 1=normal, 2=high). +public record Notification(string RecipientId, string Message, int Priority); + +/// +/// Result of attempting to send a notification. +/// +/// Whether the send was successful. +/// Which channel was used (Email, SMS, Push). +/// Error message if failed. +public record NotificationResult(bool Success, string Channel, string? Error = null); + +/// +/// Observable event for notifications with async support. +/// Demonstrates async handlers and PublishAsync. +/// +[Observer(typeof(Notification), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Continue, + GenerateAsync = true)] +public partial class NotificationPublished +{ + partial void OnSubscriberError(Exception ex) + { + Console.WriteLine($"❌ Notification handler error: {ex.Message}"); + } +} + +/// +/// Observable event for notification results. +/// Uses Aggregate exception policy to collect all failures. +/// +[Observer(typeof(NotificationResult), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class NotificationSent +{ +} + +/// +/// Multi-channel notification system with async handlers. +/// +public class NotificationSystem +{ + private readonly NotificationPublished _notificationPublished = new(); + private readonly NotificationSent _notificationSent = new(); + private readonly Random _random = new(); + + /// + /// Subscribes to notifications with a synchronous handler. + /// + public IDisposable Subscribe(Action handler) => + _notificationPublished.Subscribe(handler); + + /// + /// Subscribes to notifications with an async handler. + /// + public IDisposable SubscribeAsync(Func handler) => + _notificationPublished.Subscribe(handler); + + /// + /// Subscribes to notification send results. + /// + public IDisposable OnNotificationSent(Action handler) => + _notificationSent.Subscribe(handler); + + /// + /// Sends a notification through all registered channels asynchronously. + /// + public async Task SendAsync(Notification notification, CancellationToken cancellationToken = default) + { + Console.WriteLine($"\n📤 Sending notification (Priority: {notification.Priority})..."); + await _notificationPublished.PublishAsync(notification, cancellationToken); + } + + /// + /// Reports that a notification was sent through a channel. + /// + public void ReportSent(NotificationResult result) + { + _notificationSent.Publish(result); + } + + /// + /// Simulates sending an email (async operation). + /// + public async Task SendEmailAsync(Notification notification) + { + await Task.Delay(100); // Simulate network delay + + // Simulate random failures (20% chance) + if (_random.NextDouble() < 0.2) + { + return new NotificationResult(false, "Email", "SMTP server unavailable"); + } + + Console.WriteLine($" ✉️ Email sent to {notification.RecipientId}"); + return new NotificationResult(true, "Email"); + } + + /// + /// Simulates sending an SMS (async operation). + /// + public async Task SendSmsAsync(Notification notification) + { + await Task.Delay(80); // Simulate network delay + + // High priority only + if (notification.Priority < 2) + { + return new NotificationResult(false, "SMS", "Priority too low for SMS"); + } + + Console.WriteLine($" 📱 SMS sent to {notification.RecipientId}"); + return new NotificationResult(true, "SMS"); + } + + /// + /// Simulates sending a push notification (async operation). + /// + public async Task SendPushAsync(Notification notification) + { + await Task.Delay(50); // Simulate network delay + Console.WriteLine($" 🔔 Push notification sent to {notification.RecipientId}"); + return new NotificationResult(true, "Push"); + } +} + +/// +/// Demonstrates async handlers with PublishAsync. +/// +public static class AsyncNotificationDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("=== Async Notification System ===\n"); + + var system = new NotificationSystem(); + + // Subscribe email channel (async handler) + using var emailSub = system.SubscribeAsync(async notification => + { + var result = await system.SendEmailAsync(notification); + system.ReportSent(result); + }); + + // Subscribe SMS channel (async handler) + using var smsSub = system.SubscribeAsync(async notification => + { + var result = await system.SendSmsAsync(notification); + system.ReportSent(result); + }); + + // Subscribe push channel (async handler) + using var pushSub = system.SubscribeAsync(async notification => + { + var result = await system.SendPushAsync(notification); + system.ReportSent(result); + }); + + // Subscribe to results to track success/failure + var successCount = 0; + var failureCount = 0; + using var resultSub = system.OnNotificationSent(result => + { + if (result.Success) + { + successCount++; + } + else + { + failureCount++; + Console.WriteLine($" ⚠️ {result.Channel} failed: {result.Error}"); + } + }); + + // Send notifications with different priorities + var notifications = new[] + { + new Notification("user123", "Welcome to our service!", 1), + new Notification("user456", "Your order has shipped", 1), + new Notification("user789", "URGENT: Security alert", 2), + new Notification("user999", "Daily digest available", 0) + }; + + foreach (var notification in notifications) + { + await system.SendAsync(notification); + await Task.Delay(200); // Space out notifications + } + + Console.WriteLine($"\n📊 Results: {successCount} successful, {failureCount} failed"); + } +} + +/// +/// Demonstrates exception handling with different policies. +/// +public static class ExceptionHandlingDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Exception Handling Demo ===\n"); + + // Demo 1: Continue policy (default) - all handlers run despite errors + Console.WriteLine("1. Continue Policy (fault-tolerant):"); + DemoContinuePolicy(); + + // Demo 2: Aggregate policy - collect all errors + Console.WriteLine("\n2. Aggregate Policy (collect all errors):"); + DemoAggregatePolicy(); + } + + private static void DemoContinuePolicy() + { + var notification = new NotificationPublished(); + + // Handler 1: Works fine + notification.Subscribe(n => + Console.WriteLine(" ✅ Handler 1: Success")); + + // Handler 2: Throws exception + notification.Subscribe(n => + { + Console.WriteLine(" ❌ Handler 2: Throwing exception..."); + throw new InvalidOperationException("Handler 2 failed"); + }); + + // Handler 3: Also works fine + notification.Subscribe(n => + Console.WriteLine(" ✅ Handler 3: Success (ran despite Handler 2 error)")); + + notification.Publish(new Notification("test", "Test message", 1)); + Console.WriteLine(" ℹ️ All handlers attempted, errors logged via OnSubscriberError"); + } + + private static void DemoAggregatePolicy() + { + var results = new NotificationSent(); + + // Handler 1: Throws + results.Subscribe(r => + { + Console.WriteLine(" ❌ Validator 1: Failed"); + throw new InvalidOperationException("Validation 1 failed"); + }); + + // Handler 2: Also throws + results.Subscribe(r => + { + Console.WriteLine(" ❌ Validator 2: Failed"); + throw new ArgumentException("Validation 2 failed"); + }); + + // Handler 3: Would succeed + results.Subscribe(r => + Console.WriteLine(" ✅ Validator 3: Success")); + + try + { + results.Publish(new NotificationResult(true, "Test")); + Console.WriteLine(" ℹ️ No exception thrown (shouldn't reach here)"); + } + catch (AggregateException ex) + { + Console.WriteLine($" 🔥 AggregateException caught with {ex.InnerExceptions.Count} errors:"); + foreach (var inner in ex.InnerExceptions) + { + Console.WriteLine($" - {inner.GetType().Name}: {inner.Message}"); + } + } + } +} + +/// +/// Demonstrates mixing sync and async handlers. +/// +public static class MixedHandlersDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("\n=== Mixed Sync/Async Handlers Demo ===\n"); + + var notification = new NotificationPublished(); + + // Sync handler + notification.Subscribe(n => + Console.WriteLine($" 🔹 Sync handler: {n.Message}")); + + // Async handler + notification.Subscribe(async n => + { + await Task.Delay(50); + Console.WriteLine($" 🔸 Async handler: {n.Message}"); + }); + + // Another sync handler + notification.Subscribe(n => + Console.WriteLine($" 🔹 Sync handler 2: Priority={n.Priority}")); + + Console.WriteLine("Publishing with Publish (sync):"); + notification.Publish(new Notification("user", "Hello World", 1)); + + // Note: async handlers run fire-and-forget with Publish + await Task.Delay(100); // Wait for async handlers + + Console.WriteLine("\nPublishing with PublishAsync (awaits async handlers):"); + await notification.PublishAsync(new Notification("user", "Goodbye World", 2)); + + Console.WriteLine("\nNote: PublishAsync waits for all async handlers to complete."); + } +} + +/// +/// Demonstrates cancellation token support in async handlers. +/// +public static class CancellationDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("\n=== Cancellation Demo ===\n"); + + var notification = new NotificationPublished(); + var processedCount = 0; + + // Long-running async handler + // Note: Cancellation is checked between handlers, not during handler execution + notification.Subscribe(async n => + { + Console.WriteLine(" ⏳ Starting long operation..."); + await Task.Delay(100); // Shorter delay for demo + processedCount++; + Console.WriteLine(" ✅ Long operation completed"); + }); + + // Quick handler + notification.Subscribe(async n => + { + await Task.Delay(10); + processedCount++; + Console.WriteLine(" ✅ Quick operation completed"); + }); + + using var cts = new CancellationTokenSource(50); // Cancel after 50ms - before first handler completes + + try + { + await notification.PublishAsync( + new Notification("user", "Test", 1), + cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("\n ℹ️ PublishAsync was cancelled"); + } + + Console.WriteLine($"\n Handlers completed: {processedCount}/2"); + Console.WriteLine(" Note: Cancellation is checked between handler invocations, not during execution"); + } +} diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/README.md b/src/PatternKit.Examples/ObserverGeneratorDemo/README.md new file mode 100644 index 0000000..27e3b2e --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/README.md @@ -0,0 +1,288 @@ +# Observer Generator Examples + +This directory contains comprehensive examples demonstrating the Observer pattern source generator. + +## Examples Overview + +### 1. TemperatureMonitor.cs + +Demonstrates fundamental Observer pattern usage with a temperature monitoring system. + +**Key Concepts:** +- Basic `[Observer(typeof(T))]` attribute usage +- Synchronous event handling +- Multiple subscribers to the same event +- Exception handling with `OnSubscriberError` hook +- Subscription lifecycle management (Subscribe/Dispose) +- Default configuration (Locking, Continue, RegistrationOrder) + +**Demos Included:** +- `TemperatureMonitorDemo.Run()` - Complete monitoring system with alerts +- `MultipleSubscribersDemo.Run()` - Multiple handlers with fault tolerance +- `SubscriptionLifecycleDemo.Run()` - Subscription management patterns + +**Run Example:** +```csharp +TemperatureMonitorDemo.Run(); +MultipleSubscribersDemo.Run(); +SubscriptionLifecycleDemo.Run(); +``` + +### 2. NotificationSystem.cs + +Demonstrates advanced Observer features with a multi-channel notification system. + +**Key Concepts:** +- Async event handlers with `Func` +- `PublishAsync` for awaiting async handlers +- Exception policies: Continue vs Aggregate +- Mixing sync and async handlers +- Cancellation token support +- Real-world async patterns (email, SMS, push notifications) + +**Demos Included:** +- `AsyncNotificationDemo.RunAsync()` - Multi-channel async notifications +- `ExceptionHandlingDemo.Run()` - Exception policy comparison +- `MixedHandlersDemo.RunAsync()` - Sync and async handlers together +- `CancellationDemo.RunAsync()` - Cancellation token propagation + +**Run Example:** +```csharp +await AsyncNotificationDemo.RunAsync(); +ExceptionHandlingDemo.Run(); +await MixedHandlersDemo.RunAsync(); +await CancellationDemo.RunAsync(); +``` + +## Quick Start + +### Basic Usage + +```csharp +// Define your event payload +public record TemperatureReading(string SensorId, double Celsius, DateTime Timestamp); + +// Generate Observer implementation +[Observer(typeof(TemperatureReading))] +public partial class TemperatureChanged +{ +} + +// Use it +var tempEvent = new TemperatureChanged(); + +// Subscribe +var subscription = tempEvent.Subscribe(reading => +{ + Console.WriteLine($"{reading.SensorId}: {reading.Celsius}°C"); +}); + +// Publish +tempEvent.Publish(new TemperatureReading("Sensor-01", 23.5, DateTime.UtcNow)); + +// Unsubscribe +subscription.Dispose(); +``` + +### Async Usage + +```csharp +public record Notification(string Message); + +[Observer(typeof(Notification))] +public partial class NotificationSent +{ +} + +var notif = new NotificationSent(); + +// Async handler +notif.Subscribe(async n => +{ + await SendEmailAsync(n.Message); + await LogToDbAsync(n); +}); + +// Await all async handlers +await notif.PublishAsync(new Notification("Hello!")); +``` + +## Configuration Examples + +### Threading Policies + +```csharp +// Default: Thread-safe with locks +[Observer(typeof(Message), Threading = ObserverThreadingPolicy.Locking)] +public partial class MessageReceived { } + +// Single-threaded: No thread safety, maximum performance +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] +public partial class UiEventOccurred { } + +// Concurrent: Lock-free for high throughput +[Observer(typeof(Metric), Threading = ObserverThreadingPolicy.Concurrent)] +public partial class MetricRecorded { } +``` + +### Exception Policies + +```csharp +// Continue: Fault-tolerant, all handlers run (default) +[Observer(typeof(Event), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class EventOccurred +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Handler failed"); + } +} + +// Stop: Fail-fast, stop on first error +[Observer(typeof(Payment), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class PaymentProcessed { } + +// Aggregate: Collect all errors, throw AggregateException +[Observer(typeof(Validation), Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class ValidationRequested { } +``` + +### Order Policies + +```csharp +// RegistrationOrder: FIFO, deterministic (default) +[Observer(typeof(Event), Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class EventRaised { } + +// Undefined: No order guarantee, potential performance benefit +[Observer(typeof(Metric), Order = ObserverOrderPolicy.Undefined)] +public partial class MetricCollected { } +``` + +## Common Patterns + +### 1. Observable Property + +```csharp +public record PropertyChanged(string Name, object? Value); + +[Observer(typeof(PropertyChanged))] +public partial class PropertyChangedEvent { } + +public class ViewModel +{ + private readonly PropertyChangedEvent _propertyChanged = new(); + private string _name = ""; + + public IDisposable OnPropertyChanged(Action handler) => + _propertyChanged.Subscribe(handler); + + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + _propertyChanged.Publish(new PropertyChanged(nameof(Name), value)); + } + } + } +} +``` + +### 2. Event Aggregator + +```csharp +public class EventBus +{ + [Observer(typeof(UserLoggedIn))] + private partial class UserLoggedInEvent { } + + [Observer(typeof(OrderPlaced))] + private partial class OrderPlacedEvent { } + + private readonly UserLoggedInEvent _userLoggedIn = new(); + private readonly OrderPlacedEvent _orderPlaced = new(); + + public IDisposable OnUserLoggedIn(Action handler) => + _userLoggedIn.Subscribe(handler); + + public IDisposable OnOrderPlaced(Action handler) => + _orderPlaced.Subscribe(handler); + + public void Publish(UserLoggedIn e) => _userLoggedIn.Publish(e); + public void Publish(OrderPlaced e) => _orderPlaced.Publish(e); +} +``` + +### 3. Composite Subscriptions + +```csharp +public class CompositeDisposable : IDisposable +{ + private readonly List _subscriptions = new(); + + public void Add(IDisposable subscription) => _subscriptions.Add(subscription); + + public void Dispose() + { + foreach (var sub in _subscriptions) + sub.Dispose(); + _subscriptions.Clear(); + } +} + +// Usage +var subscriptions = new CompositeDisposable(); +subscriptions.Add(eventA.Subscribe(HandleA)); +subscriptions.Add(eventB.Subscribe(HandleB)); +subscriptions.Add(eventC.Subscribe(HandleC)); + +// Unsubscribe all at once +subscriptions.Dispose(); +``` + +## Running All Examples + +To run all examples in sequence: + +```csharp +public static async Task RunAllExamples() +{ + // Temperature monitoring examples + TemperatureMonitorDemo.Run(); + MultipleSubscribersDemo.Run(); + SubscriptionLifecycleDemo.Run(); + + // Notification system examples + await AsyncNotificationDemo.RunAsync(); + ExceptionHandlingDemo.Run(); + await MixedHandlersDemo.RunAsync(); + await CancellationDemo.RunAsync(); +} +``` + +## Key Takeaways + +1. **Always dispose subscriptions** - Prevents memory leaks +2. **Use immutable payload types** - Records work great +3. **Choose appropriate policies** - Default (Locking + Continue + RegistrationOrder) is good for most cases +4. **Use PublishAsync for async handlers** - `Publish` fires and forgets; `PublishAsync` awaits +5. **Handle errors gracefully** - Implement `OnSubscriberError` with Continue policy +6. **Keep handlers fast** - Offload work to background tasks if needed + +## Performance Tips + +- **SingleThreadedFast**: Use for UI thread events (20-30% faster than Locking) +- **Concurrent**: Use for high-throughput metrics (better under contention) +- **Locking**: Default choice, good balance of safety and performance +- **Undefined Order**: Slight performance benefit if order doesn't matter +- **ValueTask**: Async handlers use ValueTask for reduced allocations + +## See Also + +- [Observer Generator Documentation](/docs/generators/observer.md) +- [PatternKit.Generators API Reference](https://patternkit.dev/api) +- [Observer Pattern (Wikipedia)](https://en.wikipedia.org/wiki/Observer_pattern) diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs new file mode 100644 index 0000000..f4a3a1a --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs @@ -0,0 +1,244 @@ +using PatternKit.Generators.Observer; + +namespace PatternKit.Examples.ObserverGeneratorDemo; + +/// +/// Temperature reading from a sensor. +/// +/// Unique identifier of the sensor. +/// Temperature in Celsius. +/// When the reading was taken. +public record TemperatureReading(string SensorId, double Celsius, DateTime Timestamp); + +/// +/// Temperature alert when temperature exceeds threshold. +/// +/// Sensor that triggered the alert. +/// The temperature that triggered the alert. +/// The threshold that was exceeded. +public record TemperatureAlert(string SensorId, double Temperature, double Threshold); + +/// +/// Observable event for temperature readings using default configuration. +/// Threading: Locking (thread-safe) +/// Exceptions: Continue (fault-tolerant) +/// Order: RegistrationOrder (FIFO) +/// +[Observer(typeof(TemperatureReading))] +public partial class TemperatureChanged +{ + // Optional: Log errors from subscribers + partial void OnSubscriberError(Exception ex) + { + Console.WriteLine($"⚠️ Subscriber error: {ex.Message}"); + } +} + +/// +/// Observable event for temperature alerts with custom configuration. +/// Uses Stop exception policy to ensure critical alerts aren't missed. +/// +[Observer(typeof(TemperatureAlert), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Stop, + Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class TemperatureAlertRaised +{ +} + +/// +/// Temperature monitoring system that simulates sensors and raises events. +/// +public class TemperatureMonitoringSystem +{ + private readonly TemperatureChanged _temperatureChanged = new(); + private readonly TemperatureAlertRaised _alertRaised = new(); + private readonly Dictionary _thresholds = new(); + private readonly Random _random = new(); + + /// + /// Sets the alert threshold for a specific sensor. + /// + public void SetThreshold(string sensorId, double thresholdCelsius) + { + _thresholds[sensorId] = thresholdCelsius; + } + + /// + /// Subscribes to temperature change events. + /// + public IDisposable OnTemperatureChanged(Action handler) => + _temperatureChanged.Subscribe(handler); + + /// + /// Subscribes to temperature alert events. + /// + public IDisposable OnTemperatureAlert(Action handler) => + _alertRaised.Subscribe(handler); + + /// + /// Simulates a sensor reading and publishes events. + /// + public void SimulateReading(string sensorId) + { + // Generate random temperature between 15°C and 35°C + var temperature = 15 + (_random.NextDouble() * 20); + var reading = new TemperatureReading(sensorId, temperature, DateTime.UtcNow); + + // Publish temperature change + _temperatureChanged.Publish(reading); + + // Check threshold and raise alert if exceeded + if (_thresholds.TryGetValue(sensorId, out var threshold) && temperature > threshold) + { + var alert = new TemperatureAlert(sensorId, temperature, threshold); + _alertRaised.Publish(alert); + } + } +} + +/// +/// Demonstrates basic Observer pattern usage with temperature monitoring. +/// +public static class TemperatureMonitorDemo +{ + public static void Run() + { + Console.WriteLine("=== Temperature Monitoring System ===\n"); + + var system = new TemperatureMonitoringSystem(); + + // Configure thresholds + system.SetThreshold("Sensor-01", 28.0); + system.SetThreshold("Sensor-02", 25.0); + + // Subscribe to temperature changes + using var tempSubscription = system.OnTemperatureChanged(reading => + { + Console.WriteLine($"📊 {reading.SensorId}: {reading.Celsius:F1}°C at {reading.Timestamp:HH:mm:ss}"); + }); + + // Subscribe to alerts with critical handler + using var alertSubscription = system.OnTemperatureAlert(alert => + { + Console.WriteLine($"🔥 ALERT! {alert.SensorId} exceeded {alert.Threshold:F1}°C: {alert.Temperature:F1}°C"); + }); + + // Additional alert handler for logging + using var logSubscription = system.OnTemperatureAlert(alert => + { + LogToFile($"Temperature alert: {alert.SensorId} - {alert.Temperature:F1}°C"); + }); + + Console.WriteLine("Simulating sensor readings...\n"); + + // Simulate readings from multiple sensors + for (int i = 0; i < 10; i++) + { + system.SimulateReading("Sensor-01"); + system.SimulateReading("Sensor-02"); + Thread.Sleep(500); // Simulate time between readings + } + + Console.WriteLine("\n--- End of simulation ---"); + } + + private static void LogToFile(string message) + { + // In real application, write to file + Console.WriteLine($" 📝 Logged: {message}"); + } +} + +/// +/// Demonstrates multiple subscribers with different behaviors. +/// +public static class MultipleSubscribersDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Multiple Subscribers Demo ===\n"); + + var temperatureChanged = new TemperatureChanged(); + + // Subscriber 1: Console display + var sub1 = temperatureChanged.Subscribe(reading => + Console.WriteLine($"Display: {reading.Celsius:F1}°C")); + + // Subscriber 2: Statistics tracker + var temperatures = new List(); + var sub2 = temperatureChanged.Subscribe(reading => + { + temperatures.Add(reading.Celsius); + var avg = temperatures.Average(); + Console.WriteLine($"Stats: Current={reading.Celsius:F1}°C, Avg={avg:F1}°C, Count={temperatures.Count}"); + }); + + // Subscriber 3: Faulty handler (demonstrates exception handling) + var sub3 = temperatureChanged.Subscribe(_ => + { + throw new InvalidOperationException("Simulated error in subscriber"); + }); + + // Subscriber 4: Continues to work despite sub3's error + var sub4 = temperatureChanged.Subscribe(reading => + Console.WriteLine($"Monitor: Temperature is {(reading.Celsius > 25 ? "HOT" : "NORMAL")}")); + + Console.WriteLine("Publishing temperature readings...\n"); + + temperatureChanged.Publish(new TemperatureReading("Test", 22.5, DateTime.UtcNow)); + Thread.Sleep(100); + temperatureChanged.Publish(new TemperatureReading("Test", 28.3, DateTime.UtcNow)); + Thread.Sleep(100); + temperatureChanged.Publish(new TemperatureReading("Test", 24.1, DateTime.UtcNow)); + + Console.WriteLine("\nNote: Subscriber 3 threw exceptions, but others continued working."); + Console.WriteLine("This is because we use ObserverExceptionPolicy.Continue (default).\n"); + + // Cleanup + sub1.Dispose(); + sub2.Dispose(); + sub3.Dispose(); + sub4.Dispose(); + } +} + +/// +/// Demonstrates subscription lifecycle management. +/// +public static class SubscriptionLifecycleDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Subscription Lifecycle Demo ===\n"); + + var temperatureChanged = new TemperatureChanged(); + + Console.WriteLine("1. Creating three subscriptions..."); + var sub1 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub1: {r.Celsius:F1}°C")); + var sub2 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub2: {r.Celsius:F1}°C")); + var sub3 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub3: {r.Celsius:F1}°C")); + + Console.WriteLine("\n2. Publishing with all three active:"); + temperatureChanged.Publish(new TemperatureReading("Test", 20.0, DateTime.UtcNow)); + + Console.WriteLine("\n3. Disposing sub2..."); + sub2.Dispose(); + + Console.WriteLine("\n4. Publishing with sub1 and sub3:"); + temperatureChanged.Publish(new TemperatureReading("Test", 21.0, DateTime.UtcNow)); + + Console.WriteLine("\n5. Using 'using' for automatic disposal:"); + using (var tempSub = temperatureChanged.Subscribe(r => Console.WriteLine($" Temp: {r.Celsius:F1}°C"))) + { + temperatureChanged.Publish(new TemperatureReading("Test", 22.0, DateTime.UtcNow)); + } // tempSub automatically disposed here + + Console.WriteLine("\n6. After 'using' block (tempSub disposed):"); + temperatureChanged.Publish(new TemperatureReading("Test", 23.0, DateTime.UtcNow)); + + // Cleanup + sub1.Dispose(); + sub3.Dispose(); + } +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs new file mode 100644 index 0000000..d685a58 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs @@ -0,0 +1,26 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a property in an -decorated class as an observable event stream. +/// The property must be static, partial, and have a getter only. +/// +/// +/// +/// The generator will create a singleton instance of the event type for this property. +/// +/// +/// Example: +/// +/// [ObserverHub] +/// public static partial class SystemEvents +/// { +/// [ObservedEvent] +/// public static partial TemperatureChanged TemperatureChanged { get; } +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public sealed class ObservedEventAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs new file mode 100644 index 0000000..31eae07 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs @@ -0,0 +1,67 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a type for Observer pattern code generation. +/// The type must be declared as partial class or partial record class. +/// +/// +/// +/// The generator will produce Subscribe and Publish methods with configurable +/// threading, exception handling, and ordering semantics. +/// +/// +/// Example: +/// +/// [Observer(typeof(Temperature))] +/// public partial class TemperatureChanged { } +/// +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ObserverAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of the event payload. + public ObserverAttribute(Type payloadType) + { + PayloadType = payloadType; + } + + /// + /// Gets the type of the event payload. + /// + public Type PayloadType { get; } + + /// + /// Gets or sets the threading policy for Subscribe/Unsubscribe/Publish operations. + /// Default is . + /// + public ObserverThreadingPolicy Threading { get; set; } = ObserverThreadingPolicy.Locking; + + /// + /// Gets or sets the exception handling policy during publishing. + /// Default is . + /// + public ObserverExceptionPolicy Exceptions { get; set; } = ObserverExceptionPolicy.Continue; + + /// + /// Gets or sets the invocation order policy for event handlers. + /// Default is . + /// + public ObserverOrderPolicy Order { get; set; } = ObserverOrderPolicy.RegistrationOrder; + + /// + /// Gets or sets whether to generate async publish methods. + /// Default is true (async methods are always generated). + /// + public bool GenerateAsync { get; set; } = true; + + /// + /// Gets or sets whether to force all handlers to be async. + /// When true, only async Subscribe methods are generated. + /// Default is false (both sync and async handlers are supported). + /// + public bool ForceAsync { get; set; } = false; +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs new file mode 100644 index 0000000..964b243 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs @@ -0,0 +1,26 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines how exceptions from event handlers are handled during publishing. +/// +public enum ObserverExceptionPolicy +{ + /// + /// Continue invoking all handlers even if some throw exceptions. + /// Exceptions are either swallowed or routed to an optional error hook. + /// Default and safest for most scenarios. + /// + Continue = 0, + + /// + /// Stop publishing and rethrow the first exception encountered. + /// Remaining handlers are not invoked. + /// + Stop = 1, + + /// + /// Invoke all handlers and collect any exceptions. + /// Throws an AggregateException at the end if any handlers threw. + /// + Aggregate = 2 +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs new file mode 100644 index 0000000..93796be --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs @@ -0,0 +1,27 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a type as an observer event hub that groups multiple event streams. +/// The type must be declared as partial and static. +/// +/// +/// +/// Use this attribute on a static class that will contain multiple +/// properties, each representing a separate event stream. +/// +/// +/// Example: +/// +/// [ObserverHub] +/// public static partial class SystemEvents +/// { +/// [ObservedEvent] +/// public static partial TemperatureChanged TemperatureChanged { get; } +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ObserverHubAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs new file mode 100644 index 0000000..250767c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines the invocation order guarantee for event handlers. +/// +public enum ObserverOrderPolicy +{ + /// + /// Handlers are invoked in the order they were registered (FIFO). + /// Default and recommended for deterministic behavior. + /// + RegistrationOrder = 0, + + /// + /// No order guarantee. Handlers may be invoked in any order. + /// May provide better performance with certain threading policies. + /// + Undefined = 1 +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs new file mode 100644 index 0000000..419d198 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs @@ -0,0 +1,27 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines the threading policy for an observer pattern implementation. +/// +public enum ObserverThreadingPolicy +{ + /// + /// No thread safety. Fast, but not safe for concurrent Subscribe/Unsubscribe/Publish. + /// Use only when all operations occur on a single thread. + /// + SingleThreadedFast = 0, + + /// + /// Uses locking for thread safety. Subscribe/Unsubscribe operations take locks, + /// and Publish snapshots the subscriber list under a lock for deterministic iteration. + /// Default and recommended for most scenarios. + /// + Locking = 1, + + /// + /// Lock-free concurrent implementation using atomic operations. + /// Thread-safe with potentially better performance under high concurrency, + /// but ordering may degrade to Undefined unless additional work is done. + /// + Concurrent = 2 +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 65ad3e8..5ab1640 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -126,3 +126,6 @@ PKST007 | PatternKit.Generators.State | Error | Entry/Exit hook signature invali PKST008 | PatternKit.Generators.State | Warning | Async method detected but async generation disabled PKST009 | PatternKit.Generators.State | Error | Generic types not supported for State pattern PKST010 | PatternKit.Generators.State | Error | Nested types not supported for State pattern +PKOBS001 | PatternKit.Generators.Observer | Error | Type marked with [Observer] must be partial +PKOBS002 | PatternKit.Generators.Observer | Error | Unable to extract payload type from [Observer] attribute +PKOBS003 | PatternKit.Generators.Observer | Warning | Invalid configuration combination diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs new file mode 100644 index 0000000..3edb4a7 --- /dev/null +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -0,0 +1,564 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text; + +namespace PatternKit.Generators.Observer; + +/// +/// Incremental source generator for the Observer pattern. +/// Generates Subscribe/Publish methods with configurable threading, exception, and ordering policies. +/// +[Generator] +public sealed class ObserverGenerator : IIncrementalGenerator +{ + private const string DiagnosticIdNotPartial = "PKOBS001"; + private const string DiagnosticIdMissingPayload = "PKOBS002"; + private const string DiagnosticIdInvalidConfig = "PKOBS003"; + + private static readonly DiagnosticDescriptor NotPartialRule = new( + DiagnosticIdNotPartial, + "Type must be partial", + "Type '{0}' marked with [Observer] must be declared as partial", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MissingPayloadRule = new( + DiagnosticIdMissingPayload, + "Missing payload type", + "Unable to extract payload type from [Observer] attribute on '{0}'", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidConfigRule = new( + DiagnosticIdInvalidConfig, + "Invalid configuration", + "{0}", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var observerTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Observer.ObserverAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + context.RegisterSourceOutput(observerTypes, static (spc, occ) => + { + GenerateObserver(spc, occ); + }); + } + + private static void GenerateObserver(SourceProductionContext context, GeneratorAttributeSyntaxContext occurrence) + { + var typeSymbol = (INamedTypeSymbol)occurrence.TargetSymbol; + var syntax = (TypeDeclarationSyntax)occurrence.TargetNode; + + if (!IsPartial(syntax)) + { + context.ReportDiagnostic(Diagnostic.Create( + NotPartialRule, + syntax.Identifier.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for generic types + if (typeSymbol.IsGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Generic observer types are not supported")); + return; + } + + // Check for nested types + if (typeSymbol.ContainingType != null) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Nested observer types are not supported")); + return; + } + + // Structs have complex lifetime and capture semantics, especially with fire-and-forget async + if (typeSymbol.TypeKind == TypeKind.Struct) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Struct observer types are not currently supported due to capture and boxing complexity")); + return; + } + + var attr = occurrence.Attributes.Length > 0 ? occurrence.Attributes[0] : null; + if (attr == null || attr.ConstructorArguments.Length == 0 || attr.ConstructorArguments[0].Value is not INamedTypeSymbol payloadType) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingPayloadRule, + syntax.Identifier.GetLocation(), + typeSymbol.Name)); + return; + } + + var config = ExtractConfig(attr); + var source = GenerateSource(typeSymbol, payloadType, config); + var fileName = $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "").Replace("<", "_").Replace(">", "_").Replace(".", "_")}.Observer.g.cs"; + context.AddSource(fileName, source); + } + + private static bool IsPartial(TypeDeclarationSyntax syntax) + => syntax.Modifiers.Any(m => m.Text == "partial"); + + private static ObserverConfig ExtractConfig(AttributeData attr) + { + var config = new ObserverConfig(); + foreach (var arg in attr.NamedArguments) + { + switch (arg.Key) + { + case "Threading": + config.Threading = (int)arg.Value.Value!; + break; + case "Exceptions": + config.Exceptions = (int)arg.Value.Value!; + break; + case "Order": + config.Order = (int)arg.Value.Value!; + break; + case "GenerateAsync": + config.GenerateAsync = (bool)arg.Value.Value!; + break; + case "ForceAsync": + config.ForceAsync = (bool)arg.Value.Value!; + break; + } + } + return config; + } + + private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymbol payloadType, ObserverConfig config) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : typeSymbol.ContainingNamespace.ToDisplayString(); + + var typeKind = typeSymbol.TypeKind switch + { + TypeKind.Class => typeSymbol.IsRecord ? "record class" : "class", + TypeKind.Struct => typeSymbol.IsRecord ? "record struct" : "struct", + _ => "class" + }; + + var accessibility = typeSymbol.DeclaredAccessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + var typeName = typeSymbol.Name; + var payloadTypeName = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var sb = new StringBuilder(); + sb.AppendLine("#nullable enable"); + sb.AppendLine("// "); + sb.AppendLine(); + + if (ns != null) + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + sb.AppendLine($"{accessibility} partial {typeKind} {typeName}"); + sb.AppendLine("{"); + + GenerateFields(sb, config); + GenerateSubscribeMethods(sb, payloadTypeName, config); + GeneratePublishMethods(sb, payloadTypeName, config); + GenerateOnErrorHook(sb); + GenerateSubscriptionClass(sb, payloadTypeName, config); + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void GenerateFields(StringBuilder sb, ObserverConfig config) + { + // Generate a shared state object to avoid any issues with subscriptions + sb.AppendLine(" private sealed class ObserverState"); + sb.AppendLine(" {"); + + switch (config.Threading) + { + case 0: // SingleThreadedFast + sb.AppendLine(" public System.Collections.Generic.List? Subscriptions;"); + sb.AppendLine(" public int NextId;"); + break; + + case 1: // Locking + sb.AppendLine(" public readonly object Lock = new();"); + sb.AppendLine(" public System.Collections.Generic.List? Subscriptions;"); + sb.AppendLine(" public int NextId;"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + sb.AppendLine(" public System.Collections.Immutable.ImmutableList? Subscriptions;"); + sb.AppendLine(" public int NextId;"); + } + else // Undefined + { + sb.AppendLine(" public System.Collections.Concurrent.ConcurrentBag? Subscriptions;"); + sb.AppendLine(" public int NextId;"); + } + break; + } + + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private readonly ObserverState _state = new();"); + sb.AppendLine(); + } + + private static void GenerateSubscribeMethods(StringBuilder sb, string payloadType, ObserverConfig config) + { + if (!config.ForceAsync) + { + sb.AppendLine($" public System.IDisposable Subscribe(System.Action<{payloadType}> handler)"); + sb.AppendLine(" {"); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _state.NextId);"); + sb.AppendLine(" var sub = new Subscription(_state, id, handler, false);"); + GenerateAddSubscription(sb, config, " ", "_state"); + sb.AppendLine(" return sub;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + if (config.GenerateAsync) + { + sb.AppendLine($" public System.IDisposable Subscribe(System.Func<{payloadType}, System.Threading.Tasks.ValueTask> handler)"); + sb.AppendLine(" {"); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _state.NextId);"); + sb.AppendLine(" var sub = new Subscription(_state, id, handler, true);"); + GenerateAddSubscription(sb, config, " ", "_state"); + sb.AppendLine(" return sub;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + } + + private static void GenerateAddSubscription(StringBuilder sb, ObserverConfig config, string indent, string stateVar) + { + switch (config.Threading) + { + case 0: // SingleThreadedFast + sb.AppendLine($"{indent}({stateVar}.Subscriptions ??= new()).Add(sub);"); + break; + + case 1: // Locking + sb.AppendLine($"{indent}lock ({stateVar}.Lock)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} ({stateVar}.Subscriptions ??= new()).Add(sub);"); + sb.AppendLine($"{indent}}}"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder (requires ImmutableList and Linq for .ToArray()) + { + sb.AppendLine($"{indent}System.Collections.Immutable.ImmutableInterlocked.Update(ref {stateVar}.Subscriptions, static (list, s) => (list ?? System.Collections.Immutable.ImmutableList.Empty).Add(s), sub);"); + } + else // Undefined + { + sb.AppendLine($"{indent}({stateVar}.Subscriptions ??= new()).Add(sub);"); + } + break; + } + } + + private static void GeneratePublishMethods(StringBuilder sb, string payloadType, ObserverConfig config) + { + if (!config.ForceAsync) + { + sb.AppendLine($" public void Publish({payloadType} payload)"); + sb.AppendLine(" {"); + GenerateSnapshot(sb, config, " "); + sb.AppendLine(); + + if (config.Exceptions == 2) // Aggregate + { + sb.AppendLine(" System.Collections.Generic.List? errors = null;"); + sb.AppendLine(); + } + + sb.AppendLine(" foreach (var sub in snapshot)"); + sb.AppendLine(" {"); + + // Handle async subscriptions in fire-and-forget mode + sb.AppendLine(" if (sub.IsAsync)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue - fire and forget with error handling + { + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + sb.AppendLine(" OnSubscriberError(ex);"); + sb.AppendLine(" }"); + sb.AppendLine(" });"); + } + else if (config.Exceptions == 1) // Stop - fire and forget; exceptions are unobserved + { + sb.AppendLine(" // Fire-and-forget: exceptions from async handlers cannot stop sync execution"); + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" });"); + } + else // Aggregate - fire and forget with error logging via OnSubscriberError + { + sb.AppendLine(" // Fire-and-forget: async exceptions logged via OnSubscriberError (cannot aggregate synchronously)"); + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + sb.AppendLine(" OnSubscriberError(ex);"); + sb.AppendLine(" }"); + sb.AppendLine(" });"); + } + sb.AppendLine(" continue;"); + sb.AppendLine(" }"); + + if (config.Exceptions == 1) // Stop + { + sb.AppendLine(" sub.InvokeSync(payload);"); + } + else + { + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" sub.InvokeSync(payload);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue + { + sb.AppendLine(" OnSubscriberError(ex);"); + } + else // Aggregate + { + sb.AppendLine(" (errors ??= new()).Add(ex);"); + } + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + + if (config.Exceptions == 2) + { + sb.AppendLine(); + sb.AppendLine(" if (errors is { Count: > 0 })"); + sb.AppendLine(" throw new System.AggregateException(errors);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + } + + if (config.GenerateAsync) + { + sb.AppendLine($" public async System.Threading.Tasks.ValueTask PublishAsync({payloadType} payload, System.Threading.CancellationToken cancellationToken = default)"); + sb.AppendLine(" {"); + GenerateSnapshot(sb, config, " "); + sb.AppendLine(); + + if (config.Exceptions == 2) + { + sb.AppendLine(" System.Collections.Generic.List? errors = null;"); + sb.AppendLine(); + } + + sb.AppendLine(" foreach (var sub in snapshot)"); + sb.AppendLine(" {"); + sb.AppendLine(" cancellationToken.ThrowIfCancellationRequested();"); + + if (config.Exceptions == 1) // Stop + { + sb.AppendLine(" await sub.InvokeAsync(payload, cancellationToken).ConfigureAwait(false);"); + } + else + { + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, cancellationToken).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue + { + sb.AppendLine(" OnSubscriberError(ex);"); + } + else // Aggregate + { + sb.AppendLine(" (errors ??= new()).Add(ex);"); + } + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + + if (config.Exceptions == 2) + { + sb.AppendLine(); + sb.AppendLine(" if (errors is { Count: > 0 })"); + sb.AppendLine(" throw new System.AggregateException(errors);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + } + } + + private static void GenerateSnapshot(StringBuilder sb, ObserverConfig config, string indent) + { + switch (config.Threading) + { + case 0: // SingleThreadedFast + sb.AppendLine($"{indent}var snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); + break; + + case 1: // Locking + sb.AppendLine($"{indent}Subscription[] snapshot;"); + sb.AppendLine($"{indent}lock (_state.Lock)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent}}}"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + sb.AppendLine($"{indent}var snapshot = System.Threading.Volatile.Read(ref _state.Subscriptions)?.ToArray() ?? System.Array.Empty();"); + } + else // Undefined + { + sb.AppendLine($"{indent}var snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); + } + break; + } + } + + private static void GenerateOnErrorHook(StringBuilder sb) + { + sb.AppendLine(" partial void OnSubscriberError(System.Exception ex);"); + sb.AppendLine(); + } + + private static void GenerateSubscriptionClass(StringBuilder sb, string payloadType, ObserverConfig config) + { + // Subscription now uses a delegate callback instead of reflection + sb.AppendLine($" private sealed class Subscription : System.IDisposable"); + sb.AppendLine(" {"); + sb.AppendLine(" private ObserverState? _state;"); + sb.AppendLine(" private readonly int _id;"); + sb.AppendLine(" private readonly object _handler;"); + sb.AppendLine(" private readonly bool _isAsync;"); + sb.AppendLine(" private int _disposed;"); + sb.AppendLine(); + sb.AppendLine(" public int Id => _id;"); + sb.AppendLine(" public bool IsAsync => _isAsync;"); + sb.AppendLine(); + sb.AppendLine(" public Subscription(ObserverState state, int id, object handler, bool isAsync)"); + sb.AppendLine(" {"); + sb.AppendLine(" _state = state;"); + sb.AppendLine(" _id = id;"); + sb.AppendLine(" _handler = handler;"); + sb.AppendLine(" _isAsync = isAsync;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine($" public void InvokeSync({payloadType} payload)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Volatile.Read(ref _disposed) != 0) return;"); + sb.AppendLine($" ((System.Action<{payloadType}>)_handler)(payload);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine($" public System.Threading.Tasks.ValueTask InvokeAsync({payloadType} payload, System.Threading.CancellationToken ct)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Volatile.Read(ref _disposed) != 0) return default;"); + sb.AppendLine(" if (_isAsync)"); + sb.AppendLine($" return ((System.Func<{payloadType}, System.Threading.Tasks.ValueTask>)_handler)(payload);"); + sb.AppendLine(" else"); + sb.AppendLine(" {"); + sb.AppendLine($" ((System.Action<{payloadType}>)_handler)(payload);"); + sb.AppendLine(" return default;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" public void Dispose()"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); + sb.AppendLine(" var state = System.Threading.Interlocked.Exchange(ref _state, null);"); + sb.AppendLine(" if (state == null) return;"); + sb.AppendLine(); + + // Generate the appropriate unsubscribe logic based on threading policy + switch (config.Threading) + { + case 0: // SingleThreadedFast + sb.AppendLine(" state.Subscriptions?.RemoveAll(s => s.Id == _id);"); + break; + + case 1: // Locking + sb.AppendLine(" lock (state.Lock)"); + sb.AppendLine(" {"); + sb.AppendLine(" state.Subscriptions?.RemoveAll(s => s.Id == _id);"); + sb.AppendLine(" }"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref state.Subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, _id);"); + } + else + { + sb.AppendLine(" // ConcurrentBag doesn't support efficient removal."); + sb.AppendLine(" // Disposed subscriptions remain in the bag but are marked as disposed and won't be invoked."); + sb.AppendLine(" // Note: This can cause memory growth if many subscriptions are created and disposed."); + } + break; + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + private sealed class ObserverConfig + { + public int Threading { get; set; } = 1; // Locking + public int Exceptions { get; set; } = 0; // Continue + public int Order { get; set; } = 0; // RegistrationOrder + public bool GenerateAsync { get; set; } = true; + public bool ForceAsync { get; set; } = false; + } +} diff --git a/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs new file mode 100644 index 0000000..c84a998 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs @@ -0,0 +1,636 @@ +using Microsoft.CodeAnalysis; +using System.Runtime.Loader; + +namespace PatternKit.Generators.Tests; + +public class ObserverGeneratorTests +{ + private const string SimpleObserver = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial class TemperatureChanged + { + } + """; + + [Fact] + public void Generates_Observer_Without_Diagnostics() + { + var comp = RoslynTestHelpers.CreateCompilation( + SimpleObserver, + assemblyName: nameof(Generates_Observer_Without_Diagnostics)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // No generator diagnostics + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + // Confirm we generated expected file + var sources = run.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Single(sources); + Assert.Contains("Observer.g.cs", sources[0].HintName); + + // The updated compilation should compile + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Reports_Error_When_Type_Not_Partial() + { + var code = """ + using PatternKit.Generators.Observer; + namespace Test; + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public class TemperatureChanged { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + code, + assemblyName: nameof(Reports_Error_When_Type_Not_Partial)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS001"); + } + + [Fact] + public void Subscribe_And_Publish_Works() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add($"Handler1:{t.Celsius}")); + evt.Subscribe((Temperature t) => log.Add($"Handler2:{t.Celsius}")); + + evt.Publish(new Temperature(23.5)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Subscribe_And_Publish_Works)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Load and invoke Demo.Run + using var pe = new MemoryStream(); + using var pdb = new MemoryStream(); + var res = updated.Emit(pe, pdb); + Assert.True(res.Success); + + pe.Position = 0; + pdb.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe, pdb); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + Assert.NotNull(demoType); + + var runMethod = demoType.GetMethod("Run"); + Assert.NotNull(runMethod); + + var result = (string)runMethod.Invoke(null, null)!; + Assert.Equal("Handler1:23.5|Handler2:23.5", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Dispose_Removes_Subscription() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + var sub1 = evt.Subscribe((Temperature t) => log.Add($"H1:{t.Celsius}")); + var sub2 = evt.Subscribe((Temperature t) => log.Add($"H2:{t.Celsius}")); + + evt.Publish(new Temperature(10)); + sub1.Dispose(); + evt.Publish(new Temperature(20)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Dispose_Removes_Subscription)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + + // After first publish: both handlers; after second: only H2 + Assert.Equal("H1:10|H2:10|H2:20", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Registration_Order_Preserved() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("A")); + evt.Subscribe((Temperature t) => log.Add("B")); + evt.Subscribe((Temperature t) => log.Add("C")); + + evt.Publish(new Temperature(0)); + + return string.Join("", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Registration_Order_Preserved)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("ABC", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Async_Subscribe_And_PublishAsync_Works() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static async System.Threading.Tasks.Task Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe(async (Temperature t) => + { + await System.Threading.Tasks.Task.Delay(1); + log.Add($"AsyncHandler:{t.Celsius}"); + }); + + await evt.PublishAsync(new Temperature(42)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Async_Subscribe_And_PublishAsync_Works)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var task = (System.Threading.Tasks.Task)runMethod!.Invoke(null, null)!; + task.Wait(); + var result = task.Result; + Assert.Equal("AsyncHandler:42", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Continue_Does_Not_Stop_Execution() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Continue)] + public partial class TemperatureChanged + { + partial void OnSubscriberError(System.Exception ex) + { + // Swallow the error + } + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("H1")); + evt.Subscribe((Temperature t) => throw new System.Exception("Oops")); + evt.Subscribe((Temperature t) => log.Add("H3")); + + evt.Publish(new Temperature(0)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Continue_Does_Not_Stop_Execution)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + + // All three handlers should execute (H1, exception, H3) + Assert.Equal("H1|H3", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Stop_Throws_First_Exception() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Stop)] + public partial class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => { }); + evt.Subscribe((Temperature t) => throw new System.Exception("Oops")); + evt.Subscribe((Temperature t) => { }); + + try + { + evt.Publish(new Temperature(0)); + return "No exception"; + } + catch (System.Exception ex) + { + return ex.Message; + } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Stop_Throws_First_Exception)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("Oops", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Aggregate_Throws_AggregateException() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Aggregate)] + public partial class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => throw new System.Exception("Error1")); + evt.Subscribe((Temperature t) => throw new System.Exception("Error2")); + + try + { + evt.Publish(new Temperature(0)); + return "No exception"; + } + catch (System.AggregateException ex) + { + return $"{ex.InnerExceptions.Count}:{ex.InnerExceptions[0].Message}:{ex.InnerExceptions[1].Message}"; + } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Aggregate_Throws_AggregateException)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("2:Error1:Error2", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Struct_Types_Are_Not_Supported() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial struct TemperatureChanged + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Struct_Types_Are_Not_Supported)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + // Should report PKOBS003 diagnostic for struct types + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS003" && d.GetMessage().Contains("Struct observer types are not currently supported")); + } + + [Fact] + public void Supports_Record_Class() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial record class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("OK")); + evt.Publish(new Temperature(0)); + + return string.Join("", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Supports_Record_Class)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Record_Struct_Types_Are_Not_Supported() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial record struct TemperatureChanged + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Record_Struct_Types_Are_Not_Supported)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + // Should report PKOBS003 diagnostic for record struct types + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS003" && d.GetMessage().Contains("Struct observer types are not currently supported")); + } + + [Fact] + public void Mixed_Sync_And_Async_Handlers_Both_Invoked() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static async System.Threading.Tasks.Task Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + // Subscribe sync handler + evt.Subscribe((Temperature t) => log.Add("Sync")); + + // Subscribe async handler + evt.Subscribe(async (Temperature t) => + { + await System.Threading.Tasks.Task.Delay(1); + log.Add("Async"); + }); + + // Sync Publish should invoke async handlers fire-and-forget + evt.Publish(new Temperature(10)); + + // Wait a bit for fire-and-forget to complete + await System.Threading.Tasks.Task.Delay(50); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Mixed_Sync_And_Async_Handlers_Both_Invoked)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var task = (System.Threading.Tasks.Task)runMethod!.Invoke(null, null)!; + task.Wait(); + var result = task.Result; + + // Both handlers should have been invoked + Assert.Contains("Sync", result); + Assert.Contains("Async", result); + } + finally + { + alc.Unload(); + } + } +} diff --git a/test/PatternKit.Generators.Tests/packages.lock.json b/test/PatternKit.Generators.Tests/packages.lock.json index 55fe82a..00b627e 100644 --- a/test/PatternKit.Generators.Tests/packages.lock.json +++ b/test/PatternKit.Generators.Tests/packages.lock.json @@ -386,7 +386,10 @@ "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "18.0.1", - "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", @@ -407,6 +410,14 @@ "resolved": "10.0.3", "contentHash": "+bZnyzt0/vt4g3QSllhsRNGTpa09p7Juy5K8spcK73cOTOefu4+HoY89hZOgIOmzB5A4hqPyEDKnzra7KKnhZw==" }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -469,7 +480,8 @@ "patternkit.generators": { "type": "Project", "dependencies": { - "PatternKit.Generators.Abstractions": "[1.0.0, )" + "PatternKit.Generators.Abstractions": "[1.0.0, )", + "System.Collections.Immutable": "[10.0.3, )" } }, "patternkit.generators.abstractions": {