diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f999463..ac53db90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,37 @@ jobs: - name: Test with coverage timeout-minutes: 45 run: | - dotnet test PatternKit.slnx \ + dotnet test test/PatternKit.Tests/PatternKit.Tests.csproj \ --configuration Release \ --no-build \ -p:TestTfmsInParallel=false \ --collect:"XPlat Code Coverage" \ --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=core-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=generators-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Hosting.Extensions.Tests/PatternKit.Hosting.Extensions.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=hosting-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=examples-test-results" \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura - name: Install ReportGenerator @@ -171,12 +196,37 @@ jobs: - name: Test with coverage (Release) timeout-minutes: 60 run: | - dotnet test PatternKit.slnx \ + dotnet test test/PatternKit.Tests/PatternKit.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=core-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=generators-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Hosting.Extensions.Tests/PatternKit.Hosting.Extensions.Tests.csproj \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=hosting-test-results" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + dotnet test test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj \ --configuration Release \ --no-build \ -p:TestTfmsInParallel=false \ --collect:"XPlat Code Coverage" \ --results-directory ./TestResults \ + --logger "trx;LogFilePrefix=examples-test-results" \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura diff --git a/README.md b/README.md index b0ac060e..e0a44b64 100644 --- a/README.md +++ b/README.md @@ -478,7 +478,7 @@ PatternKit currently tracks 115 production-readiness patterns. Each catalog patt | Category | Count | Patterns | | --- | ---: | --- | | Application Architecture | 26 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | -| Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | +| Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | | Enterprise Integration | 41 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content Enricher, Content-Based Router, Control Bus, Correlation Identifier, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Guaranteed Delivery, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Expiration, Message Filter, Message History, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | diff --git a/benchmarks/PatternKit.Benchmarks/Behavioral/NullObjectBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Behavioral/NullObjectBenchmarks.cs new file mode 100644 index 00000000..5caa17b1 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Behavioral/NullObjectBenchmarks.cs @@ -0,0 +1,42 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Behavioral.NullObject; +using PatternKit.Generators.NullObject; + +namespace PatternKit.Benchmarks.Behavioral; + +[BenchmarkCategory("Behavioral", "NullObject")] +public class NullObjectBenchmarks +{ + private static readonly Notification Notification = new("C-100", "Statement ready"); + + [Benchmark(Baseline = true, Description = "Fluent: create null object")] + [BenchmarkCategory("Fluent", "Construction")] + public NullObject Fluent_CreateNullObject() + => NullObject + .Create(NullNotificationChannel.Instance) + .Build(); + + [Benchmark(Description = "Generated: get null object instance")] + [BenchmarkCategory("Generated", "Construction")] + public INullNotificationChannel Generated_GetInstance() + => NullNotificationChannel.Instance; + + [Benchmark(Description = "Fluent: invoke null object")] + [BenchmarkCategory("Fluent", "Execution")] + public string Fluent_Invoke() + => Fluent_CreateNullObject().Instance.Send(Notification); + + [Benchmark(Description = "Generated: invoke null object")] + [BenchmarkCategory("Generated", "Execution")] + public string Generated_Invoke() + => NullNotificationChannel.Instance.Send(Notification); +} + +public sealed record Notification(string CustomerId, string Subject); + +[GenerateNullObject(TypeName = "NullNotificationChannel")] +public interface INullNotificationChannel +{ + [NullObjectDefault("suppressed")] + string Send(Notification notification); +} diff --git a/docs/examples/index.md b/docs/examples/index.md index 2acf9b0d..8456a3c9 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -51,6 +51,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Order Projection Eventual Consistency Monitor** A Generic Host importable monitor with fluent and source-generated routes for source/target watermark convergence. See [Order Projection Eventual Consistency Monitor](order-projection-eventual-consistency-monitor.md). +* **Customer Notification Null Object** + A Generic Host importable notification fallback with fluent and source-generated routes, `IServiceCollection` registration, and deterministic no-op behavior for optional collaborators. See [Customer Notification Null Object](null-object-notification.md). + * **Minimal Web Request Router** A tiny "API gateway" that separates **first-match middleware** (side effects/logging/auth) from **first-match routes** and **content negotiation**. A crisp example of Strategy patterns in an HTTP-ish setting. diff --git a/docs/examples/null-object-notification.md b/docs/examples/null-object-notification.md new file mode 100644 index 00000000..5b538ed0 --- /dev/null +++ b/docs/examples/null-object-notification.md @@ -0,0 +1,10 @@ +# Null Object Notification Example + +The customer notification example shows how to keep a workflow production-safe when an optional notification provider is not configured. + +The generated `NullCustomerNotificationChannel` implements `ICustomerNotificationChannel` and returns a deterministic `"suppressed"` status. The fluent `NullObject` wrapper is registered through `IServiceCollection` so importing applications can inject the fallback channel without special-case `null` logic. + +Relevant files: + +- `src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs` +- `test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 389d65cb..c5198b2e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -22,6 +22,9 @@ - name: Order Projection Eventual Consistency Monitor href: order-projection-eventual-consistency-monitor.md +- name: Customer Notification Null Object + href: null-object-notification.md + - name: Spreadsheet Formula Object Pool href: spreadsheet-formula-object-pool.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 3f110a5f..e68749b6 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -56,6 +56,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Interpreter**](interpreter.md) | DSL rule factories for terminal and non-terminal expressions | `[GenerateInterpreter]` | | [**Iterator**](iterator.md) | Enumerable/async-enumerable iteration helpers | `[Iterator]` | | [**Memento**](memento.md) | Immutable snapshots with optional undo/redo history | `[Memento]` | +| [**Null Object**](null-object.md) | Deterministic no-op implementations for optional contracts | `[GenerateNullObject]` | | [**Observer**](observer.md) | Event hubs and observer dispatch | `[ObserverHub]` | | [**State Machine**](state-machine.md) | Deterministic finite state machines | `[StateMachine]` | | [**Strategy**](strategy.md) | Predicate-based dispatch with fluent builder | `[GenerateStrategy]` | diff --git a/docs/generators/null-object.md b/docs/generators/null-object.md new file mode 100644 index 00000000..4b805ab2 --- /dev/null +++ b/docs/generators/null-object.md @@ -0,0 +1,25 @@ +# Null Object Generator + +`[GenerateNullObject]` generates a sealed Null Object implementation for an interface contract. + +```csharp +[GenerateNullObject(TypeName = "NullInventoryNotifier")] +public interface IInventoryNotifier +{ + [NullObjectDefault(false)] + bool CanDeliver { get; } + + [NullObjectDefault("suppressed")] + string Notify(string sku); +} +``` + +Generated output includes: + +- a sealed implementation of the contract +- a static `Instance` property +- no-op `void` methods +- deterministic defaults for strings, booleans, numeric values, arrays, `Task`, `Task`, `ValueTask`, and `ValueTask` +- per-member defaults through `[NullObjectDefault]` + +Use the generated implementation directly or wrap it with `NullObject` for consistent dependency injection registration. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index e093d115..178aeede 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -118,6 +118,9 @@ - name: Memento href: memento.md +- name: Null Object + href: null-object.md + - name: Messaging Generators href: messaging.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 87d4f195..b7fc777b 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -29,6 +29,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Null Object | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Null Object | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregator | Construction | 14.562 ns | 168 B | 15.235 ns | 168 B | Same allocation; fluent was slightly faster in this microbenchmark. | | Aggregator | Execution | 188.000 ns | 1,088 B | 200.564 ns | 1,088 B | Same allocation; fluent was faster for order line aggregation. | | Ambassador | Construction | 55.42 ns | 448 B | 48.03 ns | 360 B | Generated reduced construction time and allocation in this microbenchmark. | @@ -250,19 +252,19 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 115 catalog patterns and 460 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 115 catalog patterns and 460 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 10 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | | Application Architecture | 26 | 104 | -| Behavioral | 11 | 44 | +| Behavioral | 12 | 48 | | Cloud Architecture | 20 | 80 | | Creational | 6 | 24 | | Enterprise Integration | 41 | 164 | | Messaging Reliability | 3 | 12 | -| Structural | 7 | 28 | +| Structural | 7 | 28 | -The generator matrix currently publishes 109 generator source route results. +The generator matrix currently publishes 110 generator source route results. ## Hosting Integration Matrix Results @@ -273,6 +275,7 @@ The generator matrix currently publishes 109 generator source route results. | Guaranteed Delivery | `IServiceCollection` | `AddPatternKitGuaranteedDelivery` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Message Channel | `IServiceCollection` | `AddPatternKitMessageChannel` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Message Store | `IServiceCollection` | `AddPatternKitMessageStore` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | +| Null Object | `IServiceCollection` | `AddPatternKitNullObject` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Priority Queue | `IServiceCollection` | `AddPatternKitPriorityQueue` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Queue-Based Load Leveling | `IServiceCollection` | `AddPatternKitQueueLoadLevelingPolicy` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | | Rate Limiting | `IServiceCollection` | `AddPatternKitRateLimitPolicy` | `src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs` | `test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs` | `docs/guides/hosting-extensions.md` | @@ -312,9 +315,10 @@ The generator matrix currently publishes 109 generator source route results. | Behavioral | Command | Covered | Covered | Covered | Covered | | Behavioral | Interpreter | Covered | Covered | Covered | Covered | | Behavioral | Iterator | Covered | Covered | Covered | Covered | -| Behavioral | Mediator | Covered | Covered | Covered | Covered | -| Behavioral | Memento | Covered | Covered | Covered | Covered | -| Behavioral | Observer | Covered | Covered | Covered | Covered | +| Behavioral | Mediator | Covered | Covered | Covered | Covered | +| Behavioral | Memento | Covered | Covered | Covered | Covered | +| Behavioral | Null Object | Covered | Covered | Covered | Covered | +| Behavioral | Observer | Covered | Covered | Covered | Covered | | Behavioral | State | Covered | Covered | Covered | Covered | | Behavioral | Strategy | Covered | Covered | Covered | Covered | | Behavioral | Template Method | Covered | Covered | Covered | Covered | @@ -447,8 +451,9 @@ The generator matrix currently publishes 109 generator source route results. | InterpreterGenerator | `src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs` | Covered | | IteratorGenerator | `src/PatternKit.Generators/Iterator/IteratorGenerator.cs` | Covered | | LeaderElectionGenerator | `src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs` | Covered | -| MaterializedViewGenerator | `src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs` | Covered | -| MementoGenerator | `src/PatternKit.Generators/MementoGenerator.cs` | Covered | +| MaterializedViewGenerator | `src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs` | Covered | +| MementoGenerator | `src/PatternKit.Generators/MementoGenerator.cs` | Covered | +| NullObjectGenerator | `src/PatternKit.Generators/NullObject/NullObjectGenerator.cs` | Covered | | BackplaneTopologyGenerator | `src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs` | Covered | | ChannelAdapterGenerator | `src/PatternKit.Generators/Messaging/ChannelAdapterGenerator.cs` | Covered | | ChannelPurgerGenerator | `src/PatternKit.Generators/Messaging/ChannelPurgerGenerator.cs` | Covered | diff --git a/docs/guides/hosting-extensions.md b/docs/guides/hosting-extensions.md index 9007647f..42ed41f8 100644 --- a/docs/guides/hosting-extensions.md +++ b/docs/guides/hosting-extensions.md @@ -83,6 +83,18 @@ public sealed record ServiceReply(bool Available); public sealed record InventoryWork(string Sku, int Priority); ``` +Register null-object fallbacks for optional collaborators: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Hosting.DependencyInjection; + +var services = new ServiceCollection(); + +services.AddPatternKitNullObject( + NullNotificationChannel.Instance); +``` + All helpers accept a `ServiceLifetime`; singleton is the default because most PatternKit runtime primitives hold useful state such as queues, windows, counters, or circuit state. ```csharp @@ -106,6 +118,7 @@ Every catalog pattern is importable through the production example catalog. The | Rate Limiting | `AddPatternKitRateLimitPolicy` | Register per-key rate windows. | | Queue-Based Load Leveling | `AddPatternKitQueueLoadLevelingPolicy` | Register queue-backed worker policies. | | Priority Queue | `AddPatternKitPriorityQueue` | Register priority-ordered work queues. | +| Null Object | `AddPatternKitNullObject` | Register deterministic no-op fallback collaborators. | The hosting integration catalog in `PatternKit.Examples.ProductionReadiness` audits every catalog pattern against this reusable surface and the example-level `AddPatternKitExamples()` import path. BenchmarkDotNet coverage includes a dedicated `HostingIntegration` matrix route for every reusable registration above. diff --git a/docs/index.md b/docs/index.md index 6cdd7b96..c88e4313 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,14 +66,14 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 114 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 115 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | | Application Architecture | 26 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | -| Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | +| Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | -| Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | +| Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | | Enterprise Integration | 41 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content Enricher, Content-Based Router, Control Bus, Correlation Identifier, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Guaranteed Delivery, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Expiration, Message Filter, Message History, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | | Messaging Reliability | 3 | Idempotent Receiver, Inbox, Outbox | | Structural | 7 | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy | diff --git a/docs/patterns/behavioral/null-object/index.md b/docs/patterns/behavioral/null-object/index.md new file mode 100644 index 00000000..8eee9c7f --- /dev/null +++ b/docs/patterns/behavioral/null-object/index.md @@ -0,0 +1,31 @@ +# Null Object + +The Null Object pattern replaces optional collaborators with a production fallback object that implements the same contract and performs deterministic no-op behavior. + +Use it when a service should continue safely without scattering `null` checks through application code. Typical examples include optional notification channels, audit sinks, metrics publishers, feature integrations, and external adapters disabled by tenant or environment. + +## Fluent Path + +```csharp +var fallback = NullObject + .Create(NullCustomerNotificationChannel.Instance) + .Build(); + +services.AddSingleton(fallback); +services.AddSingleton(fallback.Instance); +``` + +The fluent wrapper gives IoC registrations a stable, explicit fallback collaborator. + +## Source-Generated Path + +```csharp +[GenerateNullObject(TypeName = "NullCustomerNotificationChannel")] +public interface ICustomerNotificationChannel +{ + [NullObjectDefault("suppressed")] + string Send(CustomerNotification notification); +} +``` + +The generator emits a sealed implementation with an `Instance` singleton and safe default behavior for methods and properties. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index db4d30cb..e9e0633c 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -72,6 +72,8 @@ href: behavioral/memento/real-world-examples.md - name: Memento href: behavioral/memento/memento.md + - name: Null Object + href: behavioral/null-object/index.md - name: Observer href: behavioral/observer/index.md items: diff --git a/src/PatternKit.Core/Behavioral/NullObject/NullObject.cs b/src/PatternKit.Core/Behavioral/NullObject/NullObject.cs new file mode 100644 index 00000000..df90c01d --- /dev/null +++ b/src/PatternKit.Core/Behavioral/NullObject/NullObject.cs @@ -0,0 +1,51 @@ +namespace PatternKit.Behavioral.NullObject; + +/// +/// Fluent Null Object wrapper for a production fallback implementation of . +/// +/// The service, strategy, or collaborator contract represented by the null object. +public sealed class NullObject + where TContract : class +{ + private readonly TContract _instance; + + private NullObject(TContract instance) + => _instance = instance ?? throw new ArgumentNullException(nameof(instance)); + + /// + /// Gets the configured null object instance. + /// + public TContract Instance => _instance; + + /// + /// Creates a builder from an existing null object instance. + /// + public static Builder Create(TContract instance) => new(instance); + + /// + /// Creates a builder from a factory that produces the null object instance exactly once. + /// + public static Builder Create(Func factory) + { + if (factory is null) + throw new ArgumentNullException(nameof(factory)); + + return new Builder(factory()); + } + + /// + /// Fluent builder for . + /// + public sealed class Builder + { + private readonly TContract _instance; + + internal Builder(TContract instance) + => _instance = instance ?? throw new ArgumentNullException(nameof(instance)); + + /// + /// Builds an immutable null object wrapper. + /// + public NullObject Build() => new(_instance); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index e3f65f86..cd83871f 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using PatternKit.Application.WorkflowOrchestration; using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Interpreter; +using PatternKit.Behavioral.NullObject; using PatternKit.Behavioral.Strategy; using PatternKit.Behavioral.TypeDispatcher; using PatternKit.Cloud.Bulkhead; @@ -66,6 +67,7 @@ using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.MementoDemo; using PatternKit.Examples.Messaging; +using PatternKit.Examples.NullObjectDemo; using PatternKit.Examples.ObjectPoolDemo; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PatternShowcase; @@ -234,6 +236,7 @@ public sealed record OrderMaterializedViewPatternExample(OrderMaterializedViewDe public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); +public sealed record CustomerNotificationNullObjectExample(NullObject Fallback, ICustomerNotificationChannel Channel, CustomerNotificationWorkflow Workflow); public sealed record TextEditorMementoExample(MementoDemo.MementoDemo.TextEditor Editor); public sealed record ObserverEventHubExample(EventHub Hub); public sealed record ReactiveViewModelExample(ProfileViewModel ViewModel); @@ -356,6 +359,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() + .AddCustomerNotificationNullObjectExample() .AddTextEditorMementoExample() .AddObserverEventHubExample() .AddReactiveViewModelExample() @@ -1117,6 +1121,21 @@ public static IServiceCollection AddFlyweightGlyphCacheExample(this IServiceColl return services.RegisterExample("Flyweight Glyph Cache", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddCustomerNotificationNullObjectExample(this IServiceCollection services) + { + services.AddSingleton(_ => CustomerNotificationNullObjectDemo.CreateFluentFallback()); + services.AddSingleton(sp => sp.GetRequiredService>().Instance); + services.AddSingleton(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + return services.RegisterExample( + "Customer Notification Null Object", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator); + } + public static IServiceCollection AddTextEditorMementoExample(this IServiceCollection services) { services.AddTransient(); diff --git a/src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs b/src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs new file mode 100644 index 00000000..c8dffb5c --- /dev/null +++ b/src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs @@ -0,0 +1,40 @@ +using PatternKit.Behavioral.NullObject; +using PatternKit.Generators.NullObject; + +namespace PatternKit.Examples.NullObjectDemo; + +[GenerateNullObject(TypeName = "NullCustomerNotificationChannel")] +public interface ICustomerNotificationChannel +{ + string Name { get; } + + [NullObjectDefault(false)] + bool CanDeliver { get; } + + [NullObjectDefault("suppressed")] + string Send(CustomerNotification notification); +} + +public sealed record CustomerNotification(string CustomerId, string Subject, string Body); + +public sealed record CustomerNotificationResult(string Channel, string Status, bool Delivered); + +public sealed class CustomerNotificationWorkflow(ICustomerNotificationChannel channel) +{ + public CustomerNotificationResult Notify(CustomerNotification notification) + { + if (notification is null) + throw new ArgumentNullException(nameof(notification)); + + var status = channel.Send(notification); + return new CustomerNotificationResult(channel.Name, status, channel.CanDeliver); + } +} + +public static class CustomerNotificationNullObjectDemo +{ + public static NullObject CreateFluentFallback() + => NullObject + .Create(NullCustomerNotificationChannel.Instance) + .Build(); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 1eea81ec..ccab01f4 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -752,6 +752,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly, ["Flyweight"], ["identity sharing", "case-insensitive styles", "layout reuse"]), + Descriptor( + "Customer Notification Null Object", + "src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs", + "test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs", + "docs/examples/null-object-notification.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator, + ["Null Object"], + ["optional collaborator fallback", "generated no-op contract", "DI composition"]), Descriptor( "Text Editor Memento", "src/PatternKit.Examples/MementoDemo/MementoDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs index e20ccb7f..cd591afa 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitHostingIntegrationCatalog.cs @@ -59,7 +59,8 @@ public sealed class PatternKitHostingIntegrationCatalog : IPatternKitHostingInte ["Bulkhead"] = "AddPatternKitBulkheadPolicy", ["Rate Limiting"] = "AddPatternKitRateLimitPolicy", ["Queue-Based Load Leveling"] = "AddPatternKitQueueLoadLevelingPolicy", - ["Priority Queue"] = "AddPatternKitPriorityQueue" + ["Priority Queue"] = "AddPatternKitPriorityQueue", + ["Null Object"] = "AddPatternKitNullObject" }; private static readonly Lazy> LazyIntegrations = diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 3f0b321b..d8df641d 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -220,6 +220,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/FlyweightDemos/FlyweightDemoTests.cs", ["fluent cache", "generated cache", "glyph cache example"]), + Pattern("Null Object", PatternFamily.Behavioral, + "docs/patterns/behavioral/null-object/index.md", + "src/PatternKit.Core/Behavioral/NullObject/NullObject.cs", + "test/PatternKit.Tests/Behavioral/NullObject/NullObjectTests.cs", + "docs/generators/null-object.md", + "src/PatternKit.Generators/NullObject/NullObjectGenerator.cs", + "test/PatternKit.Generators.Tests/NullObjectGeneratorTests.cs", + null, + "docs/examples/null-object-notification.md", + "src/PatternKit.Examples/NullObjectDemo/CustomerNotificationNullObjectDemo.cs", + "test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs", + ["fluent fallback wrapper", "generated null implementation", "optional notification channel example"]), + Pattern("Proxy", PatternFamily.Structural, "docs/patterns/structural/proxy/index.md", "src/PatternKit.Core/Structural/Proxy/Proxy.cs", diff --git a/src/PatternKit.Generators.Abstractions/NullObject/NullObjectAttributes.cs b/src/PatternKit.Generators.Abstractions/NullObject/NullObjectAttributes.cs new file mode 100644 index 00000000..ce2d9756 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/NullObject/NullObjectAttributes.cs @@ -0,0 +1,35 @@ +namespace PatternKit.Generators.NullObject; + +/// +/// Generates a Null Object implementation for an interface contract. +/// +[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] +public sealed class GenerateNullObjectAttribute : Attribute +{ + /// + /// Gets or sets the generated implementation type name. Defaults to Null{ContractNameWithoutLeadingI}. + /// + public string? TypeName { get; set; } +} + +/// +/// Overrides the generated default return value for a Null Object member. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public sealed class NullObjectDefaultAttribute : Attribute +{ + public NullObjectDefaultAttribute(string value) => Value = value; + + public NullObjectDefaultAttribute(bool value) => Value = value; + + public NullObjectDefaultAttribute(int value) => Value = value; + + public NullObjectDefaultAttribute(long value) => Value = value; + + public NullObjectDefaultAttribute(double value) => Value = value; + + /// + /// Gets the configured constant default value. + /// + public object Value { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 81f16011..b4493ce3 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -139,6 +139,11 @@ PKSNG007 | PatternKit.Generators.Singleton | Error | Generic types are not suppo PKSNG008 | PatternKit.Generators.Singleton | Error | Nested types are not supported PKSNG009 | PatternKit.Generators.Singleton | Error | Invalid instance property name PKSNG010 | PatternKit.Generators.Singleton | Error | Abstract types not supported for Singleton pattern +PKNO001 | PatternKit.Generators.NullObject | Error | Null Object contract must be an interface +PKNO002 | PatternKit.Generators.NullObject | Error | Generic Null Object contracts are not supported +PKNO003 | PatternKit.Generators.NullObject | Error | Null Object type name is invalid +PKNO004 | PatternKit.Generators.NullObject | Error | Null Object contract member is not supported +PKNO005 | PatternKit.Generators.NullObject | Error | Null Object type name conflicts with an existing type PKADP001 | PatternKit.Generators.Adapter | Error | Adapter host must be static partial PKADP002 | PatternKit.Generators.Adapter | Error | Target must be interface or abstract class PKADP003 | PatternKit.Generators.Adapter | Error | Missing mapping for target member diff --git a/src/PatternKit.Generators/NullObject/NullObjectGenerator.cs b/src/PatternKit.Generators/NullObject/NullObjectGenerator.cs new file mode 100644 index 00000000..6deecfd1 --- /dev/null +++ b/src/PatternKit.Generators/NullObject/NullObjectGenerator.cs @@ -0,0 +1,495 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.NullObject; + +[Generator] +public sealed class NullObjectGenerator : IIncrementalGenerator +{ + private const string GenerateNullObjectAttributeName = "PatternKit.Generators.NullObject.GenerateNullObjectAttribute"; + private const string NullObjectDefaultAttributeName = "PatternKit.Generators.NullObject.NullObjectDefaultAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBeInterface = new( + "PKNO001", + "Null Object contract must be an interface", + "Type '{0}' is marked with [GenerateNullObject] but is not an interface", + "PatternKit.Generators.NullObject", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor GenericContractsNotSupported = new( + "PKNO002", + "Generic Null Object contracts are not supported", + "Type '{0}' is generic; generate a Null Object for a closed non-generic facade contract", + "PatternKit.Generators.NullObject", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidTypeName = new( + "PKNO003", + "Null Object type name is invalid", + "Generated Null Object type name '{0}' is not a valid C# identifier", + "PatternKit.Generators.NullObject", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor UnsupportedMember = new( + "PKNO004", + "Null Object contract member is not supported", + "Member '{0}' on Null Object contract '{1}' is not supported: {2}", + "PatternKit.Generators.NullObject", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor TypeNameConflict = new( + "PKNO005", + "Null Object type name conflicts with an existing type", + "Generated Null Object type name '{0}' conflicts with an existing type in namespace '{1}'", + "PatternKit.Generators.NullObject", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var contracts = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateNullObjectAttributeName, + static (node, _) => node is InterfaceDeclarationSyntax, + static (ctx, _) => ctx); + + context.RegisterSourceOutput(contracts, static (spc, contractContext) => + { + if (contractContext.TargetSymbol is not INamedTypeSymbol contract) + return; + + var attribute = contractContext.Attributes.FirstOrDefault(static attr => + attr.AttributeClass?.ToDisplayString() == GenerateNullObjectAttributeName); + if (attribute is null) + return; + + Generate(spc, contract, attribute, contractContext.TargetNode); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol contract, AttributeData attribute, SyntaxNode node) + { + if (contract.TypeKind != TypeKind.Interface) + { + context.ReportDiagnostic(Diagnostic.Create(MustBeInterface, node.GetLocation(), contract.Name)); + return; + } + + if (contract.TypeParameters.Length > 0) + { + context.ReportDiagnostic(Diagnostic.Create(GenericContractsNotSupported, node.GetLocation(), contract.Name)); + return; + } + + var typeName = GetNamedString(attribute, "TypeName") ?? $"Null{TrimInterfacePrefix(contract.Name)}"; + if (!IsIdentifier(typeName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidTypeName, node.GetLocation(), typeName)); + return; + } + + if (contract.ContainingNamespace.GetTypeMembers(typeName).Any(static type => type.TypeKind != TypeKind.Error)) + { + var namespaceName = contract.ContainingNamespace.IsGlobalNamespace + ? "" + : contract.ContainingNamespace.ToDisplayString(); + context.ReportDiagnostic(Diagnostic.Create(TypeNameConflict, node.GetLocation(), typeName, namespaceName)); + return; + } + + var unsupportedMember = GetUnsupportedMember(contract); + if (unsupportedMember is not null) + { + var location = unsupportedMember.Symbol.Locations.FirstOrDefault() ?? node.GetLocation(); + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMember, + location, + unsupportedMember.Symbol.Name, + contract.Name, + unsupportedMember.Reason)); + return; + } + + var source = GenerateSource(contract, typeName); + var hintPrefix = contract.ContainingNamespace.IsGlobalNamespace + ? contract.Name + : contract.ContainingNamespace.ToDisplayString().Replace(".", "_") + "_" + contract.Name; + context.AddSource($"{hintPrefix}.NullObject.g.cs", SourceText.From(source, Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol contract, string typeName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var hasNamespace = !contract.ContainingNamespace.IsGlobalNamespace; + if (hasNamespace) + { + sb.Append("namespace ").Append(contract.ContainingNamespace.ToDisplayString()).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(contract.DeclaredAccessibility)).Append(" sealed class ").Append(typeName) + .Append(" : ").Append(contract.ToDisplayString(TypeFormat)).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static ").Append(typeName).Append(" Instance { get; } = new();").AppendLine(); + sb.AppendLine(); + sb.Append(" private ").Append(typeName).AppendLine("()"); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + + var members = GetContractMembers(contract); + + foreach (var @event in members.OfType().Where(static e => !e.IsStatic)) + { + sb.AppendLine(); + AppendEvent(sb, @event); + } + + foreach (var property in members.OfType().Where(static p => !p.IsStatic)) + { + sb.AppendLine(); + AppendProperty(sb, property); + } + + foreach (var method in members.OfType().Where(static m => m.MethodKind == MethodKind.Ordinary && !m.IsStatic)) + { + sb.AppendLine(); + AppendMethod(sb, method); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static IEnumerable GetContractMembers(INamedTypeSymbol contract) + { + var emittedMembers = new HashSet(StringComparer.Ordinal); + + foreach (var member in contract.GetMembers()) + { + if (emittedMembers.Add(GetMemberImplementationKey(member))) + yield return member; + } + + foreach (var baseInterface in contract.AllInterfaces) + { + foreach (var member in baseInterface.GetMembers()) + { + if (emittedMembers.Add(GetMemberImplementationKey(member))) + yield return member; + } + } + } + + private static UnsupportedContractMember? GetUnsupportedMember(INamedTypeSymbol contract) + { + if (contract.ContainingType is not null) + return new UnsupportedContractMember(contract, "nested Null Object contracts are not supported"); + + var conflictingMember = GetConflictingMember(contract); + if (conflictingMember is not null) + return conflictingMember; + + foreach (var member in GetContractMembers(contract)) + { + if (member.IsStatic && member.IsAbstract) + return new UnsupportedContractMember(member, "static abstract interface members must be implemented explicitly"); + + if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary } method + && (method.ReturnsByRef || method.ReturnsByRefReadonly)) + { + return new UnsupportedContractMember(method, "by-ref return members are not supported"); + } + + if (member is IPropertySymbol property + && (property.ReturnsByRef || property.ReturnsByRefReadonly)) + { + return new UnsupportedContractMember(property, "by-ref return properties and indexers are not supported"); + } + } + + return null; + } + + private static UnsupportedContractMember? GetConflictingMember(INamedTypeSymbol contract) + { + var groups = GetAllContractMembers(contract) + .GroupBy(GetMemberConflictKey, StringComparer.Ordinal) + .Where(static group => group.Select(GetMemberImplementationKey).Distinct(StringComparer.Ordinal).Skip(1).Any()); + + foreach (var group in groups) + { + return new UnsupportedContractMember( + group.First(), + "hidden interface members with conflicting signatures are not supported"); + } + + return null; + } + + private static IEnumerable GetAllContractMembers(INamedTypeSymbol contract) + { + foreach (var member in contract.GetMembers()) + yield return member; + + foreach (var baseInterface in contract.AllInterfaces) + { + foreach (var member in baseInterface.GetMembers()) + yield return member; + } + } + + private static void AppendEvent(StringBuilder sb, IEventSymbol @event) + { + sb.Append(" public event ").Append(@event.Type.ToDisplayString(TypeFormat)).Append(' ').Append(EscapeIdentifier(@event.Name)).AppendLine(); + sb.AppendLine(" {"); + sb.AppendLine(" add { }"); + sb.AppendLine(" remove { }"); + sb.AppendLine(" }"); + } + + private static void AppendProperty(StringBuilder sb, IPropertySymbol property) + { + var type = property.Type.ToDisplayString(TypeFormat); + var defaultExpression = GetDefaultExpression(property.Type, GetConfiguredDefault(property)); + sb.Append(" public ").Append(type).Append(' '); + + if (property.IsIndexer) + sb.Append("this[").Append(string.Join(", ", property.Parameters.Select(FormatParameter))).Append(']'); + else + sb.Append(EscapeIdentifier(property.Name)); + + if (property.GetMethod is not null && property.SetMethod is not null) + { + var setterKeyword = property.SetMethod.IsInitOnly ? "init" : "set"; + sb.AppendLine(); + sb.AppendLine(" {"); + sb.Append(" get => ").Append(defaultExpression).AppendLine(";"); + sb.Append(" ").Append(setterKeyword).AppendLine(" { }"); + sb.AppendLine(" }"); + return; + } + + if (property.GetMethod is not null) + { + sb.Append(" => ").Append(defaultExpression).AppendLine(";"); + return; + } + + sb.AppendLine(); + sb.AppendLine(" {"); + sb.Append(" ").Append(property.SetMethod?.IsInitOnly == true ? "init" : "set").AppendLine(" { }"); + sb.AppendLine(" }"); + } + + private static void AppendMethod(StringBuilder sb, IMethodSymbol method) + { + var returnType = method.ReturnType.ToDisplayString(TypeFormat); + sb.Append(" public ").Append(returnType).Append(' ').Append(EscapeIdentifier(method.Name)) + .Append(GetTypeParameterList(method)).Append('('); + sb.Append(string.Join(", ", method.Parameters.Select(FormatParameter))); + sb.Append(')').Append(GetConstraintClauses(method)); + + var outParameters = method.Parameters.Where(static parameter => parameter.RefKind == RefKind.Out).ToArray(); + if (outParameters.Length > 0) + { + sb.AppendLine(); + sb.AppendLine(" {"); + foreach (var parameter in outParameters) + sb.Append(" ").Append(EscapeIdentifier(parameter.Name)).Append(" = ").Append(GetDefaultExpression(parameter.Type, null)).AppendLine(";"); + if (!method.ReturnsVoid) + sb.Append(" return ").Append(GetDefaultExpression(method.ReturnType, GetConfiguredDefault(method))).AppendLine(";"); + sb.AppendLine(" }"); + return; + } + + if (method.ReturnsVoid) + { + sb.AppendLine(); + sb.AppendLine(" {"); + sb.AppendLine(" }"); + return; + } + + sb.Append(" => ").Append(GetDefaultExpression(method.ReturnType, GetConfiguredDefault(method))).AppendLine(";"); + } + + private static string FormatParameter(IParameterSymbol parameter) + { + var prefix = parameter.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + RefKind.RefReadOnlyParameter => "ref readonly ", + _ => string.Empty + }; + + return prefix + parameter.Type.ToDisplayString(TypeFormat) + " " + EscapeIdentifier(parameter.Name); + } + + private static string GetTypeParameterList(IMethodSymbol method) + => method.TypeParameters.Length == 0 + ? string.Empty + : "<" + string.Join(", ", method.TypeParameters.Select(static parameter => parameter.Name)) + ">"; + + private static string GetConstraintClauses(IMethodSymbol method) + { + if (method.TypeParameters.Length == 0) + return string.Empty; + + var clauses = new List(); + foreach (var parameter in method.TypeParameters) + { + var constraints = new List(); + if (parameter.HasReferenceTypeConstraint) + constraints.Add(parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" : "class"); + if (parameter.HasNotNullConstraint) + constraints.Add("notnull"); + if (parameter.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + else if (parameter.HasValueTypeConstraint) + constraints.Add("struct"); + constraints.AddRange(parameter.ConstraintTypes.Select(static constraint => constraint.ToDisplayString(TypeFormat))); + if (parameter.HasConstructorConstraint) + constraints.Add("new()"); + if (constraints.Count > 0) + clauses.Add($" where {parameter.Name} : {string.Join(", ", constraints)}"); + } + + return string.Concat(clauses); + } + + private static string GetDefaultExpression(ITypeSymbol type, object? configured) + { + if (type.SpecialType == SpecialType.System_String) + return configured is not null ? GetConfiguredDefaultExpression(type, configured) : "string.Empty"; + if (type.SpecialType == SpecialType.System_Boolean) + return configured is not null ? GetConfiguredDefaultExpression(type, configured) : "false"; + if (IsNumeric(type)) + return configured is not null ? GetConfiguredDefaultExpression(type, configured) : "0"; + if (type is IArrayTypeSymbol arrayType) + return $"global::System.Array.Empty<{arrayType.ElementType.ToDisplayString(TypeFormat)}>()"; + if (type.ToDisplayString(TypeFormat) == "global::System.Threading.Tasks.Task") + return "global::System.Threading.Tasks.Task.CompletedTask"; + if (type is INamedTypeSymbol named && IsNamedType(named.ConstructedFrom, "System.Threading.Tasks.Task`1")) + return $"global::System.Threading.Tasks.Task.FromResult<{named.TypeArguments[0].ToDisplayString(TypeFormat)}>({GetDefaultExpression(named.TypeArguments[0], configured)})"; + if (type is INamedTypeSymbol valueTaskOfT && IsNamedType(valueTaskOfT.ConstructedFrom, "System.Threading.Tasks.ValueTask`1")) + return $"new global::System.Threading.Tasks.ValueTask<{valueTaskOfT.TypeArguments[0].ToDisplayString(TypeFormat)}>({GetDefaultExpression(valueTaskOfT.TypeArguments[0], configured)})"; + if (type is INamedTypeSymbol valueTask && IsNamedType(valueTask, "System.Threading.Tasks.ValueTask")) + return "default"; + + if (configured is not null) + return GetConfiguredDefaultExpression(type, configured); + + return "default!"; + } + + private static string GetConfiguredDefaultExpression(ITypeSymbol type, object value) + => value switch + { + string text when type.SpecialType == SpecialType.System_String => "@\"" + text.Replace("\"", "\"\"") + "\"", + bool flag when type.SpecialType == SpecialType.System_Boolean => flag ? "true" : "false", + int number when IsNumeric(type) => number.ToString(System.Globalization.CultureInfo.InvariantCulture), + long number when IsNumeric(type) => number.ToString(System.Globalization.CultureInfo.InvariantCulture), + double number when double.IsNaN(number) && type.SpecialType == SpecialType.System_Single => "float.NaN", + double number when double.IsPositiveInfinity(number) && type.SpecialType == SpecialType.System_Single => "float.PositiveInfinity", + double number when double.IsNegativeInfinity(number) && type.SpecialType == SpecialType.System_Single => "float.NegativeInfinity", + double number when double.IsNaN(number) && type.SpecialType == SpecialType.System_Double => "double.NaN", + double number when double.IsPositiveInfinity(number) && type.SpecialType == SpecialType.System_Double => "double.PositiveInfinity", + double number when double.IsNegativeInfinity(number) && type.SpecialType == SpecialType.System_Double => "double.NegativeInfinity", + double number when type.SpecialType == SpecialType.System_Single => number.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "F", + double number when type.SpecialType == SpecialType.System_Decimal => number.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "M", + double number when IsNumeric(type) => number.ToString("R", System.Globalization.CultureInfo.InvariantCulture), + _ => GetDefaultExpression(type, null) + }; + + private static bool IsNumeric(ITypeSymbol type) + => type.SpecialType is SpecialType.System_Byte + or SpecialType.System_SByte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64 + or SpecialType.System_Single + or SpecialType.System_Double + or SpecialType.System_Decimal; + + private static bool IsNamedType(INamedTypeSymbol type, string metadataName) + => type.ContainingNamespace.ToDisplayString() + "." + type.MetadataName == metadataName; + + private static object? GetConfiguredDefault(ISymbol symbol) + => symbol.GetAttributes() + .FirstOrDefault(static attr => attr.AttributeClass?.ToDisplayString() == NullObjectDefaultAttributeName) + ?.ConstructorArguments.FirstOrDefault().Value; + + private static string TrimInterfacePrefix(string name) + => name.Length > 1 && name[0] == 'I' && char.IsUpper(name[1]) + ? name.Substring(1) + : name; + + private static bool IsIdentifier(string value) + => !string.IsNullOrWhiteSpace(value) + && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None + && (char.IsLetter(value[0]) || value[0] == '_') + && value.Skip(1).All(static c => char.IsLetterOrDigit(c) || c == '_'); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + _ => "internal" + }; + + private static string EscapeIdentifier(string value) + => SyntaxFacts.GetKeywordKind(value) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(value) != SyntaxKind.None + ? "@" + value + : value; + + private static string GetMemberImplementationKey(ISymbol member) + => member switch + { + IMethodSymbol method when method.MethodKind == MethodKind.Ordinary => + "M:" + method.ReturnType.ToDisplayString(TypeFormat) + ":" + method.Name + "`" + method.TypeParameters.Length + "(" + string.Join(",", method.Parameters.Select(static parameter => parameter.RefKind + ":" + parameter.Type.ToDisplayString(TypeFormat))) + ")", + IPropertySymbol property when property.IsIndexer => + "P:" + property.Type.ToDisplayString(TypeFormat) + ":this(" + string.Join(",", property.Parameters.Select(static parameter => parameter.RefKind + ":" + parameter.Type.ToDisplayString(TypeFormat))) + ")", + IPropertySymbol property => "P:" + property.Type.ToDisplayString(TypeFormat) + ":" + property.Name, + IEventSymbol @event => "E:" + @event.Type.ToDisplayString(TypeFormat) + ":" + @event.Name, + _ => member.Kind + ":" + member.Name + }; + + private static string GetMemberConflictKey(ISymbol member) + => member switch + { + IMethodSymbol method when method.MethodKind == MethodKind.Ordinary => + "M:" + method.Name + "`" + method.TypeParameters.Length + "(" + string.Join(",", method.Parameters.Select(static parameter => parameter.RefKind + ":" + parameter.Type.ToDisplayString(TypeFormat))) + ")", + IPropertySymbol property when property.IsIndexer => + "P:this(" + string.Join(",", property.Parameters.Select(static parameter => parameter.RefKind + ":" + parameter.Type.ToDisplayString(TypeFormat))) + ")", + IPropertySymbol property => "P:" + property.Name, + IEventSymbol @event => "E:" + @event.Name, + _ => member.Kind + ":" + member.Name + }; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private sealed record UnsupportedContractMember(ISymbol Symbol, string Reason); +} diff --git a/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs b/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs index b2a572e1..3546fc36 100644 --- a/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs +++ b/src/PatternKit.Hosting.Extensions/DependencyInjection/PatternKitServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Behavioral.NullObject; using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CircuitBreaker; using PatternKit.Cloud.PriorityQueue; @@ -192,6 +193,50 @@ public static IServiceCollection AddPatternKitPriorityQueue( }); } + public static IServiceCollection AddPatternKitNullObject( + this IServiceCollection services, + TContract instance, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TContract : class + { + if (services is null) + throw new ArgumentNullException(nameof(services)); + if (instance is null) + throw new ArgumentNullException(nameof(instance)); + + services.AddPatternKitService( + lifetime, + _ => NullObject.Create(instance).Build()); + services.Add(ServiceDescriptor.Describe( + typeof(TContract), + provider => provider.GetRequiredService>().Instance, + lifetime)); + + return services; + } + + public static IServiceCollection AddPatternKitNullObject( + this IServiceCollection services, + Func factory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TContract : class + { + if (services is null) + throw new ArgumentNullException(nameof(services)); + if (factory is null) + throw new ArgumentNullException(nameof(factory)); + + services.AddPatternKitService( + lifetime, + provider => NullObject.Create(factory(provider)).Build()); + services.Add(ServiceDescriptor.Describe( + typeof(TContract), + provider => provider.GetRequiredService>().Instance, + lifetime)); + + return services; + } + private static IServiceCollection AddPatternKitService( this IServiceCollection services, ServiceLifetime lifetime, diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 50093ea5..2b1f373d 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -91,6 +91,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var showcase = provider.GetRequiredService(); var proxy = provider.GetRequiredService(); var flyweight = provider.GetRequiredService(); + var nullObject = provider.GetRequiredService(); var editor = provider.GetRequiredService(); var eventHub = provider.GetRequiredService(); var viewModel = provider.GetRequiredService(); @@ -205,6 +206,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("remote proxy returns remote data", proxy.RemoteProxy.Execute(42).Contains("42", StringComparison.Ordinal)), ("email proxy accepts example addresses", proxy.EmailProxy.Execute(("user@example.com", "Hello", "Body"))), ("flyweight renderer returns one glyph per character", flyweight.RenderSentence("hello").Count == 5), + ("null object notification fallback suppresses optional delivery", !nullObject.Workflow.Notify(new("C-DI", "Optional", "No channel")).Delivered), ("memento editor tracks inserted text", editor.Editor.State.Text == "hello"), ("observer event hub publishes events", received), ("reactive view model enables save", viewModel.ViewModel.CanSave.Value), diff --git a/test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs b/test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs new file mode 100644 index 00000000..a0cefd6f --- /dev/null +++ b/test/PatternKit.Examples.Tests/NullObjectDemo/CustomerNotificationNullObjectDemoTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Behavioral.NullObject; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.NullObjectDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.NullObjectDemo; + +[Feature("Null Object customer notification demo")] +public sealed class CustomerNotificationNullObjectDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generated null notification channel suppresses optional delivery")] + [Fact] + public Task Generated_Null_Notification_Channel_Suppresses_Optional_Delivery() + => Given("a workflow using the generated null channel", () => + new CustomerNotificationWorkflow(NullCustomerNotificationChannel.Instance)) + .When("sending a noncritical notification", workflow => + workflow.Notify(new CustomerNotification("C-100", "Statement ready", "Your statement is available."))) + .Then("the workflow receives a deterministic suppressed result", result => + { + ScenarioExpect.Equal(string.Empty, result.Channel); + ScenarioExpect.Equal("suppressed", result.Status); + ScenarioExpect.False(result.Delivered); + }) + .AssertPassed(); + + [Scenario("Customer notification workflow validates notification input")] + [Fact] + public Task Customer_Notification_Workflow_Validates_Notification_Input() + => Given("a workflow using the generated null channel", () => + new CustomerNotificationWorkflow(NullCustomerNotificationChannel.Instance)) + .When("sending a missing notification", workflow => + ScenarioExpect.Throws(() => workflow.Notify(null!))) + .Then("the workflow reports the invalid notification", exception => + ScenarioExpect.Equal("notification", exception.ParamName)) + .AssertPassed(); + + [Scenario("Null Object demo is importable through IServiceCollection")] + [Fact] + public Task Null_Object_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service collection with PatternKit examples", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the null object example", provider => + { + using (provider) + { + var example = provider.GetRequiredService(); + var fallback = provider.GetRequiredService>(); + var result = example.Workflow.Notify(new CustomerNotification("C-101", "Fallback", "No channel configured.")); + return new { example, fallback, result }; + } + }) + .Then("the example exposes both fluent and generated paths", ctx => + { + ScenarioExpect.Same(ctx.fallback.Instance, ctx.example.Channel); + ScenarioExpect.Equal("suppressed", ctx.result.Status); + ScenarioExpect.False(ctx.result.Delivered); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index a0a61bce..3826ebdd 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -128,7 +128,8 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Workflow Orchestration", "Snapshot / Checkpoint Management", "Timeout Manager", - "Object Pool" + "Object Pool", + "Null Object" ]; [Scenario("Catalog covers every canonical GoF pattern")] @@ -253,6 +254,7 @@ public Task Hosting_Integration_Catalog_Audits_Every_Pattern() "Guaranteed Delivery", "Message Channel", "Message Store", + "Null Object", "Priority Queue", "Queue-Based Load Leveling", "Rate Limiting", diff --git a/test/PatternKit.Generators.Tests/NullObjectGeneratorTests.cs b/test/PatternKit.Generators.Tests/NullObjectGeneratorTests.cs new file mode 100644 index 00000000..43b87a38 --- /dev/null +++ b/test/PatternKit.Generators.Tests/NullObjectGeneratorTests.cs @@ -0,0 +1,483 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.NullObject; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class NullObjectGeneratorTests +{ + [Scenario("Null Object generator attributes expose configured values")] + [Fact] + public void Null_Object_Generator_Attributes_Expose_Configured_Values() + { + var marker = new GenerateNullObjectAttribute { TypeName = "NullNotifier" }; + var text = new NullObjectDefaultAttribute("suppressed"); + var flag = new NullObjectDefaultAttribute(true); + var integer = new NullObjectDefaultAttribute(42); + var longInteger = new NullObjectDefaultAttribute(42L); + var number = new NullObjectDefaultAttribute(1.5d); + + ScenarioExpect.Equal("NullNotifier", marker.TypeName); + ScenarioExpect.Equal("suppressed", text.Value); + ScenarioExpect.Equal(true, flag.Value); + ScenarioExpect.Equal(42, integer.Value); + ScenarioExpect.Equal(42L, longInteger.Value); + ScenarioExpect.Equal(1.5d, number.Value); + } + + [Scenario("Generate Null Object For Interface Contract")] + [Fact] + public void Generate_Null_Object_For_Interface_Contract() + { + const string source = """ + using System.Threading.Tasks; + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject(TypeName = "NullNotifier")] + public interface INotifier + { + string Channel { get; } + + [NullObjectDefault(true)] + bool IsAvailable { get; } + + void Notify(string recipient, string body); + + [NullObjectDefault("suppressed")] + string Describe(string recipient); + + Task RenderAsync(string template); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_For_Interface_Contract)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "TestNamespace_INotifier.NullObject.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("public sealed class NullNotifier : global::TestNamespace.INotifier", generatedSource); + ScenarioExpect.Contains("public static NullNotifier Instance { get; } = new();", generatedSource); + ScenarioExpect.Contains("public string Channel => string.Empty;", generatedSource); + ScenarioExpect.Contains("public bool IsAvailable => true;", generatedSource); + ScenarioExpect.Contains("public void Notify(string recipient, string body)", generatedSource); + ScenarioExpect.Contains("public string Describe(string recipient) => @\"suppressed\";", generatedSource); + ScenarioExpect.Contains("global::System.Threading.Tasks.Task.FromResult(string.Empty)", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generate Null Object Supports Complete Interface Shapes")] + [Fact] + public void Generate_Null_Object_Supports_Complete_Interface_Shapes() + { + const string source = """ + using System; + using System.Threading.Tasks; + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + public sealed record NotificationStatus(string Value); + + public interface IBaseNotifier + { + string BaseName { get; } + } + + [GenerateNullObject(TypeName = "NullAdvancedNotifier")] + internal interface IAdvancedNotifier : IBaseNotifier + { + event EventHandler? Changed; + + new string BaseName { get; } + + string @class { get; } + + string this[int index] { get; set; } + + string MutableName { get; set; } + + string InitOnlyName { get; init; } + + T Resolve() where T : class; + + void Observe(ref readonly NotificationStatus status); + + void Send(string @event); + + bool TryGet(string key, out NotificationStatus status); + + Task LoadAsync(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Supports_Complete_Interface_Shapes)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .Single(gs => gs.HintName == "TestNamespace_IAdvancedNotifier.NullObject.g.cs") + .SourceText.ToString(); + + ScenarioExpect.Contains("internal sealed class NullAdvancedNotifier", generatedSource); + ScenarioExpect.Contains("public string BaseName => string.Empty;", generatedSource); + ScenarioExpect.Equal(2, generatedSource.Split("BaseName").Length); + ScenarioExpect.Contains("public string @class => string.Empty;", generatedSource); + ScenarioExpect.Contains("public event global::System.EventHandler? Changed", generatedSource); + ScenarioExpect.Contains("public string this[int index]", generatedSource); + ScenarioExpect.Contains("set { }", generatedSource); + ScenarioExpect.Contains("public string InitOnlyName", generatedSource); + ScenarioExpect.Contains("init { }", generatedSource); + ScenarioExpect.Contains("public T Resolve() where T : class => default!;", generatedSource); + ScenarioExpect.Contains("public void Observe(ref readonly global::TestNamespace.NotificationStatus status)", generatedSource); + ScenarioExpect.Contains("public void Send(string @event)", generatedSource); + ScenarioExpect.Contains("status = default!;", generatedSource); + ScenarioExpect.Contains("return false;", generatedSource); + ScenarioExpect.Contains("Task.FromResult(default!)", generatedSource); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generate Null Object Covers Default Names And Return Shapes")] + [Fact] + public void Generate_Null_Object_Covers_Default_Names_And_Return_Shapes() + { + const string source = """ + using System.Threading.Tasks; + using PatternKit.Generators.NullObject; + + [GenerateNullObject] + public interface AuditSink + { + int Count { get; } + + long BigCount { get; } + + double Ratio { get; } + + decimal Total { get; } + + string[] Tags { get; } + + int WriteOnly { set; } + + Task FlushAsync(); + + ValueTask PingAsync(); + + ValueTask LoadAsync(); + + T Create() where T : notnull, new(); + + [NullObjectDefault(42)] + int ConfiguredInt(); + + [NullObjectDefault(42L)] + long ConfiguredLong(); + + [NullObjectDefault(1.5d)] + double ConfiguredDouble(); + + [NullObjectDefault(1.5d)] + float ConfiguredFloat(); + + [NullObjectDefault(1.5d)] + decimal ConfiguredDecimal(); + + [NullObjectDefault(double.NaN)] + double ConfiguredNaN(); + + [NullObjectDefault(double.PositiveInfinity)] + float ConfiguredInfinity(); + + [NullObjectDefault("suppressed")] + Task ConfiguredTaskAsync(); + + [NullObjectDefault("suppressed")] + ValueTask ConfiguredValueTaskAsync(); + } + + [GenerateNullObject] + public interface IDefaultNotifier + { + bool Enabled { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Covers_Default_Names_And_Return_Shapes)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics)); + var generatedSources = result.Results + .SelectMany(static r => r.GeneratedSources) + .ToDictionary(static gs => gs.HintName, static gs => gs.SourceText.ToString()); + var auditSink = generatedSources["AuditSink.NullObject.g.cs"]; + var notifier = generatedSources["IDefaultNotifier.NullObject.g.cs"]; + + ScenarioExpect.Contains("public sealed class NullAuditSink : global::AuditSink", auditSink); + ScenarioExpect.Contains("public int Count => 0;", auditSink); + ScenarioExpect.Contains("public long BigCount => 0;", auditSink); + ScenarioExpect.Contains("public double Ratio => 0;", auditSink); + ScenarioExpect.Contains("public decimal Total => 0;", auditSink); + ScenarioExpect.Contains("global::System.Array.Empty()", auditSink); + ScenarioExpect.Contains("public int WriteOnly", auditSink); + ScenarioExpect.Contains("global::System.Threading.Tasks.Task.CompletedTask", auditSink); + ScenarioExpect.Contains("public global::System.Threading.Tasks.ValueTask PingAsync() => default;", auditSink); + ScenarioExpect.Contains("new global::System.Threading.Tasks.ValueTask(0)", auditSink); + ScenarioExpect.Contains("public T Create() where T : notnull, new() => default!;", auditSink); + ScenarioExpect.Contains("public int ConfiguredInt() => 42;", auditSink); + ScenarioExpect.Contains("public long ConfiguredLong() => 42;", auditSink); + ScenarioExpect.Contains("public double ConfiguredDouble() => 1.5;", auditSink); + ScenarioExpect.Contains("public float ConfiguredFloat() => 1.5F;", auditSink); + ScenarioExpect.Contains("public decimal ConfiguredDecimal() => 1.5M;", auditSink); + ScenarioExpect.Contains("public double ConfiguredNaN() => double.NaN;", auditSink); + ScenarioExpect.Contains("public float ConfiguredInfinity() => float.PositiveInfinity;", auditSink); + ScenarioExpect.Contains("Task.FromResult(@\"suppressed\")", auditSink); + ScenarioExpect.Contains("new global::System.Threading.Tasks.ValueTask(@\"suppressed\")", auditSink); + ScenarioExpect.Contains("public sealed class NullDefaultNotifier", notifier); + ScenarioExpect.Contains("public bool Enabled => false;", notifier); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generate Null Object Reports Invalid Type Name")] + [Fact] + public void Generate_Null_Object_Reports_Invalid_Type_Name() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject(TypeName = "123Nope")] + public interface INotifier + { + string Channel { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Reports_Invalid_Type_Name)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO003"); + } + + [Scenario("Generate Null Object Reports Keyword Type Name")] + [Fact] + public void Generate_Null_Object_Reports_Keyword_Type_Name() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject(TypeName = "class")] + public interface INotifier + { + string Channel { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Reports_Keyword_Type_Name)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO003"); + } + + [Scenario("Generate Null Object Rejects Generic Contracts")] + [Fact] + public void Generate_Null_Object_Rejects_Generic_Contracts() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject] + public interface ILookup + { + T Find(string key); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Generic_Contracts)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO002"); + } + + [Scenario("Generate Null Object Rejects Unsupported Static Abstract Members")] + [Fact] + public void Generate_Null_Object_Rejects_Unsupported_Static_Abstract_Members() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject] + public interface IFactory + { + static abstract string Create(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Unsupported_Static_Abstract_Members)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO004"); + } + + [Scenario("Generate Null Object Rejects Nested Contracts")] + [Fact] + public void Generate_Null_Object_Rejects_Nested_Contracts() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + public static class Contracts + { + [GenerateNullObject] + public interface INotifier + { + string Name { get; } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Nested_Contracts)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO004"); + } + + [Scenario("Generate Null Object Rejects Unsupported By Ref Returns")] + [Fact] + public void Generate_Null_Object_Rejects_Unsupported_By_Ref_Returns() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject] + public interface ICursor + { + ref int Current(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Unsupported_By_Ref_Returns)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO004"); + } + + [Scenario("Generate Null Object Rejects Unsupported By Ref Properties")] + [Fact] + public void Generate_Null_Object_Rejects_Unsupported_By_Ref_Properties() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + [GenerateNullObject] + public interface ICursor + { + ref int Current { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Unsupported_By_Ref_Properties)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO004"); + } + + [Scenario("Generate Null Object Reports Type Name Conflicts")] + [Fact] + public void Generate_Null_Object_Reports_Type_Name_Conflicts() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + public sealed class NullNotifier + { + } + + [GenerateNullObject] + public interface INotifier + { + string Name { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Reports_Type_Name_Conflicts)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO005"); + } + + [Scenario("Generate Null Object Rejects Conflicting Hidden Members")] + [Fact] + public void Generate_Null_Object_Rejects_Conflicting_Hidden_Members() + { + const string source = """ + using PatternKit.Generators.NullObject; + + namespace TestNamespace; + + public interface IBaseNotifier + { + string Name { get; } + } + + [GenerateNullObject] + public interface INotifier : IBaseNotifier + { + new int Name { get; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Generate_Null_Object_Rejects_Conflicting_Hidden_Members)); + var gen = new NullObjectGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + ScenarioExpect.Contains(diagnostics, d => d.Id == "PKNO004"); + } +} diff --git a/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs b/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs index afbd0c9b..6e76d97e 100644 --- a/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs +++ b/test/PatternKit.Hosting.Extensions.Tests/DependencyInjection/PatternKitServiceCollectionExtensionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Behavioral.NullObject; using PatternKit.Cloud.Bulkhead; using PatternKit.Cloud.CircuitBreaker; using PatternKit.Cloud.PriorityQueue; @@ -146,7 +147,9 @@ public Task Hosting_Extensions_Support_Di_Lifetimes() var services = new ServiceCollection(); services .AddPatternKitMessageChannel(lifetime: ServiceLifetime.Scoped) - .AddPatternKitRetryPolicy(lifetime: ServiceLifetime.Transient); + .AddPatternKitRetryPolicy(lifetime: ServiceLifetime.Transient) + .AddPatternKitNullObject(new SilentNotificationSink(), ServiceLifetime.Singleton) + .AddPatternKitNullObject(_ => new SilentStatusSink(), ServiceLifetime.Singleton); return services.BuildServiceProvider(validateScopes: true); }) @@ -161,8 +164,12 @@ public Task Hosting_Extensions_Support_Di_Lifetimes() var channelB = scopeB.ServiceProvider.GetRequiredService>(); var retryA = provider.GetRequiredService>(); var retryB = provider.GetRequiredService>(); + var nullObject = provider.GetRequiredService>(); + var sink = provider.GetRequiredService(); + var statusNullObject = provider.GetRequiredService>(); + var statusSink = provider.GetRequiredService(); - return new LifetimeRegistrationResult(channelA1, channelA2, channelB, retryA, retryB); + return new LifetimeRegistrationResult(channelA1, channelA2, channelB, retryA, retryB, nullObject, sink, statusNullObject, statusSink); } }) .Then("scoped registrations reuse a scope and transient registrations create new policies", result => @@ -170,6 +177,10 @@ public Task Hosting_Extensions_Support_Di_Lifetimes() ScenarioExpect.Same(result.ChannelA1, result.ChannelA2); ScenarioExpect.NotSame(result.ChannelA1, result.ChannelB); ScenarioExpect.NotSame(result.RetryA, result.RetryB); + ScenarioExpect.Same(result.NullObject.Instance, result.Sink); + ScenarioExpect.False(result.NullObject.Instance.Send("C-1", "optional")); + ScenarioExpect.Same(result.StatusNullObject.Instance, result.StatusSink); + ScenarioExpect.Equal("suppressed", result.StatusSink.Status); }) .AssertPassed(); @@ -182,12 +193,27 @@ public Task Hosting_Extensions_Validate_Registration_Input() () => inputs.MissingServices!.AddPatternKitMessageChannel()), ScenarioExpect.Throws( () => inputs.Services.AddPatternKitPriorityQueue(null!)), + ScenarioExpect.Throws( + () => inputs.MissingServices!.AddPatternKitNullObject(new SilentNotificationSink())), + ScenarioExpect.Throws( + () => inputs.MissingServices!.AddPatternKitNullObject(_ => new SilentNotificationSink())), + ScenarioExpect.Throws( + () => inputs.Services.AddPatternKitNullObject((INotificationSink)null!)), + ScenarioExpect.Throws( + () => inputs.Services.AddPatternKitNullObject((Func)null!)), + ScenarioExpect.Throws( + () => inputs.Services.AddPatternKitNullObject(_ => null!).BuildServiceProvider(validateScopes: true).GetRequiredService>()), ScenarioExpect.Throws( () => inputs.Services.AddPatternKitRetryPolicy(lifetime: (ServiceLifetime)99)))) .Then("the extensions reject invalid registrations explicitly", results => { ScenarioExpect.Equal("services", results.MissingServicesException.ParamName); ScenarioExpect.Equal("prioritySelector", results.PrioritySelectorException.ParamName); + ScenarioExpect.Equal("services", results.NullObjectInstanceMissingServicesException.ParamName); + ScenarioExpect.Equal("services", results.NullObjectFactoryMissingServicesException.ParamName); + ScenarioExpect.Equal("instance", results.NullObjectInstanceException.ParamName); + ScenarioExpect.Equal("factory", results.NullObjectFactoryException.ParamName); + ScenarioExpect.Equal("instance", results.NullObjectFactoryResultException.ParamName); ScenarioExpect.Equal("lifetime", results.InvalidLifetimeException.ParamName); }) .AssertPassed(); @@ -195,11 +221,36 @@ public Task Hosting_Extensions_Validate_Registration_Input() private sealed record OrderCommand(string OrderId, decimal Total); private sealed record ServiceReply(bool Available); private sealed record WorkItem(string Id, int Priority); + private interface INotificationSink + { + bool Send(string recipient, string body); + } + + private sealed class SilentNotificationSink : INotificationSink + { + public bool Send(string recipient, string body) => false; + } + + private interface IStatusSink + { + string Status { get; } + } + + private sealed class SilentStatusSink : IStatusSink + { + public string Status => "suppressed"; + } + private sealed record InvalidRegistrationInputs(IServiceCollection? MissingServices, IServiceCollection Services); private sealed record InvalidRegistrationResults( ArgumentNullException MissingServicesException, ArgumentNullException PrioritySelectorException, + ArgumentNullException NullObjectInstanceMissingServicesException, + ArgumentNullException NullObjectFactoryMissingServicesException, + ArgumentNullException NullObjectInstanceException, + ArgumentNullException NullObjectFactoryException, + ArgumentNullException NullObjectFactoryResultException, ArgumentOutOfRangeException InvalidLifetimeException); private sealed record MessagingRegistrationResult( @@ -229,7 +280,11 @@ private sealed record LifetimeRegistrationResult( MessageChannel ChannelA2, MessageChannel ChannelB, RetryPolicy RetryA, - RetryPolicy RetryB); + RetryPolicy RetryB, + NullObject NullObject, + INotificationSink Sink, + NullObject StatusNullObject, + IStatusSink StatusSink); private static async Task ResolveMessagingRegistrationsAsync(ServiceProvider provider) { diff --git a/test/PatternKit.Tests/Behavioral/NullObject/NullObjectTests.cs b/test/PatternKit.Tests/Behavioral/NullObject/NullObjectTests.cs new file mode 100644 index 00000000..a5e2e930 --- /dev/null +++ b/test/PatternKit.Tests/Behavioral/NullObject/NullObjectTests.cs @@ -0,0 +1,51 @@ +using PatternKit.Behavioral.NullObject; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Behavioral.NullObject; + +[Feature("Behavioral - Null Object")] +public sealed class NullObjectTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent Null Object exposes a stable fallback collaborator")] + [Fact] + public Task Fluent_Null_Object_Exposes_A_Stable_Fallback_Collaborator() + => Given("a null object wrapper around a notification sink", () => + NullObject.Create(new SilentNotificationSink()).Build()) + .When("resolving the fallback twice", wrapper => (First: wrapper.Instance, Second: wrapper.Instance)) + .Then("the fallback is stable and safe to call", resolved => + { + ScenarioExpect.Same(resolved.First, resolved.Second); + ScenarioExpect.False(resolved.First.Send("order-1", "ignored")); + }) + .AssertPassed(); + + [Scenario("Fluent Null Object validates null inputs")] + [Fact] + public Task Fluent_Null_Object_Validates_Null_Inputs() + => Given("invalid null object inputs", () => true) + .When("creating builders", _ => new + { + MissingInstance = ScenarioExpect.Throws(() => NullObject.Create((INotificationSink)null!)), + MissingFactory = ScenarioExpect.Throws(() => NullObject.Create((Func)null!)), + MissingFactoryResult = ScenarioExpect.Throws(() => NullObject.Create(static () => null!).Build()) + }) + .Then("arguments are rejected explicitly", errors => + { + ScenarioExpect.Equal("instance", errors.MissingInstance.ParamName); + ScenarioExpect.Equal("factory", errors.MissingFactory.ParamName); + ScenarioExpect.Equal("instance", errors.MissingFactoryResult.ParamName); + }) + .AssertPassed(); + + private interface INotificationSink + { + bool Send(string recipient, string body); + } + + private sealed class SilentNotificationSink : INotificationSink + { + public bool Send(string recipient, string body) => false; + } +}