-
Notifications
You must be signed in to change notification settings - Fork 0
feat(generators): Add Adapter pattern generator #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
936e468
feat(generators): Add Adapter pattern generator
JerrettDavis 73758e7
fix(adapter): Address Copilot review feedback
JerrettDavis 054d406
fix(adapter): address PR review comments
JerrettDavis b568551
fix(adapter): address all remaining PR review comments
JerrettDavis fa1d121
fix(adapter): address latest PR review feedback
JerrettDavis 27cfb82
Address review feedback: fully-qualified types, member ordering, and …
Copilot 55646f9
fix(adapter): Add missing diagnostics, validation, and deterministic …
Copilot 4a4b68a
fix(adapter): Add parameter validation and nullability-aware type com…
Copilot 2d80388
fix(adapter): Remove redundant type checks and add missing diagnostic…
Copilot 1819ae0
fix(adapter): Detect duplicate adapter names, reject indexers, valida…
Copilot 21f3d1c
fix(adapter): Cross-host collision detection, diagnostic locations, a…
Copilot bb51007
fix(adapter): Address enum defaults, partial types, ordering, and DIM…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,386 @@ | ||
| # Adapter Generator | ||
|
|
||
| ## Overview | ||
|
|
||
| The **Adapter Generator** creates object adapters that implement a target contract (interface or abstract class) by delegating to an adaptee through explicit mapping methods. This pattern allows incompatible interfaces to work together without modifying either the target or adaptee. | ||
|
|
||
| ## When to Use | ||
|
|
||
| Use the Adapter generator when you need to: | ||
|
|
||
| - **Integrate legacy code**: Wrap older implementations to work with modern interfaces | ||
| - **Abstract third-party libraries**: Create a clean boundary around external dependencies | ||
| - **Support multiple implementations**: Adapt different backends (payment gateways, loggers, etc.) to a unified interface | ||
| - **Compile-time safety**: Ensure all contract members are properly mapped | ||
|
|
||
| ## Installation | ||
|
|
||
| The generator is included in the `PatternKit.Generators` package: | ||
|
|
||
| ```bash | ||
| dotnet add package PatternKit.Generators | ||
| ``` | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```csharp | ||
| using PatternKit.Generators.Adapter; | ||
|
|
||
| // Target interface your app uses | ||
| public interface IClock | ||
| { | ||
| DateTimeOffset UtcNow { get; } | ||
| } | ||
|
|
||
| // Legacy class with different API | ||
| public class LegacyClock | ||
| { | ||
| public DateTime GetCurrentTimeUtc() => DateTime.UtcNow; | ||
| } | ||
|
|
||
| // Define mappings in a static partial class | ||
| [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] | ||
| public static partial class ClockAdapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(IClock.UtcNow))] | ||
| public static DateTimeOffset MapUtcNow(LegacyClock adaptee) | ||
| => new(adaptee.GetCurrentTimeUtc(), TimeSpan.Zero); | ||
| } | ||
| ``` | ||
|
|
||
| Generated: | ||
| ```csharp | ||
| public sealed partial class LegacyClockToIClockAdapter : IClock | ||
| { | ||
| private readonly LegacyClock _adaptee; | ||
|
|
||
| public LegacyClockToIClockAdapter(LegacyClock adaptee) | ||
| { | ||
| _adaptee = adaptee ?? throw new ArgumentNullException(nameof(adaptee)); | ||
| } | ||
|
|
||
| public DateTimeOffset UtcNow | ||
| { | ||
| get => ClockAdapters.MapUtcNow(_adaptee); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Usage: | ||
| ```csharp | ||
| // Create the adapter | ||
| IClock clock = new LegacyClockToIClockAdapter(new LegacyClock()); | ||
|
|
||
| // Use through the clean interface | ||
| var now = clock.UtcNow; | ||
| ``` | ||
|
|
||
| ## Mapping Methods | ||
|
|
||
| Each target contract member needs a mapping method marked with `[AdapterMap]`. | ||
|
|
||
| ### Property Mappings | ||
|
|
||
| For properties, the mapping method takes only the adaptee and returns the property type: | ||
|
|
||
| ```csharp | ||
| public interface IService | ||
| { | ||
| string Name { get; } | ||
| } | ||
|
|
||
| [AdapterMap(TargetMember = nameof(IService.Name))] | ||
| public static string MapName(LegacyService adaptee) => adaptee.ServiceName; | ||
| ``` | ||
|
|
||
| ### Method Mappings | ||
|
|
||
| For methods, the mapping method takes the adaptee as the first parameter, followed by all method parameters: | ||
|
|
||
| ```csharp | ||
| public interface ICalculator | ||
| { | ||
| int Add(int a, int b); | ||
| } | ||
|
|
||
| [AdapterMap(TargetMember = nameof(ICalculator.Add))] | ||
| public static int MapAdd(OldCalculator adaptee, int a, int b) | ||
| => adaptee.Sum(a, b); | ||
| ``` | ||
|
|
||
| ### Async Method Mappings | ||
|
|
||
| Async methods work the same way - just match the return type: | ||
|
|
||
| ```csharp | ||
| public interface IPaymentGateway | ||
| { | ||
| Task<PaymentResult> ChargeAsync(string token, decimal amount, CancellationToken ct); | ||
| } | ||
|
|
||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] | ||
| public static async Task<PaymentResult> MapChargeAsync( | ||
| LegacyPaymentClient adaptee, | ||
| string token, | ||
| decimal amount, | ||
| CancellationToken ct) | ||
| { | ||
| var response = await adaptee.ProcessPaymentAsync(token, (int)(amount * 100), ct); | ||
| return new PaymentResult(response.Success, response.Id); | ||
| } | ||
| ``` | ||
|
|
||
| ## Attributes | ||
|
|
||
| ### `[GenerateAdapter]` | ||
|
|
||
| Marks a static partial class as an adapter mapping host. | ||
|
|
||
| | Property | Type | Default | Description | | ||
| |---|---|---|---| | ||
| | `Target` | `Type` | Required | The interface or abstract class to implement | | ||
| | `Adaptee` | `Type` | Required | The class to adapt | | ||
| | `AdapterTypeName` | `string` | `{Adaptee}To{Target}Adapter` | Custom name for the generated adapter class | | ||
| | `MissingMap` | `AdapterMissingMapPolicy` | `Error` | How to handle unmapped members | | ||
| | `Sealed` | `bool` | `true` | Whether the adapter class is sealed | | ||
| | `Namespace` | `string` | Host namespace | Custom namespace for the adapter | | ||
|
|
||
| ### `[AdapterMap]` | ||
|
|
||
| Marks a method as a mapping for a target member. | ||
|
|
||
| | Property | Type | Default | Description | | ||
| |---|---|---|---| | ||
| | `TargetMember` | `string` | Required | Name of the target member (use `nameof()`) | | ||
|
|
||
| ## Missing Map Policies | ||
|
|
||
| Control what happens when a target member has no `[AdapterMap]`: | ||
|
|
||
| ### Error (Default) | ||
|
|
||
| Emits a compiler error. Recommended for production code: | ||
|
|
||
| ```csharp | ||
| [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] | ||
| // MissingMap = AdapterMissingMapPolicy.Error is the default | ||
| ``` | ||
|
|
||
| ### ThrowingStub | ||
|
|
||
| Generates a stub that throws `NotImplementedException`. Useful during incremental development: | ||
|
|
||
| ```csharp | ||
| [GenerateAdapter( | ||
| Target = typeof(IClock), | ||
| Adaptee = typeof(LegacyClock), | ||
| MissingMap = AdapterMissingMapPolicy.ThrowingStub)] | ||
| ``` | ||
|
|
||
| ### Ignore | ||
|
|
||
| Silently ignores unmapped members. May cause compilation errors if the target is an interface (missing implementations): | ||
|
|
||
| ```csharp | ||
| [GenerateAdapter( | ||
| Target = typeof(IPartialService), | ||
| Adaptee = typeof(Legacy), | ||
| MissingMap = AdapterMissingMapPolicy.Ignore)] | ||
| ``` | ||
|
|
||
| ## Multiple Adapters | ||
|
|
||
| You can define multiple adapters in the same host class: | ||
|
|
||
| ```csharp | ||
| [GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient))] | ||
| [GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(PayPalClient))] | ||
| public static partial class PaymentAdapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] | ||
| public static Task<PaymentResult> MapStripeChargeAsync(StripeClient adaptee, ...) { ... } | ||
|
|
||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] | ||
| public static Task<PaymentResult> MapPayPalChargeAsync(PayPalClient adaptee, ...) { ... } | ||
| } | ||
| ``` | ||
|
|
||
| The generator matches mapping methods to adapters by the first parameter type (adaptee). | ||
|
|
||
| ## Abstract Class Targets | ||
|
|
||
| The generator supports abstract classes as targets: | ||
|
|
||
| ```csharp | ||
| public abstract class ClockBase | ||
| { | ||
| public abstract DateTimeOffset Now { get; } | ||
| public virtual string TimeZone => "UTC"; // Inherited, not in contract | ||
| } | ||
|
|
||
| [GenerateAdapter(Target = typeof(ClockBase), Adaptee = typeof(LegacyClock))] | ||
| public static partial class Adapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(ClockBase.Now))] | ||
| public static DateTimeOffset MapNow(LegacyClock adaptee) => ...; | ||
| // Only abstract members need mapping | ||
JerrettDavis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| ``` | ||
|
|
||
| ## Diagnostics | ||
|
|
||
| | ID | Severity | Description | | ||
| |---|---|---| | ||
| | **PKADP001** | Error | Adapter host must be `static partial` | | ||
| | **PKADP002** | Error | Target must be interface or abstract class | | ||
| | **PKADP003** | Error | Missing `[AdapterMap]` for target member | | ||
JerrettDavis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| | **PKADP004** | Error | Multiple `[AdapterMap]` methods for same target member | | ||
| | **PKADP005** | Error | Mapping method signature doesn't match target member | | ||
| | **PKADP006** | Error | Adapter type name conflicts with existing type | | ||
| | **PKADP007** | Error | Adaptee must be a concrete class or struct | | ||
| | **PKADP008** | Error | Mapping method must be static | | ||
JerrettDavis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| | **PKADP009** | Error | Events are not supported | | ||
| | **PKADP010** | Error | Generic methods are not supported | | ||
| | **PKADP011** | Error | Overloaded methods are not supported | | ||
| | **PKADP012** | Error | Abstract class target requires accessible parameterless constructor | | ||
| | **PKADP013** | Error | Settable properties are not supported | | ||
| | **PKADP014** | Error | Nested or generic host not supported | | ||
| | **PKADP015** | Error | Mapping method must be accessible (public or internal) | | ||
| | **PKADP016** | Error | Static members are not supported | | ||
| | **PKADP017** | Error | Ref-return members are not supported | | ||
| | **PKADP018** | Error | Indexers are not supported | | ||
|
|
||
| ## Limitations | ||
|
|
||
| ### Multiple Adapters with Shared Adaptee | ||
|
|
||
| When defining multiple `[GenerateAdapter]` attributes within the same host class that share the same adaptee type, mapping ambiguity can occur. The generator matches `[AdapterMap]` methods to adapters solely by adaptee type and then by `TargetMember` name. If two target types have overlapping member names (both use `nameof(...)` resulting in the same string), mappings become inherently ambiguous, and the generator cannot reliably determine which adapter a mapping belongs to. In this case, `PKADP004` duplicate mapping diagnostics are expected given the current API design, rather than being false positives, unless mappings are split into separate hosts or the API is extended to provide additional disambiguation. | ||
|
|
||
| **Workaround:** Define separate host classes for each adapter when they share the same adaptee type: | ||
|
|
||
| ```csharp | ||
| // ✅ Good: Separate hosts avoid ambiguity | ||
| [GenerateAdapter(Target = typeof(IServiceA), Adaptee = typeof(LegacyService))] | ||
| public static partial class ServiceAAdapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(IServiceA.DoWork))] | ||
| public static void MapDoWork(LegacyService adaptee) => adaptee.Execute(); | ||
| } | ||
|
|
||
| [GenerateAdapter(Target = typeof(IServiceB), Adaptee = typeof(LegacyService))] | ||
| public static partial class ServiceBAdapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(IServiceB.DoWork))] | ||
| public static void MapDoWork(LegacyService adaptee) => adaptee.Run(); | ||
| } | ||
|
|
||
| // ⚠️ Problematic: Multiple adapters with same adaptee in one host | ||
| public static partial class AllAdapters | ||
| { | ||
| // Both IServiceA and IServiceB have DoWork() members | ||
| // The generator cannot distinguish which mapping is for which target | ||
| } | ||
| ``` | ||
|
|
||
| ## Best Practices | ||
|
|
||
| ### 1. Use `nameof()` for Type Safety | ||
|
|
||
| ```csharp | ||
| // ✅ Good: Compile-time checked | ||
| [AdapterMap(TargetMember = nameof(IClock.Now))] | ||
|
|
||
| // ❌ Bad: String literals can drift | ||
| [AdapterMap(TargetMember = "Now")] | ||
| ``` | ||
|
|
||
| ### 2. Keep Mapping Methods Simple | ||
|
|
||
| Mapping methods should be thin wrappers, not business logic: | ||
|
|
||
| ```csharp | ||
| // ✅ Good: Simple delegation with conversion | ||
| [AdapterMap(TargetMember = nameof(IService.DoWork))] | ||
| public static void MapDoWork(Legacy adaptee, string input) | ||
| => adaptee.PerformTask(input); | ||
|
|
||
| // ❌ Bad: Business logic in mapping | ||
| [AdapterMap(TargetMember = nameof(IService.DoWork))] | ||
| public static void MapDoWork(Legacy adaptee, string input) | ||
| { | ||
| if (string.IsNullOrEmpty(input)) throw new ArgumentException(); | ||
| var processed = input.ToUpper().Trim(); | ||
| adaptee.PerformTask(processed); | ||
| // This logic should be elsewhere | ||
| } | ||
| ``` | ||
|
|
||
| ### 3. Separate Mapping Hosts by Domain | ||
|
|
||
| ```csharp | ||
| // ✅ Good: Organized by domain | ||
| public static partial class PaymentAdapters { ... } | ||
| public static partial class LoggingAdapters { ... } | ||
|
|
||
| // ❌ Bad: Everything in one place | ||
| public static partial class AllAdapters { ... } | ||
| ``` | ||
|
|
||
| ### 4. Document Complex Mappings | ||
|
|
||
| ```csharp | ||
| /// <summary> | ||
| /// Maps the legacy millisecond-based delay to TimeSpan. | ||
| /// Note: Precision is limited to milliseconds. | ||
| /// </summary> | ||
| [AdapterMap(TargetMember = nameof(IClock.DelayAsync))] | ||
| public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, CancellationToken ct) | ||
| => new(adaptee.Sleep((int)duration.TotalMilliseconds, ct)); | ||
| ``` | ||
|
|
||
| ## Real-World Example: Payment Gateway Abstraction | ||
|
|
||
| ```csharp | ||
| // Unified interface for your application | ||
| public interface IPaymentGateway | ||
| { | ||
| Task<PaymentResult> ChargeAsync(string token, decimal amount, string currency, CancellationToken ct); | ||
| Task<RefundResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct); | ||
| string GatewayName { get; } | ||
| } | ||
|
|
||
| // Stripe adapter | ||
| [GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient), AdapterTypeName = "StripePaymentAdapter")] | ||
| public static partial class StripeAdapters | ||
| { | ||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))] | ||
| public static string MapGatewayName(StripeClient adaptee) => "Stripe"; | ||
|
|
||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] | ||
| public static async Task<PaymentResult> MapChargeAsync( | ||
| StripeClient adaptee, string token, decimal amount, string currency, CancellationToken ct) | ||
| { | ||
| var request = new StripeChargeRequest { Source = token, Amount = (long)(amount * 100), Currency = currency }; | ||
| var response = await adaptee.CreateChargeAsync(request, ct); | ||
| return new PaymentResult(response.Succeeded, response.ChargeId, response.Error); | ||
| } | ||
|
|
||
| [AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))] | ||
| public static async Task<RefundResult> MapRefundAsync( | ||
| StripeClient adaptee, string transactionId, decimal amount, CancellationToken ct) | ||
| { | ||
| var response = await adaptee.CreateRefundAsync(transactionId, (long)(amount * 100), ct); | ||
| return new RefundResult(response.Succeeded, response.RefundId, response.Error); | ||
| } | ||
| } | ||
|
|
||
| // Usage with DI | ||
| services.AddSingleton<StripeClient>(); | ||
| services.AddSingleton<IPaymentGateway>(sp => new StripePaymentAdapter(sp.GetRequiredService<StripeClient>())); | ||
| ``` | ||
|
|
||
| ## See Also | ||
|
|
||
| - [Facade Generator](facade.md) - For simplifying complex subsystems | ||
| - [Decorator Generator](decorator.md) - For adding behavior to objects | ||
| - [Proxy Generator](proxy.md) - For controlling access to objects | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.