diff --git a/Atc.SourceGenerators.slnx b/Atc.SourceGenerators.slnx index 9b1b8d4..afc4282 100644 --- a/Atc.SourceGenerators.slnx +++ b/Atc.SourceGenerators.slnx @@ -8,7 +8,10 @@ + + + @@ -26,6 +29,14 @@ + + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md index 4b3f1d5..b702bac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a **Roslyn C# Source Generators** project that provides compile-time code generation for .NET applications. The solution contains four main source generators: +This is a **Roslyn C# Source Generators** project that provides compile-time code generation for .NET applications. The solution contains five main source generators: 1. **DependencyRegistrationGenerator** - Automatically generates dependency injection service registrations 2. **OptionsBindingGenerator** - Automatically generates configuration options binding code -3. **MappingGenerator** - Automatically generates type-safe object-to-object mapping code -4. **AnnotationConstantsGenerator** - Automatically generates compile-time constants from DataAnnotation attributes +3. **MappingGenerator** - Automatically generates type-safe object-to-object mapping code (attribute-based) +4. **MappingConfigurationGenerator** - Automatically generates mappings for 3rd-party types via configuration (no attributes on source types) +5. **AnnotationConstantsGenerator** - Automatically generates compile-time constants from DataAnnotation attributes All generators eliminate boilerplate code and improve developer productivity while maintaining Native AOT compatibility. @@ -21,6 +22,7 @@ src/ DependencyRegistrationGenerator.cs OptionsBindingGenerator.cs ObjectMappingGenerator.cs + MappingConfigurationGenerator.cs AnnotationConstantsGenerator.cs RuleIdentifierConstants.cs # Diagnostic ID constants RuleCategoryConstants.cs # Diagnostic category constants @@ -28,6 +30,12 @@ src/ RegistrationAttribute.cs OptionsBindingAttribute.cs MapToAttribute.cs + MappingConfigurationAttribute.cs + MapConfigPropertyAttribute.cs + MapConfigIgnoreAttribute.cs + MapConfigOptionsAttribute.cs + MapTypesAttribute.cs + MappingBuilder.cs Lifetime.cs OptionsLifetime.cs @@ -46,6 +54,14 @@ sample/ Atc.SourceGenerators.Mapping/ # Object mapping API sample Atc.SourceGenerators.Mapping.Domain/ # Domain models with mappings (includes BaseEntity/AuditableEntity/Book for inheritance demo) Atc.SourceGenerators.Mapping.DataAccess/ # Database entities with mappings + Atc.SourceGenerators.MappingOnlyConfiguration/ # Config-based mapping console demo + Atc.SourceGenerators.MappingOnlyConfiguration.Domain/ # Domain with [MappingConfiguration] mappings + Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ # Simulated 3rd-party CRM SDK (no generator) + Atc.SourceGenerators.MappingCombinedConfiguration/ # Mixed attribute + config mapping demo + Atc.SourceGenerators.MappingCombinedConfiguration.Contract/ # API DTOs + Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ # Domain with mixed attribute + config mappings + Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/ # Simulated 3rd-party SDK + Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/ # Simulated 3rd-party SDK Atc.SourceGenerators.AnnotationConstants/ # DataAnnotation constants sample PetStore.Api/ # Complete 3-layer ASP.NET Core API with OpenAPI/Scalar PetStore.Api.Contract/ # API contracts (DTOs) @@ -81,6 +97,12 @@ dotnet run --project sample/Atc.SourceGenerators.OptionsBinding # Mapping sample (minimal API with 3-layer architecture) dotnet run --project sample/Atc.SourceGenerators.Mapping +# Configuration-based mapping sample (3rd-party type mapping) +dotnet run --project sample/Atc.SourceGenerators.MappingOnlyConfiguration + +# Combined attribute + configuration mapping sample +dotnet run --project sample/Atc.SourceGenerators.MappingCombinedConfiguration + # PetStore API (complete 3-layer app with all generators + OpenAPI/Scalar) dotnet run --project sample/PetStore.Api # Open browser to https://localhost:42616/scalar/v1 for API documentation @@ -976,6 +998,92 @@ PetStatus (API) - `ATCENUM001` - Target type must be an enum (Error) - `ATCENUM002` - Source enum value has no matching target value (Warning) +### MappingConfigurationGenerator + +**Key Features:** +- Map 3rd-party types (external assemblies, NuGet packages) without requiring `[MapTo]` attributes on source types +- Four complementary approaches: Mapper Class Pattern (primary), Assembly-Level `[MapTypes]`, Class-Level `[MapTypes]`, and Inline `AddMappings()`/`MappingBuilder.Configure()` +- Enum auto-detection - automatically generates switch expression mappings when properties have different enum types +- Property renaming, ignoring, nested mapping, constructor support, collection mapping +- Mixed mode - attribute-based `[MapTo]` and configuration-based mappings coexist (attribute always wins) + +**Mapper Class Pattern (Primary):** +```csharp +[MappingConfiguration] +public static partial class ExternalMappings +{ + [MapConfigProperty("FullName", "DisplayName")] + [MapConfigProperty("EmailAddress", "Email")] + [MapConfigIgnore("InternalId")] + public static partial CustomerDto MapToCustomerDto(this ThirdParty.Contact source); +} +``` + +**Assembly-Level Pattern (Shorthand):** +```csharp +[assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer), + PropertyMap = new[] { "EmailAddress:Email", "PhoneNumber:Phone" }, + IgnoreSourceProperties = new[] { "InternalId" })] +``` + +**Class-Level `[MapTypes]` Pattern:** +```csharp +[MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer), Bidirectional = true)] +[MapTypes(typeof(ExternalLib.Address), typeof(MyApp.Address))] +static class Mappings; +``` + +**Inline `AddMappings()` / `MappingBuilder.Configure()` Pattern:** +```csharp +// With DI (requires IServiceCollection in compilation) +builder.Services.AddMappings(map => +{ + map.Map(bidirectional: true); + map.Map(typeof(OrderDto), typeof(OrderEntity)); + map.Map( + propertyMap: new[] { "FullName:Name" }, + ignoreSourceProperties: new[] { "InternalId" }); +}); + +// Without DI +MappingBuilder.Configure(map => +{ + map.Map(bidirectional: true); +}); +``` + +**How Inline Registration Works:** +- `MappingBuilder` and `AddMappings()` are no-ops at runtime — all mapping code is generated at compile time +- The generator parses the lambda body syntax tree to find `Map()` invocations +- Supports both generic (`Map()`) and typeof (`Map(typeof(A), typeof(B))`) syntax +- Named arguments (`bidirectional:`, `propertyMap:`, `ignoreSourceProperties:`) are extracted from syntax +- Results feed into the same `GenerateAssemblyLevelCode()` pipeline as `[MapTypes]` + +**Enum Auto-Detection:** +When mapping properties with different enum types (e.g., `ContactType` → `CustomerCategory`), the generator automatically generates switch expressions using EnumMappingHelper's special case detection (None↔Unknown, Active↔Enabled, etc.). + +**Attributes (in Atc.SourceGenerators.Annotations):** +- `MappingConfigurationAttribute` - Marks static partial class as mapping configuration container +- `MapConfigPropertyAttribute(sourcePropertyName, targetPropertyName)` - Renames properties in mapping +- `MapConfigIgnoreAttribute(propertyName)` - Excludes properties from mapping +- `MapConfigOptionsAttribute` - Advanced options (Bidirectional, EnableFlattening, PropertyNameStrategy) +- `MapTypesAttribute(sourceType, targetType)` - Assembly-level or class-level shorthand for simple mappings +- `MappingBuilder` - Fluent API for inline mapping registration (compile-time analyzed, runtime no-op) + +**Diagnostics:** +- `ATCMCF001` - Configuration class must be static (Error) +- `ATCMCF002` - Configuration class must be partial (Error) +- `ATCMCF004` - Method must be an extension method (Error) +- `ATCMCF005` - Return type must be a class, struct, or record (Error) +- `ATCMCF006` - Source property not found for MapConfigProperty/MapConfigIgnore (Error) +- `ATCMCF007` - Target property not found for MapConfigProperty (Error) +- `ATCMCF008` - Unmatched source/target properties (Info) +- `ATCMCF010` - Assembly MapTypes source type not found (Error) +- `ATCMCF011` - Map() requires exactly two type arguments (Error) +- `ATCMCF013` - AddMappings() requires a lambda expression argument (Error) +- `ATCMAP005` - Duplicate mapping: attribute-based [MapTo] takes precedence over configuration (Warning) +- `ATCMAP006` - Duplicate configuration mapping for same source→target pair (Warning) + ### AnnotationConstantsGenerator **Key Features:** diff --git a/Directory.Build.props b/Directory.Build.props index 6ba0323..05320b0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -39,13 +39,13 @@ - - + + - + - + \ No newline at end of file diff --git a/README.md b/README.md index ff7fa3b..7225f01 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,17 @@ A collection of Roslyn C# source generators for .NET that eliminate boilerplate ## 🚀 Source Generators -- **[⚡ DependencyRegistrationGenerator](#-dependencyregistrationgenerator)** - Automatic DI service registration with attributes -- **[⚙️ OptionsBindingGenerator](#️-optionsbindinggenerator)** - Automatic configuration binding to strongly-typed options classes -- **[🗺️ MappingGenerator](#️-mappinggenerator)** - Automatic object-to-object mapping with type safety -- **[🔄 EnumMappingGenerator](#-enummappinggenerator)** - Automatic enum-to-enum mapping with intelligent matching -- **[📋 AnnotationConstantsGenerator](#-annotationconstantsgenerator)** - Compile-time access to DataAnnotation metadata +| Generator | Attribute | What It Does | +|-----------|-----------|-------------| +| **⚡ DependencyRegistration** | `[Registration]` | Automatic DI service registration with auto-detection, keyed services, factories, decorators | +| **⚙️ OptionsBinding** | `[OptionsBinding]` | Configuration binding with validation, change callbacks, named options | +| **🗺️ ObjectMapping** | `[MapTo(typeof(T))]` | Object-to-object mapping with bidirectional, nested, and collection support | +| **🔄 EnumMapping** | `[MapTo(typeof(T))]` | Enum-to-enum mapping with special case detection (None ↔ Unknown) | +| **📋 AnnotationConstants** | *(automatic)* | Compile-time access to DataAnnotation metadata without reflection | ## ✨ See It In Action -All four generators work together seamlessly in a typical 3-layer architecture: +All generators work together seamlessly in a typical 3-layer architecture: ```csharp // 1️⃣ Domain Layer - Your business logic @@ -52,10 +54,7 @@ public partial class PetStoreOptions using Atc.DependencyInjection; using Atc.Mapping; -// Register all services from Domain layer builder.Services.AddDependencyRegistrationsFromDomain(); - -// Bind all options from Domain layer builder.Services.AddOptionsFromDomain(builder.Configuration); // 3️⃣ Usage - Clean and type-safe @@ -70,846 +69,36 @@ app.MapGet("/pets/{id}", async (Guid id, IPetService service) => ## 📦 Installation -All generators are distributed in a single NuGet package. Install once to use all features. - -**Required:** - ```bash dotnet add package Atc.SourceGenerators ``` -**Optional (recommended for better IntelliSense):** +Optional (recommended for better IntelliSense): ```bash dotnet add package Atc.SourceGenerators.Annotations ``` -Or in your `.csproj`: - -```xml - - - - - - - -``` - -**Note:** The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: `$(NoWarn);CS0436` - ## ⚙️ Requirements -### Build-Time Requirements - -This package uses **Roslyn 5.0.0 (.NET 10)** for source generation. To build projects that consume this package: - -**Required:** -- **.NET 10 SDK** (or later) - -**Important Notes:** -- Projects targeting **.NET 9 (or earlier)** CAN successfully build using the .NET 10 SDK -- This is a **build-time requirement only**, not a runtime requirement -- Your application can still target and run on .NET 9, .NET 8, or earlier framework versions -- The SDK version only affects the build process, not the target framework or runtime - -**Example:** - -```bash -# Install .NET 10 SDK -# Download from: https://dotnet.microsoft.com/download/dotnet/10.0 - -# Your project can still target .NET 9 -net9.0 - -# But requires .NET 10 SDK to build (due to Roslyn 5.0.0 source generator dependency) -dotnet build # Must use .NET 10 SDK -``` - -**Why .NET 10 SDK?** - -The source generators in this package leverage Roslyn 5.0.0 APIs which ship with .NET 10. While your consuming applications can target any .NET version (including .NET 9, .NET 8, or .NET Framework), the build toolchain requires .NET 10 SDK for proper source generator execution. - ---- - -### ⚡ DependencyRegistrationGenerator - -Stop writing repetitive service registration code. Decorate your services with `[Registration]` and let the generator handle the rest. - -#### 📚 Documentation - -- **[Complete Guide](docs/DependencyRegistrationGenerators.md)** - In-depth documentation with examples -- **[Quick Start](docs/DependencyRegistrationGenerators.md#get-started---quick-guide)** - PetStore 3-layer architecture tutorial -- **[Multi-Project Setup](docs/DependencyRegistrationGenerators.md#multi-project-setup)** - Working with multiple projects -- **[Auto-Detection](docs/DependencyRegistrationGenerators.md#auto-detection)** - Understanding automatic interface detection -- **[Sample Projects](docs/DependencyRegistrationGenerators-Samples.md)** - Working code examples with architecture diagrams - -#### 😫 From This - -```csharp -// Program.cs - Manual registration hell 😫 -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddSingleton(); -services.AddTransient(); -services.AddScoped(); -services.AddScoped(); -// ... 50+ more lines of registration code -// ... spread across multiple files -// ... easy to forget or get wrong -``` - -#### ✨ To This - -```csharp -// Your services - Clean, declarative, self-documenting ✨ -[Registration(Lifetime.Scoped)] -public class UserService : IUserService { } - -[Registration(Lifetime.Scoped)] -public class OrderService : IOrderService { } - -[Registration] -public class CacheService : ICacheService { } - -// Program.cs - One line per project (with smart naming!) -using Atc.DependencyInjection; - -builder.Services.AddDependencyRegistrationsFromApi(); -builder.Services.AddDependencyRegistrationsFromDomain(); -builder.Services.AddDependencyRegistrationsFromDataAccess(); -``` - -#### ✨ Key Features - -- **🎯 Interface Auto-Detection**: Automatically registers against all implemented interfaces - no `As = typeof(IService)` needed -- **🔷 Generic Interface Registration**: Full support for open generics like `IRepository` and `IHandler` -- **🔑 Keyed Service Registration**: Multiple implementations of the same interface with different keys (.NET 8+) -- **🏭 Factory Method Registration**: Custom initialization logic via static factory methods -- **📦 Instance Registration**: Register pre-created singleton instances via static fields, properties, or methods -- **🔄 TryAdd* Registration**: Conditional registration for default implementations (library pattern) -- **⚙️ Conditional Registration**: Register services based on configuration values (feature flags, environment-specific services) -- **🎨 Decorator Pattern Support**: Wrap services with cross-cutting concerns (logging, caching, validation) using `Decorator = true` -- **🚫 Assembly Scanning Filters**: Exclude types by namespace, pattern (wildcards), or interface implementation -- **🎯 Runtime Filtering**: Exclude services when calling registration methods via optional parameters (different apps, different service subsets) -- **🔗 Transitive Registration**: Automatically discover and register services from referenced assemblies (4 overloads: default, auto-detect all, selective by name, selective multiple) -- **🧹 Smart Filtering**: System interfaces (IDisposable, etc.) are automatically excluded -- **🔍 Multi-Interface Registration**: Implementing multiple interfaces? Registers against all of them -- **🏃 Hosted Service Support**: Automatically detects BackgroundService and IHostedService implementations and uses AddHostedService() -- **✨ Smart Naming**: Generates clean method names using suffixes when unique, full names when conflicts exist -- **⚡ Zero Runtime Overhead**: All code generated at compile time -- **🚀 Native AOT Compatible**: No reflection or runtime code generation - fully trimming-safe -- **🏗️ Multi-Project Support**: Works seamlessly across layered architectures -- **🛡️ Compile-Time Validation**: Diagnostics for common errors catch issues before runtime -- **📦 Flexible Lifetimes**: Singleton (default), Scoped, and Transient support - -#### 🚀 Quick Example - -```csharp -using Atc.DependencyInjection; - -// That's it! Auto-detected as IUserService -[Registration(Lifetime.Scoped)] -public class UserService : IUserService -{ - public void CreateUser(string name) { } -} - -// Multiple interfaces? No problem - registers against ALL of them -[Registration] -public class EmailService : IEmailService, INotificationService { } - -// Need both interface AND concrete type? -[Registration(AsSelf = true)] -public class ReportService : IReportService { } - -// Custom initialization logic via factory method -[Registration(Lifetime.Scoped, As = typeof(IEmailSender), Factory = nameof(Create))] -public class EmailSender : IEmailSender -{ - private EmailSender(string apiKey) { } - - public static IEmailSender Create(IServiceProvider sp) - { - var config = sp.GetRequiredService(); - return new EmailSender(config["Email:ApiKey"]); - } -} - -// Default implementation for libraries (can be overridden by consumers) -[Registration(As = typeof(ILogger), TryAdd = true)] -public class DefaultLogger : ILogger -{ - public void Log(string message) => Console.WriteLine(message); -} - -// Decorator pattern - wrap services with cross-cutting concerns -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] -public class LoggingOrderServiceDecorator : IOrderService -{ - private readonly IOrderService inner; - private readonly ILogger logger; - - public LoggingOrderServiceDecorator(IOrderService inner, ILogger logger) - { - this.inner = inner; - this.logger = logger; - } - - public async Task PlaceOrderAsync(string orderId) - { - logger.Log($"Before placing order {orderId}"); - await inner.PlaceOrderAsync(orderId); - logger.Log($"After placing order {orderId}"); - } -} -``` - -#### 🔧 Service Lifetimes - -```csharp -[Registration] // Singleton (default) -[Registration(Lifetime.Singleton)] // Explicit singleton -[Registration(Lifetime.Scoped)] // Per-request (web apps) -[Registration(Lifetime.Transient)] // New instance every time -``` - -#### 🛡️ Compile-Time Safety - -Get errors at compile time, not runtime: - -| ID | Description | -|----|-------------| -| ATCDIR001 | `As` parameter must be an interface or abstract class type | -| ATCDIR002 | Class must implement the specified interface or inherit from abstract class | -| ATCDIR003 | Duplicate registration with different lifetimes | -| ATCDIR004 | Hosted services must use Singleton lifetime | -| ATCDIR005 | Factory method not found | -| ATCDIR006 | Factory method has invalid signature | - ---- - -### ⚙️ OptionsBindingGenerator - -Eliminate boilerplate configuration binding code. Decorate your options classes with `[OptionsBinding]` and let the generator create type-safe configuration bindings automatically. Supports DataAnnotations validation, startup validation, fail-fast validation for missing configuration sections (`ErrorOnMissingKeys`), and custom `IValidateOptions` validators for complex business rules. - -#### 📚 Documentation - -- **[Options Binding Guide](docs/OptionsBindingGenerators.md)** - Full documentation with examples -- **[Sample Projects](docs/OptionsBinding-Samples.md)** - Working examples with architecture diagrams - -#### 😫 From This - -```csharp -// Manual options binding - repetitive and error-prone 😫 -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -services.AddOptions() - .Bind(configuration.GetSection("App:Api")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -services.AddOptions() - .Bind(configuration.GetSection("Logging")) - .ValidateOnStart(); - -// ... repeated for every options class -``` - -#### ✨ To This - -```csharp -// Your options classes - Clean and declarative ✨ -[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required] - public string ConnectionString { get; set; } -} - -[OptionsBinding("App:Api", ValidateDataAnnotations = true)] -public partial class ApiOptions -{ - public string BaseUrl { get; set; } -} - -[OptionsBinding] // Section name auto-inferred as "LoggingOptions" -public partial class LoggingOptions -{ - public string Level { get; set; } -} - -// Program.cs - One line binds all options (with smart naming!) -services.AddOptionsFromApp(configuration); -``` - -#### ✨ Key Features - -- **🧠 Automatic Section Name Inference**: Smart resolution from explicit names, const fields (`SectionName`, `NameTitle`, `Name`), or auto-inferred from class names -- **🔒 Built-in Validation**: Integrated DataAnnotations validation (`ValidateDataAnnotations`) and startup validation (`ValidateOnStart`) -- **🎯 Custom Validation**: Support for `IValidateOptions` for complex business rules beyond DataAnnotations -- **🔔 Configuration Change Callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime - perfect for feature flags and runtime config updates -- **🔧 Post-Configuration Support**: Normalize or transform values after binding with `PostConfigure` callbacks (e.g., ensure paths have trailing slashes, lowercase URLs) -- **📛 Named Options**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers) -- **⚡ Early Access to Options**: Retrieve bound and validated options during service registration without BuildServiceProvider() anti-pattern (via `GetOrAdd*` methods) -- **🎯 Explicit Section Paths**: Support for nested sections like `"App:Database"` or `"Services:Email"` -- **📂 Nested Subsection Binding**: Automatic binding of complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry` → `"Storage:Database:Retry"`) -- **📦 Multiple Options Classes**: Register multiple configuration sections in a single assembly with one method call -- **🏗️ Multi-Project Support**: Smart naming generates assembly-specific extension methods (e.g., `AddOptionsFromDomain()`, `AddOptionsFromDataAccess()`) -- **🔗 Transitive Registration**: Automatically discover and register options from referenced assemblies (4 overloads: default, auto-detect all, selective by name, selective multiple) -- **⏱️ Flexible Lifetimes**: Choose between Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), or Monitor (`IOptionsMonitor`) patterns -- **⚡ Native AOT Ready**: Pure compile-time code generation with zero reflection, fully trimming-safe for modern .NET deployments -- **🛡️ Compile-Time Safety**: Catch configuration errors during build, not at runtime -- **🔧 Partial Class Requirement**: Simple `partial` keyword enables seamless extension method generation - -#### 🚀 Quick Example - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -// Automatic section name inference -[OptionsBinding] // Binds to "DatabaseOptions" section (uses full class name) -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } -} - -// Using const SectionName (2nd priority) -[OptionsBinding(ValidateDataAnnotations = true)] -public partial class CacheOptions -{ - public const string SectionName = "ApplicationCache"; // Binds to "ApplicationCache" - - [Range(1, 1000)] - public int MaxSize { get; set; } -} - -// Using const Name (4th priority) -[OptionsBinding] -public partial class EmailOptions -{ - public const string Name = "EmailConfiguration"; // Binds to "EmailConfiguration" - public string SmtpServer { get; set; } -} - -// Full priority demonstration -[OptionsBinding] -public partial class LoggingOptions -{ - public const string SectionName = "X1"; // 2nd prio - WINS - public const string NameTitle = "X2"; // 3rd prio - public const string Name = "X3"; // 4th prio - // Binds to "X1" -} - -// Explicit section path (1st priority - highest) -[OptionsBinding("App:Email:Smtp")] -public partial class SmtpOptions -{ - public string Host { get; set; } - public int Port { get; set; } -} - -// Specify lifetime for different injection patterns -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor)] -public partial class FeatureOptions -{ - public bool EnableNewFeature { get; set; } -} - -// Configuration change callbacks - auto-generated IHostedService -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))] -public partial class FeaturesOptions -{ - public bool EnableNewUI { get; set; } - public bool EnableBetaFeatures { get; set; } - - // Called automatically when configuration changes (requires reloadOnChange: true) - internal static void OnFeaturesChanged(FeaturesOptions options, string? name) - { - Console.WriteLine($"[OnChange] EnableNewUI: {options.EnableNewUI}"); - Console.WriteLine($"[OnChange] EnableBetaFeatures: {options.EnableBetaFeatures}"); - } -} - -// Usage in your services: -public class MyService -{ - public MyService(IOptions db) // Singleton - public MyService(IOptionsSnapshot smtp) // Scoped (reloads per request) - public MyService(IOptionsMonitor features) // Monitor (change notifications) -} -``` - -#### 🛡️ Compile-Time Safety - -| ID | Description | -|----|-------------| -| ATCOPT001 | Options class must be declared as partial | -| ATCOPT002 | Section name cannot be null or empty | -| ATCOPT003 | Invalid options binding configuration | -| ATCOPT004 | OnChange requires Monitor lifetime | -| ATCOPT005 | OnChange not supported with named options | -| ATCOPT006 | OnChange callback method not found | -| ATCOPT007 | OnChange callback has invalid signature | -| ATCOPT008 | PostConfigure not supported with named options | -| ATCOPT009 | PostConfigure callback method not found | -| ATCOPT010 | PostConfigure callback has invalid signature | - ---- - -### 🗺️ MappingGenerator - -Eliminate tedious object-to-object mapping code. Decorate your classes with `[MapTo(typeof(TargetType))]` and let the generator create type-safe mapping extension methods automatically. - -#### 📚 Documentation - -- **[Object Mapping Guide](docs/ObjectMappingGenerators.md)** - Full documentation with examples -- **[Quick Start](docs/ObjectMappingGenerators.md#get-started---quick-guide)** - UserApp 3-layer architecture tutorial -- **[Advanced Scenarios](docs/ObjectMappingGenerators.md#advanced-scenarios)** - Enums, nested objects, multi-layer mapping -- **[Sample Projects](docs/ObjectMappingGenerators-Samples.md)** - Working code examples with DataAccess → Domain → API - -#### 😫 From This - -```csharp -// Manual mapping - tedious, repetitive, error-prone 😫 -public UserDto MapToDto(User user) -{ - return new UserDto - { - Id = user.Id, - FirstName = user.FirstName, - LastName = user.LastName, - Email = user.Email, - Status = (UserStatusDto)user.Status, - Address = user.Address != null ? new AddressDto - { - Street = user.Address.Street, - City = user.Address.City, - State = user.Address.State, - PostalCode = user.Address.PostalCode, - Country = user.Address.Country - } : null, - CreatedAt = user.CreatedAt, - UpdatedAt = user.UpdatedAt - }; -} -// ... repeat for every type -// ... across every layer -// ... easy to forget properties -``` - -#### ✨ To This - -```csharp -// Your domain models - Clean, declarative, self-documenting ✨ -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; init; } - public string FirstName { get; init; } = string.Empty; - public string LastName { get; init; } = string.Empty; - public string Email { get; init; } = string.Empty; - public UserStatus Status { get; init; } - public Address? Address { get; init; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; init; } -} - -[MapTo(typeof(AddressDto))] -public partial class Address -{ - public string Street { get; init; } = string.Empty; - public string City { get; init; } = string.Empty; - public string State { get; init; } = string.Empty; - public string PostalCode { get; init; } = string.Empty; - public string Country { get; init; } = string.Empty; -} - -// Usage - One line per mapping -using Atc.Mapping; - -var dto = user.MapToUserDto(); -var dtos = users.Select(u => u.MapToUserDto()).ToList(); -``` - -#### ✨ Key Features - -- **📦 Collection Mapping**: Automatic mapping for `List`, `IEnumerable`, arrays, and other collection types -- **🏗️ Constructor Mapping**: Automatically detects and uses constructors for records and classes with primary constructors (C# 12+) -- **🚫 Property Exclusion**: Use `[MapIgnore]` to exclude sensitive or internal properties (works on both source and target) -- **🏷️ Custom Property Names**: Use `[MapProperty]` to map properties with different names between source and target -- **📏 Property Flattening**: Opt-in flattening support (e.g., `Address.City` → `AddressCity`) -- **🔄 Built-in Type Conversion**: DateTime ↔ string, Guid ↔ string, numeric ↔ string conversions -- **✅ Required Property Validation**: Compile-time diagnostics (ATCMAP004) for missing required properties (C# 11+) -- **🌳 Polymorphic/Derived Type Mapping**: Runtime type discrimination using switch expressions and `[MapDerivedType]` -- **🪝 Before/After Mapping Hooks**: Custom pre/post-processing logic with `BeforeMap` and `AfterMap` methods -- **🏭 Object Factories**: Custom object creation via factory methods instead of `new TargetType()` -- **♻️ Update Existing Target**: Map to existing instances (EF Core tracked entities) with `UpdateTarget = true` -- **📊 IQueryable Projections**: EF Core server-side query optimization with `GenerateProjection = true` -- **🔷 Generic Mappers**: Type-safe mapping for generic wrapper types like `Result` and `PagedResult` -- **🔐 Private Member Access**: Map to/from private and internal properties using UnsafeAccessor (.NET 8+) -- **🔤 Property Name Casing Strategies**: CamelCase and snake_case support with `PropertyNameStrategy` -- **🧬 Base Class Property Inheritance**: Automatically include properties from base classes (Entity audit fields, etc.) -- **🔁 Bidirectional Mapping**: Generate both forward and reverse mappings with `Bidirectional = true` -- **🔄 Smart Enum Conversion**: Uses safe EnumMapping extension methods when enums have `[MapTo]` attributes, falls back to casts -- **🪆 Nested Object Mapping**: Automatically chains mappings for nested properties -- **🏗️ Multi-Layer Support**: Build Entity → Domain → DTO mapping chains effortlessly -- **⚡ Zero Runtime Cost**: All code generated at compile time -- **🚀 Native AOT Compatible**: No reflection or runtime code generation - fully trimming-safe -- **🛡️ Type-Safe**: Compile-time validation catches mapping errors before runtime -- **📦 Null Safety**: Built-in null checking for nullable reference types -- **🎯 Convention-Based**: Maps properties by name - no configuration needed - -#### 🚀 Quick Example - -```csharp -using Atc.SourceGenerators.Annotations; -using Atc.Mapping; - -// Source with nested object and enum -[MapTo(typeof(PersonDto))] -public partial class Person -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public Status Status { get; set; } - public Address? Address { get; set; } -} - -[MapTo(typeof(AddressDto))] -public partial class Address -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; -} - -public enum Status { Active = 0, Inactive = 1 } - -// Target types -public class PersonDto -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public StatusDto Status { get; set; } - public AddressDto? Address { get; set; } -} - -public class AddressDto -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; -} - -public enum StatusDto { Active = 0, Inactive = 1 } - -// ✨ Use generated extension methods -var person = new Person -{ - Id = 1, - Name = "John Doe", - Status = Status.Active, - Address = new Address { Street = "123 Main St", City = "NYC" } -}; - -var dto = person.MapToPersonDto(); -// ✨ Automatic enum conversion -// ✨ Automatic nested object mapping (Address → AddressDto) -// ✨ Null safety built-in -``` - -#### 🔁 Multi-Layer Architecture - -Perfect for 3-layer architectures: - -``` -Database (Entities) → Domain (Models) → API (DTOs) -``` - -```csharp -// Data Access Layer -[MapTo(typeof(Domain.Product))] -public partial class ProductEntity -{ - public int DatabaseId { get; set; } - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public bool IsDeleted { get; set; } // DB-specific field -} - -// Domain Layer -namespace Domain; - -[MapTo(typeof(ProductDto))] -public partial class Product -{ - public Guid Id { get; init; } - public string Name { get; init; } = string.Empty; - public decimal Price { get; init; } -} - -public class ProductDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } -} - -// ✨ Complete mapping chain -var entity = repository.GetById(1); -var domain = entity.MapToProduct(); -var dto = domain.MapToProductDto(); -``` - -#### 🛡️ Compile-Time Safety - -Get errors at compile time, not runtime: - -| ID | Description | -|----|-------------| -| ATCMAP001 | Mapping class must be declared as partial | -| ATCMAP002 | Target type must be a class or struct | - ---- - -### 🔄 EnumMappingGenerator - -Eliminate manual enum conversions with intelligent enum-to-enum mapping. Decorate your enums with `[MapTo(typeof(TargetEnum))]` and let the generator create type-safe switch expression mappings with special case handling automatically. - -#### 📚 Documentation - -- **[Enum Mapping Guide](docs/EnumMappingGenerators.md)** - Full documentation with examples -- **[Quick Start](docs/EnumMappingGenerators.md#get-started---quick-guide)** - PetStore enum mapping tutorial -- **[Special Case Mappings](docs/EnumMappingGenerators.md#-special-case-mappings)** - None → Unknown, Active → Enabled, etc. -- **[Sample Projects](docs/EnumMappingGenerators-Samples.md)** - Working code examples with bidirectional mapping - -#### 😫 From This - -```csharp -// Manual enum mapping - tedious, error-prone, inflexible 😫 -public PetStatusDto MapToDto(PetStatusEntity status) -{ - return status switch - { - PetStatusEntity.None => PetStatusDto.Unknown, - PetStatusEntity.Pending => PetStatusDto.Pending, - PetStatusEntity.Available => PetStatusDto.Available, - PetStatusEntity.Adopted => PetStatusDto.Adopted, - _ => throw new ArgumentOutOfRangeException(nameof(status)), - }; -} - -public PetStatusEntity MapToEntity(PetStatusDto status) -{ - return status switch - { - PetStatusDto.Unknown => PetStatusEntity.None, - PetStatusDto.Pending => PetStatusEntity.Pending, - PetStatusDto.Available => PetStatusEntity.Available, - PetStatusDto.Adopted => PetStatusEntity.Adopted, - _ => throw new ArgumentOutOfRangeException(nameof(status)), - }; -} -// ... repeat for every enum pair -// ... across every layer -// ... easy to make mistakes -``` - -#### ✨ To This +- **.NET 10 SDK** required at build time (Roslyn 5.0.0) +- Projects can still **target** .NET 9, .NET 8, or earlier +- This is a build-time requirement only, not a runtime requirement -```csharp -// Your enums - Clean, declarative, self-documenting ✨ -using Atc.SourceGenerators.Annotations; - -// Database layer -[MapTo(typeof(PetStatusDto), Bidirectional = true)] -public enum PetStatusEntity -{ - None, // ✨ Auto-maps to PetStatusDto.Unknown (special case) - Pending, - Available, - Adopted, -} +## 📖 Documentation -// API layer -public enum PetStatusDto -{ - Unknown, // ✨ Auto-maps from PetStatusEntity.None - Available, - Pending, - Adopted, -} +Full documentation is available in the **[Wiki](https://github.com/atc-net/atc-source-generators/wiki)**: -// Usage - Generated extension methods -using Atc.Mapping; - -var entity = PetStatusEntity.None; -var dto = entity.MapToPetStatusDto(); // PetStatusDto.Unknown -var back = dto.MapToPetStatusEntity(); // PetStatusEntity.None (bidirectional!) -``` - -#### ✨ Key Features - -- **🎯 Intelligent Name Matching**: Maps enum values by name with case-insensitive support -- **🔀 Special Case Detection**: Automatically handles "zero/empty/null" state equivalents: - - `None` ↔ `Unknown`, `Default` - - `Unknown` ↔ `None`, `Default` - - `Default` ↔ `None`, `Unknown` -- **🔁 Bidirectional Mapping**: Generate both forward and reverse mappings with `Bidirectional = true` -- **🔤 Case-Insensitive**: Matches enum values regardless of casing differences -- **⚡ Zero Runtime Cost**: Pure switch expressions, no reflection or runtime code generation -- **🛡️ Type-Safe**: Compile-time validation with diagnostics (ATCENUM002) for unmapped values -- **🚀 Native AOT Compatible**: Fully trimming-safe, works with Native AOT -- **⚠️ Runtime Safety**: `ArgumentOutOfRangeException` thrown for unmapped values - -#### 🚀 Quick Example - -```csharp -using Atc.SourceGenerators.Annotations; -using Atc.Mapping; - -// Database layer enum with special case mapping -[MapTo(typeof(StatusDto), Bidirectional = true)] -public enum StatusEntity -{ - None, // ✨ Maps to StatusDto.Unknown (special case) - Active, // ✨ Exact name match - Inactive, // ✨ Exact name match -} - -public enum StatusDto -{ - Unknown, // ✨ Maps from StatusEntity.None (special case) - Active, // ✨ Exact name match - Inactive, // ✨ Exact name match -} - -// ✨ Use generated extension methods -var entity = StatusEntity.None; -var dto = entity.MapToStatusDto(); // StatusDto.Unknown -var back = dto.MapToStatusEntity(); // StatusEntity.None (bidirectional!) -``` - -#### 🛡️ Compile-Time Safety - -Get errors and warnings at compile time, not runtime: - -| ID | Description | -|----|-------------| -| ATCENUM001 | Target type must be an enum | -| ATCENUM002 | Enum value has no matching target value (Warning) | - ---- - -### 📋 AnnotationConstantsGenerator - -Access DataAnnotation attribute metadata at compile time without reflection. The generator automatically scans classes and records for `System.ComponentModel.DataAnnotations` attributes and generates strongly-typed constants. - -#### 📚 Documentation - -- **[Annotation Constants Guide](docs/AnnotationConstantsGenerator.md)** - Full documentation with examples -- **[Sample Projects](docs/AnnotationConstantsGenerator-Samples.md)** - Working code examples - -#### 😫 From This - -```csharp -// Runtime reflection to access annotation metadata 😫 -var nameProperty = typeof(Product).GetProperty("Name"); -var displayAttr = nameProperty.GetCustomAttribute(); -var displayName = displayAttr?.Name; // "Product Name" - -var maxLengthAttr = nameProperty.GetCustomAttribute(); -var maxLength = maxLengthAttr?.MaximumLength; // 100 - -// ... more reflection for each property -// ... slow, not AOT compatible -// ... no IntelliSense -``` - -#### ✨ To This - -```csharp -// Your model - No special attribute needed ✨ -using System.ComponentModel.DataAnnotations; - -public class Product -{ - [Display(Name = "Product Name", Description = "The product name")] - [Required] - [StringLength(100)] - public string Name { get; set; } = string.Empty; - - [Display(Name = "Price")] - [Required] - [Range(typeof(decimal), "0.01", "999999.99")] - public decimal Price { get; set; } -} - -// Access via generated constants - Zero reflection! ✨ -string displayName = AnnotationConstants.Product.Name.DisplayName; // "Product Name" -string description = AnnotationConstants.Product.Name.Description; // "The product name" -bool isRequired = AnnotationConstants.Product.Name.IsRequired; // true -int maxLength = AnnotationConstants.Product.Name.MaximumLength; // 100 - -string priceMin = AnnotationConstants.Product.Price.Minimum; // "0.01" -string priceMax = AnnotationConstants.Product.Price.Maximum; // "999999.99" -Type priceType = AnnotationConstants.Product.Price.OperandType; // typeof(decimal) -``` - -#### ✨ Key Features - -- **🔍 Automatic Scanning**: No opt-in attribute needed - scans all classes with DataAnnotation attributes -- **📦 Full DataAnnotations Support**: Display, Required, StringLength, Range, RegularExpression, EmailAddress, Phone, Url, CreditCard, DataType, Compare, Key, Editable, ScaffoldColumn, Timestamp -- **🔵 Atc Attributes Support**: IPAddress, Uri, String, KeyString, IsoCurrencySymbol, IgnoreDisplay, EnumGuid, CasingStyleDescription (when Atc package is referenced) -- **⚡ Zero Reflection**: All metadata accessible at compile time -- **🚀 Native AOT Compatible**: Fully trimming-safe, no runtime type inspection -- **🛡️ Type-Safe**: Full IntelliSense support with compile-time validation -- **⚙️ Configurable**: Optional `.editorconfig` setting to include unannotated properties -- **🎯 Perfect for Blazor**: Build dynamic forms without reflection -- **📝 API Documentation**: Generate OpenAPI schemas at compile time - -#### 🚀 Quick Example - -```csharp -using System.ComponentModel.DataAnnotations; - -public class Customer -{ - [Display(Name = "Full Name", Order = 1)] - [Required] - [StringLength(200)] - public string FullName { get; set; } = string.Empty; - - [Display(Name = "Email Address", Order = 2)] - [Required] - [EmailAddress] - public string Email { get; set; } = string.Empty; - - [Key] - [Editable(false)] - public Guid Id { get; set; } -} - -// ✨ Generated constants - use in validation, UI, docs -Console.WriteLine(AnnotationConstants.Customer.FullName.DisplayName); // "Full Name" -Console.WriteLine(AnnotationConstants.Customer.FullName.Order); // 1 -Console.WriteLine(AnnotationConstants.Customer.FullName.IsRequired); // true -Console.WriteLine(AnnotationConstants.Customer.FullName.MaximumLength); // 200 - -Console.WriteLine(AnnotationConstants.Customer.Email.IsEmailAddress); // true -Console.WriteLine(AnnotationConstants.Customer.Id.IsKey); // true -Console.WriteLine(AnnotationConstants.Customer.Id.IsEditable); // false -``` - ---- +| Page | Description | +|------|-------------| +| [Getting Started](https://github.com/atc-net/atc-source-generators/wiki/Getting-Started) | Installation, packages, and SDK requirements | +| [Dependency Registration](https://github.com/atc-net/atc-source-generators/wiki/Working-with-Dependency-Registration) | Automatic DI with `[Registration]` — auto-detection, keyed services, factories, decorators, transitive registration | +| [Options Binding](https://github.com/atc-net/atc-source-generators/wiki/Working-with-Options-Binding) | Configuration binding with `[OptionsBinding]` — validation, change callbacks, named options, child sections | +| [Object Mapping](https://github.com/atc-net/atc-source-generators/wiki/Working-with-Object-Mapping) | Object mapping with `[MapTo]` — bidirectional, nested objects, collections, projections, property strategies | +| [Enum Mapping](https://github.com/atc-net/atc-source-generators/wiki/Working-with-Enum-Mapping) | Enum mapping with `[MapTo]` — special case detection, bidirectional, case-insensitive | +| [Annotation Constants](https://github.com/atc-net/atc-source-generators/wiki/Working-with-Annotation-Constants) | Compile-time DataAnnotation metadata — zero reflection, Blazor-ready, Native AOT | +| [Sample Projects](https://github.com/atc-net/atc-source-generators/wiki/Sample-Projects) | Working code examples for each generator | +| [PetStore API Example](https://github.com/atc-net/atc-source-generators/wiki/PetStore-API-Example) | Full application using all generators with OpenAPI/Scalar | ## 🔨 Building @@ -923,65 +112,6 @@ dotnet build dotnet test ``` -## 📚 Sample Projects - -Working code examples demonstrating each generator in realistic scenarios: - -### ⚡ [DependencyRegistration Sample](docs/DependencyRegistrationGenerators-Samples.md) - -Multi-project console app showing automatic DI registration across layers with auto-detection of interfaces. - -```bash -cd sample/Atc.SourceGenerators.DependencyRegistration -dotnet run -``` - -### ⚙️ [OptionsBinding Sample](docs/OptionsBinding-Samples.md) - -Console app demonstrating type-safe configuration binding with validation and multiple options classes. - -```bash -cd sample/Atc.SourceGenerators.OptionsBinding -dotnet run -``` - -### 🗺️ [Mapping Sample](docs/ObjectMappingGenerators-Samples.md) - -ASP.NET Core Minimal API showing 3-layer mapping (Entity → Domain → DTO) with automatic enum conversion and nested objects. - -```bash -cd sample/Atc.SourceGenerators.Mapping -dotnet run -``` - -### 🔄 [EnumMapping Sample](docs/EnumMappingGenerators-Samples.md) - -Console app demonstrating intelligent enum-to-enum mapping with special case handling (None → Unknown, Active → Enabled), bidirectional mappings, and case-insensitive matching. - -```bash -cd sample/Atc.SourceGenerators.EnumMapping -dotnet run -``` - -### 📋 [AnnotationConstants Sample](docs/AnnotationConstantsGenerator-Samples.md) - -Console app demonstrating compile-time access to DataAnnotation metadata without reflection. Shows how to access Display names, validation rules, and data type constraints via generated constants. - -```bash -cd sample/Atc.SourceGenerators.AnnotationConstants -dotnet run -``` - -### 🎯 [PetStore API - Complete Example](docs/PetStoreApi-Samples.md) - -Full-featured ASP.NET Core application using **all four generators** together with OpenAPI/Scalar documentation. This demonstrates production-ready patterns for modern .NET applications. - -```bash -cd sample/PetStore.Api -dotnet run -# Open https://localhost:42616/scalar/v1 for API documentation -``` - ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/docs/AnnotationConstantsGenerator-FeatureRoadmap.md b/docs/AnnotationConstantsGenerator-FeatureRoadmap.md deleted file mode 100644 index 9a4de8c..0000000 --- a/docs/AnnotationConstantsGenerator-FeatureRoadmap.md +++ /dev/null @@ -1,720 +0,0 @@ -# 📝 Feature Roadmap - Annotation Constants Generator - -This document outlines the feature roadmap for the **AnnotationConstantsGenerator**, a source generator that extracts DataAnnotation metadata from class/record properties and generates compile-time accessible constants. - -## 🔍 Research Sources - -This roadmap is based on analysis of: - -1. **System.ComponentModel.DataAnnotations** - [Official Documentation](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations) - - Validation attributes (Required, StringLength, Range, etc.) - - Display attributes (Display, DisplayFormat, DataType) - - Metadata attributes (Key, Editable, ScaffoldColumn) - -2. **Common Developer Pain Points**: - - Runtime reflection overhead when accessing annotation metadata - - Hard-to-discover attribute values (need to navigate to source files) - - No compile-time constants for validation rules - - Manual extraction of metadata for documentation or UI generation - -3. **Use Cases**: - - Client-side validation (send max length to JavaScript without reflection) - - API documentation generation (extract display names and constraints) - - Form generation (access validation rules at compile time) - - Blazor/MAUI applications (avoid reflection for AOT compatibility) - -### 📊 Key Insights - -**What Developers Need**: - -- **Zero-reflection access** - Get annotation values without runtime reflection -- **Compile-time constants** - Use values in switch expressions, attribute arguments -- **Type safety** - Strongly-typed constants (int for lengths, string for names) -- **Discoverability** - IntelliSense for annotation values -- **Native AOT compatibility** - Works with trimming and ahead-of-time compilation - -**Common Scenarios**: - -- Building form validation in Blazor/MAUI without reflection -- Generating API documentation from DataAnnotations -- Sending validation constraints to client-side JavaScript -- Creating compile-time validated configuration - ---- - -## 📊 Current State - -### ✅ AnnotationConstantsGenerator - Implemented Features - -This generator is **fully implemented** and supports: - -- **Automatic class scanning** - No opt-in attribute required, scans all classes with DataAnnotation attributes -- **Display attribute extraction** - DisplayName, Description, ShortName, GroupName, Prompt, Order -- **Validation attribute extraction** - Required, StringLength, Range, MinLength, MaxLength, RegularExpression -- **Data type attribute extraction** - EmailAddress, Phone, Url, CreditCard, DataType -- **Metadata attribute extraction** - Key, Editable, ScaffoldColumn, Timestamp, Compare -- **Atc attributes support** - IPAddress, Uri, String, KeyString, IsoCurrencySymbol, IgnoreDisplay, EnumGuid, CasingStyleDescription -- **Configuration via .editorconfig** - Include/exclude unannotated properties -- **Nested class structure** - AnnotationConstants.{TypeName}.{PropertyName}.{ConstantName} -- **Native AOT compatible** - Zero reflection, compile-time generation - ---- - -## 📋 Feature Status Overview - -| Status | Feature | Priority | -|:------:|---------|----------| -| ✅ | [Display Attribute Support](#1-display-attribute-support) | 🔴 High | -| ✅ | [Required Attribute Support](#2-required-attribute-support) | 🔴 High | -| ✅ | [StringLength Attribute Support](#3-stringlength-attribute-support) | 🔴 High | -| ✅ | [Range Attribute Support](#4-range-attribute-support) | 🔴 High | -| ✅ | [MinLength/MaxLength Attribute Support](#5-minlengthmaxlength-attribute-support) | 🟡 Medium | -| ✅ | [RegularExpression Attribute Support](#6-regularexpression-attribute-support) | 🟡 Medium | -| ✅ | [Data Type Attributes Support](#7-data-type-attributes-support) | 🟡 Medium | -| ✅ | [Metadata Attributes Support](#8-metadata-attributes-support) | 🟢 Low | -| ✅ | [Configuration via .editorconfig](#9-configuration-via-editorconfig) | 🟡 Medium | -| ✅ | [Atc Attributes Support](#10-atc-attributes-support) | 🟡 Medium | -| ❌ | [Multi-Assembly Support](#11-multi-assembly-support) | 🟢 Low | - -**Legend:** - -- ✅ **Implemented** - Feature is complete and available -- ❌ **Not Implemented** - Feature is planned but not yet developed -- 🚫 **Not Planned** - Feature is out of scope or not aligned with project goals - ---- - -## 🎯 Need to Have (High Priority) - -These features address the core functionality of extracting annotation metadata. - -### 1. Display Attribute Support - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: System.ComponentModel.DataAnnotations.DisplayAttribute - -**Description**: Extract Display attribute properties and generate constants. - -**User Story**: -> "As a developer, I want to access DisplayAttribute.Name values at compile time for form labels without using reflection." - -**Example**: - -```csharp -// Input -public class Product -{ - [Display(Name = "Product Name", Description = "The name of the product", ShortName = "Name", Order = 1)] - public string Name { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Name - { - public const string DisplayName = "Product Name"; - public const string Description = "The name of the product"; - public const string ShortName = "Name"; - public const int Order = 1; - } - } -} - -// Usage -var label = AnnotationConstants.Product.Name.DisplayName; // "Product Name" -``` - -**Generated Constants**: - -| Constant | Type | Source | -|----------|------|--------| -| DisplayName | string | Display.Name | -| Description | string | Display.Description | -| ShortName | string | Display.ShortName | -| GroupName | string | Display.GroupName | -| Prompt | string | Display.Prompt | -| Order | int | Display.Order | - ---- - -### 2. Required Attribute Support - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: System.ComponentModel.DataAnnotations.RequiredAttribute - -**Description**: Extract Required attribute and generate IsRequired constant. - -**Example**: - -```csharp -// Input -public class Customer -{ - [Required(ErrorMessage = "Name is required", AllowEmptyStrings = false)] - public string Name { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Customer - { - public static partial class Name - { - public const bool IsRequired = true; - public const bool AllowEmptyStrings = false; - public const string RequiredErrorMessage = "Name is required"; - } - } -} -``` - ---- - -### 3. StringLength Attribute Support - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: System.ComponentModel.DataAnnotations.StringLengthAttribute - -**Description**: Extract StringLength min/max values as constants. - -**Example**: - -```csharp -// Input -public class Product -{ - [StringLength(100, MinimumLength = 3, ErrorMessage = "Name must be 3-100 characters")] - public string Name { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Name - { - public const int MinimumLength = 3; - public const int MaximumLength = 100; - public const string StringLengthErrorMessage = "Name must be 3-100 characters"; - } - } -} - -// Usage - send to client-side JavaScript -const maxLength = @AnnotationConstants.Product.Name.MaximumLength; // 100 -``` - ---- - -### 4. Range Attribute Support - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: System.ComponentModel.DataAnnotations.RangeAttribute - -**Description**: Extract Range min/max values. Since Range supports different types (int, double, decimal), values are stored as strings with an OperandType property. - -**Example**: - -```csharp -// Input -public class Product -{ - [Range(typeof(decimal), "0.01", "999999.99", ErrorMessage = "Price must be between $0.01 and $999,999.99")] - public decimal Price { get; set; } - - [Range(1, 100)] - public int Quantity { get; set; } -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Price - { - public const string Minimum = "0.01"; - public const string Maximum = "999999.99"; - public static readonly System.Type OperandType = typeof(decimal); - public const string RangeErrorMessage = "Price must be between $0.01 and $999,999.99"; - } - - public static partial class Quantity - { - public const string Minimum = "1"; - public const string Maximum = "100"; - public static readonly System.Type OperandType = typeof(int); - } - } -} - -// Usage - parse to appropriate type -decimal minPrice = decimal.Parse(AnnotationConstants.Product.Price.Minimum); // 0.01m -``` - -**Design Decision**: Range values are stored as strings because: - -- Range attribute accepts `typeof(int)`, `typeof(double)`, `typeof(decimal)`, etc. -- No single numeric type can represent all possibilities -- Consumer can parse using the provided `OperandType` -- `OperandType` is `static readonly` (not `const`) because `Type` cannot be a constant - ---- - -## 💡 Nice to Have (Medium Priority) - -### 5. MinLength/MaxLength Attribute Support - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** - -**Description**: Extract MinLength and MaxLength attribute values. - -**Example**: - -```csharp -// Input -public class Product -{ - [MinLength(3)] - [MaxLength(100)] - public string[] Tags { get; set; } = []; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Tags - { - public const int MinimumLength = 3; - public const int MaximumLength = 100; - } - } -} -``` - ---- - -### 6. RegularExpression Attribute Support - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** - -**Description**: Extract regex pattern from RegularExpression attribute. - -**Example**: - -```csharp -// Input -public class User -{ - [RegularExpression(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", ErrorMessage = "Invalid email")] - public string Email { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class User - { - public static partial class Email - { - public const string Pattern = @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; - public const string RegularExpressionErrorMessage = "Invalid email"; - } - } -} -``` - ---- - -### 7. Data Type Attributes Support - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** - -**Description**: Extract data type information from EmailAddress, Phone, Url, CreditCard, and DataType attributes. - -**Example**: - -```csharp -// Input -public class Contact -{ - [EmailAddress] - public string Email { get; set; } = string.Empty; - - [Phone] - public string PhoneNumber { get; set; } = string.Empty; - - [Url] - public string Website { get; set; } = string.Empty; - - [CreditCard] - public string CardNumber { get; set; } = string.Empty; - - [DataType(DataType.Password)] - public string Password { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Contact - { - public static partial class Email - { - public const bool IsEmailAddress = true; - } - - public static partial class PhoneNumber - { - public const bool IsPhone = true; - } - - public static partial class Website - { - public const bool IsUrl = true; - } - - public static partial class CardNumber - { - public const bool IsCreditCard = true; - } - - public static partial class Password - { - public const int DataType = 11; // DataType.Password enum value - } - } -} -``` - ---- - -### 8. Metadata Attributes Support - -**Priority**: 🟢 **Low** -**Status**: ✅ **Implemented** - -**Description**: Extract metadata from Key, Editable, ScaffoldColumn, Timestamp, and Compare attributes. - -**Example**: - -```csharp -// Input -public class Entity -{ - [Key] - public Guid Id { get; set; } - - [Editable(false)] - public DateTimeOffset CreatedAt { get; set; } - - [ScaffoldColumn(false)] - public string InternalData { get; set; } = string.Empty; - - [Timestamp] - public byte[] RowVersion { get; set; } = []; - - [Compare(nameof(Email))] - public string ConfirmEmail { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class Entity - { - public static partial class Id - { - public const bool IsKey = true; - } - - public static partial class CreatedAt - { - public const bool IsEditable = false; - } - - public static partial class InternalData - { - public const bool IsScaffoldColumn = false; - } - - public static partial class RowVersion - { - public const bool IsTimestamp = true; - } - - public static partial class ConfirmEmail - { - public const string CompareProperty = "Email"; - } - } -} -``` - ---- - -### 9. Configuration via .editorconfig - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** - -**Description**: Allow customization of generator behavior via .editorconfig or MSBuild properties. - -**Configuration Options**: - -```ini -# .editorconfig -[*.cs] -# Include properties without any DataAnnotation attributes (default: false) -atc_annotation_constants.include_unannotated_properties = true - -# Namespace for generated constants (default: same as source type) -atc_annotation_constants.namespace = MyApp.Generated - -# Generate error messages (default: true) -atc_annotation_constants.include_error_messages = false -``` - -**MSBuild Properties**: - -```xml - - true - -``` - ---- - -### 10. Atc Attributes Support - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** - -**Description**: Extract validation attributes from the Atc package when referenced. These include IPAddressAttribute, UriAttribute, StringAttribute, KeyStringAttribute, IsoCurrencySymbolAttribute, and metadata attributes like IgnoreDisplayAttribute, EnumGuidAttribute, and CasingStyleDescriptionAttribute. - -**Example**: - -```csharp -// Add Atc package reference: - -public class NetworkConfig -{ - [Display(Name = "Server IP")] - [IPAddress(Required = true)] - public string ServerAddress { get; set; } = string.Empty; - - [Display(Name = "API Endpoint")] - [UriAttribute(Required = true, AllowHttp = false, AllowHttps = true, AllowFtp = false, AllowFtps = false, AllowFile = false, AllowOpcTcp = false)] - public string ApiEndpoint { get; set; } = string.Empty; - - [Display(Name = "Currency")] - [IsoCurrencySymbol(IsoCurrencySymbols = new[] { "USD", "EUR", "GBP" })] - public string Currency { get; set; } = "USD"; - - [Display(Name = "Service Key")] - [KeyString] - public string ServiceKey { get; set; } = string.Empty; -} - -// Generated -public static partial class AnnotationConstants -{ - public static partial class NetworkConfig - { - public static partial class ServerAddress - { - public const string DisplayName = "Server IP"; - public const bool IsIPAddress = true; - public const bool IPAddressRequired = true; - } - - public static partial class ApiEndpoint - { - public const string DisplayName = "API Endpoint"; - public const bool IsAtcUri = true; - public const bool AtcUriRequired = true; - public const bool AtcUriAllowHttp = false; - public const bool AtcUriAllowHttps = true; - // ... other URI scheme flags - } - - public static partial class Currency - { - public const string DisplayName = "Currency"; - public const bool IsIsoCurrencySymbol = true; - public static readonly string[] AllowedIsoCurrencySymbols = new[] { "USD", "EUR", "GBP" }; - } - - public static partial class ServiceKey - { - public const string DisplayName = "Service Key"; - public const bool IsAtcString = true; - public const bool IsKeyString = true; - } - } -} -``` - -**Supported Atc Attributes**: - -| Attribute | Generated Constants | -|----------|-------------------| -| `IPAddressAttribute` | `IsIPAddress`, `IPAddressRequired` | -| `IsoCurrencySymbolAttribute` | `IsIsoCurrencySymbol`, `IsoCurrencySymbolRequired`, `AllowedIsoCurrencySymbols` | -| `StringAttribute` | `IsAtcString`, `AtcStringRequired`, `AtcStringMinLength`, `AtcStringMaxLength`, `AtcStringRegularExpression`, `AtcStringInvalidCharacters`, `AtcStringInvalidPrefixStrings` | -| `KeyStringAttribute` | `IsKeyString` (plus all StringAttribute constants) | -| `UriAttribute` | `IsAtcUri`, `AtcUriRequired`, `AtcUriAllowHttp`, `AtcUriAllowHttps`, `AtcUriAllowFtp`, `AtcUriAllowFtps`, `AtcUriAllowFile`, `AtcUriAllowOpcTcp` | -| `IgnoreDisplayAttribute` | `IsIgnoreDisplay` | -| `EnumGuidAttribute` | `EnumGuid` | -| `CasingStyleDescriptionAttribute` | `CasingStyleDefault`, `CasingStylePrefix` | - ---- - -### 11. Multi-Assembly Support - -**Priority**: 🟢 **Low** -**Status**: ❌ **Not Implemented** - -**Description**: Generate constants from types across multiple assemblies with smart naming to avoid conflicts. - -**Example**: - -```csharp -// If "Contracts" suffix is unique: -// MyApp.Contracts.Product → AnnotationConstants.Product - -// If multiple assemblies have same type name: -// MyApp.Contracts.Product → AnnotationConstantsFromContracts.Product -// OtherApp.Contracts.Product → AnnotationConstantsFromOtherAppContracts.Product -``` - ---- - -## ⛔ Not Planned (Out of Scope) - -### Runtime Validation Support - -**Reason**: This generator focuses on compile-time constant extraction. Validation is handled by DataAnnotations at runtime. - -**Status**: 🚫 Not Planned - ---- - -### Custom Attribute Support - -**Reason**: Focus on standard System.ComponentModel.DataAnnotations attributes. Custom attributes can be added in future versions based on demand. - -**Status**: 🚫 Not Planned (v1.0) - ---- - -### Localization Support - -**Reason**: Display.ResourceType for localized strings requires runtime access. Constants are compile-time only. - -**Status**: 🚫 Not Planned - ---- - -## 📅 Implementation Status - -All planned features for v1.0 have been implemented: - -### Phase 1: Core Extraction ✅ Complete - -**Goal**: Extract essential validation metadata - -1. ✅ **Display Attribute Support** - DisplayName, Description, ShortName, GroupName, Prompt, Order -2. ✅ **Required Attribute Support** - IsRequired flag, AllowEmptyStrings, ErrorMessage -3. ✅ **StringLength Attribute Support** - Min/Max length with ErrorMessage -4. ✅ **Range Attribute Support** - Min/Max values with OperandType and ErrorMessage - ---- - -### Phase 2: Extended Validation ✅ Complete - -**Goal**: Additional validation attribute support - -1. ✅ **MinLength/MaxLength Attribute Support** - Separate length attributes -2. ✅ **RegularExpression Attribute Support** - Pattern with ErrorMessage -3. ✅ **Data Type Attributes Support** - EmailAddress, Phone, Url, CreditCard, DataType - ---- - -### Phase 3: Configuration & Metadata ✅ Complete - -**Goal**: Customization and metadata support - -1. ✅ **Configuration via .editorconfig** - Include unannotated properties option -2. ✅ **Metadata Attributes Support** - Key, Editable, ScaffoldColumn, Timestamp, Compare - ---- - -### Phase 4: Atc Package Integration ✅ Complete - -**Goal**: Support Atc-specific validation attributes - -1. ✅ **Atc Attributes Support** - IPAddress, Uri, String, KeyString, IsoCurrencySymbol, IgnoreDisplay, EnumGuid, CasingStyleDescription - ---- - -### Future: Multi-Assembly Support 🔮 Planned - -1. ❌ **Multi-Assembly Support** - Smart naming for types across assemblies (not yet implemented) - ---- - -## 🎯 Success Metrics - -1. **Adoption** - Track NuGet downloads and GitHub stars -2. **Performance** - Measure compile-time impact (should be minimal) -3. **Coverage** - % of DataAnnotation attributes supported -4. **Community Feedback** - Issue tracker activity and feature requests -5. **AOT Compatibility** - Verify zero reflection in generated code - ---- - -## 📝 Notes - -### Design Philosophy - -- **Compile-time constants** - All values are compile-time constants where possible -- **Type safety** - Use appropriate types (int for lengths, string for names) -- **Discoverability** - Nested class structure mirrors source type structure -- **No reflection** - Generated code uses no reflection APIs -- **AOT compatible** - Works with Native AOT and IL trimming - -### Key Design Decisions - -1. **Range values as strings**: Since Range attribute accepts different numeric types, min/max are stored as strings with a Type property -2. **No opt-in attribute**: Generator scans all types automatically (unlike other generators in this project) -3. **Partial classes**: Generated classes are partial to allow manual extensions -4. **Same namespace**: Constants are generated in the same namespace as the source type - ---- - -## 🔗 Related Resources - -- **System.ComponentModel.DataAnnotations**: -- **Blazor Form Validation**: -- **Sample Projects**: See `/sample/Atc.SourceGenerators.AnnotationConstants` - ---- - -**Last Updated**: 2025-12-19 -**Version**: 1.0 (Implemented) -**Maintained By**: Atc.SourceGenerators Team diff --git a/docs/AnnotationConstantsGenerator-Samples.md b/docs/AnnotationConstantsGenerator-Samples.md deleted file mode 100644 index f012d32..0000000 --- a/docs/AnnotationConstantsGenerator-Samples.md +++ /dev/null @@ -1,569 +0,0 @@ -# 🎯 Annotation Constants Generator - Sample Projects - -This document provides working examples demonstrating the **AnnotationConstantsGenerator** in realistic scenarios. - -## 📁 Sample Project Location - -``` -sample/ -└── Atc.SourceGenerators.AnnotationConstants/ - ├── Atc.SourceGenerators.AnnotationConstants.csproj - ├── Models/ - │ ├── Product.cs - │ └── Customer.cs - └── Program.cs -``` - -## 🚀 Running the Sample - -```bash -cd sample/Atc.SourceGenerators.AnnotationConstants -dotnet run -``` - -**Expected Output:** - -```bash -=== Annotation Constants Generator Demo === - -Product.Name: - DisplayName: Product Name - MaximumLength: 100 - MinimumLength: 3 - IsRequired: true - -Product.Price: - DisplayName: Price - Minimum: 0.01 - Maximum: 999999.99 - OperandType: System.Decimal - IsRequired: true - -Product.Sku: - DisplayName: SKU - MaximumLength: 20 - Pattern: ^[A-Z]{3}-\d{4}$ - IsRequired: false - -Customer.Email: - DisplayName: Email Address - IsRequired: true - IsEmailAddress: true - -Customer.Phone: - DisplayName: Phone Number - IsPhone: true -``` - ---- - -## 📋 Sample Code - -### Models/Product.cs - -```csharp -using System.ComponentModel.DataAnnotations; - -namespace Atc.SourceGenerators.Sample.Models; - -/// -/// Demonstrates DataAnnotation extraction for a product entity. -/// -public class Product -{ - /// - /// Product name with display metadata and validation constraints. - /// - [Display(Name = "Product Name", Description = "The display name of the product")] - [Required(ErrorMessage = "Product name is required")] - [StringLength(100, MinimumLength = 3, ErrorMessage = "Name must be 3-100 characters")] - public string Name { get; set; } = string.Empty; - - /// - /// Product price with Range validation using decimal type. - /// - [Display(Name = "Price", Description = "The retail price of the product")] - [Required] - [Range(typeof(decimal), "0.01", "999999.99", ErrorMessage = "Price must be between $0.01 and $999,999.99")] - public decimal Price { get; set; } - - /// - /// Stock Keeping Unit with regex pattern validation. - /// - [Display(Name = "SKU", ShortName = "SKU", Description = "Stock Keeping Unit identifier")] - [StringLength(20)] - [RegularExpression(@"^[A-Z]{3}-\d{4}$", ErrorMessage = "SKU must be in format AAA-0000")] - public string? Sku { get; set; } - - /// - /// Product quantity with integer Range validation. - /// - [Display(Name = "Quantity in Stock")] - [Range(0, 10000)] - public int Quantity { get; set; } - - /// - /// Product category with display grouping. - /// - [Display(Name = "Category", GroupName = "Classification")] - [Required] - public string Category { get; set; } = string.Empty; - - /// - /// Internal notes - not editable via forms. - /// - [Display(Name = "Internal Notes")] - [Editable(false)] - [ScaffoldColumn(false)] - public string? InternalNotes { get; set; } -} -``` - -### Models/Customer.cs - -```csharp -using System.ComponentModel.DataAnnotations; - -namespace Atc.SourceGenerators.Sample.Models; - -/// -/// Demonstrates DataAnnotation extraction for a customer entity. -/// -public class Customer -{ - /// - /// Primary key identifier. - /// - [Key] - public Guid Id { get; set; } - - /// - /// Customer's full name. - /// - [Display(Name = "Full Name", Prompt = "Enter customer's full name")] - [Required(ErrorMessage = "Name is required")] - [StringLength(150, MinimumLength = 2)] - public string Name { get; set; } = string.Empty; - - /// - /// Email address with specialized validation. - /// - [Display(Name = "Email Address", Description = "Primary contact email")] - [Required] - [EmailAddress(ErrorMessage = "Invalid email format")] - public string Email { get; set; } = string.Empty; - - /// - /// Email confirmation for registration. - /// - [Display(Name = "Confirm Email")] - [Compare(nameof(Email), ErrorMessage = "Emails must match")] - public string ConfirmEmail { get; set; } = string.Empty; - - /// - /// Phone number with specialized validation. - /// - [Display(Name = "Phone Number")] - [Phone] - public string? Phone { get; set; } - - /// - /// Customer website URL. - /// - [Display(Name = "Website")] - [Url] - public string? Website { get; set; } - - /// - /// Credit card for payment. - /// - [Display(Name = "Credit Card")] - [CreditCard] - public string? CreditCard { get; set; } - - /// - /// Customer age with range validation. - /// - [Display(Name = "Age")] - [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")] - public int? Age { get; set; } - - /// - /// Account creation timestamp. - /// - [Display(Name = "Created At")] - [Editable(false)] - [DataType(DataType.DateTime)] - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - - /// - /// Optimistic concurrency token. - /// - [Timestamp] - public byte[]? RowVersion { get; set; } -} -``` - -### Program.cs - -```csharp -using Atc.SourceGenerators.Sample.Models; - -Console.WriteLine("=== Annotation Constants Generator Demo ==="); -Console.WriteLine(); - -// ===== Product Annotations ===== -Console.WriteLine("Product.Name:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Name.DisplayName}"); -Console.WriteLine($" Description: {AnnotationConstants.Product.Name.Description}"); -Console.WriteLine($" MaximumLength: {AnnotationConstants.Product.Name.MaximumLength}"); -Console.WriteLine($" MinimumLength: {AnnotationConstants.Product.Name.MinimumLength}"); -Console.WriteLine($" IsRequired: {AnnotationConstants.Product.Name.IsRequired}"); -Console.WriteLine($" ErrorMessage: {AnnotationConstants.Product.Name.StringLengthErrorMessage}"); -Console.WriteLine(); - -Console.WriteLine("Product.Price:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Price.DisplayName}"); -Console.WriteLine($" Minimum: {AnnotationConstants.Product.Price.Minimum}"); -Console.WriteLine($" Maximum: {AnnotationConstants.Product.Price.Maximum}"); -Console.WriteLine($" OperandType: {AnnotationConstants.Product.Price.OperandType}"); -Console.WriteLine($" IsRequired: {AnnotationConstants.Product.Price.IsRequired}"); -Console.WriteLine(); - -Console.WriteLine("Product.Sku:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Sku.DisplayName}"); -Console.WriteLine($" ShortName: {AnnotationConstants.Product.Sku.ShortName}"); -Console.WriteLine($" MaximumLength: {AnnotationConstants.Product.Sku.MaximumLength}"); -Console.WriteLine($" Pattern: {AnnotationConstants.Product.Sku.Pattern}"); -Console.WriteLine(); - -Console.WriteLine("Product.Quantity:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Quantity.DisplayName}"); -Console.WriteLine($" Minimum: {AnnotationConstants.Product.Quantity.Minimum}"); -Console.WriteLine($" Maximum: {AnnotationConstants.Product.Quantity.Maximum}"); -Console.WriteLine(); - -Console.WriteLine("Product.InternalNotes:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Product.InternalNotes.DisplayName}"); -Console.WriteLine($" IsEditable: {AnnotationConstants.Product.InternalNotes.IsEditable}"); -Console.WriteLine($" IsScaffoldColumn: {AnnotationConstants.Product.InternalNotes.IsScaffoldColumn}"); -Console.WriteLine(); - -// ===== Customer Annotations ===== -Console.WriteLine("Customer.Id:"); -Console.WriteLine($" IsKey: {AnnotationConstants.Customer.Id.IsKey}"); -Console.WriteLine(); - -Console.WriteLine("Customer.Name:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.Name.DisplayName}"); -Console.WriteLine($" Prompt: {AnnotationConstants.Customer.Name.Prompt}"); -Console.WriteLine($" IsRequired: {AnnotationConstants.Customer.Name.IsRequired}"); -Console.WriteLine(); - -Console.WriteLine("Customer.Email:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.Email.DisplayName}"); -Console.WriteLine($" IsRequired: {AnnotationConstants.Customer.Email.IsRequired}"); -Console.WriteLine($" IsEmailAddress: {AnnotationConstants.Customer.Email.IsEmailAddress}"); -Console.WriteLine(); - -Console.WriteLine("Customer.ConfirmEmail:"); -Console.WriteLine($" CompareProperty: {AnnotationConstants.Customer.ConfirmEmail.CompareProperty}"); -Console.WriteLine(); - -Console.WriteLine("Customer.Phone:"); -Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.Phone.DisplayName}"); -Console.WriteLine($" IsPhone: {AnnotationConstants.Customer.Phone.IsPhone}"); -Console.WriteLine(); - -Console.WriteLine("Customer.Website:"); -Console.WriteLine($" IsUrl: {AnnotationConstants.Customer.Website.IsUrl}"); -Console.WriteLine(); - -Console.WriteLine("Customer.CreditCard:"); -Console.WriteLine($" IsCreditCard: {AnnotationConstants.Customer.CreditCard.IsCreditCard}"); -Console.WriteLine(); - -Console.WriteLine("Customer.Age:"); -Console.WriteLine($" Minimum: {AnnotationConstants.Customer.Age.Minimum}"); -Console.WriteLine($" Maximum: {AnnotationConstants.Customer.Age.Maximum}"); -Console.WriteLine($" RangeErrorMessage: {AnnotationConstants.Customer.Age.RangeErrorMessage}"); -Console.WriteLine(); - -Console.WriteLine("Customer.CreatedAt:"); -Console.WriteLine($" IsEditable: {AnnotationConstants.Customer.CreatedAt.IsEditable}"); -Console.WriteLine($" DataType: {AnnotationConstants.Customer.CreatedAt.DataType}"); -Console.WriteLine(); - -Console.WriteLine("Customer.RowVersion:"); -Console.WriteLine($" IsTimestamp: {AnnotationConstants.Customer.RowVersion.IsTimestamp}"); -Console.WriteLine(); - -// ===== Practical Usage Examples ===== -Console.WriteLine("=== Practical Usage Examples ==="); -Console.WriteLine(); - -// Example 1: Client-side validation rules -Console.WriteLine("1. Generate JavaScript Validation Rules:"); -Console.WriteLine($$""" - const productValidation = { - name: { - required: {{AnnotationConstants.Product.Name.IsRequired.ToString().ToLowerInvariant()}}, - maxLength: {{AnnotationConstants.Product.Name.MaximumLength}}, - minLength: {{AnnotationConstants.Product.Name.MinimumLength}} - }, - price: { - required: {{AnnotationConstants.Product.Price.IsRequired.ToString().ToLowerInvariant()}}, - min: {{AnnotationConstants.Product.Price.Minimum}}, - max: {{AnnotationConstants.Product.Price.Maximum}} - }, - sku: { - pattern: '{{AnnotationConstants.Product.Sku.Pattern}}' - } - }; - """); -Console.WriteLine(); - -// Example 2: Form field generation -Console.WriteLine("2. Generate Form Fields:"); -GenerateFormField("Name", AnnotationConstants.Product.Name.DisplayName, - AnnotationConstants.Product.Name.Description, - AnnotationConstants.Product.Name.MaximumLength, - AnnotationConstants.Product.Name.IsRequired); -Console.WriteLine(); - -// Example 3: API documentation -Console.WriteLine("3. Generate API Documentation:"); -Console.WriteLine($$""" - ## POST /api/products - - | Field | Description | Required | Constraints | - |-------|-------------|----------|-------------| - | name | {{AnnotationConstants.Product.Name.Description}} | {{AnnotationConstants.Product.Name.IsRequired}} | {{AnnotationConstants.Product.Name.MinimumLength}}-{{AnnotationConstants.Product.Name.MaximumLength}} chars | - | price | {{AnnotationConstants.Product.Price.Description}} | {{AnnotationConstants.Product.Price.IsRequired}} | ${{AnnotationConstants.Product.Price.Minimum}}-${{AnnotationConstants.Product.Price.Maximum}} | - | sku | {{AnnotationConstants.Product.Sku.Description}} | No | Pattern: {{AnnotationConstants.Product.Sku.Pattern}} | - """); - -static void GenerateFormField(string fieldName, string displayName, string? description, int maxLength, bool required) -{ - var requiredAttr = required ? " required" : ""; - Console.WriteLine($$""" -
- - - {{(description != null ? $"{description}" : "")}} -
- """); -} -``` - ---- - -## 🏗️ Project Structure - -### Generated Code Structure - -When you build the sample project, the generator creates: - -```bash -obj/ -└── Debug/ - └── net10.0/ - └── generated/ - └── Atc.SourceGenerators/ - └── Atc.SourceGenerators.AnnotationConstantsGenerator/ - ├── AnnotationConstants.Atc.SourceGenerators.Sample.Models.Product.g.cs - └── AnnotationConstants.Atc.SourceGenerators.Sample.Models.Customer.g.cs -``` - -### Generated File Example - -**AnnotationConstants.Atc.SourceGenerators.Sample.Models.Product.g.cs:** - -```csharp -// -#nullable enable - -namespace Atc.SourceGenerators.Sample.Models; - -/// -/// Annotation constants for types in Atc.SourceGenerators.Sample.Models. -/// -[global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.AnnotationConstants", "1.0.0")] -[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -[global::System.Runtime.CompilerServices.CompilerGenerated] -[global::System.Diagnostics.DebuggerNonUserCode] -[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Name - { - public const string DisplayName = "Product Name"; - public const string Description = "The display name of the product"; - public const int MinimumLength = 3; - public const int MaximumLength = 100; - public const bool IsRequired = true; - public const string RequiredErrorMessage = "Product name is required"; - public const string StringLengthErrorMessage = "Name must be 3-100 characters"; - } - - public static partial class Price - { - public const string DisplayName = "Price"; - public const string Description = "The retail price of the product"; - public const string Minimum = "0.01"; - public const string Maximum = "999999.99"; - public static readonly global::System.Type OperandType = typeof(decimal); - public const bool IsRequired = true; - public const string RangeErrorMessage = "Price must be between $0.01 and $999,999.99"; - } - - public static partial class Sku - { - public const string DisplayName = "SKU"; - public const string ShortName = "SKU"; - public const string Description = "Stock Keeping Unit identifier"; - public const int MaximumLength = 20; - public const string Pattern = @"^[A-Z]{3}-\d{4}$"; - public const string RegularExpressionErrorMessage = "SKU must be in format AAA-0000"; - } - - public static partial class Quantity - { - public const string DisplayName = "Quantity in Stock"; - public const string Minimum = "0"; - public const string Maximum = "10000"; - public static readonly global::System.Type OperandType = typeof(int); - } - - public static partial class Category - { - public const string DisplayName = "Category"; - public const string GroupName = "Classification"; - public const bool IsRequired = true; - } - - public static partial class InternalNotes - { - public const string DisplayName = "Internal Notes"; - public const bool IsEditable = false; - public const bool IsScaffoldColumn = false; - } - } -} -``` - ---- - -## 🎨 Real-World Use Cases - -### Use Case 1: Blazor Form Generation - -```razor -@* FormField.razor - Generic form field component *@ -@typeparam TModel - -
- - - - - @if (!string.IsNullOrEmpty(Description)) - { - @Description - } - - -
- -@code { - [Parameter] public string FieldName { get; set; } = string.Empty; - [Parameter] public string DisplayName { get; set; } = string.Empty; - [Parameter] public string? Description { get; set; } - [Parameter] public string? Prompt { get; set; } - [Parameter] public int MaxLength { get; set; } = 255; - [Parameter] public bool Required { get; set; } - [Parameter] public string Value { get; set; } = string.Empty; - [Parameter] public EventCallback ValueChanged { get; set; } -} -``` - -**Usage with generated constants:** - -```razor - -``` - -### Use Case 2: Minimal API Documentation - -```csharp -app.MapPost("/api/products", async (Product product, AppDbContext db) => -{ - db.Products.Add(product); - await db.SaveChangesAsync(); - return Results.Created($"/api/products/{product.Id}", product); -}) -.WithName("CreateProduct") -.WithOpenApi(operation => -{ - operation.Description = "Creates a new product"; - operation.Parameters[0].Description = - $"{AnnotationConstants.Product.Name.DisplayName}: " + - $"{AnnotationConstants.Product.Name.MinimumLength}-{AnnotationConstants.Product.Name.MaximumLength} characters, " + - $"Required: {AnnotationConstants.Product.Name.IsRequired}"; - return operation; -}); -``` - -### Use Case 3: TypeScript Type Generation - -```csharp -// Generate TypeScript interfaces with validation metadata -var tsGenerator = new TypeScriptGenerator(); - -tsGenerator.AddType("Product", new Dictionary -{ - ["name"] = new( - DisplayName: AnnotationConstants.Product.Name.DisplayName, - Required: AnnotationConstants.Product.Name.IsRequired, - MaxLength: AnnotationConstants.Product.Name.MaximumLength, - MinLength: AnnotationConstants.Product.Name.MinimumLength - ), - ["price"] = new( - DisplayName: AnnotationConstants.Product.Price.DisplayName, - Required: AnnotationConstants.Product.Price.IsRequired, - Min: decimal.Parse(AnnotationConstants.Product.Price.Minimum), - Max: decimal.Parse(AnnotationConstants.Product.Price.Maximum) - ), - ["sku"] = new( - DisplayName: AnnotationConstants.Product.Sku.DisplayName, - Pattern: AnnotationConstants.Product.Sku.Pattern - ) -}); - -var typescript = tsGenerator.Generate(); -// Output: -// interface Product { -// name: string; // Product Name, required, 3-100 chars -// price: number; // Price, required, 0.01-999999.99 -// sku?: string; // SKU, pattern: ^[A-Z]{3}-\d{4}$ -// } -``` - ---- - -## 🔗 Related Documentation - -- [Main Documentation](AnnotationConstantsGenerator.md) -- [Feature Roadmap](AnnotationConstantsGenerator-FeatureRoadmap.md) diff --git a/docs/AnnotationConstantsGenerator.md b/docs/AnnotationConstantsGenerator.md deleted file mode 100644 index 5d0741d..0000000 --- a/docs/AnnotationConstantsGenerator.md +++ /dev/null @@ -1,439 +0,0 @@ -# 📝 Annotation Constants Source Generator - -Automatically extract DataAnnotation metadata from class/record properties and generate compile-time accessible constants without reflection. - -**Key Benefits:** - -- 🎯 **Zero reflection** - Access annotation values at compile time -- ⚡ **Native AOT ready** - Works with trimming and ahead-of-time compilation -- 🛡️ **Type-safe constants** - Strongly-typed int, string, and bool values -- 🔍 **Discoverable** - Full IntelliSense support via nested class structure -- 🚀 **Zero configuration** - Works out of the box, no opt-in attribute needed - -**Quick Example:** - -```csharp -// Input: Class with DataAnnotation attributes -public class Product -{ - [Display(Name = "Product Name")] - [Required] - [StringLength(100)] - public string Name { get; set; } = string.Empty; - - [Display(Name = "Price")] - [Range(typeof(decimal), "0.01", "999999.99")] - public decimal Price { get; set; } -} - -// Generated: Compile-time constants -string label = AnnotationConstants.Product.Name.DisplayName; // "Product Name" -int maxLength = AnnotationConstants.Product.Name.MaximumLength; // 100 -bool required = AnnotationConstants.Product.Name.IsRequired; // true - -string minPrice = AnnotationConstants.Product.Price.Minimum; // "0.01" -string maxPrice = AnnotationConstants.Product.Price.Maximum; // "999999.99" -``` - -## 📖 Documentation Navigation - -- **[📋 Feature Roadmap](AnnotationConstantsGenerator-FeatureRoadmap.md)** - See all implemented and planned features -- **[🎯 Sample Projects](AnnotationConstantsGenerator-Samples.md)** - Working code examples - -> **Note:** This generator supports both **Microsoft DataAnnotations** and **Atc attributes**. When the Atc package is referenced, additional validation attributes (IPAddress, Uri, String, KeyString, IsoCurrencySymbol) are also extracted. - -## 📑 Table of Contents - -- [📝 Annotation Constants Source Generator](#-annotation-constants-source-generator) - - [📖 Documentation Navigation](#-documentation-navigation) - - [📑 Table of Contents](#-table-of-contents) - - [📖 Overview](#-overview) - - [😫 Before (Reflection Approach)](#-before-reflection-approach) - - [✨ After (With Source Generator)](#-after-with-source-generator) - - [🚀 Quick Start](#-quick-start) - - [1. Install the Package](#1-install-the-package) - - [2. Add DataAnnotation Attributes](#2-add-dataannotation-attributes) - - [3. Use Generated Constants](#3-use-generated-constants) - - [✨ Features](#-features) - - [📦 Supported Attributes](#-supported-attributes) - - [Display Attributes](#display-attributes) - - [Validation Attributes](#validation-attributes) - - [Data Type Attributes](#data-type-attributes) - - [Metadata Attributes](#metadata-attributes) - - [Atc Attributes (from Atc Package)](#atc-attributes-from-atc-package) - - [💡 Usage Examples](#-usage-examples) - - [Blazor Form Labels](#blazor-form-labels) - - [Client-Side Validation](#client-side-validation) - - [API Documentation](#api-documentation) - - [🔧 Configuration](#-configuration) - - [Include Unannotated Properties](#include-unannotated-properties) - - [🔧 How It Works](#-how-it-works) - - [1. Class Scanning](#1-class-scanning) - - [2. Attribute Extraction](#2-attribute-extraction) - - [3. Code Generation](#3-code-generation) - - [🛡️ Compile-Time Safety](#️-compile-time-safety) - - [🎯 Use Cases](#-use-cases) - -## 📖 Overview - -### 😫 Before (Reflection Approach) - -```csharp -// Using reflection to get annotation values - slow and not AOT-compatible 😫 -var displayAttr = typeof(Product) - .GetProperty(nameof(Product.Name)) - ?.GetCustomAttribute(); - -string? displayName = displayAttr?.Name; // Runtime reflection call - -var stringLengthAttr = typeof(Product) - .GetProperty(nameof(Product.Name)) - ?.GetCustomAttribute(); - -int? maxLength = stringLengthAttr?.MaximumLength; // Another reflection call - -// Problems: -// - Runtime overhead for every access -// - Not compatible with Native AOT -// - No compile-time validation -// - Verbose and repetitive code -``` - -### ✨ After (With Source Generator) - -```csharp -// Using generated constants - zero reflection, AOT-compatible ✨ -string displayName = AnnotationConstants.Product.Name.DisplayName; // Compile-time constant -int maxLength = AnnotationConstants.Product.Name.MaximumLength; // Compile-time constant - -// Benefits: -// - Zero runtime overhead (compile-time constants) -// - Native AOT compatible -// - Full IntelliSense support -// - Type-safe (int for lengths, string for names) -``` - -## 🚀 Quick Start - -### 1. Install the Package - -```bash -dotnet add package Atc.SourceGenerators -``` - -Or in your `.csproj`: - -```xml - - - -``` - -### 2. Add DataAnnotation Attributes - -No special setup needed! Just use standard DataAnnotation attributes on your classes: - -```csharp -using System.ComponentModel.DataAnnotations; - -namespace MyApp.Models; - -public class Customer -{ - [Display(Name = "Customer Name", Description = "Full legal name")] - [Required(ErrorMessage = "Name is required")] - [StringLength(100, MinimumLength = 2)] - public string Name { get; set; } = string.Empty; - - [Display(Name = "Email Address")] - [Required] - [EmailAddress] - public string Email { get; set; } = string.Empty; - - [Display(Name = "Age")] - [Range(18, 120)] - public int Age { get; set; } -} -``` - -### 3. Use Generated Constants - -The generator automatically creates a nested static class structure: - -```csharp -using MyApp.Models; - -// Access constants without reflection -Console.WriteLine($"Field: {AnnotationConstants.Customer.Name.DisplayName}"); // "Customer Name" -Console.WriteLine($"Max: {AnnotationConstants.Customer.Name.MaximumLength}"); // 100 -Console.WriteLine($"Min: {AnnotationConstants.Customer.Name.MinimumLength}"); // 2 -Console.WriteLine($"Required: {AnnotationConstants.Customer.Name.IsRequired}"); // true - -Console.WriteLine($"Email Required: {AnnotationConstants.Customer.Email.IsRequired}"); // true -Console.WriteLine($"Is Email: {AnnotationConstants.Customer.Email.IsEmailAddress}"); // true - -Console.WriteLine($"Age Min: {AnnotationConstants.Customer.Age.Minimum}"); // "18" -Console.WriteLine($"Age Max: {AnnotationConstants.Customer.Age.Maximum}"); // "120" -``` - -## ✨ Features - -- **🔍 Automatic Scanning** - No opt-in attribute needed; scans all classes with DataAnnotation attributes -- **📋 Display Attribute** - DisplayName, Description, ShortName, GroupName, Prompt, Order -- **✅ Validation Attributes** - Required, StringLength, Range, MinLength, MaxLength, RegularExpression -- **📧 Data Type Attributes** - EmailAddress, Phone, Url, CreditCard, DataType -- **🔑 Metadata Attributes** - Key, Editable, ScaffoldColumn, Timestamp, Compare -- **⚙️ Configurable** - Customize behavior via .editorconfig -- **📦 Multi-Assembly** - Works across project references -- **⚡ Zero Runtime Cost** - All constants generated at compile time -- **🚀 Native AOT Ready** - No reflection, fully trimming-safe - -## 📦 Supported Attributes - -### Display Attributes - -| Attribute Property | Generated Constant | Type | -|-----------------------|--------------------|----------| -| `Display.Name` | `DisplayName` | `string` | -| `Display.Description` | `Description` | `string` | -| `Display.ShortName` | `ShortName` | `string` | -| `Display.GroupName` | `GroupName` | `string` | -| `Display.Prompt` | `Prompt` | `string` | -| `Display.Order` | `Order` | `int` | - -### Validation Attributes - -| Attribute | Generated Constants | Types | -|----------|-------------------|-------| -| `Required` | `IsRequired`, `AllowEmptyStrings`, `RequiredErrorMessage` | `bool`, `bool`, `string` | -| `StringLength` | `MinimumLength`, `MaximumLength`, `StringLengthErrorMessage` | `int`, `int`, `string` | -| `Range` | `Minimum`, `Maximum`, `OperandType`, `RangeErrorMessage` | `string`, `string`, `Type`, `string` | -| `MinLength` | `MinimumLength`, `MinLengthErrorMessage` | `int`, `string` | -| `MaxLength` | `MaximumLength`, `MaxLengthErrorMessage` | `int`, `string` | -| `RegularExpression` | `Pattern`, `RegularExpressionErrorMessage` | `string`, `string` | - -### Data Type Attributes - -| Attribute | Generated Constant | Type | -|----------|-------------------|------| -| `EmailAddress` | `IsEmailAddress` | `bool` | -| `Phone` | `IsPhone` | `bool` | -| `Url` | `IsUrl` | `bool` | -| `CreditCard` | `IsCreditCard` | `bool` | -| `DataType` | `DataType` | `int` (enum value) | - -### Metadata Attributes - -| Attribute | Generated Constant | Type | -|----------|-------------------|------| -| `Key` | `IsKey` | `bool` | -| `Editable` | `IsEditable` | `bool` | -| `ScaffoldColumn` | `IsScaffoldColumn` | `bool` | -| `Timestamp` | `IsTimestamp` | `bool` | -| `Compare` | `CompareProperty` | `string` | - -### Atc Attributes (from Atc Package) - -When your project references the [Atc](https://www.nuget.org/packages/Atc/) package, the generator also extracts constants from Atc-specific validation attributes: - -| Attribute | Generated Constants | Types | -|----------|-------------------|-------| -| `IPAddressAttribute` | `IsIPAddress`, `IPAddressRequired` | `bool`, `bool` | -| `IsoCurrencySymbolAttribute` | `IsIsoCurrencySymbol`, `IsoCurrencySymbolRequired`, `AllowedIsoCurrencySymbols` | `bool`, `bool`, `string[]` | -| `StringAttribute` | `IsAtcString`, `AtcStringRequired`, `AtcStringMinLength`, `AtcStringMaxLength`, `AtcStringRegularExpression`, `AtcStringInvalidCharacters`, `AtcStringInvalidPrefixStrings` | `bool`, `bool`, `uint`, `uint`, `string`, `char[]`, `string[]` | -| `KeyStringAttribute` | `IsKeyString` (plus all StringAttribute constants) | `bool` | -| `UriAttribute` | `IsAtcUri`, `AtcUriRequired`, `AtcUriAllowHttp`, `AtcUriAllowHttps`, `AtcUriAllowFtp`, `AtcUriAllowFtps`, `AtcUriAllowFile`, `AtcUriAllowOpcTcp` | all `bool` | -| `IgnoreDisplayAttribute` | `IsIgnoreDisplay` | `bool` | -| `EnumGuidAttribute` | `EnumGuid` | `string` | -| `CasingStyleDescriptionAttribute` | `CasingStyleDefault`, `CasingStylePrefix` | `string`, `string` | - -**Example with Atc attributes:** - -```csharp -// Add Atc package reference -// - -public class NetworkConfig -{ - [Display(Name = "Server IP")] - [IPAddress(Required = true)] - public string ServerAddress { get; set; } = string.Empty; - - [Display(Name = "API Endpoint")] - [UriAttribute(Required = true, AllowHttp = false, AllowHttps = true)] - public string ApiEndpoint { get; set; } = string.Empty; - - [Display(Name = "Currency")] - [IsoCurrencySymbol(IsoCurrencySymbols = new[] { "USD", "EUR", "GBP" })] - public string Currency { get; set; } = "USD"; -} - -// Access Atc constants -bool isIPAddress = AnnotationConstants.NetworkConfig.ServerAddress.IsIPAddress; // true -bool httpsOnly = AnnotationConstants.NetworkConfig.ApiEndpoint.AtcUriAllowHttps; // true -string[] currencies = AnnotationConstants.NetworkConfig.Currency.AllowedIsoCurrencySymbols; // ["USD", "EUR", "GBP"] -``` - -## 💡 Usage Examples - -### Blazor Form Labels - -```razor -@* Use generated constants in Blazor forms *@ - -
- - - @AnnotationConstants.Customer.Name.Description -
-
-``` - -### Client-Side Validation - -```csharp -// Generate JavaScript validation rules from constants -var validationRules = new -{ - name = new - { - required = AnnotationConstants.Customer.Name.IsRequired, - maxLength = AnnotationConstants.Customer.Name.MaximumLength, - minLength = AnnotationConstants.Customer.Name.MinimumLength - }, - email = new - { - required = AnnotationConstants.Customer.Email.IsRequired, - isEmail = AnnotationConstants.Customer.Email.IsEmailAddress - } -}; - -// Serialize and send to JavaScript -var json = JsonSerializer.Serialize(validationRules); -``` - -### API Documentation - -```csharp -// Generate OpenAPI/Swagger documentation from constants -app.MapPost("/customers", (Customer customer) => { }) - .WithDescription($""" - Creates a new customer. - - Name: {AnnotationConstants.Customer.Name.Description} - - Required: {AnnotationConstants.Customer.Name.IsRequired} - - Max Length: {AnnotationConstants.Customer.Name.MaximumLength} - - Email: {AnnotationConstants.Customer.Email.DisplayName} - - Required: {AnnotationConstants.Customer.Email.IsRequired} - """); -``` - -## 🔧 Configuration - -The generator can be configured via `.editorconfig` or MSBuild properties. - -### Include Unannotated Properties - -By default, only properties with at least one DataAnnotation attribute are included. To include all public properties: - -**Option 1: .editorconfig** - -```ini -[*.cs] -atc_annotation_constants.include_unannotated_properties = true -``` - -**Option 2: MSBuild property** - -```xml - - true - -``` - -## 🔧 How It Works - -### 1. Class Scanning - -The generator scans all classes and records in your project that have properties with DataAnnotation attributes. - -```csharp -// This class will be scanned (has [Display] on Name) -public class Product -{ - [Display(Name = "Product Name")] - public string Name { get; set; } = string.Empty; - - public string Sku { get; set; } = string.Empty; // Ignored by default -} - -// This class will NOT be scanned (no DataAnnotation attributes) -public class InternalEntity -{ - public int Id { get; set; } - public string Value { get; set; } = string.Empty; -} -``` - -### 2. Attribute Extraction - -For each property with DataAnnotation attributes, the generator extracts: - -- Display metadata (name, description, etc.) -- Validation rules (required, length constraints, etc.) -- Data type hints (email, phone, etc.) -- Entity metadata (key, editable, etc.) - -### 3. Code Generation - -The generator creates a nested static partial class hierarchy: - -```csharp -// Generated in: AnnotationConstants.MyNamespace.Product.g.cs - -namespace MyNamespace; - -[global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.AnnotationConstants", "1.0.0")] -[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -[global::System.Runtime.CompilerServices.CompilerGenerated] -[global::System.Diagnostics.DebuggerNonUserCode] -[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -public static partial class AnnotationConstants -{ - public static partial class Product - { - public static partial class Name - { - public const string DisplayName = "Product Name"; - public const int MaximumLength = 100; - public const bool IsRequired = true; - } - - public static partial class Price - { - public const string DisplayName = "Price"; - public const string Minimum = "0.01"; - public const string Maximum = "999999.99"; - public static readonly System.Type OperandType = typeof(decimal); - } - } -} -``` - -## 🎯 Use Cases - -1. **Blazor/MAUI Forms** - Generate form labels and validation rules without reflection -2. **API Documentation** - Extract constraint metadata for OpenAPI/Swagger -3. **Client-Side Validation** - Send validation rules to JavaScript/TypeScript -4. **Code Generation** - Use constants in T4 templates or other generators -5. **Testing** - Verify validation rules match expected values -6. **Native AOT Applications** - Access metadata without breaking trimming - ---- - -**See Also:** - -- [Feature Roadmap](AnnotationConstantsGenerator-FeatureRoadmap.md) -- [Sample Projects](AnnotationConstantsGenerator-Samples.md) diff --git a/docs/DependencyRegistrationGenerators-FeatureRoadmap.md b/docs/DependencyRegistrationGenerators-FeatureRoadmap.md deleted file mode 100644 index 00c481d..0000000 --- a/docs/DependencyRegistrationGenerators-FeatureRoadmap.md +++ /dev/null @@ -1,819 +0,0 @@ -# 🎯 Feature Roadmap - Dependency Registration Generator - -This document outlines the feature roadmap for the **DependencyRegistrationGenerator**, based on analysis of popular DI registration libraries and real-world usage patterns. - -## 🔍 Research Sources - -This roadmap is based on comprehensive analysis of: - -1. **Scrutor** - [khellang/Scrutor](https://github.com/khellang/Scrutor) - 4.2k⭐, 273 forks, 11.8k dependent projects - - Runtime assembly scanning and decoration - - Convention-based registration - - Mature ecosystem (MIT license) - -2. **AutoRegisterInject** - [patrickklaeren/AutoRegisterInject](https://github.com/patrickklaeren/AutoRegisterInject) - 119⭐ - - Source generator approach with attributes - - Per-type registration with `[RegisterScoped]`, `[RegisterSingleton]`, etc. - - Multi-assembly support - -3. **Jab** - [pakrym/jab](https://github.com/pakrym/jab) - Compile-time DI container - - 200x faster startup than Microsoft.Extensions.DependencyInjection - - 7x faster resolution - - AOT-friendly, zero reflection - -4. **Microsoft.Extensions.DependencyInjection** - Standard .NET DI abstractions - - Keyed services (.NET 8+) - - `IHostedService` and `BackgroundService` registration - - Factory methods and implementation instances - -### 📊 Key Insights from Community - -**What Users Care About** (from Scrutor's 11.8k dependents): - -- **Convention-based registration** - Reduce boilerplate for large projects -- **Generic interface support** - Handle `IRepository`, `IHandler` -- **Decorator pattern** - Wrap existing registrations without modifying original code -- **Assembly scanning** - Auto-discover services from referenced assemblies -- **Filtering capabilities** - Exclude specific namespaces, types, or patterns -- **Lifetime flexibility** - Different services need different lifetimes - -**Jab's Performance Claims**: - -- Compile-time generation eliminates startup overhead -- Clean stack traces (no reflection noise) -- Registration validation at compile time - -**AutoRegisterInject's Approach**: - -- Decentralized registration (attributes on types, not central config) -- Reduces merge conflicts in team environments -- Assembly-specific extension methods for modular registration - ---- - -## 📊 Current State - -### ✅ DependencyRegistrationGenerator - Implemented Features - -- **Auto-interface detection** - Automatically registers all implemented interfaces (excluding System.*) -- **Explicit interface override** - Use `As` parameter to specify exact interface -- **Register as self** - Use `As = typeof(void)` to register concrete type only -- **Smart naming** - Generate unique extension method names (`AddDependencyRegistrationsFromDomain()`) -- **Transitive registration** - 4 overloads support automatic or selective assembly registration -- **Hosted service detection** - Automatically uses `AddHostedService()` for `BackgroundService` and `IHostedService` -- **Generic interface registration** - Support open generic types like `IRepository`, `IHandler` -- **Keyed service registration** - Multiple implementations with keys (.NET 8+) -- **Factory method registration** - Custom initialization logic via static factory methods -- **Instance registration** - Register pre-created singleton instances via static fields, properties, or methods -- **TryAdd* registration** - Conditional registration for default implementations (library pattern) -- **Decorator pattern support** - Wrap services with cross-cutting concerns (logging, caching, validation) -- **Assembly scanning filters** - Exclude types by namespace, pattern, or interface (supports wildcards) -- **Lifetime support** - Singleton (default), Scoped, Transient -- **Multi-project support** - Assembly-specific extension methods -- **Compile-time validation** - Diagnostics for invalid configurations (ATCDIR001-010) -- **Native AOT compatible** - Zero reflection, compile-time generation - ---- - -## 📋 Feature Status Overview - -| Status | Feature | Priority | -|:------:|---------|----------| -| ✅ | [Generic Interface Registration](#1-generic-interface-registration) | 🔴 Critical | -| ✅ | [Keyed Service Registration](#2-keyed-service-registration) | 🔴 High | -| ✅ | [Factory Method Registration](#3-factory-method-registration) | 🔴 High | -| ✅ | [TryAdd* Registration](#4-tryadd-registration) | 🟡 Medium | -| ✅ | [Assembly Scanning Filters](#5-assembly-scanning-filters) | 🟡 Medium | -| ✅ | [Decorator Pattern Support](#6-decorator-pattern-support) | 🟢 Low-Medium | -| ✅ | [Implementation Instance Registration](#7-implementation-instance-registration) | 🟢 Low-Medium | -| ✅ | [Conditional Registration](#8-conditional-registration) | 🟢 Low-Medium | -| ❌ | [Auto-Discovery by Convention](#9-auto-discovery-by-convention) | 🟢 Low | -| ❌ | [Registration Validation Diagnostics](#10-registration-validation-diagnostics) | 🟢 Low | -| ⚠️ | [Multi-Interface Registration](#11-multi-interface-registration-enhanced) | 🟢 Low | -| 🚫 | [Runtime Assembly Scanning](#12-runtime-assembly-scanning) | - | -| 🚫 | [Property/Field Injection](#13-propertyfield-injection) | - | -| 🚫 | [Auto-Wiring Based on Reflection](#14-auto-wiring-based-on-reflection) | - | -| 🚫 | [Service Replacement/Override at Runtime](#15-service-replacementoverride-at-runtime) | - | - -**Legend:** - -- ✅ **Implemented** - Feature is complete and available -- ⚠️ **Partially Implemented** - Some aspects are available, others are in progress -- ❌ **Not Implemented** - Feature is planned but not yet developed -- 🚫 **Not Planned** - Feature is out of scope or not aligned with project goals - ---- - -## 🎯 Need to Have (High Priority) - -These features are essential based on Scrutor's popularity and real-world DI patterns. - -### 1. Generic Interface Registration - -**Priority**: 🔴 **Critical** -**Status**: ✅ **Implemented** (v1.1) -**Inspiration**: Scrutor's generic type support - -**Description**: Support registering services that implement open generic interfaces like `IRepository`, `IHandler`. - -**User Story**: -> "As a developer using the repository pattern, I want to register `IRepository` implementations without manually registering each entity type." - -**Example**: - -```csharp -// Generic interface -public interface IRepository where T : class -{ - Task GetByIdAsync(int id); - Task SaveAsync(T entity); -} - -// Generic implementation -[Registration(Lifetime = Lifetime.Scoped)] -public class Repository : IRepository where T : class -{ - private readonly DbContext _context; - - public Repository(DbContext context) => _context = context; - - public Task GetByIdAsync(int id) => _context.Set().FindAsync(id).AsTask(); - public Task SaveAsync(T entity) { /* ... */ } -} - -// Generated code should register open generic: -services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); -``` - -**Implementation Notes**: - -- ✅ Detects when service implements open generic interface -- ✅ Generates `typeof(IInterface<>)` and `typeof(Implementation<>)` syntax -- ✅ Validates generic constraints match between interface and implementation -- ✅ Supports multiple generic parameters (`IHandler`) -- ✅ Works with explicit `As` parameter and auto-detection -- ✅ Supports constraints (where T : class, IEntity, new()) - ---- - -### 2. Keyed Service Registration - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** (v1.1) -**Inspiration**: .NET 8+ keyed services, Scrutor's named registrations - -**Description**: Support keyed service registration for multiple implementations of the same interface. - -**User Story**: -> "As a developer, I want to register multiple implementations of `IPaymentProcessor` (Stripe, PayPal, Square) and resolve them by key." - -**Example**: - -```csharp -[Registration(As = typeof(IPaymentProcessor), Key = "Stripe")] -public class StripePaymentProcessor : IPaymentProcessor -{ - public Task ProcessPaymentAsync(decimal amount) { /* ... */ } -} - -[Registration(As = typeof(IPaymentProcessor), Key = "PayPal")] -public class PayPalPaymentProcessor : IPaymentProcessor -{ - public Task ProcessPaymentAsync(decimal amount) { /* ... */ } -} - -// Generated code: -services.AddKeyedScoped("Stripe"); -services.AddKeyedScoped("PayPal"); - -// Usage: -public class CheckoutService -{ - public CheckoutService([FromKeyedServices("Stripe")] IPaymentProcessor processor) - { - // ... - } -} -``` - -**Implementation Notes**: - -- ✅ Added `Key` parameter to `[Registration]` attribute -- ✅ Generates `AddKeyed{Lifetime}()` calls -- ✅ Supports both string and type keys -- ✅ Works with generic types (AddKeyedScoped(typeof(IRepository<>), "Key", typeof(Repository<>))) - ---- - -### 3. Factory Method Registration - -**Priority**: 🟡 **Medium-High** -**Status**: ✅ **Implemented** (v1.1) -**Inspiration**: Microsoft.Extensions.DependencyInjection factories, Jab's custom instantiation - -**Description**: Support registering services via factory methods for complex initialization logic. - -**User Story**: -> "As a developer, I want to register services that require custom initialization logic (like reading configuration, conditional setup, etc.) without creating intermediate builder classes." - -**Example**: - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IEmailSender), Factory = nameof(CreateEmailSender))] -public class EmailSender : IEmailSender -{ - private readonly string apiKey; - - private EmailSender(string apiKey) => this.apiKey = apiKey; - - // Factory method signature: static T Create(IServiceProvider provider) - public static IEmailSender CreateEmailSender(IServiceProvider provider) - { - var config = provider.GetRequiredService(); - var apiKey = config["EmailSettings:ApiKey"] ?? throw new InvalidOperationException(); - return new EmailSender(apiKey); - } - - public Task SendAsync(string to, string subject, string body) { /* ... */ } -} - -// Generated code: -services.AddScoped(sp => EmailSender.CreateEmailSender(sp)); -``` - -**Implementation Notes**: - -- ✅ Added `Factory` property to `[Registration]` attribute -- ✅ Factory method must be `static` and return the service type (interface or class) -- ✅ Factory method must accept `IServiceProvider` as single parameter -- ✅ Validates factory method signature at compile time -- ✅ Generates factory delegate registration: `services.Add{Lifetime}(sp => Class.Factory(sp))` -- ✅ Works with all lifetimes (Singleton, Scoped, Transient) -- ✅ Supports registering as interface, as self, or multiple interfaces -- ✅ Diagnostics: ATCDIR005 (factory method not found), ATCDIR006 (invalid signature) - ---- - -### 4. TryAdd* Registration - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** (v1.2) -**Inspiration**: Scrutor's TryAdd support, AutoRegisterInject's "Try" variants - -**Description**: Support conditional registration that only adds services if not already registered. - -**User Story**: -> "As a library author, I want to register default implementations that can be overridden by application code." - -**Example**: - -```csharp -[Registration(As = typeof(ILogger), TryAdd = true)] -public class DefaultLogger : ILogger -{ - public void Log(string message) => Console.WriteLine(message); -} - -// Generated code: -services.TryAddScoped(); - -// User can override: -services.AddScoped(); // This wins -``` - -**Implementation Notes**: - -- ✅ Added `TryAdd` boolean parameter to `[Registration]` attribute -- ✅ Generates `TryAdd{Lifetime}()` calls instead of `Add{Lifetime}()` -- ✅ Works with factory methods: `services.TryAddScoped(sp => Factory(sp))` -- ✅ Supports all lifetimes: TryAddSingleton, TryAddScoped, TryAddTransient -- ✅ Works with generic types: `services.TryAddScoped(typeof(IRepository<>), typeof(Repository<>))` -- ✅ Compatible with AsSelf and multiple interface registrations -- ⚠️ Note: Keyed services take precedence (no TryAdd support for keyed registrations) -- ✅ Requires `using Microsoft.Extensions.DependencyInjection.Extensions;` (automatically added to generated code) - ---- - -### 5. Assembly Scanning Filters - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** (v1.2) -**Inspiration**: Scrutor's filtering capabilities - -**Description**: Provide filtering options to exclude specific types, namespaces, or patterns from transitive registration. - -**User Story**: -> "As a developer, I want to exclude internal services or test utilities from automatic registration when using `includeReferencedAssemblies: true`." - -**Example**: - -```csharp -// Option A: Exclude specific namespace (supports arrays) -[assembly: RegistrationFilter(ExcludeNamespaces = new[] { "MyApp.Internal", "MyApp.Tests" })] - -// Option B: Exclude by naming pattern (supports wildcards) -[assembly: RegistrationFilter(ExcludePatterns = new[] { "*Test*", "*Mock*" })] - -// Option C: Exclude types implementing specific interface -[assembly: RegistrationFilter(ExcludeImplementing = new[] { typeof(ITestUtility) })] - -// Option D: Multiple filters in one attribute -[assembly: RegistrationFilter( - ExcludeNamespaces = new[] { "MyApp.Internal" }, - ExcludePatterns = new[] { "*Test*", "*Mock*" })] - -// Generated code only includes non-excluded types -``` - -**Implementation Notes**: - -- ✅ Assembly-level `RegistrationFilterAttribute` with `AllowMultiple = true` -- ✅ Support namespace exclusion (exact match + sub-namespaces) -- ✅ Support wildcard patterns: `*` (any characters), `?` (single character) -- ✅ Support interface exclusion with proper generic type comparison -- ✅ Multiple filter attributes can be applied -- ✅ Filters applied to both current assembly and referenced assemblies -- ✅ All properties accept arrays for multiple values -- ✅ Pattern matching is case-insensitive -- ✅ Sub-namespace matching: "MyApp.Internal" excludes "MyApp.Internal.Deep" - ---- - -### 6. Decorator Pattern Support - -**Priority**: 🟢 **Low-Medium** ⭐ *Highly valued by Scrutor users* -**Status**: ✅ **Implemented** (v1.3 - January 2025) -**Inspiration**: Scrutor's `Decorate()` method - -**Description**: Support decorating already-registered services with additional functionality (logging, caching, validation, etc.). - -**User Story**: -> "As a developer, I want to add cross-cutting concerns (logging, caching, retry logic) to services without modifying the original implementation." - -**Example**: - -```csharp -// Original service -[Registration(As = typeof(IOrderService))] -public class OrderService : IOrderService -{ - public Task PlaceOrderAsync(Order order) { /* ... */ } -} - -// Decorator (wraps original) -[Registration(As = typeof(IOrderService), Decorator = true)] -public class LoggingOrderServiceDecorator : IOrderService -{ - private readonly IOrderService _inner; - private readonly ILogger _logger; - - public LoggingOrderServiceDecorator(IOrderService inner, ILogger logger) - { - _inner = inner; - _logger = logger; - } - - public async Task PlaceOrderAsync(Order order) - { - _logger.LogInformation("Placing order {OrderId}", order.Id); - await _inner.PlaceOrderAsync(order); - _logger.LogInformation("Order {OrderId} placed successfully", order.Id); - } -} - -// Generated code: -services.AddScoped(); -services.Decorate(); // Wraps existing registration -``` - -**Implementation Notes**: - -- ✅ Added `Decorator` boolean parameter to `[Registration]` attribute -- ✅ Decorators must specify explicit `As` parameter (interface being decorated) -- ✅ Decorator registration automatically comes after base service registration -- ✅ Decorator constructor must accept the interface as first parameter -- ✅ Supports multiple decorators (chaining) - applied in discovery order -- ✅ Generates `Decorate()` extension methods (both generic and non-generic for open generics) -- ✅ Uses `ActivatorUtilities.CreateInstance()` to properly inject inner service -- ✅ Preserves service lifetime from original registration -- ✅ Works with Singleton, Scoped, and Transient lifetimes -- ✅ Complete test coverage with 7 unit tests -- ✅ Documented in comprehensive decorator pattern section of docs - ---- - -### 7. Implementation Instance Registration - -**Priority**: 🟢 **Low** -**Status**: ✅ **Implemented** (v1.4 - January 2025) - -**Description**: Register pre-created singleton instances via static fields, properties, or methods. - -**User Story**: -> "As a developer, I want to register pre-configured singleton instances (like immutable configuration objects) without requiring factory methods or runtime initialization." - -**Example**: - -```csharp -[Registration(As = typeof(IConfiguration), Instance = nameof(DefaultInstance))] -public class AppConfiguration : IConfiguration -{ - // Static field providing pre-created instance - public static readonly AppConfiguration DefaultInstance = new() - { - Setting1 = "default", - Setting2 = 42 - }; - - private AppConfiguration() { } // Private constructor enforces singleton - - public string Setting1 { get; init; } = string.Empty; - public int Setting2 { get; init; } -} - -// Generated code: -services.AddSingleton(AppConfiguration.DefaultInstance); -``` - -**Alternative patterns supported**: - -```csharp -// Static property -[Registration(As = typeof(ICache), Instance = nameof(Instance))] -public class MemoryCache : ICache -{ - public static MemoryCache Instance { get; } = new(); -} - -// Static method -[Registration(As = typeof(ILogger), Instance = nameof(GetDefault))] -public class DefaultLogger : ILogger -{ - public static DefaultLogger GetDefault() => new(); -} -``` - -**Implementation Notes**: - -- ✅ Added `Instance` property to `[Registration]` attribute -- ✅ Supports static fields, properties, and parameterless methods -- ✅ Generates `services.AddSingleton(ClassName.MemberName)` or `services.AddSingleton(ClassName.Method())` -- ✅ **Constraint**: Instance registration requires Singleton lifetime (enforced at compile-time) -- ✅ **Constraint**: Instance and Factory parameters are mutually exclusive -- ✅ Validates member exists and is static at compile-time -- ✅ Diagnostics: ATCDIR007 (member not found), ATCDIR008 (not static), ATCDIR009 (mutually exclusive), ATCDIR010 (requires Singleton) -- ✅ Works with TryAdd: `services.TryAddSingleton(ClassName.Instance)` -- ✅ Complete test coverage with 8 unit tests -- ✅ Demonstrated in both DependencyRegistration and PetStore samples - ---- - -## 💡 Nice to Have (Medium Priority) - -These features would improve usability but are not critical for initial adoption. - -### 8. Conditional Registration - -**Priority**: 🟢 **Low-Medium** -**Status**: ✅ **Implemented** (v1.5 - January 2025) - -**Description**: Register services based on configuration values at runtime (feature flags, environment-specific services). - -**User Story**: -> "As a developer, I want to register different service implementations based on configuration values (feature flags) without code changes or redeployment." - -**Example**: - -```csharp -// appsettings.json -{ - "Features": { - "UseRedisCache": true - } -} - -[Registration(As = typeof(ICache), Condition = "Features:UseRedisCache")] -public class RedisCache : ICache { } - -[Registration(As = typeof(ICache), Condition = "!Features:UseRedisCache")] -public class MemoryCache : ICache { } - -// Generated code checks configuration at runtime -public static IServiceCollection AddDependencyRegistrationsFromDomain( - this IServiceCollection services, - IConfiguration configuration) // ← IConfiguration parameter added automatically -{ - if (configuration.GetValue("Features:UseRedisCache")) - { - services.AddSingleton(); - } - - if (!configuration.GetValue("Features:UseRedisCache")) - { - services.AddSingleton(); - } - - return services; -} - -// Usage -services.AddDependencyRegistrationsFromDomain(configuration); -``` - -**Implementation Notes**: - -- ✅ Added `Condition` property to `[Registration]` attribute -- ✅ Supports negation with `!` prefix -- ✅ IConfiguration parameter automatically added to all method overloads when conditional services exist -- ✅ Generates `if (configuration.GetValue("key"))` checks wrapping registration calls -- ✅ Configuration is NOT passed transitively to referenced assemblies (each assembly manages its own) -- ✅ Works with all lifetimes (Singleton, Scoped, Transient) -- ✅ Fully Native AOT compatible (simple boolean reads from configuration) -- ✅ Thread-safe configuration reading -- ✅ Complete test coverage with 6 unit tests -- ✅ Demonstrated in both DependencyRegistration and PetStore samples -- ✅ Comprehensive documentation in Conditional Registration section - -**Benefits**: - -- 🎯 Feature Flags - Enable/disable features without redeployment -- 🌍 Environment-Specific - Different implementations for dev/staging/prod -- 🧪 A/B Testing - Easy experimentation with different implementations -- 💰 Cost Optimization - Disable expensive services when not needed -- 🚀 Gradual Rollout - Safely test new implementations before full deployment - ---- - -### 9. Auto-Discovery by Convention - -**Priority**: 🟢 **Low-Medium** -**Status**: ❌ Not Implemented -**Inspiration**: Scrutor's convention-based scanning - -**Description**: Automatically register types based on naming conventions without requiring attributes. - -**Example**: - -```csharp -// Convention: Classes ending with "Service" implement I{ClassName} -public class UserService : IUserService { } // Auto-registered -public class OrderService : IOrderService { } // Auto-registered - -// Generated code discovers these by convention -``` - -**Considerations**: - -- Conflicts with our explicit opt-in philosophy -- May lead to unexpected registrations -- Consider as opt-in feature with assembly-level attribute - ---- - -### 10. Registration Validation Diagnostics - -**Priority**: 🟡 **Medium** -**Status**: ❌ Not Implemented - -**Description**: Provide compile-time diagnostics for common DI mistakes. - -**Examples**: - -- Warning if service has no public constructor -- Warning if constructor parameters cannot be resolved (missing registrations) -- Warning if circular dependencies detected -- Error if hosted service is not registered as Singleton - -**Implementation**: - -- Analyze constructor parameters -- Build dependency graph -- Detect cycles and missing dependencies - ---- - -### 11. Multi-Interface Registration (Enhanced) - -**Priority**: 🟢 **Low** -**Status**: ⚠️ Partially Implemented (auto-detects all interfaces) - -**Description**: Allow explicit control over which interfaces to register when a class implements multiple interfaces. - -**Current behavior**: Registers ALL implemented interfaces (excluding System.*) - -**Enhancement**: Allow selective registration of specific interfaces - -**Example**: - -```csharp -// Register only specific interfaces -[Registration(As = new[] { typeof(IUserService), typeof(IEmailService) })] -public class UserService : IUserService, IEmailService, IAuditLogger -{ - // Only IUserService and IEmailService are registered, not IAuditLogger -} -``` - ---- - -## ⛔ Do Not Need (Low Priority / Out of Scope) - -These features either conflict with design principles or are too complex. - -### 12. Runtime Assembly Scanning - -**Reason**: Conflicts with compile-time source generation philosophy. Scrutor already handles this well for runtime scenarios. - -**Status**: ❌ Out of Scope - ---- - -### 13. Property/Field Injection - -**Reason**: Constructor injection is the recommended pattern. Property injection is an anti-pattern that hides dependencies. - -**Status**: ❌ Not Planned - ---- - -### 14. Auto-Wiring Based on Reflection - -**Reason**: Breaks AOT compatibility. Defeats the purpose of compile-time generation. - -**Status**: ❌ Out of Scope - ---- - -### 15. Service Replacement/Override at Runtime - -**Reason**: DI container should be immutable after configuration. Runtime replacement is fragile. - -**Status**: ❌ Not Planned - ---- - -## 📅 Proposed Implementation Order - -Based on priority, user demand, and implementation complexity: - -### Phase 1: Essential Features (v1.1 - Q1 2025) ✅ COMPLETED - -**Goal**: Support advanced DI patterns (generics, keyed services, factory methods) - -1. ✅ **Generic Interface Registration** 🔴 Critical - `IRepository`, `IHandler` -2. ✅ **Keyed Service Registration** 🔴 High - Multiple implementations with keys (.NET 8+) -3. ✅ **Factory Method Registration** 🟡 Medium-High - Custom initialization logic - -**Status**: ✅ COMPLETED (January 2025) -**Impact**: Unlock repository pattern, multi-tenant scenarios, plugin architectures, complex initialization - ---- - -### Phase 2: Flexibility & Control (v1.2 - Q1 2025) ✅ COMPLETED - -**Goal**: Conditional registration and filtering - -1. ✅ **TryAdd* Registration** 🟡 Medium - Conditional registration for library scenarios -2. ✅ **Assembly Scanning Filters** 🟡 Medium - Exclude namespaces/patterns from transitive registration - -**Status**: ✅ COMPLETED (January 2025) -**Impact**: Better control over transitive registration, library author support - ---- - -### Phase 2.5: Advanced Patterns (v1.3 - Q1 2025) ✅ COMPLETED - -**Goal**: Decorator pattern for cross-cutting concerns - -1. ✅ **Decorator Pattern Support** 🟢 Low-Medium ⭐ - Wrap services with logging, caching, validation - -**Status**: ✅ COMPLETED (January 2025) -**Impact**: Enterprise-grade cross-cutting concerns without code modification - ---- - -### Phase 3: Advanced Scenarios (v1.4 - Q1 2025) ✅ COMPLETED - -**Goal**: Instance registration for pre-created singletons - -1. ✅ **Implementation Instance Registration** 🟢 Low - Pre-created singleton instances - -**Status**: ✅ COMPLETED (January 2025) -**Impact**: Support immutable configuration objects and pre-initialized singletons - ---- - -### Phase 4: Advanced Scenarios (v1.5 - Q2 2025) - -**Goal**: Validation and diagnostics - -1. **Multi-Interface Registration** 🟢 Low - Selective interface registration -2. **Registration Validation Diagnostics** 🟡 Medium - Compile-time warnings for missing dependencies -3. **Conditional Registration** 🟢 Low-Medium - Feature flag-based registration - -**Estimated effort**: 3-4 weeks -**Impact**: Catch DI mistakes at compile time, support feature toggles - ---- - -### Phase 5: Enterprise Features (v2.0 - Q3 2025) - -**Goal**: Convention-based patterns - -1. **Auto-Discovery by Convention** 🟢 Low-Medium - Optional convention-based registration - -**Estimated effort**: 2-3 weeks -**Impact**: Reduce boilerplate further with conventions - ---- - -### Feature Prioritization Matrix - -| Feature | Priority | User Demand | Complexity | Phase | Status | -|---------|----------|-------------|------------|-------|--------| -| Generic Interface Registration | 🔴 Critical | ⭐⭐⭐ | High | 1.1 | ✅ Done | -| Keyed Service Registration | 🔴 High | ⭐⭐⭐ | Medium | 1.1 | ✅ Done | -| Factory Method Registration | 🟡 Med-High | ⭐⭐ | Medium | 1.1 | ✅ Done | -| TryAdd* Registration | 🟡 Medium | ⭐⭐ | Low | 1.2 | ✅ Done | -| Assembly Scanning Filters | 🟡 Medium | ⭐⭐ | Medium | 1.2 | ✅ Done | -| Decorator Pattern | 🟢 Low-Med | ⭐⭐⭐ | Very High | 1.3 | ✅ Done | -| Implementation Instance Registration | 🟢 Low | ⭐ | Medium | 1.4 | ✅ Done | -| Multi-Interface Registration | 🟢 Low | ⭐ | Low | 1.5 | 📋 Planned | -| Registration Validation | 🟡 Medium | ⭐⭐ | High | 1.5 | 📋 Planned | -| Conditional Registration | 🟢 Low-Med | ⭐ | Medium | 1.5 | 📋 Planned | -| Convention-Based Discovery | 🟢 Low-Med | ⭐⭐ | Medium | 2.0 | 📋 Planned | - ---- - -## 🎯 Success Metrics - -To determine if these features are meeting user needs: - -1. **Adoption Rate** - NuGet download statistics -2. **GitHub Issues** - Track feature requests and pain points -3. **Performance Benchmarks** - Compare startup time vs. Scrutor/runtime registration -4. **Community Feedback** - Surveys, blog posts, conference talks -5. **Real-World Usage** - Case studies from production applications - ---- - -## 📝 Notes - -### Design Philosophy - -- **Guiding Principle**: **Explicit opt-in**, **compile-time safety**, **AOT-compatible** -- **Trade-offs**: We prefer attribute-based registration over convention-based to maintain predictability -- **Scrutor vs. Our Approach**: Scrutor is runtime-based (assembly scanning), we are compile-time (source generation). Both have their place. -- **Performance Focus**: Like Jab, we eliminate reflection overhead by generating code at compile time - -### Key Differences from Scrutor - -**What we do differently**: - -1. **Compile-time generation** - Zero startup overhead vs. Scrutor's runtime scanning -2. **Per-type attributes** - Explicit `[Registration]` on each service vs. assembly-wide conventions -3. **Transitive registration** - Our 4-overload approach vs. Scrutor's fluent API -4. **AOT-friendly** - Native AOT compatible out of the box - -**What we learn from Scrutor**: - -- ⭐ **Generic interface support** is critical for repository/handler patterns -- ⭐ **Decorator pattern** is highly valued (cross-cutting concerns) -- ⭐ **Filtering capabilities** prevent unintended registrations in large codebases -- ⚠️ Runtime flexibility (Scrutor's strength) is less important for our compile-time approach - -### Lessons from AutoRegisterInject - -**From 119 stars and attribute-based approach**: - -- **Decentralized registration** reduces merge conflicts in teams -- **Assembly-specific extension methods** enable modular registration -- **TryAdd variants** are important for library authors -- **Keyed services** support multi-tenant scenarios (.NET 8+) - -### Lessons from Jab - -**From performance-focused DI container**: - -- **Compile-time validation** catches errors before runtime -- **Readable generated code** builds developer trust -- **Zero reflection** is essential for AOT and startup performance -- **Clean stack traces** improve debugging experience - ---- - -## 🔗 Related Resources - -- **Scrutor**: (4.2k⭐, runtime scanning) -- **AutoRegisterInject**: (119⭐, attribute-based) -- **Jab**: (compile-time DI container) -- **Microsoft.Extensions.DependencyInjection**: -- **Our Documentation**: See `/docs/generators/DependencyRegistration.md` -- **Sample Projects**: See `/sample/PetStore.Api` for complete example - ---- - -**Last Updated**: 2025-01-17 (Implementation Instance Registration completed) -**Version**: 1.4 -**Research Date**: January 2025 (Scrutor v6.1.0) -**Maintained By**: Atc.SourceGenerators Team diff --git a/docs/DependencyRegistrationGenerators-Samples.md b/docs/DependencyRegistrationGenerators-Samples.md deleted file mode 100644 index 7aa40b7..0000000 --- a/docs/DependencyRegistrationGenerators-Samples.md +++ /dev/null @@ -1,235 +0,0 @@ -# Dependency Registration Sample - -## 🎯 Focus - -This sample demonstrates the **DependencyRegistrationGenerator** in a multi-project setup with layered architecture. The focus is on: - -- **Zero boilerplate DI registration** across multiple projects -- **Auto-detection** of implemented interfaces -- **Multi-interface support** for services implementing multiple contracts -- **Assembly-specific extension methods** for clean project separation -- **Lifetime management** (Singleton, Scoped, Transient) - -## 📁 Sample Projects - -- **Atc.SourceGenerators.DependencyRegistration** - Console application (entry point) -- **Atc.SourceGenerators.DependencyRegistration.Domain** - Domain layer with services - -## 🏗️ Architecture - -```mermaid -graph TB - subgraph "Atc.SourceGenerators.DependencyRegistration (Console App)" - Program[Program.cs] - SC[ServiceCollection] - end - - subgraph "Atc.SourceGenerators.DependencyRegistration.Domain" - US[UserService] - CS[CacheService] - ES[EmailService] - IUS[IUserService] - ICS[ICacheService] - IES[IEmailService] - INS[INotificationService] - - US -->|implements| IUS - CS -->|implements| ICS - ES -->|implements| IES - ES -->|implements| INS - end - - subgraph "Generated Code" - EXT1["AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistration()"] - EXT2["AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistrationDomain()"] - end - - Program --> SC - SC --> EXT1 - SC --> EXT2 - EXT1 -->|registers| Program - EXT2 -->|registers| US - EXT2 -->|registers| CS - EXT2 -->|registers| ES - - style US fill:#0969da - style CS fill:#0969da - style ES fill:#0969da - style EXT1 fill:#2ea44f - style EXT2 fill:#2ea44f -``` - -## 🔄 Registration Flow - -```mermaid -sequenceDiagram - participant App as Program.cs - participant Gen as Source Generator - participant DI as ServiceCollection - - Note over Gen: Build Time - Gen->>Gen: Scan for [Registration] attributes - Gen->>Gen: Detect implemented interfaces - Gen->>Gen: Generate AddDependencyRegistrationsFromXXX() - - Note over App,DI: Runtime - App->>DI: AddDependencyRegistrationsFromDomain() - DI->>DI: services.AddScoped() - DI->>DI: services.AddSingleton() - DI->>DI: services.AddScoped() - DI->>DI: services.AddScoped() - DI-->>App: Configured ServiceProvider -``` - -## 💻 Code Example - -### Domain Layer Services - -```csharp -using Atc.DependencyInjection; - -namespace Atc.SourceGenerators.DependencyRegistration.Domain; - -// Auto-detected as IUserService -[Registration(Lifetime.Scoped)] -public class UserService : IUserService -{ - public void CreateUser(string name) - { - Console.WriteLine($"Creating user: {name}"); - } -} - -// Auto-detected as ICacheService -[Registration] // Defaults to Singleton -public class CacheService : ICacheService -{ - public void Set(string key, object value) - { - Console.WriteLine($"Caching {key}"); - } -} - -// Multi-interface example - registers against BOTH interfaces -[Registration(Lifetime.Scoped)] -public class EmailService : IEmailService, INotificationService -{ - public void SendEmail(string to, string subject) - { - Console.WriteLine($"Email to {to}: {subject}"); - } - - public void Notify(string message) - { - Console.WriteLine($"Notification: {message}"); - } -} -``` - -### Application Setup - -**Option 1 - Manual Registration:** - -```csharp -using Atc.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; - -var services = new ServiceCollection(); - -// One line per project - that's it! -services.AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistration(); -services.AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistrationDomain(); - -var provider = services.BuildServiceProvider(); - -// Resolve and use services -var userService = provider.GetRequiredService(); -userService.CreateUser("John Doe"); - -var emailService = provider.GetRequiredService(); -emailService.SendEmail("john@example.com", "Welcome"); - -// Same instance via second interface! -var notifier = provider.GetRequiredService(); -notifier.Notify("System is ready"); -``` - -**Option 2 - Transitive Registration (Recommended):** - -```csharp -using Atc.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; - -var services = new ServiceCollection(); - -// Single call automatically registers both projects! -services.AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistration(includeReferencedAssemblies: true); - -var provider = services.BuildServiceProvider(); - -// All services available, including from Domain layer -var userService = provider.GetRequiredService(); -userService.CreateUser("John Doe"); - -var emailService = provider.GetRequiredService(); -emailService.SendEmail("john@example.com", "Welcome"); - -// Same instance via second interface! -var notifier = provider.GetRequiredService(); -notifier.Notify("System is ready"); -``` - -**All Available Overloads:** - -```csharp -// Overload 1: Default (no transitive registration) -services.AddDependencyRegistrationsFromYourProject(); - -// Overload 2: Auto-detect ALL referenced assemblies recursively -services.AddDependencyRegistrationsFromYourProject(includeReferencedAssemblies: true); - -// Overload 3: Register specific referenced assembly (short or full name) -services.AddDependencyRegistrationsFromYourProject("Domain"); -services.AddDependencyRegistrationsFromYourProject("MyApp.Domain"); - -// Overload 4: Register multiple specific assemblies -services.AddDependencyRegistrationsFromYourProject("Domain", "DataAccess", "Infrastructure"); -``` - -## 📝 Generated Code - -The generator creates this extension method in each project: - -```csharp -// -namespace Atc.DependencyInjection; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddDependencyRegistrationsFromAtcSourceGeneratorsDependencyRegistrationDomain( - this IServiceCollection services) - { - services.AddScoped(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - - return services; - } -} -``` - -## ✨ Key Takeaways - -1. **Zero Configuration**: Services are automatically registered against all implemented interfaces -2. **Type Safety**: Compile-time errors if interfaces don't match or lifetimes conflict -3. **Multi-Project**: Each project gets its own `AddDependencyRegistrationsFromXXX()` method -4. **Multi-Interface**: One service can be registered against multiple interfaces automatically -5. **Smart Filtering**: System interfaces (IDisposable, etc.) are automatically excluded - -## 🔗 Related Documentation - -- [DependencyRegistration Generator Guide](DependencyRegistrationGenerators.md) - Full generator documentation -- [Mapping Sample](ObjectMappingGenerators-Samples.md) - Object mapping example -- [OptionsBinding Sample](OptionsBinding-Samples.md) - Configuration binding example -- [PetStore API Sample](PetStoreApi-Samples.md) - Complete application using all generators diff --git a/docs/DependencyRegistrationGenerators.md b/docs/DependencyRegistrationGenerators.md deleted file mode 100644 index a83bca9..0000000 --- a/docs/DependencyRegistrationGenerators.md +++ /dev/null @@ -1,3184 +0,0 @@ -# 🎯 Dependency Registration Generator - -Automatically register services in the dependency injection container using attributes instead of manual registration code. The generator creates type-safe registration code at compile time, eliminating boilerplate and reducing errors. - -**Key Benefits:** - -- 🎯 **Zero boilerplate** - Attribute-based registration eliminates manual `AddScoped()` calls -- 🚀 **Compile-time safety** - Catch registration errors at build time, not runtime -- ⚡ **Auto-detection** - Automatically registers all implemented interfaces -- 🔧 **Multi-project support** - Smart naming for assembly-specific registration methods -- 🎨 **Advanced patterns** - Generics, keyed services, factories, decorators, and more - -**Quick Example:** - -```csharp -// Input: Attribute decoration -[Registration(Lifetime.Scoped)] -public class UserService : IUserService { } - -// Generated output -services.AddScoped(); -``` - -## 📖 Documentation Navigation - -- **[📋 Feature Roadmap](DependencyRegistrationGenerators-FeatureRoadmap.md)** - See all implemented and planned features -- **[🎯 Sample Projects](DependencyRegistrationGenerators-Samples.md)** - Working code examples with architecture diagrams - -## 📑 Table of Contents - -- [🎯 Dependency Registration Generator](#-dependency-registration-generator) - - [� Documentation Navigation](#-documentation-navigation) - - [📑 Table of Contents](#-table-of-contents) - - [🚀 Get Started - Quick Guide](#-get-started---quick-guide) - - [📂 Project Structure](#-project-structure) - - [1️⃣ Setup Projects](#1️⃣-setup-projects) - - [2️⃣ Data Access Layer (PetStore.DataAccess) 💾](#2️⃣-data-access-layer-petstoredataaccess-) - - [3️⃣ Domain Layer (PetStore.Domain) 🧠](#3️⃣-domain-layer-petstoredomain-) - - [4️⃣ API Layer (PetStore.Api) 🌐](#4️⃣-api-layer-petstoreapi-) - - [5️⃣ Program.cs (Minimal API Setup) ⚡](#5️⃣-programcs-minimal-api-setup-) - - [🎨 What Gets Generated](#-what-gets-generated) - - [6️⃣ Testing the Application 🧪](#6️⃣-testing-the-application-) - - [🔍 Viewing Generated Code (Optional)](#-viewing-generated-code-optional) - - [🎯 Key Takeaways](#-key-takeaways) - - [📦 Installation](#-installation) - - [💡 Basic Usage](#-basic-usage) - - [1️⃣ Add Using Directives](#1️⃣-add-using-directives) - - [2️⃣ Decorate Your Services](#2️⃣-decorate-your-services) - - [3️⃣ Register in DI Container](#3️⃣-register-in-di-container) - - [✨ Features](#-features) - - [🏗️ Multi-Project Setup](#️-multi-project-setup) - - [📁 Example Structure](#-example-structure) - - [⚡ Program.cs Registration](#-programcs-registration) - - [🔄 Transitive Dependency Registration](#-transitive-dependency-registration) - - [🏷️ Method Naming Convention](#️-method-naming-convention) - - [✨ Smart Naming](#-smart-naming) - - [🔍 Auto-Detection](#-auto-detection) - - [1️⃣ Single Interface](#1️⃣-single-interface) - - [🔢 Multiple Interfaces](#-multiple-interfaces) - - [🧹 System Interfaces Filtered](#-system-interfaces-filtered) - - [🎯 Explicit Override](#-explicit-override) - - [🔀 Register As Both Interface and Concrete Type](#-register-as-both-interface-and-concrete-type) - - [⏱️ Service Lifetimes](#️-service-lifetimes) - - [🔒 Singleton (Default)](#-singleton-default) - - [🔄 Scoped](#-scoped) - - [⚡ Transient](#-transient) - - [🚀 Native AOT Compatibility](#-native-aot-compatibility) - - [✅ Why It Works](#-why-it-works) - - [🎯 Key Benefits](#-key-benefits) - - [🚀 Native AOT Example](#-native-aot-example) - - [⚙️ RegistrationAttribute Parameters](#️-registrationattribute-parameters) - - [📝 Examples](#-examples) - - [🛡️ Diagnostics](#️-diagnostics) - - [❌ ATCDIR001: As Type Must Be Interface or Abstract Class](#-atcdir001-as-type-must-be-interface-or-abstract-class) - - [❌ ATCDIR002: Class Does Not Implement Interface or Inherit Abstract Class](#-atcdir002-class-does-not-implement-interface-or-inherit-abstract-class) - - [⚠️ ATCDIR003: Duplicate Registration with Different Lifetime](#️-atcdir003-duplicate-registration-with-different-lifetime) - - [❌ ATCDIR004: Hosted Services Must Use Singleton Lifetime](#-atcdir004-hosted-services-must-use-singleton-lifetime) - - [🔷 Generic Interface Registration](#-generic-interface-registration) - - [Single Type Parameter](#single-type-parameter) - - [Multiple Type Parameters](#multiple-type-parameters) - - [Complex Constraints](#complex-constraints) - - [Explicit Generic Registration](#explicit-generic-registration) - - [🔑 Keyed Service Registration](#-keyed-service-registration) - - [String Keys](#string-keys) - - [Generic Keyed Services](#generic-keyed-services) - - [🏭 Factory Method Registration](#-factory-method-registration) - - [Basic Factory Method](#basic-factory-method) - - [Factory Method Requirements](#factory-method-requirements) - - [Factory Method with Multiple Interfaces](#factory-method-with-multiple-interfaces) - - [Factory Method Best Practices](#factory-method-best-practices) - - [Factory Method Diagnostics](#factory-method-diagnostics) - - [📦 Instance Registration](#-instance-registration) - - [Basic Instance Registration (Static Field)](#basic-instance-registration-static-field) - - [Instance Registration with Static Property](#instance-registration-with-static-property) - - [Instance Registration with Static Method](#instance-registration-with-static-method) - - [Instance Registration Requirements](#instance-registration-requirements) - - [Instance Registration with Multiple Interfaces](#instance-registration-with-multiple-interfaces) - - [Instance Registration Best Practices](#instance-registration-best-practices) - - [Instance Registration Diagnostics](#instance-registration-diagnostics) - - [Instance vs Factory Method](#instance-vs-factory-method) - - [🔄 TryAdd\* Registration](#-tryadd-registration) - - [Basic TryAdd Registration](#basic-tryadd-registration) - - [How TryAdd Works](#how-tryadd-works) - - [Library Author Pattern](#library-author-pattern) - - [TryAdd with Different Lifetimes](#tryadd-with-different-lifetimes) - - [TryAdd with Factory Methods](#tryadd-with-factory-methods) - - [TryAdd with Generic Types](#tryadd-with-generic-types) - - [TryAdd with Multiple Interfaces](#tryadd-with-multiple-interfaces) - - [TryAdd Best Practices](#tryadd-best-practices) - - [Important Notes](#important-notes) - - [🚫 Assembly Scanning Filters](#-assembly-scanning-filters) - - [Basic Filter Usage](#basic-filter-usage) - - [Namespace Exclusion](#namespace-exclusion) - - [Pattern Exclusion](#pattern-exclusion) - - [Interface Exclusion](#interface-exclusion) - - [Combining Multiple Filters](#combining-multiple-filters) - - [Multiple Filter Attributes](#multiple-filter-attributes) - - [Real-World Example](#real-world-example) - - [Filter Priority and Behavior](#filter-priority-and-behavior) - - [Verification](#verification) - - [Best Practices](#best-practices) - - [🎯 Runtime Filtering](#-runtime-filtering) - - [Basic Usage](#basic-usage) - - [🔹 Filter by Type](#-filter-by-type) - - [🔹 Filter by Namespace](#-filter-by-namespace) - - [🔹 Filter by Pattern](#-filter-by-pattern) - - [🔹 Combining Filters](#-combining-filters) - - [🔹 Filters with Transitive Registration](#-filters-with-transitive-registration) - - [Runtime vs. Compile-Time Filtering](#runtime-vs-compile-time-filtering) - - [Complete Example: Multi-Application Scenario](#complete-example-multi-application-scenario) - - [Best Practices](#best-practices-1) - - [Verification](#verification-1) - - [🎨 Decorator Pattern](#-decorator-pattern) - - [✨ How It Works](#-how-it-works) - - [📝 Basic Example](#-basic-example) - - [Generated Code](#generated-code) - - [🔄 Multiple Decorators](#-multiple-decorators) - - [🎯 Common Use Cases](#-common-use-cases) - - [1. Logging/Auditing](#1-loggingauditing) - - [2. Caching](#2-caching) - - [3. Validation](#3-validation) - - [4. Retry Logic](#4-retry-logic) - - [⚠️ Important Notes](#️-important-notes) - - [🔍 Complete Example](#-complete-example) - - [🎛️ Conditional Registration](#️-conditional-registration) - - [✨ How It Works](#-how-it-works-1) - - [📝 Basic Example](#-basic-example-1) - - [Generated Code](#generated-code-1) - - [🔄 Negation Support](#-negation-support) - - [🎯 Common Use Cases](#-common-use-cases-1) - - [1. Feature Flags](#1-feature-flags) - - [2. Environment-Specific Services](#2-environment-specific-services) - - [3. A/B Testing](#3-ab-testing) - - [4. Cost Optimization](#4-cost-optimization) - - [🎨 Advanced Scenarios](#-advanced-scenarios) - - [Multiple Conditional Services](#multiple-conditional-services) - - [Combining with Different Lifetimes](#combining-with-different-lifetimes) - - [Mixing Conditional and Unconditional](#mixing-conditional-and-unconditional) - - [⚙️ Configuration Best Practices](#️-configuration-best-practices) - - [🔍 IConfiguration Parameter Behavior](#-iconfiguration-parameter-behavior) - - [⚠️ Important Notes](#️-important-notes-1) - - [✅ Benefits](#-benefits) - - [📝 Complete Example](#-complete-example-1) - - [📚 Additional Examples](#-additional-examples) - ---- - -## 🚀 Get Started - Quick Guide - -This guide demonstrates a realistic 3-layer architecture for a PetStore application using minimal APIs. - -### 📂 Project Structure - -``` -PetStore.sln -├── PetStore.Api/ (Presentation layer) -├── PetStore.Domain/ (Business logic layer) -└── PetStore.DataAccess/ (Data access layer) -``` - -### 1️⃣ Setup Projects - -**PetStore.DataAccess.csproj** (Base layer): - -```xml - - - net9.0 - enable - enable - - - - - - - -``` - -**PetStore.Domain.csproj** (Middle layer): - -```xml - - - net9.0 - enable - enable - - - - - - - - - - -``` - -**PetStore.Api.csproj** (Top layer): - -```xml - - - net9.0 - enable - enable - - - - - - - - -``` - -> **Note:** The `Microsoft.Extensions.DependencyInjection.Abstractions` package is needed for projects that use the `IServiceCollection` extension methods. Logging requires `Microsoft.Extensions.Logging.Abstractions`. - -### 2️⃣ Data Access Layer (PetStore.DataAccess) 💾 - -**Models/Pet.cs**: - -```csharp -namespace PetStore.DataAccess.Models; - -public class Pet -{ - public int Id { get; set; } - public required string Name { get; set; } - public required string Species { get; set; } - public int Age { get; set; } -} -``` - -**Repositories/IPetRepository.cs**: - -```csharp -namespace PetStore.DataAccess.Repositories; - -public interface IPetRepository -{ - Task GetByIdAsync(int id); - Task> GetAllAsync(); - Task CreateAsync(Pet pet); - Task UpdateAsync(Pet pet); - Task DeleteAsync(int id); -} -``` - -**Repositories/PetRepository.cs**: - -```csharp -using Atc.DependencyInjection; -using PetStore.DataAccess.Models; - -namespace PetStore.DataAccess.Repositories; - -[Registration(Lifetime.Scoped)] -public class PetRepository : IPetRepository -{ - // In-memory storage for demo purposes - private static readonly List InMemoryPets = new() - { - new Pet { Id = 1, Name = "Buddy", Species = "Dog", Age = 3 }, - new Pet { Id = 2, Name = "Whiskers", Species = "Cat", Age = 5 }, - new Pet { Id = 3, Name = "Goldie", Species = "Fish", Age = 1 } - }; - - private static int _nextId = 4; - - public Task GetByIdAsync(int id) - { - var pet = InMemoryPets.FirstOrDefault(p => p.Id == id); - return Task.FromResult(pet); - } - - public Task> GetAllAsync() - { - return Task.FromResult(InMemoryPets.AsEnumerable()); - } - - public Task CreateAsync(Pet pet) - { - pet.Id = _nextId++; - InMemoryPets.Add(pet); - return Task.FromResult(pet.Id); - } - - public Task UpdateAsync(Pet pet) - { - var existing = InMemoryPets.FirstOrDefault(p => p.Id == pet.Id); - if (existing is null) return Task.FromResult(false); - - existing.Name = pet.Name; - existing.Species = pet.Species; - existing.Age = pet.Age; - return Task.FromResult(true); - } - - public Task DeleteAsync(int id) - { - var pet = InMemoryPets.FirstOrDefault(p => p.Id == id); - if (pet is null) return Task.FromResult(false); - - InMemoryPets.Remove(pet); - return Task.FromResult(true); - } -} -``` - -### 3️⃣ Domain Layer (PetStore.Domain) 🧠 - -**Models/PetDto.cs**: - -```csharp -namespace PetStore.Domain.Models; - -public record PetDto(int Id, string Name, string Species, int Age); - -public record CreatePetRequest(string Name, string Species, int Age); - -public record UpdatePetRequest(string Name, string Species, int Age); - -public record ValidationResult(bool IsValid, List Errors); -``` - -**Services/IPetService.cs**: - -```csharp -namespace PetStore.Domain.Services; - -public interface IPetService -{ - Task GetPetAsync(int id); - Task> GetAllPetsAsync(); - Task CreatePetAsync(CreatePetRequest request); - Task UpdatePetAsync(int id, UpdatePetRequest request); - Task DeletePetAsync(int id); -} -``` - -**Services/PetService.cs**: - -```csharp -using Atc.DependencyInjection; -using Microsoft.Extensions.Logging; -using PetStore.DataAccess.Models; -using PetStore.DataAccess.Repositories; -using PetStore.Domain.Models; - -namespace PetStore.Domain.Services; - -[Registration(Lifetime.Scoped)] -public class PetService : IPetService -{ - private readonly IPetRepository _repository; - private readonly ILogger _logger; - - public PetService(IPetRepository repository, ILogger logger) - { - _repository = repository; - _logger = logger; - } - - public async Task GetPetAsync(int id) - { - _logger.LogInformation("Getting pet with ID {PetId}", id); - var pet = await _repository.GetByIdAsync(id); - return pet is not null ? MapToDto(pet) : null; - } - - public async Task> GetAllPetsAsync() - { - _logger.LogInformation("Getting all pets"); - var pets = await _repository.GetAllAsync(); - return pets.Select(MapToDto); - } - - public async Task CreatePetAsync(CreatePetRequest request) - { - _logger.LogInformation("Creating pet: {PetName}", request.Name); - var pet = new Pet - { - Name = request.Name, - Species = request.Species, - Age = request.Age - }; - return await _repository.CreateAsync(pet); - } - - public async Task UpdatePetAsync(int id, UpdatePetRequest request) - { - _logger.LogInformation("Updating pet with ID {PetId}", id); - var pet = new Pet - { - Id = id, - Name = request.Name, - Species = request.Species, - Age = request.Age - }; - return await _repository.UpdateAsync(pet); - } - - public async Task DeletePetAsync(int id) - { - _logger.LogInformation("Deleting pet with ID {PetId}", id); - return await _repository.DeleteAsync(id); - } - - private static PetDto MapToDto(Pet pet) => new(pet.Id, pet.Name, pet.Species, pet.Age); -} -``` - -**Validators/IPetValidator.cs**: - -```csharp -using PetStore.Domain.Models; - -namespace PetStore.Domain.Validators; - -public interface IPetValidator -{ - ValidationResult Validate(CreatePetRequest request); -} -``` - -**Validators/PetValidator.cs**: - -```csharp -using Atc.DependencyInjection; -using PetStore.Domain.Models; - -namespace PetStore.Domain.Validators; - -[Registration] // Default Singleton lifetime -public class PetValidator : IPetValidator -{ - public ValidationResult Validate(CreatePetRequest request) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(request.Name)) - errors.Add("Pet name is required"); - - if (request.Age < 0) - errors.Add("Pet age must be positive"); - - return new ValidationResult(errors.Count == 0, errors); - } -} -``` - -### 4️⃣ API Layer (PetStore.Api) 🌐 - -**Handlers/IPetHandlers.cs**: - -```csharp -namespace PetStore.Api.Handlers; - -public interface IPetHandlers -{ - void MapEndpoints(IEndpointRouteBuilder app); -} -``` - -**Handlers/PetHandlers.cs**: - -```csharp -using Atc.DependencyInjection; -using PetStore.Domain.Models; -using PetStore.Domain.Services; -using PetStore.Domain.Validators; - -namespace PetStore.Api.Handlers; - -[Registration] // Default Singleton lifetime -public class PetHandlers : IPetHandlers -{ - public void MapEndpoints(IEndpointRouteBuilder app) - { - var pets = app.MapGroup("/api/pets").WithTags("Pets"); - - pets.MapGet("/", GetAllPets) - .WithName("GetAllPets"); - - pets.MapGet("/{id:int}", GetPet) - .WithName("GetPet"); - - pets.MapPost("/", CreatePet) - .WithName("CreatePet"); - - pets.MapPut("/{id:int}", UpdatePet) - .WithName("UpdatePet"); - - pets.MapDelete("/{id:int}", DeletePet) - .WithName("DeletePet"); - } - - private static async Task GetAllPets(IPetService petService) - { - var pets = await petService.GetAllPetsAsync(); - return Results.Ok(pets); - } - - private static async Task GetPet(int id, IPetService petService) - { - var pet = await petService.GetPetAsync(id); - return pet is not null ? Results.Ok(pet) : Results.NotFound(); - } - - private static async Task CreatePet( - CreatePetRequest request, - IPetService petService, - IPetValidator validator) - { - var validation = validator.Validate(request); - if (!validation.IsValid) - return Results.BadRequest(new { errors = validation.Errors }); - - var id = await petService.CreatePetAsync(request); - return Results.Created($"/api/pets/{id}", new { id }); - } - - private static async Task UpdatePet( - int id, - UpdatePetRequest request, - IPetService petService) - { - var success = await petService.UpdatePetAsync(id, request); - return success ? Results.NoContent() : Results.NotFound(); - } - - private static async Task DeletePet(int id, IPetService petService) - { - var success = await petService.DeletePetAsync(id); - return success ? Results.NoContent() : Results.NotFound(); - } -} -``` - -### 5️⃣ Program.cs (Minimal API Setup) ⚡ - -**Option 1 - Manual Registration (Scenario A):** - -```csharp -using Atc.DependencyInjection; -using PetStore.Api.Handlers; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -// Register all services from all three layers manually -builder.Services.AddDependencyRegistrationsFromDataAccess(); -builder.Services.AddDependencyRegistrationsFromDomain(); -builder.Services.AddDependencyRegistrationsFromApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -// Map endpoints using handler -var petHandlers = app.Services.GetRequiredService(); -petHandlers.MapEndpoints(app); - -app.Run(); -``` - -**Option 2 - Transitive Registration (Scenario B - Recommended):** - -```csharp -using Atc.DependencyInjection; -using PetStore.Api.Handlers; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -// Single call registers all services from Domain + DataAccess automatically! -builder.Services.AddDependencyRegistrationsFromDomain(includeReferencedAssemblies: true); - -// Only need to register API layer services separately -builder.Services.AddDependencyRegistrationsFromApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -// Map endpoints using handler -var petHandlers = app.Services.GetRequiredService(); -petHandlers.MapEndpoints(app); - -app.Run(); -``` - -### 🎨 What Gets Generated - -For each project, the generator creates an assembly-specific extension method (with smart naming): - -**PetStore.DataAccess** → `AddDependencyRegistrationsFromDataAccess()` (suffix "DataAccess" is unique) - -```csharp -services.AddScoped(); -``` - -**PetStore.Domain** → `AddDependencyRegistrationsFromDomain()` (suffix "Domain" is unique) - -```csharp -services.AddScoped(); -services.AddSingleton(); -``` - -**PetStore.Api** → `AddDependencyRegistrationsFromApi()` (suffix "Api" is unique) - -```csharp -services.AddSingleton(); -``` - -### 6️⃣ Testing the Application 🧪 - -**Build and run:** - -```bash -dotnet run --project PetStore.Api -``` - -**Test the endpoints:** - -```bash -# Get all pets -curl http://localhost:5265/api/pets/ - -# Get specific pet -curl http://localhost:5265/api/pets/1 - -# Create a new pet -curl -X POST http://localhost:5265/api/pets/ \ - -H "Content-Type: application/json" \ - -d '{"name":"Max","species":"Dog","age":2}' - -# Test validation (should return errors) -curl -X POST http://localhost:5265/api/pets/ \ - -H "Content-Type: application/json" \ - -d '{"name":"","species":"Cat","age":-1}' - -# Update a pet -curl -X PUT http://localhost:5265/api/pets/1 \ - -H "Content-Type: application/json" \ - -d '{"name":"Buddy Updated","species":"Dog","age":4}' - -# Delete a pet -curl -X DELETE http://localhost:5265/api/pets/1 -``` - -**Expected output:** - -- All endpoints work with proper dependency injection -- Validation errors return structured JSON: `{"errors":["Pet name is required","Pet age must be positive"]}` -- Logging appears in console for all service operations - -### 🔍 Viewing Generated Code (Optional) - -To see what code the generator creates, add this to your `.csproj` files: - -```xml - - true - $(BaseIntermediateOutputPath)Generated - -``` - -Generated files will appear in `obj/Generated/Atc.SourceGenerators/`: - -- `RegistrationAttribute.g.cs` - The `[Registration]` attribute definition -- `ServiceCollectionExtensions.g.cs` - The `AddDependencyRegistrationsFrom...()` method - -### 🎯 Key Takeaways - -This quick guide demonstrated: - -✅ **Zero boilerplate** - No manual `services.AddScoped()` calls -✅ **Type safety** - Compile-time validation of registrations -✅ **Auto-detection** - Automatically registers against implemented interfaces -✅ **Multi-project support** - Each assembly gets its own registration method -✅ **Flexible lifetimes** - Singleton, Scoped, and Transient support -✅ **Clean architecture** - Clear separation between layers - -Compare this to manual registration: - -```csharp -// ❌ Without Source Generator (manual, error-prone) -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -// ... and so on for every service - -// ✅ With Source Generator (automatic, type-safe, smart naming) -builder.Services.AddDependencyRegistrationsFromDataAccess(); -builder.Services.AddDependencyRegistrationsFromDomain(); -builder.Services.AddDependencyRegistrationsFromApi(); -``` - ---- - -## 📦 Installation - -Add the NuGet package to each project that contains services to register: - -**Required:** - -```bash -dotnet add package Atc.SourceGenerators -``` - -**Optional (recommended for better IntelliSense):** - -```bash -dotnet add package Atc.SourceGenerators.Annotations -``` - -Or add to your `.csproj` file: - -```xml - - - - - - - -``` - -**Note:** The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: `$(NoWarn);CS0436` - -The generator runs at compile time and has zero runtime overhead. - ---- - -## 💡 Basic Usage - -### 1️⃣ Add Using Directives - -```csharp -using Atc.DependencyInjection; -``` - -### 2️⃣ Decorate Your Services - -```csharp -// Simple registration with default singleton lifetime -[Registration] -public class CacheService -{ - public void Store(string key, string value) { } -} - -// Interface auto-detection -[Registration] -public class UserService : IUserService -{ - public void CreateUser(string name) { } -} - -// Scoped lifetime -[Registration(Lifetime.Scoped)] -public class OrderService : IOrderService -{ - public void ProcessOrder(int orderId) { } -} - -// Transient lifetime -[Registration(Lifetime.Transient)] -public class LoggerService -{ - public void Log(string message) { } -} -``` - -### 3️⃣ Register in DI Container - -```csharp -using Microsoft.Extensions.DependencyInjection; - -var services = new ServiceCollection(); - -// Call the generated extension method -services.AddDependencyRegistrationsFromYourAssemblyName(); - -var serviceProvider = services.BuildServiceProvider(); -``` - ---- - -## ✨ Features - -- **Automatic Service Registration**: Decorate classes with `[Registration]` attribute for automatic DI registration -- **Generic Interface Registration**: Full support for open generic types like `IRepository` and `IHandler` 🆕 -- **Keyed Service Registration**: Multiple implementations of the same interface with different keys (.NET 8+) -- **Factory Method Registration**: Custom initialization logic via static factory methods -- **TryAdd* Registration**: Conditional registration for default implementations (library pattern) -- **Conditional Registration**: Register services based on configuration values (feature flags, environment-specific services) 🆕 -- **Assembly Scanning Filters**: Exclude types by namespace, pattern (wildcards), or interface implementation -- **Runtime Filtering**: Exclude services at registration time with method parameters (different apps, different service subsets) 🆕 -- **Hosted Service Support**: Automatically detects `BackgroundService` and `IHostedService` implementations and uses `AddHostedService()` -- **Interface Auto-Detection**: Automatically registers against all implemented interfaces (no `As` parameter needed!) -- **Smart Filtering**: System interfaces (IDisposable, etc.) are automatically excluded -- **Multiple Interface Support**: Services implementing multiple interfaces are registered against all of them -- **Flexible Lifetimes**: Support for Singleton, Scoped, and Transient service lifetimes -- **Explicit Override**: Optional `As` parameter to override auto-detection when needed -- **Dual Registration**: Register services as both interface and concrete type with `AsSelf` -- **Compile-time Validation**: Diagnostics for common errors (invalid interface types, missing implementations, incorrect hosted service lifetimes) -- **Zero Runtime Overhead**: All code is generated at compile time -- **Native AOT Compatible**: No reflection or runtime code generation - fully trimming-safe and AOT-ready -- **Multi-Project Support**: Each project generates its own registration method - ---- - -## 🏗️ Multi-Project Setup - -When using the generator across multiple projects, each project generates its own extension method with a unique name based on the assembly name. - -### 📁 Example Structure - -```text -Solution/ -├── MyApp.Api/ → AddDependencyRegistrationsFromApi() -├── MyApp.Domain/ → AddDependencyRegistrationsFromDomain() -└── MyApp.DataAccess/ → AddDependencyRegistrationsFromDataAccess() -``` - -### ⚡ Program.cs Registration - -**Scenario A - Manual Registration (Traditional):** - -Call all registration methods explicitly in your startup: - -```csharp -var builder = WebApplication.CreateBuilder(args); - -// Register services from all projects manually -builder.Services.AddDependencyRegistrationsFromDataAccess(); -builder.Services.AddDependencyRegistrationsFromDomain(); -builder.Services.AddDependencyRegistrationsFromApi(); - -var app = builder.Build(); -``` - -**Scenario B - Transitive Registration (Recommended):** - -Let the generator automatically register services from referenced assemblies: - -```csharp -var builder = WebApplication.CreateBuilder(args); - -// Single call automatically registers: -// - Services from MyApp.Domain -// - Services from MyApp.DataAccess (referenced by Domain) -builder.Services.AddDependencyRegistrationsFromDomain(includeReferencedAssemblies: true); - -var app = builder.Build(); -``` - -### 🔄 Transitive Dependency Registration - -Each generated registration method has **4 overloads** to support different registration scenarios: - -```csharp -// Overload 1: Default (no transitive registration) -builder.Services.AddDependencyRegistrationsFromDomain(); - -// Overload 2: Auto-detect ALL referenced assemblies recursively -builder.Services.AddDependencyRegistrationsFromDomain(includeReferencedAssemblies: true); - -// Overload 3: Register specific referenced assembly by name (short or full name) -builder.Services.AddDependencyRegistrationsFromDomain("DataAccess"); -builder.Services.AddDependencyRegistrationsFromDomain("MyApp.DataAccess"); - -// Overload 4: Register multiple specific referenced assemblies -builder.Services.AddDependencyRegistrationsFromDomain("DataAccess", "Infrastructure"); -``` - -**How It Works:** - -1. **Auto-detect mode** (`includeReferencedAssemblies: true`): - - Scans ALL referenced assemblies that contain `[Registration]` attributes - - Recursively registers services from the entire dependency chain - - Example: Domain → DataAccess → Infrastructure (all registered with ONE call) - -2. **Manual mode** (assembly name parameters): - - Only registers assemblies with matching prefix (e.g., "MyApp.*") - - Supports both full names ("MyApp.DataAccess") and short names ("DataAccess") - - Allows fine-grained control over which assemblies to include - -3. **Silent skip**: - - If a specified assembly doesn't exist or has no registrations, it's silently skipped - - No compile-time or runtime errors - -**Benefits:** - -✅ **Clean Architecture**: Api → Domain → DataAccess (no need for Api to reference DataAccess directly) -✅ **Less Boilerplate**: One registration call instead of many -✅ **Maintainable**: Adding new projects doesn't require updating Program.cs -✅ **Type-Safe**: All registrations validated at compile time - -### 🏷️ Method Naming Convention - -The generator creates extension methods based on assembly names with **smart naming**: - -| Assembly Name | Smart Naming Result | -|---------------|---------------------| -| `PetStore.Domain` (unique suffix) | `AddDependencyRegistrationsFromDomain()` | -| `PetStore.DataAccess` (unique suffix) | `AddDependencyRegistrationsFromDataAccess()` | -| `PetStore.Domain` + `AnotherApp.Domain` | `AddDependencyRegistrationsFromPetStoreDomain()` and `AddDependencyRegistrationsFromAnotherAppDomain()` | - -Assembly names are sanitized to create valid C# identifiers (dots, dashes, spaces removed). - -### ✨ Smart Naming - -The generator uses **smart suffix-based naming** to create cleaner, more readable method names: - -**How it works:** - -- ✅ If the assembly suffix (last segment after final dot) is **unique** among all assemblies → use short suffix -- ⚠️ If multiple assemblies have the **same suffix** → use full sanitized name to avoid conflicts - -**Examples:** - -```csharp -// ✅ Unique suffixes (cleaner names) -PetStore.Domain → AddDependencyRegistrationsFromDomain() -PetStore.DataAccess → AddDependencyRegistrationsFromDataAccess() -PetStore.Api → AddDependencyRegistrationsFromApi() - -// ⚠️ Conflicting suffixes (full names prevent collisions) -PetStore.Domain → AddDependencyRegistrationsFromPetStoreDomain() -AnotherApp.Domain → AddDependencyRegistrationsFromAnotherAppDomain() -``` - -**Benefits:** - -- 🎯 **Cleaner API**: Shorter method names when there are no conflicts -- 🛡️ **Automatic Conflict Prevention**: Fallback to full names prevents naming collisions -- ⚡ **Zero Configuration**: Works automatically based on compilation context -- 🔄 **Context-Aware**: Method names adapt to the assemblies in your solution - ---- - -## 🔍 Auto-Detection - -The generator automatically detects and registers services against their implemented interfaces: - -### 1️⃣ Single Interface - -```csharp -public interface IUserService { } - -[Registration] -public class UserService : IUserService { } -``` - -**Generated:** - -```csharp -services.AddSingleton(); -``` - -### 🔢 Multiple Interfaces - -```csharp -public interface IEmailService { } -public interface INotificationService { } - -[Registration] -public class EmailService : IEmailService, INotificationService { } -``` - -**Generated:** - -```csharp -services.AddSingleton(); -services.AddSingleton(); -``` - -### 🧹 System Interfaces Filtered - -System and Microsoft namespace interfaces are automatically excluded: - -```csharp -[Registration] -public class CacheService : IDisposable -{ - public void Dispose() { } -} -``` - -**Generated:** - -```csharp -services.AddSingleton(); // IDisposable ignored -``` - -### 🎯 Explicit Override - -Use `As` parameter to register against a specific interface only: - -```csharp -[Registration(As = typeof(IUserService))] -public class UserService : IUserService, INotificationService { } -``` - -**Generated:** - -```csharp -services.AddSingleton(); // Only IUserService -``` - -### 🔀 Register As Both Interface and Concrete Type - -Use `AsSelf = true` to register both ways: - -```csharp -[Registration(AsSelf = true)] -public class EmailService : IEmailService { } -``` - -**Generated:** - -```csharp -services.AddSingleton(); -services.AddSingleton(); -``` - ---- - -## ⏱️ Service Lifetimes - -### 🔒 Singleton (Default) - -Single instance for the entire application lifetime: - -```csharp -[Registration] // or [Registration(Lifetime.Singleton)] -public class CacheService { } -``` - -**Use for:** Stateless services, shared state, expensive-to-create objects - -### 🔄 Scoped - -New instance per scope (e.g., per HTTP request): - -```csharp -[Registration(Lifetime.Scoped)] -public class OrderService : IOrderService { } -``` - -**Use for:** Database contexts, request-specific services, unit of work pattern - -### ⚡ Transient - -New instance every time it's requested: - -```csharp -[Registration(Lifetime.Transient)] -public class LoggerService { } -``` - -**Use for:** Lightweight, stateless services, services that shouldn't be shared - ---- - -## 🚀 Native AOT Compatibility - -The Dependency Registration Generator is **fully compatible with Native AOT** compilation, making it ideal for modern .NET applications that require fast startup times and minimal deployment sizes. - -### ✅ Why It Works - -All registration code is generated at compile time with **no reflection or runtime code generation**: - -```csharp -// Your attributed service -[Registration(Lifetime.Scoped)] -public class UserService : IUserService { } - -// Generated code (compile-time, no reflection!) -public static class DependencyRegistrationExtensions -{ - public static IServiceCollection AddDependencyRegistrationsFromApp( - this IServiceCollection services) - { - services.AddScoped(); - return services; - } -} -``` - -### 🎯 Key Benefits - -- **✅ No reflection required**: All service registration happens through direct method calls -- **✅ Fully trimming-safe**: No dynamic code means the trimmer can safely remove unused code -- **✅ AOT-ready**: All dependencies are resolved at compile time -- **✅ Faster startup**: No runtime scanning or reflection overhead -- **✅ Smaller deployments**: Dead code elimination works perfectly -- **✅ Better performance**: Native code execution with zero runtime overhead - -### 🚀 Native AOT Example - -```xml - - - true - - - - - -``` - -```csharp -// Your services work seamlessly with Native AOT -[Registration(Lifetime.Singleton)] -public class CacheService : ICacheService { } - -[Registration(Lifetime.Scoped)] -public class UserService : IUserService { } - -// Program.cs -var builder = WebApplication.CreateSlimBuilder(args); -builder.Services.AddDependencyRegistrationsFromApp(); -var app = builder.Build(); -``` - -**Result:** Fast startup, minimal memory footprint, and production-ready native binaries. - ---- - -## ⚙️ RegistrationAttribute Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `lifetime` | `Lifetime` | `Singleton` | Service lifetime (Singleton, Scoped, or Transient) | -| `As` | `Type?` | `null` | Explicit interface type to register against (overrides auto-detection) | -| `AsSelf` | `bool` | `false` | Also register the concrete type when interfaces are detected/specified | -| `Key` | `object?` | `null` | Service key for keyed service registration (.NET 8+) | -| `Factory` | `string?` | `null` | Name of static factory method for custom initialization | -| `TryAdd` | `bool` | `false` | Use TryAdd* methods for conditional registration (library pattern) | -| `Decorator` | `bool` | `false` | Mark this service as a decorator that wraps the previous registration of the same interface | -| `Condition` | `string?` | `null` | Configuration key path for conditional registration (feature flags). Prefix with "!" for negation | - -### 📝 Examples - -```csharp -// Default singleton, auto-detect interfaces -[Registration] - -// Explicit lifetime -[Registration(Lifetime.Scoped)] - -// Explicit interface -[Registration(As = typeof(IUserService))] - -// Combination -[Registration(Lifetime.Transient, As = typeof(ILogger))] - -// Also register concrete type -[Registration(AsSelf = true)] - -// Keyed service -[Registration(Key = "Primary", As = typeof(ICache))] - -// Factory method -[Registration(Factory = nameof(Create))] - -// TryAdd registration -[Registration(TryAdd = true)] - -// Decorator pattern -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] - -// Conditional registration -[Registration(As = typeof(ICache), Condition = "Features:UseRedisCache")] - -// Negated conditional -[Registration(As = typeof(ICache), Condition = "!Features:UseRedisCache")] - -// All parameters -[Registration(Lifetime.Scoped, As = typeof(IService), AsSelf = true, TryAdd = true)] -``` - ---- - -## 🛡️ Diagnostics - -The generator provides compile-time diagnostics to catch common errors: - -### ❌ ATCDIR001: As Type Must Be Interface or Abstract Class - -**Severity:** Error - -**Description:** The type specified in `As` parameter must be an interface or abstract class. - -```csharp -// ❌ Error: BaseService is a concrete class -public class BaseService { } - -[Registration(As = typeof(BaseService))] -public class UserService : BaseService { } - -// ✅ OK: Abstract base class -public abstract class AbstractBaseService { } - -[Registration(As = typeof(AbstractBaseService))] -public class UserService : AbstractBaseService { } - -// ✅ OK: Interface -public interface IUserService { } - -[Registration(As = typeof(IUserService))] -public class UserService : IUserService { } -``` - -**Fix:** Use an interface, abstract class, or remove the `As` parameter. - -### ❌ ATCDIR002: Class Does Not Implement Interface or Inherit Abstract Class - -**Severity:** Error - -**Description:** Class does not implement the interface or inherit from the abstract class specified in `As` parameter. - -```csharp -public interface IUserService { } - -// ❌ Error: UserService doesn't implement IUserService -[Registration(As = typeof(IUserService))] -public class UserService { } - -public abstract class AuthenticationStateProvider { } - -// ❌ Error: UserService doesn't inherit from AuthenticationStateProvider -[Registration(As = typeof(AuthenticationStateProvider))] -public class UserService { } -``` - -**Fix:** Implement the interface, inherit from the abstract class, or remove the `As` parameter. - -### ⚠️ ATCDIR003: Duplicate Registration with Different Lifetime - -**Severity:** Warning - -**Description:** Service is registered multiple times with different lifetimes. - -```csharp -public interface IUserService { } - -// ⚠️ Warning: Same interface registered with two different lifetimes -[Registration(Lifetime.Singleton)] -public class UserServiceSingleton : IUserService { } - -[Registration(Lifetime.Scoped)] -public class UserServiceScoped : IUserService { } -``` - -**Fix:** Ensure consistent lifetimes or use different interfaces. - -### ❌ ATCDIR004: Hosted Services Must Use Singleton Lifetime - -**Severity:** Error - -**Description:** Hosted services (BackgroundService or IHostedService implementations) must use Singleton lifetime. - -```csharp -// ❌ Error: Hosted services cannot use Scoped or Transient lifetime -[Registration(Lifetime.Scoped)] -public class MyBackgroundService : BackgroundService -{ - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } -} -``` - -**Fix:** Use Singleton lifetime (or default [Registration]) for hosted services: - -```csharp -// ✅ Correct: Singleton lifetime (explicit) -[Registration(Lifetime.Singleton)] -public class MyBackgroundService : BackgroundService { } - -// ✅ Correct: Default lifetime is Singleton -[Registration] -public class MyBackgroundService : BackgroundService { } -``` - -**Generated Registration:** - -```csharp -services.AddHostedService(); -``` - ---- - -## 🔷 Generic Interface Registration - -The generator supports open generic types, enabling the repository pattern and other generic service patterns. - -### Single Type Parameter - -```csharp -// Generic interface -public interface IRepository where T : class -{ - T? GetById(int id); - IEnumerable GetAll(); - void Add(T entity); -} - -// Generic implementation -[Registration(Lifetime.Scoped)] -public class Repository : IRepository where T : class -{ - public T? GetById(int id) => /* implementation */; - public IEnumerable GetAll() => /* implementation */; - public void Add(T entity) => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); -``` - -**Usage:** - -```csharp -// Resolve for specific types -var userRepository = serviceProvider.GetRequiredService>(); -var productRepository = serviceProvider.GetRequiredService>(); -``` - -### Multiple Type Parameters - -```csharp -// Handler interface with two type parameters -public interface IHandler -{ - TResponse Handle(TRequest request); -} - -[Registration(Lifetime.Transient)] -public class Handler : IHandler -{ - public TResponse Handle(TRequest request) => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.AddTransient(typeof(IHandler<,>), typeof(Handler<,>)); -``` - -### Complex Constraints - -```csharp -// Interface with multiple constraints -public interface IRepository - where T : class, IEntity, new() -{ - T Create(); - void Save(T entity); -} - -[Registration(Lifetime.Scoped)] -public class Repository : IRepository - where T : class, IEntity, new() -{ - public T Create() => new T(); - public void Save(T entity) => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); -``` - -### Explicit Generic Registration - -You can also use explicit `As` parameter with open generic types: - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IRepository<>))] -public class Repository : IRepository where T : class -{ - // Implementation -} -``` - ---- - -## 🔑 Keyed Service Registration - -Register multiple implementations of the same interface and resolve them by key (.NET 8+). - -### String Keys - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IPaymentProcessor), Key = "Stripe")] -public class StripePaymentProcessor : IPaymentProcessor -{ - public Task ProcessPaymentAsync(decimal amount) { /* Stripe implementation */ } -} - -[Registration(Lifetime.Scoped, As = typeof(IPaymentProcessor), Key = "PayPal")] -public class PayPalPaymentProcessor : IPaymentProcessor -{ - public Task ProcessPaymentAsync(decimal amount) { /* PayPal implementation */ } -} -``` - -**Generated Code:** - -```csharp -services.AddKeyedScoped("Stripe"); -services.AddKeyedScoped("PayPal"); -``` - -**Usage:** - -```csharp -// Constructor injection with [FromKeyedServices] -public class CheckoutService( - [FromKeyedServices("Stripe")] IPaymentProcessor stripeProcessor, - [FromKeyedServices("PayPal")] IPaymentProcessor paypalProcessor) -{ - // Use specific implementations -} - -// Manual resolution -var stripeProcessor = serviceProvider.GetRequiredKeyedService("Stripe"); -var paypalProcessor = serviceProvider.GetRequiredKeyedService("PayPal"); -``` - -### Generic Keyed Services - -Keyed services work with generic types: - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IRepository<>), Key = "Primary")] -public class PrimaryRepository : IRepository where T : class -{ - public T? GetById(int id) => /* Primary database */; -} - -[Registration(Lifetime.Scoped, As = typeof(IRepository<>), Key = "ReadOnly")] -public class ReadOnlyRepository : IRepository where T : class -{ - public T? GetById(int id) => /* Read-only replica */; -} -``` - -**Generated Code:** - -```csharp -services.AddKeyedScoped(typeof(IRepository<>), "Primary", typeof(PrimaryRepository<>)); -services.AddKeyedScoped(typeof(IRepository<>), "ReadOnly", typeof(ReadOnlyRepository<>)); -``` - ---- - -## 🏭 Factory Method Registration - -Factory methods allow custom initialization logic for services that require configuration values, conditional setup, or complex dependencies. - -### Basic Factory Method - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IEmailSender), Factory = nameof(CreateEmailSender))] -public class EmailSender : IEmailSender -{ - private readonly string smtpHost; - private readonly int smtpPort; - - private EmailSender(string smtpHost, int smtpPort) - { - this.smtpHost = smtpHost; - this.smtpPort = smtpPort; - } - - public Task SendEmailAsync(string to, string subject, string body) - { - // Implementation... - } - - /// - /// Factory method for creating EmailSender instances. - /// Must be static and accept IServiceProvider as parameter. - /// - public static IEmailSender CreateEmailSender(IServiceProvider serviceProvider) - { - // Resolve configuration from DI container - var config = serviceProvider.GetRequiredService(); - var smtpHost = config["Email:SmtpHost"] ?? "smtp.example.com"; - var smtpPort = int.Parse(config["Email:SmtpPort"] ?? "587"); - - return new EmailSender(smtpHost, smtpPort); - } -} -``` - -**Generated Code:** - -```csharp -services.AddScoped(sp => EmailSender.CreateEmailSender(sp)); -``` - -### Factory Method Requirements - -- ✅ Must be `static` -- ✅ Must accept `IServiceProvider` as the single parameter -- ✅ Must return the service type (interface specified in `As` parameter, or class type if no `As` specified) -- ✅ Can be `public`, `internal`, or `private` - -### Factory Method with Multiple Interfaces - -```csharp -[Registration(Lifetime.Singleton, Factory = nameof(CreateService))] -public class CacheService : ICacheService, IHealthCheck -{ - private readonly string connectionString; - - private CacheService(string connectionString) - { - this.connectionString = connectionString; - } - - public static ICacheService CreateService(IServiceProvider sp) - { - var config = sp.GetRequiredService(); - var connString = config.GetConnectionString("Redis"); - return new CacheService(connString); - } - - // ICacheService members... - // IHealthCheck members... -} -``` - -**Generated Code:** - -```csharp -// Registers against both interfaces using the same factory -services.AddSingleton(sp => CacheService.CreateService(sp)); -services.AddSingleton(sp => CacheService.CreateService(sp)); -``` - -### Factory Method Best Practices - -**When to Use Factory Methods:** - -- ✅ Service requires configuration values from `IConfiguration` -- ✅ Conditional initialization based on runtime environment -- ✅ Complex dependency resolution beyond constructor injection -- ✅ Services with private constructors that require initialization - -**When NOT to Use Factory Methods:** - -- ❌ Simple services with no special initialization - use regular constructor injection -- ❌ Services that can use `IOptions` pattern instead -- ❌ When factory logic is overly complex - consider using a dedicated factory class - -### Factory Method Diagnostics - -The generator provides compile-time validation: - -**ATCDIR005: Factory method not found** - -```csharp -// ❌ Error: Factory method doesn't exist -[Registration(Factory = "NonExistentMethod")] -public class MyService : IMyService { } -``` - -**ATCDIR006: Invalid factory method signature** - -```csharp -// ❌ Error: Factory method must be static -[Registration(Factory = nameof(Create))] -public class MyService : IMyService -{ - public IMyService Create(IServiceProvider sp) => this; // Not static! -} - -// ❌ Error: Wrong parameter type -public static IMyService Create(string config) => new MyService(); - -// ❌ Error: Wrong return type -public static string Create(IServiceProvider sp) => "wrong"; -``` - -**Correct signature:** - -```csharp -// ✅ Correct: static, accepts IServiceProvider, returns service type -public static IMyService Create(IServiceProvider sp) => new MyService(); -``` - ---- - -## 📦 Instance Registration - -Instance registration allows you to register pre-created singleton instances via static fields, properties, or methods. This is ideal for immutable configuration objects or singleton instances that are initialized at startup. - -### Basic Instance Registration (Static Field) - -```csharp -[Registration(As = typeof(IAppConfiguration), Instance = nameof(DefaultInstance))] -public class AppConfiguration : IAppConfiguration -{ - // Static field providing the pre-created instance - public static readonly AppConfiguration DefaultInstance = new() - { - ApplicationName = "My Application", - Environment = "Production", - MaxConnections = 100, - IsDebugMode = false, - }; - - // Private constructor enforces singleton pattern - private AppConfiguration() { } - - public string ApplicationName { get; init; } = string.Empty; - public string Environment { get; init; } = string.Empty; - public int MaxConnections { get; init; } - public bool IsDebugMode { get; init; } -} -``` - -**Generated Code:** - -```csharp -services.AddSingleton(AppConfiguration.DefaultInstance); -``` - -### Instance Registration with Static Property - -```csharp -[Registration(As = typeof(ICache), Instance = nameof(Instance))] -public class MemoryCache : ICache -{ - // Static property providing the singleton instance - public static MemoryCache Instance { get; } = new MemoryCache(); - - private MemoryCache() { } - - public void Set(string key, object value) { /* implementation */ } - public object? Get(string key) { /* implementation */ } -} -``` - -**Generated Code:** - -```csharp -services.AddSingleton(MemoryCache.Instance); -``` - -### Instance Registration with Static Method - -```csharp -[Registration(As = typeof(ILogger), Instance = nameof(GetDefaultLogger))] -public class DefaultLogger : ILogger -{ - private static readonly DefaultLogger instance = new(); - - private DefaultLogger() { } - - // Static method returning the singleton instance - public static DefaultLogger GetDefaultLogger() => instance; - - public void Log(string message) => Console.WriteLine($"[{DateTime.UtcNow:O}] {message}"); -} -``` - -**Generated Code:** - -```csharp -services.AddSingleton(DefaultLogger.GetDefaultLogger()); -``` - -### Instance Registration Requirements - -- ✅ The `Instance` parameter must reference a **static** field, property, or parameterless method -- ✅ Instance registration **requires Singleton lifetime** (enforced at compile-time) -- ✅ The member can be `public`, `internal`, or `private` -- ✅ Works with `TryAdd`: `services.TryAddSingleton(ClassName.Instance)` -- ❌ **Cannot** be used with `Factory` parameter (mutually exclusive) -- ❌ **Cannot** be used with Scoped or Transient lifetimes - -### Instance Registration with Multiple Interfaces - -When a class implements multiple interfaces, the same instance is registered for each interface: - -```csharp -[Registration(Instance = nameof(DefaultInstance))] -public class ServiceHub : IServiceA, IServiceB, IHealthCheck -{ - public static readonly ServiceHub DefaultInstance = new(); - - private ServiceHub() { } - - // IServiceA members... - // IServiceB members... - // IHealthCheck members... -} -``` - -**Generated Code:** - -```csharp -// Same instance registered for all interfaces -services.AddSingleton(ServiceHub.DefaultInstance); -services.AddSingleton(ServiceHub.DefaultInstance); -services.AddSingleton(ServiceHub.DefaultInstance); -``` - -### Instance Registration Best Practices - -**When to Use Instance Registration:** - -- ✅ Immutable configuration objects with default values -- ✅ Pre-initialized singleton services (caches, registries, etc.) -- ✅ Singleton instances that should be shared across the application -- ✅ Services with complex initialization that should happen once at startup - -**When NOT to Use Instance Registration:** - -- ❌ Services that need different instances per scope/request - use factory methods instead -- ❌ Services requiring runtime configuration - use `IOptions` pattern or factory methods -- ❌ Services with mutable state that shouldn't be shared -- ❌ Non-singleton lifetimes - instance registration only supports Singleton - -### Instance Registration Diagnostics - -The generator provides compile-time validation: - -**❌ ATCDIR007: Instance member not found** - -```csharp -[Registration(As = typeof(ICache), Instance = "NonExistentMember")] -public class CacheService : ICache { } -// Error: Member 'NonExistentMember' not found. Must be a static field, property, or method. -``` - -**❌ ATCDIR008: Instance member must be static** - -```csharp -[Registration(As = typeof(ICache), Instance = nameof(InstanceField))] -public class CacheService : ICache -{ - public readonly CacheService InstanceField = new(); // Not static! -} -// Error: Instance member 'InstanceField' must be static. -``` - -**❌ ATCDIR009: Instance and Factory are mutually exclusive** - -```csharp -[Registration( - As = typeof(IEmailSender), - Factory = nameof(Create), - Instance = nameof(DefaultInstance))] // Cannot use both! -public class EmailSender : IEmailSender { } -// Error: Cannot use both Instance and Factory parameters on the same service. -``` - -**❌ ATCDIR010: Instance registration requires Singleton lifetime** - -```csharp -[Registration(Lifetime.Scoped, As = typeof(ICache), Instance = nameof(Instance))] -public class CacheService : ICache -{ - public static CacheService Instance { get; } = new(); -} -// Error: Instance registration can only be used with Singleton lifetime. Current lifetime is 'Scoped'. -``` - -### Instance vs Factory Method - -**Use Instance when:** - -- The instance is pre-created and immutable -- No runtime dependencies or configuration needed -- Singleton pattern with guaranteed single instance - -**Use Factory Method when:** - -- Service requires `IServiceProvider` to resolve dependencies -- Runtime configuration is needed (from `IConfiguration`, environment, etc.) -- Conditional initialization based on runtime state - -**Example comparison:** - -```csharp -// Instance: Pre-created, immutable configuration -[Registration(As = typeof(IAppSettings), Instance = nameof(Default))] -public class AppSettings : IAppSettings -{ - public static readonly AppSettings Default = new() { Timeout = 30 }; -} - -// Factory: Runtime configuration from IConfiguration -[Registration(Lifetime.Singleton, As = typeof(IEmailClient), Factory = nameof(Create))] -public class EmailClient : IEmailClient -{ - public static IEmailClient Create(IServiceProvider sp) - { - var config = sp.GetRequiredService(); - var apiKey = config["Email:ApiKey"]; - return new EmailClient(apiKey); - } -} -``` - ---- - -## 🔄 TryAdd* Registration - -TryAdd* registration enables conditional service registration that only adds services if they're not already registered. This is particularly useful for library authors who want to provide default implementations that can be easily overridden by application code. - -### Basic TryAdd Registration - -```csharp -[Registration(As = typeof(ILogger), TryAdd = true)] -public class DefaultLogger : ILogger -{ - public void Log(string message) - { - Console.WriteLine($"[DefaultLogger] {message}"); - } -} -``` - -**Generated Code:** - -```csharp -services.TryAddSingleton(); -``` - -### How TryAdd Works - -When `TryAdd = true`, the generator uses `TryAdd{Lifetime}()` methods instead of `Add{Lifetime}()`: - -- `TryAddSingleton()` - Only registers if no `T` is already registered -- `TryAddScoped()` - Only registers if no `T` is already registered -- `TryAddTransient()` - Only registers if no `T` is already registered - -This allows consumers to override default implementations: - -```csharp -// Application code (runs BEFORE library registration) -services.AddSingleton(); // This takes precedence - -// Library registration (uses TryAdd) -services.AddDependencyRegistrationsFromLibrary(); -// DefaultLogger will NOT be registered because CustomLogger is already registered -``` - -### Library Author Pattern - -TryAdd is perfect for libraries that want to provide sensible defaults: - -```csharp -// Library code: PetStore.Domain -namespace PetStore.Domain.Services; - -[Registration(Lifetime.Singleton, As = typeof(IHealthCheck), TryAdd = true)] -public class DefaultHealthCheck : IHealthCheck -{ - public Task CheckHealthAsync() - { - Console.WriteLine("DefaultHealthCheck: Performing basic health check (always healthy)"); - return Task.FromResult(true); - } -} -``` - -**Consumer can override:** - -```csharp -// Application code -services.AddSingleton(); // Custom implementation -services.AddDependencyRegistrationsFromDomain(); // DefaultHealthCheck won't be added -``` - -**Or consumer can use default:** - -```csharp -// Application code -services.AddDependencyRegistrationsFromDomain(); // DefaultHealthCheck is added -``` - -### TryAdd with Different Lifetimes - -```csharp -// Scoped with TryAdd -[Registration(Lifetime.Scoped, As = typeof(ICache), TryAdd = true)] -public class DefaultCache : ICache -{ - public string Get(string key) => /* implementation */; -} - -// Transient with TryAdd -[Registration(Lifetime.Transient, As = typeof(IMessageFormatter), TryAdd = true)] -public class DefaultMessageFormatter : IMessageFormatter -{ - public string Format(string message) => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.TryAddScoped(); -services.TryAddTransient(); -``` - -### TryAdd with Factory Methods - -TryAdd works seamlessly with factory methods: - -```csharp -[Registration(Lifetime.Singleton, As = typeof(IEmailSender), TryAdd = true, Factory = nameof(CreateEmailSender))] -public class DefaultEmailSender : IEmailSender -{ - private readonly string smtpHost; - - private DefaultEmailSender(string smtpHost) - { - this.smtpHost = smtpHost; - } - - public static IEmailSender CreateEmailSender(IServiceProvider provider) - { - var config = provider.GetRequiredService(); - var host = config["Email:SmtpHost"] ?? "localhost"; - return new DefaultEmailSender(host); - } - - public Task SendEmailAsync(string to, string subject, string body) - { - // Implementation... - } -} -``` - -**Generated Code:** - -```csharp -services.TryAddSingleton(sp => DefaultEmailSender.CreateEmailSender(sp)); -``` - -### TryAdd with Generic Types - -TryAdd supports generic interface registration: - -```csharp -[Registration(Lifetime.Scoped, TryAdd = true)] -public class DefaultRepository : IRepository where T : class -{ - public T? GetById(int id) => /* implementation */; - public IEnumerable GetAll() => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.TryAddScoped(typeof(IRepository<>), typeof(DefaultRepository<>)); -``` - -### TryAdd with Multiple Interfaces - -When a service implements multiple interfaces, TryAdd is applied to each registration: - -```csharp -[Registration(TryAdd = true)] -public class DefaultNotificationService : IEmailNotificationService, ISmsNotificationService -{ - public Task SendEmailAsync(string email, string message) => /* implementation */; - public Task SendSmsAsync(string phoneNumber, string message) => /* implementation */; -} -``` - -**Generated Code:** - -```csharp -services.TryAddSingleton(); -services.TryAddSingleton(); -``` - -### TryAdd Best Practices - -**When to Use TryAdd:** - -- ✅ Library projects providing default implementations -- ✅ Fallback services that applications may want to customize -- ✅ Services with sensible defaults but customizable behavior -- ✅ Avoiding registration conflicts in modular applications - -**When NOT to Use TryAdd:** - -- ❌ Core application services that should always be registered -- ❌ Services where registration order matters for business logic -- ❌ When you need to explicitly override existing registrations (use regular registration) - -### Important Notes - -**Keyed Services:** -TryAdd is **not supported** with keyed services. When both `Key` and `TryAdd` are specified, the generator will prioritize keyed registration and ignore `TryAdd`: - -```csharp -// ⚠️ TryAdd is ignored when Key is specified -[Registration(Key = "Primary", TryAdd = true)] -public class PrimaryCache : ICache { } - -// Generated (keyed registration, no TryAdd): -services.AddKeyedSingleton("Primary"); -``` - -**Registration Order:** -For TryAdd to work correctly, ensure library registrations happen **after** application-specific registrations: - -```csharp -// ✅ Correct order -services.AddSingleton(); // Application override -services.AddDependencyRegistrationsFromLibrary(); // Library defaults (TryAdd) - -// ❌ Wrong order -services.AddDependencyRegistrationsFromLibrary(); // Library defaults register first -services.AddSingleton(); // This creates a duplicate registration! -``` - ---- - -## 🚫 Assembly Scanning Filters - -Assembly Scanning Filters allow you to exclude specific types, namespaces, or patterns from automatic registration. This is particularly useful for: - -- Excluding internal/test services from production builds -- Preventing mock/stub services from being registered -- Filtering out utilities that shouldn't be in the DI container - -### Basic Filter Usage - -Filters are applied using the `[RegistrationFilter]` attribute at the assembly level: - -```csharp -using Atc.DependencyInjection; - -// Exclude internal services -[assembly: RegistrationFilter(ExcludeNamespaces = new[] { "MyApp.Internal" })] - -// Your services -namespace MyApp.Services -{ - [Registration] - public class ProductionService : IProductionService { } // ✅ Will be registered -} - -namespace MyApp.Internal -{ - [Registration] - public class InternalService : IInternalService { } // ❌ Excluded by filter -} -``` - -### Namespace Exclusion - -Exclude types in specific namespaces. Sub-namespaces are also excluded: - -```csharp -[assembly: RegistrationFilter(ExcludeNamespaces = new[] { - "MyApp.Internal", - "MyApp.Testing", - "MyApp.Utilities" -})] - -namespace MyApp.Services -{ - [Registration] - public class UserService : IUserService { } // ✅ Registered -} - -namespace MyApp.Internal -{ - [Registration] - public class InternalCache : ICache { } // ❌ Excluded -} - -namespace MyApp.Internal.Deep.Nested -{ - [Registration] - public class DeepService : IDeepService { } // ❌ Also excluded (sub-namespace) -} -``` - -**How Namespace Filtering Works:** - -- Exact match: `"MyApp.Internal"` excludes types in that namespace -- Sub-namespace match: Also excludes `"MyApp.Internal.Something"`, `"MyApp.Internal.Deep.Nested"`, etc. - -### Pattern Exclusion - -Exclude types whose names match wildcard patterns. Supports `*` (any characters) and `?` (single character): - -```csharp -[assembly: RegistrationFilter(ExcludePatterns = new[] { - "*Mock*", // Excludes MockEmailService, EmailMockService, etc. - "*Test*", // Excludes TestHelper, UserTestService, etc. - "Temp*", // Excludes TempService, TempCache, etc. - "Old?Data" // Excludes OldAData, OldBData, but NOT OldAbcData -})] - -namespace MyApp.Services -{ - [Registration] - public class ProductionEmailService : IEmailService { } // ✅ Registered - - [Registration] - public class MockEmailService : IEmailService { } // ❌ Excluded (*Mock*) - - [Registration] - public class UserTestHelper : ITestHelper { } // ❌ Excluded (*Test*) - - [Registration] - public class TempCache : ICache { } // ❌ Excluded (Temp*) -} -``` - -**Pattern Matching Rules:** - -- `*` matches zero or more characters -- `?` matches exactly one character -- Matching is case-insensitive -- Patterns match against the type name (not the full namespace) - -### Interface Exclusion - -Exclude types that implement specific interfaces: - -```csharp -[assembly: RegistrationFilter(ExcludeImplementing = new[] { - typeof(ITestUtility), - typeof(IInternalTool) -})] - -namespace MyApp.Services -{ - public interface ITestUtility { } - public interface IProductionService { } - - [Registration] - public class ProductionService : IProductionService { } // ✅ Registered - - [Registration] - public class TestHelper : ITestUtility { } // ❌ Excluded (implements ITestUtility) - - [Registration] - public class MockDatabase : IDatabase, ITestUtility { } // ❌ Excluded (implements ITestUtility) -} -``` - -**How Interface Filtering Works:** - -- Checks all interfaces implemented by the type -- Uses proper generic type comparison (`SymbolEqualityComparer`) -- Works with generic interfaces like `IRepository` - -### Combining Multiple Filters - -You can combine multiple filter types in a single attribute: - -```csharp -[assembly: RegistrationFilter( - ExcludeNamespaces = new[] { "MyApp.Internal", "MyApp.Testing" }, - ExcludePatterns = new[] { "*Mock*", "*Test*", "*Fake*" }, - ExcludeImplementing = new[] { typeof(ITestUtility) })] - -// All filter rules are applied -// A type is excluded if it matches ANY of the rules -``` - -### Multiple Filter Attributes - -You can also apply multiple `[RegistrationFilter]` attributes: - -```csharp -// Filter 1: Exclude internal namespaces -[assembly: RegistrationFilter(ExcludeNamespaces = new[] { - "MyApp.Internal" -})] - -// Filter 2: Exclude test patterns -[assembly: RegistrationFilter(ExcludePatterns = new[] { - "*Mock*", - "*Test*" -})] - -// Filter 3: Exclude utility interfaces -[assembly: RegistrationFilter(ExcludeImplementing = new[] { - typeof(ITestUtility) -})] - -// All filters are combined -``` - -### Real-World Example - -Here's a complete example showing filters in action: - -```csharp -// AssemblyInfo.cs -using Atc.DependencyInjection; - -[assembly: RegistrationFilter( - ExcludeNamespaces = new[] { - "MyApp.Internal", - "MyApp.Development" - }, - ExcludePatterns = new[] { - "*Mock*", - "*Test*", - "*Fake*", - "Temp*" - })] -``` - -```csharp -// Production service - WILL be registered -namespace MyApp.Services -{ - [Registration] - public class EmailService : IEmailService - { - public void SendEmail(string to, string message) { } - } -} - -// Internal service - EXCLUDED by namespace -namespace MyApp.Internal -{ - [Registration] - public class InternalCache : ICache { } // ❌ Excluded -} - -// Mock service - EXCLUDED by pattern -namespace MyApp.Services -{ - [Registration] - public class MockEmailService : IEmailService { } // ❌ Excluded -} - -// Test helper - EXCLUDED by pattern -namespace MyApp.Testing -{ - [Registration] - public class TestDataBuilder : ITestHelper { } // ❌ Excluded -} -``` - -### Filter Priority and Behavior - -**Important Notes:** - -1. **Filters are applied first**: Types are filtered OUT before any registration happens -2. **ANY match excludes**: If a type matches ANY filter rule, it's excluded -3. **Applies to all registrations**: Filters affect both current assembly and referenced assemblies -4. **No diagnostics for filtered types**: Filtered types are silently skipped (this is intentional) - -### Verification - -You can verify filters are working by trying to resolve filtered services: - -```csharp -var services = new ServiceCollection(); -services.AddDependencyRegistrationsFromMyApp(); - -var provider = services.BuildServiceProvider(); - -// This will return null (service was filtered out) -var mockService = provider.GetService(); -Console.WriteLine($"MockEmailService registered: {mockService != null}"); // False - -// This will succeed (service was not filtered) -var emailService = provider.GetRequiredService(); -Console.WriteLine($"EmailService registered: {emailService != null}"); // True -``` - -### Best Practices - -**When to Use Filters:** - -- ✅ Excluding internal implementation details from DI -- ✅ Preventing test/mock services from production builds -- ✅ Filtering development-only utilities -- ✅ Clean separation between production and development code - -**When NOT to Use Filters:** - -- ❌ Don't use filters as the primary way to control registration (use conditional compilation instead) -- ❌ Don't create overly complex filter patterns that are hard to understand -- ❌ Don't filter services that SHOULD be in DI but you forgot to configure properly - -**Recommended Patterns:** - -```csharp -// ✅ Good: Clear, specific exclusions -[assembly: RegistrationFilter( - ExcludeNamespaces = new[] { "MyApp.Internal" }, - ExcludePatterns = new[] { "*Mock*", "*Test*" })] - -// ❌ Avoid: Overly broad patterns -[assembly: RegistrationFilter(ExcludePatterns = new[] { "*" })] // Excludes everything! - -// ❌ Avoid: Filters as primary registration control -// Instead of filtering, just don't add [Registration] attribute -``` - ---- - -## 🎯 Runtime Filtering - -Runtime filtering allows you to exclude specific services **when calling** the registration methods, rather than at compile time. This is extremely useful when: - -- Different applications need different subsets of services from a shared library -- You want to exclude services conditionally based on runtime configuration -- Testing scenarios require specific services to be excluded -- Multiple applications share the same domain library but have different infrastructure needs - -### Basic Usage - -All generated `AddDependencyRegistrationsFrom*()` methods support three optional filter parameters: - -```csharp -services.AddDependencyRegistrationsFromDomain( - excludedNamespaces: new[] { "MyApp.Domain.Internal" }, - excludedPatterns: new[] { "*Test*", "*Mock*" }, - excludedTypes: new[] { typeof(EmailService), typeof(SmsService) }); -``` - -### 🔹 Filter by Type - -Exclude specific types explicitly: - -```csharp -// Exclude specific services by type -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(EmailService), typeof(NotificationService) }); -``` - -**Example Scenario**: PetStore.Api uses email services, but PetStore.WpfApp doesn't need them: - -```csharp -// PetStore.Api - includes all services -services.AddDependencyRegistrationsFromDomain(); - -// PetStore.WpfApp - excludes email/notification services -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(EmailService), typeof(INotificationService) }); -``` - -### 🔹 Filter by Namespace - -Exclude entire namespaces (including sub-namespaces): - -```csharp -// Exclude all services in the Internal namespace -services.AddDependencyRegistrationsFromDomain( - excludedNamespaces: new[] { "MyApp.Domain.Internal" }); - -// Also excludes MyApp.Domain.Internal.Utils, MyApp.Domain.Internal.Deep, etc. -``` - -**Example Scenario**: Different deployment environments need different services: - -```csharp -#if PRODUCTION - // Production: exclude development/debug services - services.AddDependencyRegistrationsFromDomain( - excludedNamespaces: new[] { "MyApp.Domain.Development", "MyApp.Domain.Debug" }); -#else - // Development: include all services - services.AddDependencyRegistrationsFromDomain(); -#endif -``` - -### 🔹 Filter by Pattern - -Exclude services using wildcard patterns (`*` = any characters, `?` = single character): - -```csharp -// Exclude all mock, test, and fake services -services.AddDependencyRegistrationsFromDomain( - excludedPatterns: new[] { "*Mock*", "*Test*", "*Fake*", "*Stub*" }); - -// Exclude all services ending with "Helper" or "Utility" -services.AddDependencyRegistrationsFromDomain( - excludedPatterns: new[] { "*Helper", "*Utility" }); -``` - -**Example Scenario**: Exclude all logging services in a minimal deployment: - -```csharp -// Minimal deployment - no logging services -services.AddDependencyRegistrationsFromDomain( - excludedPatterns: new[] { "*Logger*", "*Logging*", "*Log*" }); -``` - -### 🔹 Combining Filters - -All three filter types can be used together: - -```csharp -services.AddDependencyRegistrationsFromDomain( - excludedNamespaces: new[] { "MyApp.Domain.Internal" }, - excludedPatterns: new[] { "*Test*", "*Development*" }, - excludedTypes: new[] { typeof(LegacyService), typeof(DeprecatedFeature) }); -``` - -### 🔹 Filters with Transitive Registration - -Runtime filters are automatically propagated to referenced assemblies: - -```csharp -// Filters apply to Domain AND all referenced assemblies (DataAccess, Infrastructure, etc.) -services.AddDependencyRegistrationsFromDomain( - includeReferencedAssemblies: true, - excludedNamespaces: new[] { "*.Internal" }, - excludedPatterns: new[] { "*Test*" }, - excludedTypes: new[] { typeof(EmailService) }); -``` - -All referenced assemblies will also exclude: - -- Any namespace ending with `.Internal` -- Any type matching `*Test*` pattern -- The `EmailService` type - -### Runtime vs. Compile-Time Filtering - -| Feature | Compile-Time (Assembly) | Runtime (Method Parameters) | -|---------|------------------------|----------------------------| -| **Applied When** | During source generation | During service registration | -| **Scope** | All registrations from assembly | Specific registration call | -| **Use Case** | Global exclusions (test/mock services) | Application-specific exclusions | -| **Configured In** | `AssemblyInfo.cs` with `[RegistrationFilter]` | Method call parameters | -| **Flexibility** | Fixed at compile time | Can vary per application/scenario | - -### Complete Example: Multi-Application Scenario - -**Shared Domain Library** (PetStore.Domain): - -```csharp -namespace PetStore.Domain.Services; - -[Registration] -public class PetService : IPetService { } // Core service - needed by all apps - -[Registration] -public class EmailService : IEmailService { } // Email - only needed by API - -[Registration] -public class ReportService : IReportService { } // Reports - only needed by Admin - -[Registration] -public class NotificationService : INotificationService { } // Notifications - only API -``` - -**PetStore.Api** (Web API - needs email + notifications): - -```csharp -// Include all services -services.AddDependencyRegistrationsFromDomain(); -``` - -**PetStore.WpfApp** (Desktop app - needs reports, not email): - -```csharp -// Exclude email and notification services -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(EmailService), typeof(NotificationService) }); -``` - -**PetStore.AdminPortal** (Admin - needs reports, not notifications): - -```csharp -// Exclude notification services -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(NotificationService) }); -``` - -**PetStore.MobileApp** (Minimal deployment): - -```csharp -// Exclude email, notifications, and reports -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] - { - typeof(EmailService), - typeof(NotificationService), - typeof(ReportService) - }); -``` - -### Best Practices - -✅ **Do:** - -- Use runtime filtering when different applications need different service subsets -- Use type exclusion for specific services you know by name -- Use pattern exclusion for groups of services (e.g., all `*Mock*` services) -- Use namespace exclusion for entire feature areas -- Combine with compile-time filters for maximum control - -```csharp -// Good: Application-specific exclusions -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(EmailService) }); // This app doesn't send emails -``` - -❌ **Avoid:** - -- Using overly broad patterns that might accidentally exclude needed services -- Runtime filtering as a replacement for proper service design -- Filtering when you should just not add `[Registration]` attribute - -```csharp -// Bad: Overly broad pattern -services.AddDependencyRegistrationsFromDomain( - excludedPatterns: new[] { "*Service*" }); // Excludes almost everything! - -// Bad: Should use compile-time filtering instead -services.AddDependencyRegistrationsFromDomain( - excludedPatterns: new[] { "*Test*" }); // Tests should be excluded at compile time -``` - -### Verification - -You can verify which services are excluded by inspecting the service collection: - -```csharp -services.AddDependencyRegistrationsFromDomain( - excludedTypes: new[] { typeof(EmailService) }); - -// Verify EmailService is not registered -var emailService = serviceProvider.GetService(); -Console.WriteLine($"EmailService registered: {emailService != null}"); // False -``` - ---- - -## 🎨 Decorator Pattern - -The decorator pattern allows you to wrap existing services with additional functionality (logging, caching, validation, etc.) without modifying the original implementation. This is perfect for implementing cross-cutting concerns in a clean, maintainable way. - -### ✨ How It Works - -1. **Register the base service** normally with `[Registration]` -2. **Create a decorator class** that implements the same interface and wraps the base service -3. **Mark the decorator** with `[Registration(Decorator = true)]` -4. The generator automatically: - - Registers base services first - - Then registers decorators that wrap them - - Preserves the service lifetime - -### 📝 Basic Example - -```csharp -// Interface -public interface IOrderService -{ - Task PlaceOrderAsync(string orderId); -} - -// Base service - registered first -[Registration(Lifetime.Scoped, As = typeof(IOrderService))] -public class OrderService : IOrderService -{ - public Task PlaceOrderAsync(string orderId) - { - Console.WriteLine($"[OrderService] Processing order {orderId}"); - return Task.CompletedTask; - } -} - -// Decorator - wraps the base service -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] -public class LoggingOrderServiceDecorator : IOrderService -{ - private readonly IOrderService inner; - private readonly ILogger logger; - - // First parameter MUST be the interface being decorated - public LoggingOrderServiceDecorator( - IOrderService inner, - ILogger logger) - { - this.inner = inner; - this.logger = logger; - } - - public async Task PlaceOrderAsync(string orderId) - { - logger.LogInformation("Before placing order {OrderId}", orderId); - await inner.PlaceOrderAsync(orderId); - logger.LogInformation("After placing order {OrderId}", orderId); - } -} -``` - -**Usage:** - -```csharp -services.AddDependencyRegistrationsFromDomain(); - -var orderService = serviceProvider.GetRequiredService(); -await orderService.PlaceOrderAsync("ORDER-123"); - -// Output: -// [LoggingDecorator] Before placing order ORDER-123 -// [OrderService] Processing order ORDER-123 -// [LoggingDecorator] After placing order ORDER-123 -``` - -### Generated Code - -The generator creates special `Decorate` extension methods that: - -1. Find the existing service registration -2. Remove it from the service collection -3. Create a new registration that resolves the original and wraps it - -```csharp -// Generated registration code -services.AddScoped(); // Base service -services.Decorate((provider, inner) => // Decorator -{ - return ActivatorUtilities.CreateInstance(provider, inner); -}); -``` - -### 🔄 Multiple Decorators - -You can stack multiple decorators - they are applied in the order they are discovered: - -```csharp -[Registration(Lifetime.Scoped, As = typeof(IOrderService))] -public class OrderService : IOrderService { } - -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] -public class LoggingDecorator : IOrderService { } - -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] -public class ValidationDecorator : IOrderService { } - -[Registration(Lifetime.Scoped, As = typeof(IOrderService), Decorator = true)] -public class CachingDecorator : IOrderService { } -``` - -**Result:** `CachingDecorator → ValidationDecorator → LoggingDecorator → OrderService` - -### 🎯 Common Use Cases - -#### 1. Logging/Auditing - -```csharp -[Registration(Decorator = true)] -public class AuditingDecorator : IPetService -{ - private readonly IPetService inner; - private readonly IAuditLog auditLog; - - public Pet CreatePet(CreatePetRequest request) - { - var result = inner.CreatePet(request); - auditLog.Log($"Created pet {result.Id} by {currentUser}"); - return result; - } -} -``` - -#### 2. Caching - -```csharp -[Registration(Decorator = true)] -public class CachingPetServiceDecorator : IPetService -{ - private readonly IPetService inner; - private readonly IMemoryCache cache; - - public Pet? GetById(Guid id) - { - return cache.GetOrCreate($"pet:{id}", entry => - { - entry.SlidingExpiration = TimeSpan.FromMinutes(5); - return inner.GetById(id); - }); - } -} -``` - -#### 3. Validation - -```csharp -[Registration(Decorator = true)] -public class ValidationDecorator : IPetService -{ - private readonly IPetService inner; - private readonly IValidator validator; - - public Pet CreatePet(CreatePetRequest request) - { - var validationResult = validator.Validate(request); - if (!validationResult.IsValid) - { - throw new ValidationException(validationResult.Errors); - } - return inner.CreatePet(request); - } -} -``` - -#### 4. Retry Logic - -```csharp -[Registration(Decorator = true)] -public class RetryDecorator : IExternalApiService -{ - private readonly IExternalApiService inner; - - public async Task CallApiAsync() - { - for (int i = 0; i < 3; i++) - { - try - { - return await inner.CallApiAsync(); - } - catch (HttpRequestException) when (i < 2) - { - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); - } - } - } -} -``` - -### ⚠️ Important Notes - -1. **Explicit `As` Required**: Decorators MUST specify the `As` parameter to indicate which interface they decorate - - ```csharp - // ❌ Won't work - missing As parameter - [Registration(Decorator = true)] - public class MyDecorator : IService { } - - // ✅ Correct - [Registration(As = typeof(IService), Decorator = true)] - public class MyDecorator : IService { } - ``` - -2. **Constructor First Parameter**: The decorator's constructor must accept the interface as the first parameter - - ```csharp - // ✅ Correct - interface is first parameter - public MyDecorator(IService inner, ILogger logger) { } - - // ✅ Also correct - only parameter - public MyDecorator(IService inner) { } - ``` - -3. **Matching Lifetime**: Decorators inherit the lifetime of the base service registration - -4. **Registration Order**: Base services are always registered before decorators, regardless of file order - -### 🔍 Complete Example - -See the **PetStore.Domain** sample for a complete working example: - -- Base service: [PetService.cs](../../sample/PetStore.Domain/Services/PetService.cs) -- Decorator: [LoggingPetServiceDecorator.cs](../../sample/PetStore.Domain/Services/LoggingPetServiceDecorator.cs) - ---- - -## 🎛️ Conditional Registration - -Conditional Registration allows you to register services based on configuration values at runtime. This is perfect for feature flags, environment-specific services, A/B testing, and gradual rollouts. - -### ✨ How It Works - -Services with a `Condition` parameter are only registered if the configuration value at the specified key path evaluates to `true`. The condition is checked at runtime when the registration methods are called. - -When an assembly contains services with conditional registration: - -- An `IConfiguration` parameter is **automatically added** to all generated extension method signatures -- The configuration value is checked using `configuration.GetValue("key")` -- Services are registered inside `if` blocks based on the condition - -### 📝 Basic Example - -```csharp -// Register RedisCache only when Features:UseRedisCache is true -[Registration(As = typeof(ICache), Condition = "Features:UseRedisCache")] -public class RedisCache : ICache -{ - public string Get(string key) => /* Redis implementation */; - public void Set(string key, string value) => /* Redis implementation */; -} - -// Register MemoryCache only when Features:UseRedisCache is false -[Registration(As = typeof(ICache), Condition = "!Features:UseRedisCache")] -public class MemoryCache : ICache -{ - public string Get(string key) => /* In-memory implementation */; - public void Set(string key, string value) => /* In-memory implementation */; -} -``` - -**Configuration (appsettings.json):** - -```json -{ - "Features": { - "UseRedisCache": true - } -} -``` - -**Usage:** - -```csharp -var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .Build(); - -// IConfiguration is required when conditional services exist -services.AddDependencyRegistrationsFromDomain(configuration); - -// Resolves to RedisCache (because Features:UseRedisCache = true) -var cache = serviceProvider.GetRequiredService(); -``` - -### Generated Code - -```csharp -public static IServiceCollection AddDependencyRegistrationsFromDomain( - this IServiceCollection services, - IConfiguration configuration) -{ - // Conditional registration with positive check - if (configuration.GetValue("Features:UseRedisCache")) - { - services.AddSingleton(); - } - - // Conditional registration with negation - if (!configuration.GetValue("Features:UseRedisCache")) - { - services.AddSingleton(); - } - - return services; -} -``` - -### 🔄 Negation Support - -Prefix the condition with `!` to negate it (register when the value is `false`): - -```csharp -// Register when Features:UseRedisCache is FALSE -[Registration(As = typeof(ICache), Condition = "!Features:UseRedisCache")] -public class MemoryCache : ICache { } -``` - -**Generated:** - -```csharp -if (!configuration.GetValue("Features:UseRedisCache")) -{ - services.AddSingleton(); -} -``` - -### 🎯 Common Use Cases - -#### 1. Feature Flags - -Enable/disable features without code changes: - -```csharp -// Premium features only when enabled -[Registration(Lifetime.Scoped, As = typeof(IPremiumService), Condition = "Features:EnablePremium")] -public class PremiumService : IPremiumService -{ - public void ExecutePremiumFeature() { /* Premium logic */ } -} -``` - -**Configuration:** - -```json -{ - "Features": { - "EnablePremium": true - } -} -``` - -#### 2. Environment-Specific Services - -Different implementations for different environments: - -```csharp -// Production email service -[Registration(As = typeof(IEmailService), Condition = "Environment:IsProduction")] -public class SendGridEmailService : IEmailService { } - -// Development email service (logs instead of sending) -[Registration(As = typeof(IEmailService), Condition = "!Environment:IsProduction")] -public class LoggingEmailService : IEmailService { } -``` - -#### 3. A/B Testing - -Register different implementations based on experiment configuration: - -```csharp -[Registration(As = typeof(IRecommendationEngine), Condition = "Experiments:UseNewAlgorithm")] -public class NewRecommendationEngine : IRecommendationEngine { } - -[Registration(As = typeof(IRecommendationEngine), Condition = "!Experiments:UseNewAlgorithm")] -public class LegacyRecommendationEngine : IRecommendationEngine { } -``` - -#### 4. Cost Optimization - -Disable expensive services when not needed: - -```csharp -// AI service only when enabled (cost-saving) -[Registration(As = typeof(IAIService), Condition = "Services:EnableAI")] -public class OpenAIService : IAIService { } - -// Fallback simple service -[Registration(As = typeof(IAIService), Condition = "!Services:EnableAI")] -public class BasicTextService : IAIService { } -``` - -### 🎨 Advanced Scenarios - -#### Multiple Conditional Services - -```csharp -[Registration(As = typeof(IStorage), Condition = "Storage:UseAzure")] -public class AzureBlobStorage : IStorage { } - -[Registration(As = typeof(IStorage), Condition = "Storage:UseAWS")] -public class S3Storage : IStorage { } - -[Registration(As = typeof(IStorage), Condition = "Storage:UseLocal")] -public class LocalFileStorage : IStorage { } -``` - -**Configuration:** - -```json -{ - "Storage": { - "UseAzure": false, - "UseAWS": true, - "UseLocal": false - } -} -``` - -#### Combining with Different Lifetimes - -```csharp -// Scoped Redis cache (production) -[Registration(Lifetime.Scoped, As = typeof(ICache), Condition = "Cache:UseRedis")] -public class RedisCache : ICache { } - -// Singleton memory cache (development) -[Registration(Lifetime.Singleton, As = typeof(ICache), Condition = "!Cache:UseRedis")] -public class MemoryCache : ICache { } -``` - -#### Mixing Conditional and Unconditional - -```csharp -// Always registered (core service) -[Registration(Lifetime.Scoped, As = typeof(IUserService))] -public class UserService : IUserService { } - -// Conditionally registered (optional feature) -[Registration(Lifetime.Scoped, As = typeof(IAnalyticsService), Condition = "Features:EnableAnalytics")] -public class AnalyticsService : IAnalyticsService { } -``` - -### ⚙️ Configuration Best Practices - -**1. Use Hierarchical Configuration Keys:** - -```csharp -// Good: Organized hierarchy -[Registration(Condition = "Features:Cache:UseRedis")] -[Registration(Condition = "Features:Email:UseSendGrid")] -[Registration(Condition = "Experiments:NewUI:Enabled")] -``` - -**2. Boolean Values:** - -Conditions always use `GetValue()`, so ensure configuration values are boolean: - -```json -{ - "Features": { - "UseRedisCache": true, // ✅ Correct - "EnablePremium": "true", // ⚠️ Works but not ideal - "UseNewAlgorithm": 1 // ❌ Won't work as expected - } -} -``` - -**3. Default Values:** - -If a configuration key is missing, `GetValue()` returns `false` by default: - -```csharp -// If "Features:UseRedisCache" doesn't exist in config, MemoryCache is used -[Registration(Condition = "Features:UseRedisCache")] -public class RedisCache : ICache { } - -[Registration(Condition = "!Features:UseRedisCache")] -public class MemoryCache : ICache { } // ← This one is registered (default false) -``` - -### 🔍 IConfiguration Parameter Behavior - -**Without Conditional Services:** - -```csharp -// No conditional services → no IConfiguration parameter -services.AddDependencyRegistrationsFromDomain(); -``` - -**With Conditional Services:** - -```csharp -// Has conditional services → IConfiguration parameter required -services.AddDependencyRegistrationsFromDomain(configuration); -``` - -**All Overloads Updated:** - -When conditional services exist, ALL generated overloads include the `IConfiguration` parameter: - -```csharp -// Overload 1: Basic registration -services.AddDependencyRegistrationsFromDomain(configuration); - -// Overload 2: With transitive registration -services.AddDependencyRegistrationsFromDomain(configuration, includeReferencedAssemblies: true); - -// Overload 3: With specific assembly -services.AddDependencyRegistrationsFromDomain(configuration, "DataAccess"); - -// Overload 4: With multiple assemblies -services.AddDependencyRegistrationsFromDomain(configuration, "DataAccess", "Infrastructure"); -``` - -### ⚠️ Important Notes - -**1. Configuration Not Passed Transitively:** - -Configuration is NOT passed to referenced assemblies automatically. Each assembly manages its own conditional services: - -```csharp -// Domain has conditional services → needs configuration -services.AddDependencyRegistrationsFromDomain(configuration); - -// DataAccess also has conditional services → call it directly with configuration -services.AddDependencyRegistrationsFromDataAccess(configuration); - -// Or use transitive registration (but configuration isn't passed through) -services.AddDependencyRegistrationsFromDomain(configuration, includeReferencedAssemblies: true); -// ↑ This registers DataAccess services, but they won't have conditional logic applied -``` - -**2. Thread-Safe:** - -Configuration reading is thread-safe and can be used in concurrent scenarios. - -**3. Native AOT Compatible:** - -Conditional registration is fully compatible with Native AOT since all checks are simple boolean reads from configuration. - -**4. No Circular Dependencies:** - -Be careful not to create circular dependencies between conditional services. - -### ✅ Benefits - -- **🎯 Feature Flags**: Enable/disable features dynamically without redeployment -- **🌍 Environment-Specific**: Different implementations for dev/staging/prod -- **🧪 A/B Testing**: Easy experimentation with different implementations -- **💰 Cost Optimization**: Disable expensive services when not needed -- **🚀 Gradual Rollout**: Safely test new implementations before full deployment -- **🎨 Clean Code**: No `#if` preprocessor directives needed -- **⚡ Runtime Flexibility**: Change service implementations via configuration -- **🔒 Type-Safe**: All registrations validated at compile time - -### 📝 Complete Example - -See the **Atc.SourceGenerators.DependencyRegistration** sample for a complete working example: - -- **appsettings.json**: Feature flag configuration -- **RedisCache.cs**: Conditional service (Features:UseRedisCache = true) -- **MemoryCache.cs**: Conditional service (Features:UseRedisCache = false) -- **PremiumFeatureService.cs**: Conditional premium features -- **Program.cs**: Usage demonstration - ---- - -## 📚 Additional Examples - -See the [sample projects](../sample) for complete working examples: - -- **Simple Sample**: [Atc.SourceGenerators.DependencyRegistration](../sample/Atc.SourceGenerators.DependencyRegistration) -- **Domain Layer**: [Atc.SourceGenerators.DependencyRegistration.Domain](../sample/Atc.SourceGenerators.DependencyRegistration.Domain) diff --git a/docs/EnumMappingGenerators-Samples.md b/docs/EnumMappingGenerators-Samples.md deleted file mode 100644 index 2ce58d5..0000000 --- a/docs/EnumMappingGenerators-Samples.md +++ /dev/null @@ -1,390 +0,0 @@ -# 🔄 Enum Mapping Sample - -This sample demonstrates the **EnumMappingGenerator** in action with realistic enum mapping scenarios including special case handling, bidirectional mappings, and case-insensitive matching. - -## 📂 Project Location - -```text -sample/Atc.SourceGenerators.EnumMapping/ -``` - -## 🚀 Running the Sample - -```bash -cd sample/Atc.SourceGenerators.EnumMapping -dotnet run -``` - -Expected output: - -```text -=== Atc.SourceGenerators - Enum Mapping Sample === - -1. Testing PetStatusEntity → PetStatusDto mapping: - - Special case: None → Unknown - - Bidirectional: true - - None → Unknown - Available → Available - Pending → Pending - Adopted → Adopted - -2. Testing PetStatusDto → PetStatusEntity (reverse mapping): - Unknown → None - -3. Testing FeatureState → FeatureFlag mapping: - - Exact name matching - - Bidirectional: false - - Active → Active - Inactive → Inactive - Testing → Testing - -4. Testing case-insensitive matching: - All enum values match regardless of casing - -5. Performance characteristics: - ✓ Zero runtime cost - pure switch expressions - ✓ Compile-time safety with exhaustive checking - ✓ ArgumentOutOfRangeException for unmapped values - -=== All tests completed successfully! === -``` - -## 🎯 What This Sample Demonstrates - -### 1. Special Case Mapping: None → Unknown - -**PetStatusEntity.cs**: - -```csharp -[MapTo(typeof(PetStatusDto), Bidirectional = true)] -public enum PetStatusEntity -{ - None, // Special case: maps to PetStatusDto.Unknown - Pending, - Available, - Adopted, -} -``` - -**PetStatusDto.cs**: - -```csharp -public enum PetStatusDto -{ - Unknown, // Special case: maps from PetStatusEntity.None - Available, - Pending, - Adopted, -} -``` - -**Key Points**: - -- `None` automatically maps to `Unknown` (common pattern) -- `Bidirectional = true` generates both forward and reverse mappings -- All other values map by exact name match - -### 2. Exact Name Matching - -**FeatureState.cs**: - -```csharp -[MapTo(typeof(FeatureFlag))] -public enum FeatureState -{ - Active, // Exact match to FeatureFlag.Active - Inactive, // Exact match to FeatureFlag.Inactive - Testing, // Exact match to FeatureFlag.Testing -} -``` - -**FeatureFlag.cs**: - -```csharp -public enum FeatureFlag -{ - Active, // Exact match from FeatureState.Active - Inactive, // Exact match from FeatureState.Inactive - Testing, // Exact match from FeatureState.Testing -} -``` - -**Key Points**: - -- All values match by exact name -- Unidirectional mapping (Bidirectional = false) - -## 📁 Project Structure - -```text -sample/Atc.SourceGenerators.EnumMapping/ -├── Atc.SourceGenerators.EnumMapping.csproj -├── GlobalUsings.cs -├── Program.cs -└── Enums/ - ├── PetStatusEntity.cs (Database layer enum) - ├── PetStatusDto.cs (API layer enum) - ├── FeatureState.cs (Domain layer enum) - └── FeatureFlag.cs (Configuration layer enum) -``` - -## 🔍 Generated Code - -The source generator creates extension methods in the `Atc.Mapping` namespace: - -**EnumMappingExtensions.g.cs** (simplified): - -```csharp -namespace Atc.Mapping; - -public static class EnumMappingExtensions -{ - // Forward mapping: PetStatusEntity → PetStatusDto - public static PetStatusDto MapToPetStatusDto(this PetStatusEntity source) - { - return source switch - { - PetStatusEntity.None => PetStatusDto.Unknown, // Special case - PetStatusEntity.Pending => PetStatusDto.Pending, - PetStatusEntity.Available => PetStatusDto.Available, - PetStatusEntity.Adopted => PetStatusDto.Adopted, - _ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unmapped enum value"), - }; - } - - // Reverse mapping: PetStatusDto → PetStatusEntity (Bidirectional = true) - public static PetStatusEntity MapToPetStatusEntity(this PetStatusDto source) - { - return source switch - { - PetStatusDto.Unknown => PetStatusEntity.None, // Special case - PetStatusDto.Available => PetStatusEntity.Available, - PetStatusDto.Pending => PetStatusEntity.Pending, - PetStatusDto.Adopted => PetStatusEntity.Adopted, - _ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unmapped enum value"), - }; - } - - // Forward mapping: FeatureState → FeatureFlag - public static FeatureFlag MapToFeatureFlag(this FeatureState source) - { - return source switch - { - FeatureState.Active => FeatureFlag.Active, - FeatureState.Inactive => FeatureFlag.Inactive, - FeatureState.Testing => FeatureFlag.Testing, - _ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unmapped enum value"), - }; - } -} -``` - -## 🎨 Key Features Demonstrated - -### ✅ Special Case Detection - -The generator automatically recognizes common enum naming patterns: - -| Pattern | Equivalent Values | Notes | -|---------|------------------|-------| -| **Zero/Null States** | None ↔ Unknown, Default, NotSet | Common default state mapping | -| **Active States** | Active ↔ Enabled, On, Running | Service/feature activation | -| **Inactive States** | Inactive ↔ Disabled, Off, Stopped | Service/feature deactivation | -| **Deletion States** | Deleted ↔ Removed, Archived | Soft delete patterns | -| **Pending States** | Pending ↔ InProgress, Processing | Async operation states | -| **Completion States** | Completed ↔ Done, Finished | Task completion states | - -**Example:** - -```csharp -// Database enum -public enum ServiceStatusEntity -{ - None, // Maps to Unknown in API - Active, // Maps to Enabled in API - Inactive // Maps to Disabled in API -} - -// API enum -public enum ServiceStatus -{ - Unknown, // Maps from None in database - Enabled, // Maps from Active in database - Disabled // Maps from Inactive in database -} -``` - -**Smart matching**: The generator uses exact name matching first, then falls back to case-insensitive matching, and finally checks for special case patterns. This ensures predictable behavior while supporting common enum naming variations. - -### ✅ Bidirectional Mapping - -With `Bidirectional = true`: - -```csharp -[MapTo(typeof(PetStatusDto), Bidirectional = true)] -public enum PetStatusEntity { ... } -``` - -You get **two methods**: - -- `PetStatusEntity.MapToPetStatusDto()` (forward) -- `PetStatusDto.MapToPetStatusEntity()` (reverse) - -### ✅ Case-Insensitive Matching - -Enum values match regardless of casing: - -```csharp -// These all match: -SourceEnum.ACTIVE → TargetEnum.Active -SourceEnum.active → TargetEnum.Active -SourceEnum.Active → TargetEnum.Active -SourceEnum.AcTiVe → TargetEnum.Active -``` - -### ✅ Compile-Time Safety - -Unmapped values generate warnings: - -```text -Warning ATCENUM002: Enum value 'SourceStatus.Deleted' has no matching value in target enum 'TargetStatus' -``` - -### ✅ Runtime Safety - -Unmapped values throw at runtime: - -```csharp -var status = SourceStatus.Deleted; // Unmapped value -var dto = status.MapToTargetDto(); // Throws ArgumentOutOfRangeException -``` - -## 💡 Usage Patterns - -### Pattern 1: Database → Domain → API - -**Use Case**: Multi-layer architecture with enum separation - -```csharp -// Database layer -[MapTo(typeof(Domain.Status))] -public enum StatusEntity -{ - None, - Active, - Inactive, -} - -// Domain layer -[MapTo(typeof(Api.StatusDto))] -public enum Status -{ - Unknown, - Active, - Inactive, -} - -// API layer -public enum StatusDto -{ - Unknown, - Active, - Inactive, -} - -// Complete chain -var entity = database.GetStatus(); // StatusEntity.None -var domain = entity.MapToStatus(); // Status.Unknown -var dto = domain.MapToStatusDto(); // StatusDto.Unknown -``` - -### Pattern 2: Configuration → Domain - -**Use Case**: Feature flags with consistent naming - -```csharp -// Configuration (appsettings.json representation) -[MapTo(typeof(FeatureState))] -public enum FeatureFlag -{ - Active, - Inactive, -} - -// Domain (business logic representation) -public enum FeatureState -{ - Active, // ← FeatureFlag.Active (exact match) - Inactive, // ← FeatureFlag.Inactive (exact match) -} - -// Usage -var config = configuration.GetValue("MyFeature"); -var state = config.MapToFeatureState(); - -if (state == FeatureState.Active) -{ - // Feature is active -} -``` - -### Pattern 3: External API → Internal Domain - -**Use Case**: Third-party API integration with case normalization - -```csharp -// External API enum (from SDK) -[MapTo(typeof(InternalStatus))] -public enum ExternalStatus -{ - NONE, - PENDING, - ACTIVE, -} - -// Internal domain enum (your naming) -public enum InternalStatus -{ - Unknown, // ← ExternalStatus.NONE (special case: None → Unknown) - Pending, // ← ExternalStatus.PENDING (case-insensitive) - Active, // ← ExternalStatus.ACTIVE (case-insensitive) -} - -// Usage -var external = apiClient.GetStatus(); // ExternalStatus.NONE -var internal = external.MapToInternalStatus(); // InternalStatus.Unknown -``` - -## 🏗️ Real-World Example: PetStore - -This pattern is used in the [PetStore sample](PetStoreApi.md) to separate enum concerns across layers: - -```text -PetStatusEntity (DataAccess) - ↓ MapToPetStatus() -PetStatus (Domain) - ↓ MapToPetStatus() [different types, same method name] -PetStatus (Api.Contract) -``` - -Each layer has its own enum definition, and mappings are generated automatically. - -## 📝 Notes - -- **No Reflection**: All mappings use pure switch expressions for maximum performance -- **AOT Compatible**: Works with Native AOT compilation -- **Type Safe**: Compiler errors if target types are incorrect -- **IntelliSense Support**: Generated methods appear in IDE autocomplete - -## 📖 Related Samples - -- [Object Mapping Sample](ObjectMappingGenerators-Samples.md) - For class-to-class mappings -- [PetStore Sample](PetStoreApi-Samples.md) - Complete application using all generators -- [Dependency Registration Sample](DependencyRegistrationGenerators-Samples.md) - DI registration -- [Options Binding Sample](OptionsBinding-Samples.md) - Configuration binding - ---- - -**Need more examples?** Check the [EnumMapping Generator documentation](EnumMappingGenerators.md) for comprehensive guides and patterns. diff --git a/docs/EnumMappingGenerators.md b/docs/EnumMappingGenerators.md deleted file mode 100644 index 66ca0c4..0000000 --- a/docs/EnumMappingGenerators.md +++ /dev/null @@ -1,745 +0,0 @@ -# 🔄 Enum Mapping Generator - -Automatically generate type-safe enum-to-enum mapping code using attributes. The generator creates efficient switch expression mappings at compile time with intelligent name matching and special case handling, eliminating manual enum conversions and reducing errors. - -**Key Benefits:** - -- 🎯 **Zero runtime cost** - Pure switch expressions generated at compile time -- 🧠 **Intelligent matching** - Automatic special case detection (None → Unknown, Active → Enabled, etc.) -- 🔄 **Bidirectional support** - Generate forward and reverse mappings with one attribute -- 🛡️ **Type-safe** - Compile-time diagnostics for unmapped values -- ⚡ **Native AOT ready** - No reflection, fully trimming-safe - -**Quick Example:** - -```csharp -// Input: Decorate your enum -[MapTo(typeof(PetStatusDto), Bidirectional = true)] -public enum PetStatus { None, Available, Adopted } - -// Generated: Efficient switch expression -public static PetStatusDto MapToPetStatusDto(this PetStatus source) => - source switch { - PetStatus.None => PetStatusDto.Unknown, // Special case auto-detected - PetStatus.Available => PetStatusDto.Available, - PetStatus.Adopted => PetStatusDto.Adopted, - _ => throw new ArgumentOutOfRangeException(nameof(source)) - }; -``` - -## 📖 Documentation Navigation - -- **[🎯 Sample Projects](EnumMappingGenerators-Samples.md)** - Working code examples with architecture diagrams - -## 📑 Table of Contents - -- [� Enum Mapping Generator](#-enum-mapping-generator) - - [📑 Table of Contents](#-table-of-contents) - - [🚀 Get Started - Quick Guide](#-get-started---quick-guide) - - [📂 Project Structure](#-project-structure) - - [1️⃣ Setup Project](#1️⃣-setup-project) - - [2️⃣ Define Enums](#2️⃣-define-enums) - - [3️⃣ Use Generated Mappings](#3️⃣-use-generated-mappings) - - [🎨 What Gets Generated](#-what-gets-generated) - - [🎯 Key Takeaways](#-key-takeaways) - - [✨ Features](#-features) - - [📦 Installation](#-installation) - - [💡 Basic Usage](#-basic-usage) - - [1️⃣ Add Using Directives](#1️⃣-add-using-directives) - - [2️⃣ Decorate Your Enums](#2️⃣-decorate-your-enums) - - [3️⃣ Use Generated Mappings](#3️⃣-use-generated-mappings-1) - - [🏗️ Advanced Scenarios](#️-advanced-scenarios) - - [🔀 Special Case Mappings](#-special-case-mappings) - - [🔁 Bidirectional Mapping](#-bidirectional-mapping) - - [🔤 Case-Insensitive Matching](#-case-insensitive-matching) - - [🏛️ Multi-Layer Architecture](#️-multi-layer-architecture) - - [⚙️ MapToAttribute Parameters](#️-maptoattribute-parameters) - - [🛡️ Diagnostics](#️-diagnostics) - - [❌ ATCENUM001: Target Type Must Be Enum](#-atcenum001-target-type-must-be-enum) - - [⚠️ ATCENUM002: Unmapped Enum Value](#️-atcenum002-unmapped-enum-value) - - [🚀 Native AOT Compatibility](#-native-aot-compatibility) - - [✅ AOT-Safe Features](#-aot-safe-features) - - [🏗️ How It Works](#️-how-it-works) - - [📋 Example Generated Code](#-example-generated-code) - - [📚 Additional Examples](#-additional-examples) - - [Example 1: Order Status with None/Unknown](#example-1-order-status-with-noneunknown) - - [Example 2: Bidirectional Mapping](#example-2-bidirectional-mapping) - - [Example 3: Case-Insensitive Matching](#example-3-case-insensitive-matching) - - [🔧 Best Practices](#-best-practices) - - [📖 Related Documentation](#-related-documentation) - ---- - -## 🚀 Get Started - Quick Guide - -This guide demonstrates using enum mapping in a realistic application scenario with database entities and API DTOs. - -### 📂 Project Structure - -``` -PetStore.sln -├── PetStore.Api/ (Presentation layer - DTOs) -├── PetStore.Domain/ (Business logic layer - Domain enums) -└── PetStore.DataAccess/ (Data access layer - Entity enums) -``` - -### 1️⃣ Setup Project - -**PetStore.DataAccess.csproj**: - -```xml - - - net10.0 - enable - - - - - - - -``` - -### 2️⃣ Define Enums - -**PetStore.DataAccess/Entities/PetStatusEntity.cs**: - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace PetStore.DataAccess.Entities; - -/// -/// Pet status in the database layer. -/// -[MapTo(typeof(Domain.Models.PetStatus), Bidirectional = true)] -public enum PetStatusEntity -{ - None, // Will map to PetStatus.Unknown (special case) - Pending, - Available, - Adopted, -} -``` - -**PetStore.Domain/Models/PetStatus.cs**: - -```csharp -namespace PetStore.Domain.Models; - -/// -/// Pet status in the domain layer. -/// -public enum PetStatus -{ - Unknown, // Maps from PetStatusEntity.None - Available, - Pending, - Adopted, -} -``` - -### 3️⃣ Use Generated Mappings - -**PetStore.DataAccess/Repositories/PetRepository.cs**: - -```csharp -using Atc.Mapping; // Generated extension methods live here -using PetStore.DataAccess.Entities; -using PetStore.Domain.Models; - -public class PetRepository -{ - public IEnumerable GetByStatus(PetStatus status) - { - // Use generated mapping to convert domain enum to entity enum - var entityStatus = status.MapToPetStatusEntity(); - - var pets = database - .Pets - .Where(p => p.Status == entityStatus) - .ToList(); - - // Use generated mapping to convert entities back to domain - return pets.Select(e => new Pet - { - Id = e.Id, - Name = e.Name, - Status = e.Status.MapToPetStatus(), // ✨ Generated mapping - }); - } -} -``` - -### 🎨 What Gets Generated - -The generator creates extension methods with switch expressions in the `Atc.Mapping` namespace: - -**Generated Code**: - -```csharp -// -#nullable enable - -namespace Atc.Mapping; - -/// -/// Extension methods for enum mapping. -/// -[global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.EnumMapping", "1.0.0")] -public static class EnumMappingExtensions -{ - /// - /// Maps - /// to . - /// - public static PetStore.Domain.Models.PetStatus MapToPetStatus( - this PetStore.DataAccess.Entities.PetStatusEntity source) - { - return source switch - { - PetStatusEntity.None => PetStatus.Unknown, // Special case mapping - PetStatusEntity.Pending => PetStatus.Pending, - PetStatusEntity.Available => PetStatus.Available, - PetStatusEntity.Adopted => PetStatus.Adopted, - _ => throw new global::System.ArgumentOutOfRangeException( - nameof(source), source, "Unmapped enum value"), - }; - } - - /// - /// Maps - /// to . - /// - public static PetStore.DataAccess.Entities.PetStatusEntity MapToPetStatusEntity( - this PetStore.Domain.Models.PetStatus source) - { - return source switch - { - PetStatus.Unknown => PetStatusEntity.None, // Special case mapping - PetStatus.Available => PetStatusEntity.Available, - PetStatus.Pending => PetStatusEntity.Pending, - PetStatus.Adopted => PetStatusEntity.Adopted, - _ => throw new global::System.ArgumentOutOfRangeException( - nameof(source), source, "Unmapped enum value"), - }; - } -} -``` - -### 🎯 Key Takeaways - -✅ **Zero Runtime Cost** - Pure switch expressions, no reflection -✅ **Compile-Time Safety** - Catch errors before runtime -✅ **Intelligent Matching** - Automatic special case detection (None → Unknown → Default) -✅ **Case-Insensitive** - Matches enum values regardless of casing -✅ **Bidirectional** - Generate both forward and reverse mappings with one attribute -✅ **Runtime Safety** - ArgumentOutOfRangeException for unmapped values - ---- - -## ✨ Features - -- **🎯 Intelligent Name Matching** - Maps enum values by name with case-insensitive support -- **🔀 Special Case Detection** - Automatically handles "zero/empty/null" state equivalents: - - `None` ↔ `Unknown`, `Default` - - `Unknown` ↔ `None`, `Default` - - `Default` ↔ `None`, `Unknown` -- **⚡ Zero Runtime Cost** - Pure switch expressions, no reflection or runtime code generation -- **🚀 Native AOT Compatible** - Fully trimming-safe, works with Native AOT -- **🛡️ Type-Safe** - Compile-time validation with diagnostics for unmapped values -- **🔁 Bidirectional Mapping** - Generate both forward and reverse mappings with `Bidirectional = true` -- **🔤 Case-Insensitive** - Matches enum values regardless of casing differences -- **⚠️ Runtime Safety** - `ArgumentOutOfRangeException` thrown for unmapped values - ---- - -## 📦 Installation - -**Required:** - -```bash -dotnet add package Atc.SourceGenerators -``` - -**Optional (recommended for better IntelliSense):** - -```bash -dotnet add package Atc.SourceGenerators.Annotations -``` - -Or in your `.csproj`: - -```xml - - - - - - - -``` - -**Note:** The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: `$(NoWarn);CS0436` - ---- - -## 💡 Basic Usage - -### 1️⃣ Add Using Directives - -```csharp -using Atc.SourceGenerators.Annotations; // For [MapTo] -using Atc.Mapping; // For generated extension methods -``` - -### 2️⃣ Decorate Your Enums - -```csharp -// Source enum -[MapTo(typeof(StatusDto))] -public enum StatusEntity -{ - None, - Active, - Inactive, -} - -// Target enum -public enum StatusDto -{ - Unknown, - Active, - Inactive, -} -``` - -### 3️⃣ Use Generated Mappings - -```csharp -var entity = StatusEntity.None; -var dto = entity.MapToStatusDto(); // StatusDto.Unknown - -Console.WriteLine(dto); // Output: Unknown -``` - ---- - -## 🏗️ Advanced Scenarios - -### 🔀 Special Case Mappings - -The generator automatically handles common enum naming patterns: - -```csharp -// Entity layer -[MapTo(typeof(StatusDto))] -public enum StatusEntity -{ - None, // Maps to StatusDto.Unknown - Active, // Maps to StatusDto.Active (exact match) - Inactive, // Maps to StatusDto.Inactive (exact match) -} - -// DTO layer -public enum StatusDto -{ - Unknown, // Maps from StatusEntity.None (special case) - Active, // Maps from StatusEntity.Active (exact match) - Inactive, // Maps from StatusEntity.Inactive (exact match) -} - -// Usage -var entity = StatusEntity.None; -var dto = entity.MapToStatusDto(); // StatusDto.Unknown -``` - -**Supported Special Cases**: - -- **"Zero/Empty/Null" State Equivalents**: `None` ↔ `Unknown` ↔ `Default` -- Limited to just these three values to avoid unexpected mappings -- Use exact name matching for all other enum values - -### 🔁 Bidirectional Mapping - -Generate both forward and reverse mappings with a single attribute: - -```csharp -[MapTo(typeof(StatusDto), Bidirectional = true)] -public enum StatusEntity -{ - None, - Active, - Inactive, -} - -// Generated methods: -// - StatusEntity.MapToStatusDto() -// - StatusDto.MapToStatusEntity() - -var entity = StatusEntity.Active; -var dto = entity.MapToStatusDto(); // Forward -var back = dto.MapToStatusEntity(); // Reverse -``` - -### 🔤 Case-Insensitive Matching - -Enum values match regardless of casing: - -```csharp -[MapTo(typeof(TargetStatus))] -public enum SourceStatus -{ - ACTIVE, // Matches TargetStatus.Active - pending, // Matches TargetStatus.Pending - InActive, // Matches TargetStatus.Inactive -} - -public enum TargetStatus -{ - Active, - Pending, - Inactive, -} -``` - -### 🏛️ Multi-Layer Architecture - -Perfect for 3-layer architectures with clean enum separation: - -``` -Database (Entity Enums) → Domain (Model Enums) → API (DTO Enums) -``` - -**PetStore.DataAccess/Entities/PetStatusEntity.cs**: - -```csharp -[MapTo(typeof(Domain.Models.PetStatus))] -public enum PetStatusEntity -{ - None, // Database: 0 = unknown/null state - Pending, // Database: 1 - Available, // Database: 2 - Adopted, // Database: 3 -} -``` - -**PetStore.Domain/Models/PetStatus.cs**: - -```csharp -[MapTo(typeof(Api.Contract.PetStatus))] -public enum PetStatus -{ - Unknown, // Domain: maps from PetStatusEntity.None - Available, // Domain representation - Pending, - Adopted, -} -``` - -**PetStore.Api.Contract/PetStatus.cs**: - -```csharp -public enum PetStatus -{ - Unknown, // API: client-facing representation - Available, - Pending, - Adopted, -} -``` - -**Complete Mapping Chain**: - -```csharp -// Repository: Entity → Domain -var entity = database.Pets.First(); -var domain = entity.Status.MapToPetStatus(); // PetStatusEntity → PetStatus - -// Service: Domain → DTO -var dto = domain.MapToPetStatus(); // PetStatus (Domain) → PetStatus (API) -``` - ---- - -## ⚙️ MapToAttribute Parameters - -| Parameter | Type | Default | Description | -|----------------|----------|---------|-------------| -| `targetType` | `Type` | *(required)* | The target enum type to map to. Must be an enum type. | -| `Bidirectional` | `bool` | `false` | When `true`, generates both forward (Source → Target) and reverse (Target → Source) mappings. | - -**Examples**: - -```csharp -// Basic mapping (one direction) -[MapTo(typeof(StatusDto))] -public enum StatusEntity { ... } - -// Bidirectional mapping (both directions) -[MapTo(typeof(StatusDto), Bidirectional = true)] -public enum StatusEntity { ... } -``` - ---- - -## 🛡️ Diagnostics - -The generator reports diagnostics for potential issues at compile time. - -### ❌ ATCENUM001: Target Type Must Be Enum - -**Severity**: Error -**Category**: EnumMapping - -**Cause**: The target type specified in `[MapTo(typeof(...))]` is not an enum. - -**Example**: - -```csharp -public class StatusDto { } // ❌ Not an enum - -[MapTo(typeof(StatusDto))] // ❌ Error ATCENUM001 -public enum StatusEntity -{ - Active, - Inactive, -} -``` - -**Fix**: Ensure the target type is an enum. - -```csharp -public enum StatusDto // ✅ Enum type -{ - Active, - Inactive, -} - -[MapTo(typeof(StatusDto))] // ✅ OK -public enum StatusEntity -{ - Active, - Inactive, -} -``` - -### ⚠️ ATCENUM002: Unmapped Enum Value - -**Severity**: Warning -**Category**: EnumMapping - -**Cause**: A value in the source enum has no matching value in the target enum (including special cases). - -**Example**: - -```csharp -public enum TargetStatus -{ - Active, - Inactive, -} - -[MapTo(typeof(TargetStatus))] -public enum SourceStatus -{ - Active, - Inactive, - Deleted, // ⚠️ Warning ATCENUM002: No match for 'Deleted' - Archived, // ⚠️ Warning ATCENUM002: No match for 'Archived' -} -``` - -**Generated Code** (unmapped values are excluded from switch): - -```csharp -public static TargetStatus MapToTargetStatus(this SourceStatus source) -{ - return source switch - { - SourceStatus.Active => TargetStatus.Active, - SourceStatus.Inactive => TargetStatus.Inactive, - // Deleted and Archived are unmapped - will throw at runtime if used - _ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unmapped enum value"), - }; -} -``` - -**Fix Options**: - -1. **Add missing values to target enum**: - -```csharp -public enum TargetStatus -{ - Active, - Inactive, - Deleted, // ✅ Added - Archived, // ✅ Added -} -``` - -2. **Use exact name matching or rename values**: - -```csharp -public enum TargetStatus -{ - Active, - Inactive, - Deleted, // ✅ Renamed to match source - Archived, // ✅ Renamed to match source -} -``` - -3. **Accept the warning** if those values should never be used in the mapping context. - ---- - -## 🚀 Native AOT Compatibility - -The Enum Mapping Generator is **fully compatible with Native AOT** compilation, producing code that meets all AOT requirements: - -### ✅ AOT-Safe Features - -- **Zero reflection** - All mappings use switch expressions, not reflection-based converters -- **Compile-time generation** - Mapping code is generated during build, not at runtime -- **Trimming-safe** - No dynamic type discovery or metadata dependencies -- **Value type optimization** - Enums remain stack-allocated value types -- **Static analysis friendly** - All code paths are visible to the AOT compiler - -### 🏗️ How It Works - -1. **Build-time analysis**: The generator scans enums with `[MapTo]` attributes during compilation -2. **Switch expression generation**: Creates pure C# switch expressions without any reflection -3. **Direct value mapping**: Each enum value maps to target value via simple assignment -4. **AOT compilation**: The generated code compiles to native machine code with full optimizations - -### 📋 Example Generated Code - -```csharp -// Source: [MapTo(typeof(Status))] public enum EntityStatus { Active, Inactive } - -// Generated AOT-safe code: -public static Status MapToStatus(this EntityStatus source) -{ - return source switch - { - EntityStatus.Active => Status.Active, - EntityStatus.Inactive => Status.Inactive, - _ => throw new global::System.ArgumentOutOfRangeException( - nameof(source), source, "Unmapped enum value") - }; -} -``` - -**Why This Is AOT-Safe:** - -- No `Enum.Parse()` or `Enum.GetValues()` calls (reflection) -- No dynamic type conversion -- All branches known at compile time -- Exception paths are concrete and traceable -- Zero heap allocations for value type operations - ---- - -## 📚 Additional Examples - -### Example 1: Order Status with None/Unknown - -```csharp -// Database layer -[MapTo(typeof(OrderStatusDto))] -public enum OrderStatusEntity -{ - None, // → OrderStatusDto.Unknown (special case) - Pending, - Completed, - Cancelled, -} - -// API layer -public enum OrderStatusDto -{ - Unknown, // ← OrderStatusEntity.None - Pending, - Completed, - Cancelled, -} - -// Usage -var entity = OrderStatusEntity.None; -var dto = entity.MapToOrderStatusDto(); // OrderStatusDto.Unknown -``` - -### Example 2: Bidirectional Mapping - -```csharp -// Domain enum -[MapTo(typeof(StatusDto), Bidirectional = true)] -public enum StatusEntity -{ - None, // → StatusDto.Unknown - Active, - Inactive, -} - -// DTO enum -public enum StatusDto -{ - Unknown, // ← StatusEntity.None - Active, - Inactive, -} - -// Usage - both directions work -var entity = StatusEntity.None; -var dto = entity.MapToStatusDto(); // StatusDto.Unknown -var backToEntity = dto.MapToStatusEntity(); // StatusEntity.None (bidirectional) -``` - -### Example 3: Case-Insensitive Matching - -```csharp -[MapTo(typeof(TargetPriority))] -public enum SourcePriority -{ - LOW, // Matches TargetPriority.Low (case-insensitive) - MEDIUM, // Matches TargetPriority.Medium - HIGH, // Matches TargetPriority.High -} - -public enum TargetPriority -{ - Low, - Medium, - High, -} -``` - ---- - -## 🔧 Best Practices - -1. **Use Bidirectional When Appropriate**: If you need to convert in both directions, use `Bidirectional = true` to avoid duplicate attributes. - -2. **Leverage Special Cases**: The generator knows "zero/empty/null" state equivalents. Use names like `None`/`Unknown`/`Default` to get automatic mappings for these common states. - -3. **Handle Warnings**: `ATCENUM002` warnings indicate potential runtime errors. Address them by adding missing values or accepting the risk. - -4. **Layer Separation**: Keep enum definitions separate per layer (Entity, Domain, DTO) to maintain clean architecture. - -5. **Namespace Organization**: Generated extension methods live in `Atc.Mapping`. Add `using Atc.Mapping;` where you use the mappings. - ---- - -## 📖 Related Documentation - -- [Object Mapping Generator](ObjectMappingGenerators.md) - For class-to-class mappings -- [Dependency Registration Generator](DependencyRegistrationGenerators.md) - For automatic DI registration -- [Options Binding Generator](OptionsBindingGenerators.md) - For configuration binding -- [Sample Projects](EnumMappingGenerators-Samples.md) - Working code examples - ---- - -**Need Help?** Check out the [sample project](EnumMappingGenerators-Samples.md) for a complete working example. diff --git a/docs/ObjectMappingGenerators-FeatureRoadmap.md b/docs/ObjectMappingGenerators-FeatureRoadmap.md deleted file mode 100644 index 7c5a3d7..0000000 --- a/docs/ObjectMappingGenerators-FeatureRoadmap.md +++ /dev/null @@ -1,1868 +0,0 @@ -# 🗺️ Feature Roadmap - Mapping Generators - -This document outlines the feature roadmap for the **EnumMappingGenerator** and **ObjectMappingGenerator**, categorized by priority based on analysis of [Mapperly](https://mapperly.riok.app/docs/intro/) and real-world usage patterns. - -## 🔍 Research Sources - -This roadmap is based on comprehensive analysis of: - -1. **Mapperly Documentation** - [mapperly.riok.app](https://mapperly.riok.app/docs/intro/) - Feature reference -2. **Mapperly GitHub Repository** - [riok/mapperly](https://github.com/riok/mapperly) - 3.7k⭐, 46 contributors, 1,900+ dependent projects -3. **Community Feature Requests** - Analysis of 52 open enhancement requests and 66 total issues -4. **Real-World Usage Patterns** - Based on what users actually request and struggle with - -### 📊 Key Insights from Mapperly Community - -**Most Requested Features** (by GitHub issue activity): - -- **Polymorphic Type Mapping** - Users want better support for derived types and type discriminators -- **Multi-Source Consolidation** - Merge multiple source objects into one destination -- **Property Exclusion Shortcuts** - Exclude all properties except explicitly mapped ones -- **Base Class Configuration** - Inherit mapping configurations from base classes -- **SnakeCase Naming Strategy** - Map PascalCase to snake_case properties (API scenarios) -- **IQueryable Projection Improvements** - Better EF Core integration for server-side projections - -**Mapperly's Success Factors**: - -- ✅ Transparent, readable generated code (developers trust what they can see) -- ✅ Zero runtime overhead (performance-critical scenarios) -- ✅ Simple attribute-based API (`[Mapper]` on partial classes) -- ✅ Comprehensive documentation and examples -- ✅ Active maintenance and community engagement - -## 📊 Current State - -### ✅ EnumMappingGenerator - Implemented Features - -- **Intelligent name matching** - Case-insensitive enum value matching -- **Special case detection** - Automatic patterns (None ↔ Unknown, Active ↔ Enabled, etc.) -- **Bidirectional mapping** - Generate both forward and reverse mappings -- **Zero runtime cost** - Pure switch expressions, no reflection -- **Type-safe** - Compile-time diagnostics for unmapped values -- **Native AOT compatible** - Fully trimming-safe - -### ✅ ObjectMappingGenerator - Implemented Features - -- **Direct property mapping** - Same name and type properties mapped automatically (case-insensitive) -- **Constructor mapping** - Automatically detects and uses constructors for records and classes with primary constructors -- **Mixed initialization** - Constructor parameters + object initializer for remaining properties -- **Smart enum conversion** - Uses EnumMapping extension methods when available, falls back to casts -- **Collection mapping** - Automatic mapping of List, IEnumerable, arrays, IReadOnlyList, etc. -- **Nested object mapping** - Automatic chaining of MapTo methods -- **Record support** - Works with classes, records, and structs -- **Null safety** - Proper handling of nullable reference types -- **Multi-layer support** - Entity → Domain → DTO mapping chains -- **Bidirectional mapping** - Generate both Source → Target and Target → Source -- **Extension methods** - Clean, fluent API in `Atc.Mapping` namespace -- **Native AOT compatible** - Zero reflection, compile-time generation - ---- - -## 📋 Feature Status Overview - -| Status | Feature | Priority | -|:------:|---------|----------| -| ✅ | [Collection Mapping Support](#1-collection-mapping-support) | 🔴 Critical | -| ✅ | [Constructor Mapping](#2-constructor-mapping) | 🔴 High | -| ✅ | [Ignore Properties](#3-ignore-properties) | 🔴 High | -| ✅ | [Custom Property Name Mapping](#4-custom-property-name-mapping) | 🟡 Medium-High | -| ✅ | [Flattening Support](#5-flattening-support) | 🟡 Medium | -| ✅ | [Built-in Type Conversion](#6-built-in-type-conversion) | 🟡 Medium | -| ✅ | [Required Property Validation](#7-required-property-validation) | 🟡 Medium | -| ✅ | [Polymorphic / Derived Type Mapping](#8-polymorphic--derived-type-mapping) | 🔴 High | -| ✅ | [Before/After Mapping Hooks](#9-beforeafter-mapping-hooks) | 🟢 Low-Medium | -| ✅ | [Object Factories](#10-object-factories) | 🟢 Low-Medium | -| ✅ | [Map to Existing Target Instance](#11-map-to-existing-target-instance) | 🟢 Low-Medium | -| ✅ | [IQueryable Projections](#13-iqueryable-projections) | 🟢 Low-Medium | -| ✅ | [Generic Mappers](#14-generic-mappers) | 🟢 Low | -| ❌ | [Reference Handling / Circular Dependencies](#12-reference-handling--circular-dependencies) | 🟢 Low | -| ✅ | [Private Member Access](#15-private-member-access) | 🟢 Low | -| ❌ | [Multi-Source Consolidation](#16-multi-source-consolidation) | 🟢 Low-Medium | -| ❌ | [Value Converters](#17-value-converters) | 🟢 Low-Medium | -| ❌ | [Format Providers](#18-format-providers) | 🟢 Low | -| ✅ | [Property Name Casing Strategies](#19-property-name-casing-strategies-snakecase-camelcase) | 🟢 Low-Medium | -| ✅ | [Base Class Configuration Inheritance](#20-base-class-configuration-inheritance) | 🟢 Low | -| 🚫 | [External Mappers / Mapper Composition](#21-external-mappers--mapper-composition) | - | -| 🚫 | [Advanced Enum Strategies](#22-advanced-enum-strategies-beyond-special-cases) | - | -| 🚫 | [Deep Cloning Support](#23-deep-cloning-support) | - | -| 🚫 | [Conditional Mapping](#24-conditional-mapping-map-if-condition-is-true) | - | -| 🚫 | [Asynchronous Mapping](#25-asynchronous-mapping) | - | -| 🚫 | [Mapping Configuration Files](#26-mapping-configuration-files-jsonxml) | - | -| 🚫 | [Runtime Dynamic Mapping](#27-runtime-dynamic-mapping) | - | - -**Legend:** - -- ✅ **Implemented** - Feature is complete and available -- ❌ **Not Implemented** - Feature is planned but not yet developed -- 🚫 **Not Planned** - Feature is out of scope or not aligned with project goals - ---- - -## 🎯 Need to Have (High Priority) - -These features are essential for real-world usage and align with common mapping scenarios. They should be implemented in the near term. - -### 1. Collection Mapping Support - -**Priority**: 🔴 **Critical** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.0 - January 2025) - -**Description**: Automatically map collections between types (List, IEnumerable, arrays, ICollection, etc.). - -**User Story**: -> "As a developer, I want to map `List` to `List` without manually calling `.Select(u => u.MapToUserDto()).ToList()` every time." - -**Example**: - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - public IList
Addresses { get; set; } = new List
(); -} - -public class UserDto -{ - public Guid Id { get; set; } - public IReadOnlyList Addresses { get; set; } = Array.Empty(); -} - -// Generated code automatically handles collection mapping: -Addresses = source.Addresses?.Select(x => x.MapToAddressDto()).ToList()! -``` - -**Implementation Details**: - -✅ **Supported Collection Types**: - -- `List`, `IList` → `.ToList()` -- `IEnumerable` → `.ToList()` -- `ICollection`, `IReadOnlyCollection` → `.ToList()` -- `IReadOnlyList` → `.ToList()` -- `T[]` (arrays) → `.ToArray()` -- `Collection` → `new Collection(...)` -- `ReadOnlyCollection` → `new ReadOnlyCollection(...)` - -✅ **Features**: - -- Automatic collection type detection -- LINQ `.Select()` with element mapping method -- Null-safe handling with `?.` operator -- Proper collection constructor selection based on target type -- Works with nested collections and multi-layer architectures -- Full Native AOT compatibility - -✅ **Testing**: - -- 5 comprehensive unit tests covering all collection types -- Tested in PetStore.Api sample across 3 layers: - - `PetEntity`: `ICollection Children` - - `Pet`: `IList Children` - - `PetResponse`: `IReadOnlyList Children` - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with collection mapping details -- Includes examples and conversion rules - ---- - -### 2. Constructor Mapping - -**Priority**: 🔴 **High** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.0 - January 2025) - -**Description**: Map to types that use constructors instead of object initializers (common with records and immutable types). - -**User Story**: -> "As a developer, I want to map to records and classes with primary constructors without having to manually write constructor calls." - -**Example**: - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; init; } - public string Name { get; init; } = string.Empty; -} - -// Target uses constructor -public record UserDto(Guid Id, string Name); - -// Generated code uses constructor: -return new UserDto(source.Id, source.Name); -``` - -**Implementation Details**: - -✅ **Constructor Detection**: - -- Automatically detects public constructors where ALL parameters match source properties -- Uses case-insensitive matching (supports `Id` matching `id`, `ID`, etc.) -- Prefers constructors with more parameters -- Falls back to object initializer syntax when no matching constructor exists - -✅ **Supported Scenarios**: - -- **Records with positional parameters** (C# 9+) -- **Classes with primary constructors** (C# 12+) -- **Mixed initialization** - Constructor for required parameters + object initializer for remaining properties -- **Bidirectional mapping** - Both directions automatically detect and use constructors - -✅ **Features**: - -- Case-insensitive parameter matching (PascalCase properties → camelCase parameters) -- Automatic ordering of constructor arguments -- Mixed constructor + initializer generation -- Works with nested objects and collections -- Full Native AOT compatibility - -✅ **Testing**: - -- 9 comprehensive unit tests covering all scenarios: - - Simple record constructors - - Record with all properties in constructor - - Mixed constructor + initializer - - Bidirectional record mapping - - Nested object mapping with constructors - - Enum mapping with constructors - - Collection mapping with constructors - - Case-insensitive parameter matching - - Class-to-record and record-to-record mappings - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with constructor mapping details -- Includes examples for simple, bidirectional, mixed, and case-insensitive scenarios - -✅ **Sample Code**: - -- Added `Product` and `Order` examples in `sample/Atc.SourceGenerators.Mapping.Domain` -- Demonstrates record-to-record and class-to-record mapping with constructors - ---- - -### 3. Ignore Properties - -**Priority**: 🔴 **High** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Explicitly exclude specific properties from mapping using an attribute. - -**User Story**: -> "As a developer, I want to exclude certain properties (like audit fields or internal state) from being mapped to my DTOs." - -**Example**: - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - [MapIgnore] - public byte[] PasswordHash { get; set; } = Array.Empty(); // ❌ Never map this - - [MapIgnore] - public DateTime CreatedAt { get; set; } // Internal audit field -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - // PasswordHash and CreatedAt are NOT mapped -} -``` - -**Implementation Details**: - -✅ **MapIgnoreAttribute Created**: - -- Attribute available in Atc.SourceGenerators.Annotations -- Fallback attribute generated automatically by ObjectMappingGenerator -- Applied to properties: `[AttributeUsage(AttributeTargets.Property)]` - -✅ **Source Property Filtering**: - -- Properties with `[MapIgnore]` on source type are excluded from mapping -- Ignored source properties are never read during mapping generation - -✅ **Target Property Filtering**: - -- Properties with `[MapIgnore]` on target type are excluded from mapping -- Ignored target properties are never set during mapping generation - -✅ **Features**: - -- Works with simple properties -- Works with nested objects (ignored properties in nested objects are excluded) -- Works with bidirectional mappings (properties can be ignored in either direction) -- Works with constructor mappings (ignored properties excluded from constructor parameters) -- Full Native AOT compatibility - -✅ **Testing**: - -- 4 comprehensive unit tests covering all scenarios: - - Source property ignore - - Target property ignore - - Nested object property ignore - - Bidirectional mapping property ignore - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with MapIgnore information -- Includes examples and use cases - -✅ **Sample Code**: - -- Added to `User` in `sample/Atc.SourceGenerators.Mapping.Domain` -- Added to `Pet` in `sample/PetStore.Domain` -- Demonstrates sensitive data and audit field exclusion - ---- - -### 4. Custom Property Name Mapping - -**Priority**: 🟡 **Medium-High** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented (v1.1 - January 2025)** - -**Description**: Map properties with different names using an attribute to specify the target property name. - -**User Story**: -> "As a developer, I want to map properties with different names (like `FirstName` → `Name`) without having to rename my domain models." - -**Example**: - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - - [MapProperty("FullName")] - public string Name { get; set; } = string.Empty; // Maps to UserDto.FullName - - [MapProperty("Age")] - public int YearsOld { get; set; } // Maps to UserDto.Age -} - -public class UserDto -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; - public int Age { get; set; } -} - -// Generated code: -FullName = source.Name, -Age = source.YearsOld -``` - -**Implementation Details**: - -✅ **MapPropertyAttribute Created**: - -- Attribute available in Atc.SourceGenerators.Annotations -- Fallback attribute generated automatically by ObjectMappingGenerator -- Applied to properties: `[AttributeUsage(AttributeTargets.Property)]` -- Constructor accepts target property name as string parameter - -✅ **Custom Property Name Resolution**: - -- Properties with `[MapProperty("TargetName")]` are mapped to the specified target property name -- Supports both string literals and nameof() expressions -- Case-insensitive matching for target property names - -✅ **Compile-Time Validation**: - -- Validates that target property exists on target type at compile time -- Reports `ATCMAP003` diagnostic if target property is not found -- Prevents runtime errors by catching mismatches during build - -✅ **Features**: - -- Works with simple properties (strings, numbers, dates, etc.) -- Works with nested objects (custom property names on nested object references) -- Works with bidirectional mappings (apply `[MapProperty]` on both sides) -- Works with constructor mappings (custom names resolved when matching constructor parameters) -- Full Native AOT compatibility - -✅ **Testing**: - -- 4 comprehensive unit tests covering all scenarios: - - Basic custom property mapping with string literals - - Bidirectional mapping with custom property names - - Error diagnostic for non-existent target properties - - Custom property mapping with nested objects - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with MapProperty information -- Includes examples and use cases -- Added `ATCMAP003` diagnostic documentation - -✅ **Sample Code**: - -- Added to `User` in `sample/Atc.SourceGenerators.Mapping.Domain` (PreferredName → DisplayName) -- Added to `Pet` in `sample/PetStore.Domain` (NickName → DisplayName) -- Demonstrates real-world usage patterns - ---- - -### 5. Flattening Support - -**Priority**: 🟡 **Medium** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Automatically flatten nested properties into a flat structure using naming conventions. - -**User Story**: -> "As a developer, I want to flatten nested objects (like `Address.City`) to flat DTOs (like `AddressCity`) without manual property mapping." - -**Example**: - -```csharp -[MapTo(typeof(UserDto), EnableFlattening = true)] -public partial class User -{ - public string Name { get; set; } = string.Empty; - public Address Address { get; set; } = new(); -} - -public class Address -{ - public string City { get; set; } = string.Empty; - public string Street { get; set; } = string.Empty; -} - -public class UserFlatDto -{ - public string Name { get; set; } = string.Empty; - // Flattened properties (convention: {PropertyName}{NestedPropertyName}) - public string AddressCity { get; set; } = string.Empty; - public string AddressStreet { get; set; } = string.Empty; -} - -// Generated code: -Name = source.Name, -AddressCity = source.Address?.City!, -AddressStreet = source.Address?.Street! -``` - -**Implementation Details**: - -✅ **Flattening Detection**: - -- Opt-in via `EnableFlattening = true` parameter on `[MapTo]` attribute -- Naming convention: `{PropertyName}{NestedPropertyName}` (e.g., `Address.City` → `AddressCity`) -- Case-insensitive matching for flattened property names -- Only flattens class/struct types (not primitive types like string, DateTime) - -✅ **Null Safety**: - -- Automatically handles nullable nested objects with null-conditional operator (`?.`) -- Generates `source.Address?.City!` for nullable nested objects -- Generates `source.Address.City` for non-nullable nested objects - -✅ **Features**: - -- One-level deep flattening (can be extended to multi-level in future) -- Works with bidirectional mappings -- Supports multiple nested objects of the same type (e.g., `HomeAddress`, `WorkAddress`) -- Compatible with other mapping features (MapIgnore, MapProperty, etc.) -- Full Native AOT compatibility - -✅ **Testing**: - -- 4 comprehensive unit tests covering all scenarios: - - Basic flattening with multiple properties - - Default behavior (no flattening when disabled) - - Multiple nested objects of same type - - Nullable nested objects - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with flattening information -- Includes examples and use cases - -✅ **Sample Code**: - -- Added `UserFlatDto` in `sample/Atc.SourceGenerators.Mapping.Contract` -- Added `PetSummaryResponse` in `sample/PetStore.Api.Contract` -- Added `Owner` model in `sample/PetStore.Domain` -- Demonstrates realistic usage with address and owner information - ---- - -### 6. Built-in Type Conversion - -**Priority**: 🟡 **Medium** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Automatically convert between common types (DateTime ↔ string, int ↔ string, GUID ↔ string, etc.). - -**User Story**: -> "As a developer, I want to convert between common types (like DateTime to string) without writing custom conversion logic." - -**Example**: - -```csharp -[MapTo(typeof(UserEventDto))] -public partial class UserEvent -{ - public Guid EventId { get; set; } - public DateTimeOffset Timestamp { get; set; } - public int DurationSeconds { get; set; } - public bool Success { get; set; } -} - -public class UserEventDto -{ - public string EventId { get; set; } = string.Empty; // Guid → string - public string Timestamp { get; set; } = string.Empty; // DateTimeOffset → string (ISO 8601) - public string DurationSeconds { get; set; } = string.Empty; // int → string - public string Success { get; set; } = string.Empty; // bool → string -} - -// Generated code: -EventId = source.EventId.ToString(), -Timestamp = source.Timestamp.ToString("O", global::System.Globalization.CultureInfo.InvariantCulture), -DurationSeconds = source.DurationSeconds.ToString(global::System.Globalization.CultureInfo.InvariantCulture), -Success = source.Success.ToString() -``` - -**Implementation Details**: - -✅ **Supported Conversions**: - -- `DateTime` ↔ `string` (ISO 8601 format: "O") -- `DateTimeOffset` ↔ `string` (ISO 8601 format: "O") -- `Guid` ↔ `string` -- Numeric types ↔ `string` (int, long, short, byte, decimal, double, float, etc.) -- `bool` ↔ `string` - -✅ **Features**: - -- Automatic type detection and conversion code generation -- Uses InvariantCulture for all numeric and DateTime conversions -- ISO 8601 format for DateTime/DateTimeOffset to string conversion -- Parse methods for string to strong type conversions -- Full Native AOT compatibility - -✅ **Testing**: - -- 4 comprehensive unit tests covering all scenarios: - - DateTime/DateTimeOffset/Guid to string conversion - - String to DateTime/DateTimeOffset/Guid conversion - - Numeric types to string conversion - - String to numeric types conversion - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Includes examples and conversion rules - -✅ **Sample Code**: - -- Added `UserEvent` and `UserEventDto` in `sample/Atc.SourceGenerators.Mapping` -- Added `PetDetailsDto` in `sample/PetStore.Api` -- Demonstrates real-world usage with API endpoints - ---- - -### 7. Required Property Validation - -**Priority**: 🟡 **Medium** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Validate at compile time that all required properties on the target type are mapped. - -**User Story**: -> "As a developer, I want compile-time errors if I forget to map required properties on my target type." - -**Example**: - -```csharp -// ❌ This will generate ATCMAP004 warning at compile time -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; - // Missing: Email property (required in target) -} - -public class UserRegistrationDto -{ - public Guid Id { get; set; } - public required string Email { get; set; } // ⚠️ Required but not mapped - public required string FullName { get; set; } -} - -// Diagnostic: Warning ATCMAP004: Required property 'Email' on target type 'UserRegistrationDto' has no mapping from source type 'UserRegistration' - -// ✅ Correct implementation - all required properties mapped -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string Email { get; set; } = string.Empty; // ✅ Required property mapped - public string FullName { get; set; } = string.Empty; // ✅ Required property mapped -} -``` - -**Implementation Details**: - -**Features**: - -- Detects `required` keyword on target properties (C# 11+) -- Generates **ATCMAP004** diagnostic (Warning severity) if required property has no mapping -- Validates during compilation - catches missing mappings before runtime -- Warning can be elevated to error via `.editorconfig` or project settings -- Ignores properties marked with `[MapIgnore]` attribute - -**Diagnostic**: `ATCMAP004` - Required property on target type has no mapping - -**Severity**: Warning (configurable to Error) - -**How It Works**: - -1. After property mappings are determined, validator checks all target properties -2. For each target property marked with `required` keyword: - - Check if it appears in the property mappings list - - If not mapped, report ATCMAP004 diagnostic with property name, target type, and source type - -**Testing**: 4 unit tests added - -- `Generator_Should_Generate_Warning_For_Missing_Required_Property` - Single missing required property -- `Generator_Should_Not_Generate_Warning_When_All_Required_Properties_Are_Mapped` - All required properties present -- `Generator_Should_Generate_Warning_For_Multiple_Missing_Required_Properties` - Multiple missing required properties -- `Generator_Should_Not_Generate_Warning_For_Non_Required_Properties` - Non-required properties can be omitted - -**Documentation**: See [Object Mapping - Required Property Validation](ObjectMappingGenerators.md#-required-property-validation) - -**Sample Code**: - -- `Atc.SourceGenerators.Mapping`: `UserRegistration` → `UserRegistrationDto` (lines in Program.cs) -- `PetStore.Api`: `Pet` → `UpdatePetRequest` with required Name and Species properties - ---- - -### 8. Polymorphic / Derived Type Mapping - -**Priority**: 🔴 **High** ⭐ *Highly requested by Mapperly users* -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.0 - January 2025) - -**Description**: Support mapping of derived types and interfaces using type checks and pattern matching. - -**User Story**: -> "As a developer, I want to map abstract base classes or interfaces to their concrete implementations based on runtime type." - -**Example**: - -```csharp -public abstract class AnimalEntity { } -public class DogEntity : AnimalEntity { public string Breed { get; set; } = ""; } -public class CatEntity : AnimalEntity { public int Lives { get; set; } } - -public abstract class Animal { } -public class Dog : Animal { public string Breed { get; set; } = ""; } -public class Cat : Animal { public int Lives { get; set; } } - -[MapTo(typeof(Animal))] -[MapDerivedType(typeof(Dog), typeof(DogDto))] -[MapDerivedType(typeof(Cat), typeof(CatDto))] -public abstract partial class Animal { } - -[MapTo(typeof(DogDto))] -public partial class Dog : Animal { } - -[MapTo(typeof(CatDto))] -public partial class Cat : Animal { } - -// Generated code: -public static AnimalDto MapToAnimalDto(this Animal source) -{ - if (source is null) - { - return default!; - } - - return source switch - { - Dog dog => dog.MapToDogDto(), - Cat cat => cat.MapToCatDto(), - _ => throw new ArgumentException($"Unknown derived type: {source.GetType().Name}") - }; -} -``` - -**Implementation Details**: - -✅ **Implemented Features**: - -- `[MapDerivedType(Type sourceType, Type targetType)]` attribute -- Switch expression generation with type pattern matching -- Null safety checks for source parameter -- Automatic delegation to derived type mapping methods -- Descriptive exception for unmapped derived types -- Support for multiple derived type mappings via `AllowMultiple = true` - -**Testing**: 3 unit tests added - -- `Generator_Should_Generate_Polymorphic_Mapping_With_Switch_Expression` - Basic Dog/Cat example -- `Generator_Should_Handle_Single_Derived_Type_Mapping` - Single derived type -- `Generator_Should_Support_Multiple_Polymorphic_Mappings` - Three derived types (Circle/Square/Triangle) - -**Documentation**: See [Object Mapping - Polymorphic Type Mapping](ObjectMappingGenerators.md#-polymorphic--derived-type-mapping) - -**Sample Code**: - -- `Atc.SourceGenerators.Mapping`: `Animal` → `AnimalDto` with `Dog`/`Cat` derived types -- `PetStore.Api`: `Notification` → `NotificationDto` with `EmailNotification`/`SmsNotification` derived types - ---- - -## 💡 Nice to Have (Medium Priority) - -These features would improve usability and flexibility but are not critical for initial adoption. They can be implemented based on user feedback and demand. - -### 9. Before/After Mapping Hooks - -**Priority**: 🟢 **Low-Medium** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Execute custom logic before or after the mapping operation. - -**User Story**: -> "As a developer, I want to execute custom validation or post-processing logic before or after mapping objects, without having to write wrapper methods around the generated mapping code." - -**Example**: - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto), BeforeMap = nameof(ValidateUser), AfterMap = nameof(EnrichDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Called before the mapping operation (after null check) - private static void ValidateUser(User source) - { - if (string.IsNullOrWhiteSpace(source.Name)) - { - throw new ArgumentException("Name cannot be empty"); - } - } - - // Called after the mapping operation (before return) - private static void EnrichDto(User source, UserDto target) - { - target.DisplayName = $"{source.Name} (ID: {source.Id})"; - } -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; -} - -// Generated code: -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - // BeforeMap hook - called after null check, before mapping - User.ValidateUser(source); - - var target = new UserDto - { - Id = source.Id, - Name = source.Name - }; - - // AfterMap hook - called after mapping, before return - User.EnrichDto(source, target); - - return target; -} -``` - -**Implementation Details**: - -✅ **BeforeMap Hook**: - -- Called after null check, before object creation -- Signature: `static void MethodName(SourceType source)` -- Use for validation, preprocessing, or throwing exceptions -- Has access to source object only - -✅ **AfterMap Hook**: - -- Called after object creation, before return -- Signature: `static void MethodName(SourceType source, TargetType target)` -- Use for post-processing, enrichment, or additional property setting -- Has access to both source and target objects - -✅ **Features**: - -- Hook methods must be static -- Hooks are called via fully qualified name (e.g., `User.ValidateUser(source)`) -- Both hooks are optional - use one, both, or neither -- Hooks are specified by method name as string (e.g., `BeforeMap = nameof(ValidateUser)`) -- Works with all mapping features (collections, nested objects, constructors, etc.) -- Reverse mappings (Bidirectional = true) do not inherit hooks -- Full Native AOT compatibility - -✅ **Execution Order**: - -1. Null check on source -2. **BeforeMap hook** (if specified) -3. Polymorphic type check (if derived type mappings exist) -4. Object creation (constructor or object initializer) -5. **AfterMap hook** (if specified) -6. Return target object - -✅ **Testing**: - -- 3 comprehensive unit tests covering all scenarios: - - BeforeMap hook called before mapping - - AfterMap hook called after mapping - - Both hooks called in correct order - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with hooks information -- Includes examples and use cases - -✅ **Sample Code**: - -- Planned to be added to `sample/Atc.SourceGenerators.Mapping` -- Planned to be added to `sample/PetStore.Api` - -**Use Cases**: - -- **Validation** - Throw exceptions if source data is invalid before mapping -- **Logging** - Log mapping operations for debugging -- **Enrichment** - Add computed properties to target that don't exist in source -- **Auditing** - Track when objects are mapped -- **Side Effects** - Call external services or update caches - ---- - -### 10. Object Factories - -**Priority**: 🟢 **Low-Medium** -**Generator**: ObjectMappingGenerator -**Status**: ✅ **Implemented** (v1.1 - January 2025) - -**Description**: Use custom factory methods for object creation instead of `new()`. - -**User Story**: -> "As a developer, I want to use custom factory methods to create target instances during mapping, so I can initialize objects with default values, use object pooling, or apply other custom creation logic." - -**Example**: - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto), Factory = nameof(CreateUserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Factory method creates the target instance - internal static UserDto CreateUserDto() - { - return new UserDto - { - CreatedAt = DateTimeOffset.UtcNow, // Set default value - }; - } -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } -} - -// Generated code: -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - var target = User.CreateUserDto(); // Factory creates instance - - target.Id = source.Id; // Property mappings applied - target.Name = source.Name; - - return target; -} -``` - -**Implementation Details**: - -✅ **Factory Method**: - -- Signature: `static TargetType MethodName()` -- Replaces `new TargetType()` for object creation -- Property mappings are applied after factory creates the instance -- Factory method must be static and accessible - -✅ **Features**: - -- Factory method specified by name (e.g., `Factory = nameof(CreateUserDto)`) -- Fully compatible with BeforeMap/AfterMap hooks -- Works with all mapping features (nested objects, collections, etc.) -- Reverse mappings (Bidirectional = true) do not inherit factory methods -- Full Native AOT compatibility - -✅ **Execution Order**: - -1. Null check on source -2. **BeforeMap hook** (if specified) -3. **Factory method** creates target instance -4. Property mappings applied to target -5. **AfterMap hook** (if specified) -6. Return target object - -✅ **Testing**: - -- 3 unit tests added (skipped in test harness, manually verified in samples) -- Tested with BeforeMap/AfterMap hooks -- Verified property mapping after factory creation - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated CLAUDE.md with factory information -- Includes examples and use cases - -✅ **Sample Code**: - -- Added to `sample/PetStore.Api` (EmailNotification with factory) -- Demonstrates factory method with runtime default values - -**Use Cases**: - -- **Default Values** - Set properties that don't exist in source (e.g., CreatedAt timestamp) -- **Object Pooling** - Reuse objects from a pool for performance -- **Lazy Initialization** - Defer expensive initialization until needed -- **Dependency Injection** - Use service locator pattern to create instances -- **Custom Logic** - Apply any custom creation logic (caching, logging, etc.) - -**Limitations**: - -- Factory pattern doesn't work with init-only properties (records with `init` setters) -- For init-only properties, use constructor mapping or object initializers instead - ---- - -### 11. Map to Existing Target Instance - -**Priority**: 🟢 **Low-Medium** -**Status**: ✅ **Implemented** - -**Description**: Update an existing object instead of creating a new one (useful for EF Core tracked entities). - -**User Story**: -> "As a developer working with EF Core, I want to update existing tracked entities without creating new instances, so that EF Core's change tracking works correctly and I can efficiently update database records." - -**Implementation Details**: - -When `UpdateTarget = true` is specified in the `MapToAttribute`, the generator creates **two methods**: - -1. **Standard method** - Creates and returns a new instance: - - ```csharp - public static TargetType MapToTargetType(this SourceType source) - ``` - -2. **Update method** - Updates an existing instance (void return): - - ```csharp - public static void MapToTargetType(this SourceType source, TargetType target) - ``` - -**Generated Code Pattern**: - -```csharp -// Input: -[MapTo(typeof(UserDto), UpdateTarget = true)] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; -} - -// Generated output (both methods): -public static UserDto MapToUserDto(this User source) -{ - if (source is null) return default!; - - return new UserDto - { - Id = source.Id, - Name = source.Name, - Email = source.Email - }; -} - -public static void MapToUserDto(this User source, UserDto target) -{ - if (source is null) return; - if (target is null) return; - - target.Id = source.Id; - target.Name = source.Name; - target.Email = source.Email; -} -``` - -**Testing Status**: - -- ✅ Unit tests added (`Generator_Should_Generate_Update_Target_Method`) -- ✅ Unit tests added (`Generator_Should_Not_Generate_Update_Target_Method_When_False`) -- ✅ Unit tests added (`Generator_Should_Include_Hooks_In_Update_Target_Method`) -- ✅ All tests passing (171 succeeded, 13 skipped) - -**Sample Code Locations**: - -- `sample/Atc.SourceGenerators.Mapping.Domain/Settings.cs` - Simple update target example -- `sample/PetStore.Domain/Models/Pet.cs` - EF Core entity update example (with `Bidirectional = true`) - -**Use Cases**: - -1. **EF Core Tracked Entities**: - - ```csharp - // Fetch tracked entity from database - var existingPet = await dbContext.Pets.FindAsync(petId); - - // Update it with new data (EF Core tracks changes) - domainPet.MapToPetEntity(existingPet); - - // Save changes (only modified properties are updated in database) - await dbContext.SaveChangesAsync(); - ``` - -2. **Reduce Object Allocations**: - - ```csharp - // Reuse existing DTO instance - var settingsDto = new SettingsDto(); - - settings1.MapToSettingsDto(settingsDto); - // ... use settingsDto - - settings2.MapToSettingsDto(settingsDto); // Reuse same instance - // ... use settingsDto - ``` - -3. **Update UI ViewModels**: - - ```csharp - // Update existing ViewModel without creating new instance - var viewModel = this.DataContext as UserViewModel; - updatedUser.MapToUserViewModel(viewModel); - ``` - -**Important Notes**: - -- The update method performs **null checks** for both source and target -- The update method has a **void return type** (no return value) -- **BeforeMap** and **AfterMap** hooks are fully supported in both methods -- The update method **does not use Factory** (factory is only for creating new instances) -- Works seamlessly with **Bidirectional = true** (both directions get update overloads) -- All properties are updated, including nullable properties - -**When to Use**: - -- ✅ Updating EF Core tracked entities -- ✅ Reducing allocations for frequently mapped objects -- ✅ Updating existing ViewModels or DTOs -- ✅ Scenarios where you need to preserve object identity - -**When NOT to Use**: - -- ❌ When you always need new instances -- ❌ With immutable types (records with init-only properties) -- ❌ When Factory method is needed (factory creates new instances) - ---- - -### 12. Reference Handling / Circular Dependencies - -**Priority**: 🟢 **Low** -**Status**: ❌ Not Implemented - -**Description**: Handle circular references and maintain object identity during mapping. - -**Example**: - -```csharp -public class User -{ - public List Posts { get; set; } = new(); -} - -public class Post -{ - public User Author { get; set; } = null!; // Circular reference -} - -// Need to track mapped instances to avoid infinite recursion -``` - ---- - -### 13. IQueryable Projections - -**Priority**: 🟢 **Low-Medium** -**Status**: ✅ **Implemented** (v1.2 - January 2025) - -**Description**: Generate `Expression>` for use in EF Core `.Select()` queries (server-side projection). - -**User Story**: -> "As a developer using EF Core, I want to project database queries to DTOs server-side without fetching unnecessary data, so that I can improve query performance and reduce database load." - -**Example**: - -```csharp -[MapTo(typeof(UserSummaryDto), GenerateProjection = true)] -public partial class User -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatusDto Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -// Generated expression: -public static Expression> ProjectToUserSummaryDto() -{ - return source => new UserSummaryDto - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - Status = (UserStatusDto)source.Status, - CreatedAt = source.CreatedAt - }; -} - -// Usage with EF Core: -var users = await dbContext.Users - .Where(u => u.IsActive) - .Select(User.ProjectToUserSummaryDto()) - .ToListAsync(); -// SQL: SELECT Id, FirstName, LastName, Email, Status, CreatedAt FROM Users WHERE IsActive = 1 -``` - -**Implementation Details**: - -✅ **GenerateProjection Property**: - -- Opt-in via `GenerateProjection = true` parameter on `[MapTo]` attribute -- Generates a static method that returns `Expression>` -- Only includes simple property mappings (no nested objects, collections, or hooks) - -✅ **Projection Limitations**: - -- **No BeforeMap/AfterMap hooks** - Expressions can't call methods -- **No Factory methods** - Expressions must use object initializers -- **No nested objects** - Would require method calls like `.MapToX()` -- **No collections** - Would require `.Select()` method calls -- **No built-in type conversions** - Only simple casts work in expressions -- Only simple property-to-property mappings and enum conversions (simple casts) are supported - -✅ **Features**: - -- Clean method signature: `ProjectTo{TargetType}()` -- Returns `Expression>` for use with `.Select()` -- Full Native AOT compatibility -- Optimizes EF Core queries by selecting only required columns -- Supports enum conversions via simple casts -- Comprehensive XML documentation explaining limitations - -✅ **Testing**: - -- 4 comprehensive unit tests added (skipped in test harness, verified in samples): - - Basic projection method generation - - Enum conversion in projections - - Nested objects excluded from projections - - No projection when GenerateProjection = false - -✅ **Documentation**: - -- Added comprehensive section in `docs/generators/ObjectMapping.md` -- Updated MapToAttribute XML documentation with projection details -- Includes examples and use cases - -✅ **Sample Code**: - -- `Atc.SourceGenerators.Mapping`: `User` → `UserSummaryDto` with GenerateProjection -- `PetStore.Api`: `Pet` → `PetListItemDto` with GenerateProjection -- Demonstrates realistic EF Core query optimization scenarios - -**Benefits**: - -- Reduce database round-trips by selecting only required columns -- Better performance with EF Core server-side queries -- Server-side filtering and projection -- Compile-time safety for query expressions -- Optimal SQL generation for list/grid views - ---- - -### 14. Generic Mappers - -**Priority**: 🟢 **Low** -**Status**: ✅ **Implemented** (v1.2) - -**Description**: Generate type-safe mapping methods for generic wrapper types, preserving type parameters and constraints. - -**Implementation Details**: - -✅ **Core Functionality**: - -- Automatic detection of generic types with `[MapTo(typeof(TargetType<>))]` syntax -- Generates generic extension methods like `MapToResultDto()` -- Preserves all type parameter constraints (`where T : class`, `where T : struct`, `where T : new()`, etc.) -- Supports multiple type parameters (`Result`) -- Bidirectional mapping support for generic types -- UpdateTarget method generation for generic types - -✅ **Generated Code Pattern**: - -```csharp -// Input: -[MapTo(typeof(ResultDto<>), Bidirectional = true)] -public partial class Result -{ - public T Data { get; set; } = default!; - public bool Success { get; set; } - public string Message { get; set; } = string.Empty; -} - -// Generated Output: -public static ResultDto MapToResultDto(this Result source) -{ - if (source is null) return default!; - return new ResultDto - { - Data = source.Data, - Success = source.Success, - Message = source.Message - }; -} - -// Bidirectional reverse mapping: -public static Result MapToResult(this ResultDto source) -{ - // ... reverse mapping -} -``` - -✅ **Constraint Preservation**: - -```csharp -// Input with constraints: -[MapTo(typeof(PagedResultDto<>))] -public partial class PagedResult where T : class -{ - public ICollection Items { get; set; } = new List(); - public int TotalCount { get; set; } -} - -// Generated with preserved constraints: -public static PagedResultDto MapToPagedResultDto( - this PagedResult source) - where T : class -{ - // ... -} -``` - -✅ **Testing**: - -- 6 comprehensive unit tests added (skipped in test harness similar to Factory/UpdateTarget, verified in samples): - - Generic mapping method generation - - Constraints preservation (class, struct) - - Multiple type parameters - - Bidirectional generic mapping - - UpdateTarget for generic types - -✅ **Sample Code**: - -- `Atc.SourceGenerators.Mapping.Domain`: `Result` → `ResultDto` with bidirectional mapping -- `Atc.SourceGenerators.Mapping.Domain`: `PagedResult` → `PagedResultDto` with constraints -- `Atc.SourceGenerators.Mapping\Program.cs`: Demonstrates usage in API endpoints - -**Benefits**: - -- Type-safe wrapper types (Result, Optional, PagedResult, etc.) -- Eliminates boilerplate for generic DTOs -- Compile-time safety with preserved constraints -- Works with any arity (single or multiple type parameters) -- Full Native AOT compatibility - ---- - -### 15. Private Member Access - -**Priority**: 🟢 **Low** -**Status**: ✅ **Implemented** (v1.2) - -**Description**: Map to/from private and internal properties using UnsafeAccessor for AOT-safe, zero-overhead access without reflection. - -**Benefits**: - -- ✅ **AOT Compatible** - Uses .NET 8+ UnsafeAccessor (no reflection) -- ✅ **Zero Overhead** - Direct method calls at runtime -- ✅ **Compile-Time Safety** - Errors detected during build, not runtime -- ✅ **Encapsulation** - Map internal/domain models with private state -- ✅ **Clean API** - Simple boolean flag to enable the feature - -**Example**: - -```csharp -[MapTo(typeof(SecureAccountDto), IncludePrivateMembers = true)] -public partial class SecureAccount -{ - public Guid Id { get; set; } - public string AccountNumber { get; set; } = string.Empty; - - // Private property - will be mapped using UnsafeAccessor - private string InternalCode { get; set; } = string.Empty; - - // Internal property - will be mapped using UnsafeAccessor - internal int SecurityLevel { get; set; } -} - -public partial class SecureAccountDto -{ - public Guid Id { get; set; } - public string AccountNumber { get; set; } = string.Empty; - public string InternalCode { get; set; } = string.Empty; - public int SecurityLevel { get; set; } -} -``` - -**Generated Code**: - -```csharp -public static class ObjectMappingExtensions -{ - // UnsafeAccessor methods for reading private members - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_InternalCode")] - private static extern string UnsafeGetSecureAccount_InternalCode(SecureAccount instance); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_SecurityLevel")] - private static extern int UnsafeGetSecureAccount_SecurityLevel(SecureAccount instance); - - // UnsafeAccessor methods for writing to private members - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_InternalCode")] - private static extern void UnsafeSetSecureAccountDto_InternalCode(SecureAccountDto instance, string value); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_SecurityLevel")] - private static extern void UnsafeSetSecureAccountDto_SecurityLevel(SecureAccountDto instance, int value); - - public static SecureAccountDto MapToSecureAccountDto(this SecureAccount source) - { - if (source is null) - { - return default!; - } - - return new SecureAccountDto - { - Id = source.Id, - AccountNumber = source.AccountNumber, - InternalCode = UnsafeGetSecureAccount_InternalCode(source), - SecurityLevel = UnsafeGetSecureAccount_SecurityLevel(source) - }; - } -} -``` - -**Implementation Details**: - -- **Accessibility Detection**: Automatically detects which properties require UnsafeAccessor based on property/method accessibility -- **Getter Accessors**: Generated for reading from private/internal source properties -- **Setter Accessors**: Generated for writing to private/internal target properties (used in UpdateTarget pattern) -- **Deduplication**: Accessor methods are deduplicated to avoid generating duplicates -- **Seamless Integration**: Works with all existing features (nested objects, collections, enums, etc.) -- **Fallback Attribute**: `IncludePrivateMembers` property included in fallback MapToAttribute generation - -**Requirements**: - -- .NET 8.0 or later (for UnsafeAccessor support) -- Default is `false` - only public members are mapped unless explicitly enabled - ---- - -### 16. Multi-Source Consolidation - -**Priority**: 🟢 **Low-Medium** ⭐ *Requested by Mapperly users* -**Status**: ❌ Not Implemented - -**Description**: Merge multiple source objects into a single destination object. - -**User Story**: -> "As a developer, I want to combine data from multiple sources (like User + UserProfile + UserSettings) into a single DTO without writing manual merging logic." - -**Example**: - -```csharp -public class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; -} - -public class UserProfile -{ - public string Bio { get; set; } = string.Empty; - public string AvatarUrl { get; set; } = string.Empty; -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Bio { get; set; } = string.Empty; - public string AvatarUrl { get; set; } = string.Empty; -} - -// Potential API: -public static UserDto MapToUserDto(this User user, UserProfile profile) -{ - // Merge both sources into UserDto -} -``` - -**Implementation Considerations**: - -- Allow multiple source parameters in mapping methods -- Handle property conflicts (which source takes precedence?) -- Consider opt-in via attribute parameter: `[MapFrom(typeof(User), typeof(UserProfile))]` - ---- - -### 17. Value Converters - -**Priority**: 🟢 **Low-Medium** -**Status**: ❌ Not Implemented - -**Description**: Apply custom conversion logic to specific properties. - -**Example**: - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - [MapConverter(typeof(UpperCaseConverter))] - public string Name { get; set; } = string.Empty; -} - -public class UpperCaseConverter : IValueConverter -{ - public string Convert(string value) => value.ToUpperInvariant(); -} -``` - ---- - -### 18. Format Providers - -**Priority**: 🟢 **Low** -**Status**: ❌ Not Implemented - -**Description**: Support culture-specific formatting during type conversions. - -**Example**: - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - [MapFormat("C", CultureInfo = "en-US")] - public decimal Salary { get; set; } // Format as USD currency -} -``` - ---- - -### 19. Property Name Casing Strategies (SnakeCase, camelCase) - -**Priority**: 🟢 **Low-Medium** ⭐ *SnakeCase requested by Mapperly users* -**Status**: ✅ **Implemented** in v1.3 - -**Description**: Automatically map properties with different casing conventions (common when mapping to/from JSON APIs). - -**User Story**: -> "As a developer integrating with external APIs, I want to map PascalCase domain models to snake_case JSON DTOs without manually specifying each property mapping." - -**Example**: - -```csharp -[MapTo(typeof(UserDto), PropertyNameStrategy = PropertyNameStrategy.SnakeCase)] -public partial class User -{ - public string FirstName { get; set; } = string.Empty; // → first_name - public string LastName { get; set; } = string.Empty; // → last_name - public DateTime DateOfBirth { get; set; } // → date_of_birth -} - -public class UserDto -{ - public string first_name { get; set; } = string.Empty; - public string last_name { get; set; } = string.Empty; - public DateTime date_of_birth { get; set; } -} -``` - -**Supported Strategies**: - -- `PropertyNameStrategy.PascalCase` (default) -- `PropertyNameStrategy.CamelCase` (FirstName → firstName) -- `PropertyNameStrategy.SnakeCase` (FirstName → first_name) -- `PropertyNameStrategy.KebabCase` (FirstName → first-name) - -**Implementation Notes**: - -- Only enable when explicitly opted-in via attribute parameter -- Can be combined with `[MapProperty]` for overrides -- Useful for API boundary mappings (REST, GraphQL, etc.) - ---- - -### 20. Base Class Configuration Inheritance - -**Priority**: 🟢 **Low** ⭐ *Requested by Mapperly users* -**Status**: ✅ **Implemented** - -**Description**: Automatically inherit mapping configurations from base classes to reduce duplication. - -**Example**: - -```csharp -public abstract partial class Entity -{ - public Guid Id { get; set; } - public DateTime CreatedAt { get; set; } -} - -// UserEntity automatically inherits Id and CreatedAt in the mapping -[MapTo(typeof(UserDto))] -public partial class UserEntity : Entity -{ - public string Name { get; set; } = string.Empty; -} - -public class UserDto -{ - public Guid Id { get; set; } // Mapped from Entity.Id - public DateTime CreatedAt { get; set; } // Mapped from Entity.CreatedAt - public string Name { get; set; } = string.Empty; -} -``` - -**Benefits**: - -- Reduce boilerplate in inheritance hierarchies -- Maintain DRY principle -- Common for entity base classes with audit fields -- Supports multi-level inheritance (e.g., Entity → AuditableEntity → ConcreteEntity) -- Works with all other mapping features (PropertyNameStrategy, Bidirectional, MapIgnore, etc.) - ---- - -## ⛔ Do Not Need (Low Priority / Out of Scope) - -These features are either too niche, too complex, or don't align with the design philosophy of this library. They may be reconsidered based on strong user demand. - -### 21. External Mappers / Mapper Composition - -**Reason**: Adds complexity; users can manually compose mappings if needed. Our extension method approach already allows natural composition. - -**Status**: ❌ Not Planned - -**Example of current approach**: - -```csharp -// Already possible with extension methods -var dto = entity.MapToDomainModel().MapToDto(); -``` - ---- - -### 22. Advanced Enum Strategies Beyond Special Cases - -**Reason**: Our current enum support (with EnumMappingGenerator) is already comprehensive and handles 99% of use cases with special case detection, bidirectional mapping, and case-insensitive matching. - -**Status**: ❌ Not Needed - ---- - -### 23. Deep Cloning Support - -**Reason**: Not a mapping concern; users should use dedicated cloning libraries if needed. Mapping is about transforming between different types, not duplicating the same type. - -**Status**: ❌ Out of Scope - ---- - -### 24. Conditional Mapping (Map if condition is true) - -**Reason**: Too complex for source generation; users can use before/after hooks or manual logic. Conditionals introduce runtime behavior that's hard to express at compile time. - -**Status**: ❌ Not Planned - ---- - -### 25. Asynchronous Mapping - -**Reason**: Mapping should be synchronous and side-effect-free. Async logic (like database lookups) belongs outside the mapping layer in services or repositories. - -**Status**: ❌ Out of Scope - -**Better pattern**: - -```csharp -// Don't do this -var dto = await entity.MapToUserDtoAsync(); // ❌ Mapping shouldn't be async - -// Do this instead -var user = entity.MapToUser(); // ✅ Sync mapping -await _userService.EnrichWithDataAsync(user); // ✅ Async enrichment in service layer -``` - ---- - -### 26. Mapping Configuration Files (JSON/XML) - -**Reason**: Attribute-based configuration is more discoverable, type-safe, and IDE-friendly. Configuration files add external dependencies, complexity, and lose compile-time validation. - -**Status**: ❌ Not Planned - ---- - -### 27. Runtime Dynamic Mapping - -**Reason**: Conflicts with compile-time source generation philosophy and Native AOT compatibility. Dynamic mapping requires reflection, which we explicitly avoid for performance and trimming safety. - -**Status**: ❌ Out of Scope - ---- - -## 📅 Proposed Implementation Order - -Based on priority, dependencies, and community demand (⭐ = high user demand from Mapperly community): - -### Phase 1: Essential Features (v1.1 - Q1 2025) - -**Goal**: Make the generators production-ready for 80% of use cases - -1. **Collection Mapping** 🔴 Critical - Map `List` to `List` -2. **Ignore Properties** 🔴 High - `[MapIgnore]` attribute -3. **Constructor Mapping** 🔴 High - Records and primary constructor support - -**Estimated effort**: 3-4 weeks -**Impact**: Unlocks most real-world scenarios - ---- - -### Phase 2: Flexibility & Customization (v1.2 - Q2 2025) - -**Goal**: Handle edge cases and custom naming - -4. **Custom Property Name Mapping** 🟡 Medium-High - `[MapProperty("TargetName")]` -5. **Built-in Type Conversion** 🟡 Medium - DateTime ↔ string, Guid ↔ string -6. **Polymorphic Type Mapping** 🔴 High ⭐ - Derived types and interfaces - -**Estimated effort**: 4-5 weeks -**Impact**: Handle 95% of mapping scenarios - ---- - -### Phase 3: Advanced Features (v1.3 - Q3 2025) - -**Goal**: Validation and advanced scenarios - -7. **Required Property Validation** 🟡 Medium - Compile-time warnings -8. **Flattening Support** 🟡 Medium - `Address.City` → `AddressCity` -9. **Property Exclusion Shortcuts** 🟢 Low-Medium ⭐ - Exclude all except mapped - -**Estimated effort**: 3-4 weeks -**Impact**: Better developer experience and safety - ---- - -### Phase 4: Professional Scenarios (v2.0 - Q4 2025) - -**Goal**: Enterprise and EF Core integration - -10. **IQueryable Projections** 🟢 Low-Medium ⭐ - EF Core server-side projections -11. **Map to Existing Instance** 🟢 Low-Medium - Update tracked entities -12. **Before/After Hooks** 🟢 Low-Medium - Custom pre/post logic - -**Estimated effort**: 5-6 weeks -**Impact**: Support complex enterprise scenarios - ---- - -### Phase 5: Optional Enhancements (v2.1+ - 2026) - -**Goal**: Nice-to-have features based on feedback - -13. **Multi-Source Consolidation** 🟢 Low-Medium ⭐ - Merge multiple sources -14. **SnakeCase/CamelCase Strategies** 🟢 Low-Medium ⭐ - API boundary mapping -15. **Base Class Configuration** 🟢 Low ⭐ - Inherit mappings from base classes -16. **Object Factories** 🟢 Low-Medium - Custom object creation -17. **Reference Handling** 🟢 Low - Circular dependencies -18. **Value Converters** 🟢 Low-Medium - Custom property conversions -19. **Generic Mappers** 🟢 Low - `Result` scenarios -20. **Format Providers** 🟢 Low - Culture-specific formatting - -**Estimated effort**: Variable, based on priority and user demand -**Impact**: Polish and edge cases - ---- - -### Feature Prioritization Matrix - -| Feature | Priority | User Demand | Complexity | Phase | -|---------|----------|-------------|------------|-------| -| Collection Mapping | 🔴 Critical | ⭐⭐⭐ | Medium | 1.1 | -| Ignore Properties | 🔴 High | ⭐⭐⭐ | Low | 1.1 | -| Constructor Mapping | 🔴 High | ⭐⭐⭐ | Medium | 1.1 | -| Polymorphic Mapping | 🔴 High | ⭐⭐⭐ | High | 1.2 | -| Custom Property Names | 🟡 Med-High | ⭐⭐ | Low | 1.2 | -| Type Conversion | 🟡 Medium | ⭐⭐ | Medium | 1.2 | -| Required Validation | 🟡 Medium | ⭐⭐ | Low | 1.3 | -| Flattening | 🟡 Medium | ⭐⭐ | Medium | 1.3 | -| IQueryable Projections | 🟢 Low-Med | ⭐⭐⭐ | High | 2.0 | -| Multi-Source | 🟢 Low-Med | ⭐⭐ | High | 2.1+ | -| SnakeCase | 🟢 Low-Med | ⭐⭐ | Low | 2.1+ | - ---- - -## 🎯 Success Metrics - -To determine if these features are meeting user needs: - -1. **GitHub Issues & Feature Requests** - Track user-requested features -2. **NuGet Download Stats** - Adoption rate -3. **Documentation Feedback** - Are users understanding how to use advanced features? -4. **Community Contributions** - PRs and discussions -5. **Comparison to Mapperly** - Feature parity vs. simplicity trade-offs - ---- - -## 📝 Notes - -### Design Philosophy - -- **Guiding Principle**: Keep the generators **simple**, **predictable**, and **AOT-compatible** -- **Trade-offs**: We intentionally avoid runtime reflection and dynamic features to maintain Native AOT support -- **Mapperly Inspiration**: While Mapperly is comprehensive (with 100+ features across the API), we aim to cover the **80% use case with 20% of the complexity** -- **User Feedback**: This roadmap is a living document and will evolve based on real-world usage patterns - -### Key Differences from Mapperly - -**What we do differently**: - -1. **Extension Methods** - We generate fluent extension methods (`user.MapToUserDto()`) vs. Mapperly's mapper classes -2. **Explicit opt-in** - Each type must have `[MapTo]` attribute vs. Mapperly's convention-based approach -3. **Simpler API surface** - Fewer attributes and configuration options for easier onboarding -4. **EnumMappingGenerator** - We have a dedicated, powerful enum mapping generator with special case detection - -**What we learn from Mapperly**: - -- ⭐ **Collection mapping** is absolutely essential (appears in nearly every issue) -- ⭐ **Polymorphic type mapping** is highly requested for real-world inheritance scenarios -- ⭐ **IQueryable projections** are critical for EF Core users (performance optimization) -- ⭐ **Property naming strategies** (especially SnakeCase) are needed for API boundary scenarios -- ⚠️ Complex features like multi-source consolidation and base class inheritance can wait until user demand is proven - -### Lessons from Mapperly's GitHub - -**From 3.7k stars and 66 open issues**: - -- **Documentation is critical** - Users need clear examples for each feature -- **Performance benchmarks matter** - Developers want proof that source generation is faster -- **Readable generated code** - Developers trust what they can inspect and debug -- **Active issue triage** - Quick responses to bugs and feature requests build community trust -- **Good first issues** - Tagged beginner-friendly issues encourage contributions - -### Updated Priorities Based on Community Insights - -**Originally "Do Not Need" → Reconsidered**: - -- ✅ **Property Naming Strategies** (SnakeCase) - Moved to "Nice to Have" due to API integration demand -- ✅ **Multi-Source Consolidation** - Added to "Nice to Have" based on Mapperly user requests -- ✅ **Base Class Configuration** - Added to "Nice to Have" for inheritance scenarios - -**Elevated Priority**: - -- 🔴 **Polymorphic Mapping** - Raised from Medium to High based on issue frequency -- 🔴 **IQueryable Projections** - Recognized as critical for EF Core users despite complexity - ---- - -## 🔗 Related Resources - -- **Mapperly Documentation**: -- **Mapperly GitHub**: (3.7k⭐) -- **Our Documentation**: See `/docs/generators/ObjectMapping.md` and `/docs/generators/EnumMapping.md` -- **Sample Projects**: See `/sample/PetStore.Api` for complete example - ---- - -**Last Updated**: 2025-01-17 (Updated with GitHub community insights) -**Version**: 1.1 -**Research Date**: January 2025 (Mapperly v4.3.0) -**Maintained By**: Atc.SourceGenerators Team diff --git a/docs/ObjectMappingGenerators-Samples.md b/docs/ObjectMappingGenerators-Samples.md deleted file mode 100644 index 9c9225a..0000000 --- a/docs/ObjectMappingGenerators-Samples.md +++ /dev/null @@ -1,477 +0,0 @@ -# Object Mapping Sample - -## 🎯 Focus - -This sample demonstrates the **MappingGenerator** in a realistic 3-layer architecture with ASP.NET Core Minimal API. The focus is on: - -- **Type-safe object mapping** across application layers -- **Multi-layer mapping chains** (Entity → Domain → DTO) -- **Base class property inheritance** with audit fields (BaseEntity → AuditableEntity → Book) -- **Automatic enum conversion** between compatible enum types -- **Nested object mapping** with automatic chaining -- **Null safety** for nullable reference types -- **Convention-based mapping** without configuration - -## 📁 Sample Projects - -- **Atc.SourceGenerators.Mapping** - ASP.NET Core Minimal API (entry point) -- **Atc.SourceGenerators.Mapping.Domain** - Domain models and business logic -- **Atc.SourceGenerators.Mapping.DataAccess** - Data access layer with entities -- **Atc.SourceGenerators.Mapping.Contract** - API contracts (DTOs) - -## 🏗️ Architecture - -```mermaid -graph TB - subgraph "API Layer" - API[Minimal API Endpoints] - end - - subgraph "Contract Layer" - DTO[DTOs: UserDto, AddressDto, UserStatusDto] - end - - subgraph "Domain Layer" - DM[Domain Models: User, Address] - DE[Domain Enums: UserStatus] - end - - subgraph "Data Access Layer" - ENT[Entities: UserEntity, AddressEntity] - EE[Entity Enums: UserStatusEntity] - end - - subgraph "Generated Bidirectional Mappings" - M1["User ↔ UserEntity"] - M2["User → UserDto"] - M3["Address ↔ AddressEntity"] - M4["Address → AddressDto"] - M5["UserStatus ↔ UserStatusEntity"] - M6["UserStatus → UserStatusDto"] - end - - API --> DTO - DTO --> DM - DM --> M1 - DM --> M2 - DM --> M3 - DM --> M4 - DE --> M5 - DE --> M6 - M1 --> ENT - M3 --> ENT - M5 --> EE - M2 --> DTO - M4 --> DTO - M6 --> DTO - - style M1 fill:#2ea44f - style M2 fill:#2ea44f - style M3 fill:#2ea44f - style M4 fill:#2ea44f - style M5 fill:#2ea44f - style M6 fill:#2ea44f -``` - -## 🔄 Mapping Flow - -```mermaid -sequenceDiagram - participant Client - participant API as API Endpoint - participant Domain as Domain Layer - participant DataAccess as Data Access - participant DB as Database - - Note over Client,DB: POST /users - Create User - - Client->>API: POST { name, email, status } - API->>API: Validate request - API->>Domain: CreateUserAsync(request) - Note over Domain: request.MapToUser() - Domain->>DataAccess: SaveAsync(user) - Note over DataAccess: user.MapToUserEntity() - DataAccess->>DB: INSERT UserEntity - DB-->>DataAccess: UserEntity (with ID) - Note over DataAccess: entity.MapToUser() - DataAccess-->>Domain: User - Note over Domain: user.MapToUserDto() - Domain-->>API: UserDto - API-->>Client: 201 Created + UserDto - - Note over Client,DB: GET /users/{id} - Get User - - Client->>API: GET /users/123 - API->>Domain: GetUserAsync(id) - Domain->>DataAccess: GetByIdAsync(id) - DataAccess->>DB: SELECT * FROM Users - DB-->>DataAccess: UserEntity - Note over DataAccess: entity.MapToUser() - auto-maps nested Address - DataAccess-->>Domain: User - Note over Domain: user.MapToUserDto() - auto-converts enum - Domain-->>API: UserDto - API-->>Client: 200 OK + UserDto -``` - -## 💻 Code Example - -### Data Access Layer - -```csharp -namespace Atc.SourceGenerators.Mapping.DataAccess.Entities; - -// Entity - NO mapping attribute (mapping defined in Domain) -public class UserEntity -{ - public int DatabaseId { get; set; } - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatusEntity Status { get; set; } - public AddressEntity? Address { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public bool IsDeleted { get; set; } // DB-specific field - public byte[] RowVersion { get; set; } = []; // DB-specific field -} - -public class AddressEntity -{ - public int Id { get; set; } - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } -} - -public enum UserStatusEntity -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3 -} -``` - -### Domain Layer - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace Atc.SourceGenerators.Mapping.Domain; - -// Domain model with BIDIRECTIONAL mapping to Entity and forward mapping to DTO -[MapTo(typeof(UserDto))] -[MapTo(typeof(UserEntity), Bidirectional = true)] -public partial class User -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatus Status { get; set; } - public Address? Address { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } -} - -[MapTo(typeof(AddressDto))] -[MapTo(typeof(AddressEntity), Bidirectional = true)] -public partial class Address -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; -} - -[MapTo(typeof(UserStatusDto))] -[MapTo(typeof(UserStatusEntity), Bidirectional = true)] -public enum UserStatus -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3 -} -``` - -### Contract Layer - -```csharp -namespace Atc.SourceGenerators.Mapping.Contract; - -// DTOs - no mapping attributes needed (mapped from Domain) -public class UserDto -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatusDto Status { get; set; } - public AddressDto? Address { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } -} - -public class AddressDto -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; -} - -public enum UserStatusDto -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3 -} -``` - -### API Usage - -```csharp -using Atc.Mapping; -using Atc.SourceGenerators.Mapping.Contract; -using Atc.SourceGenerators.Mapping.Domain; -using Microsoft.AspNetCore.Mvc; - -var builder = WebApplication.CreateBuilder(args); - -// Register services -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -var app = builder.Build(); - -// GET endpoint - Retrieve user by ID -app.MapGet("/users/{id:guid}", (Guid id, UserService userService) => -{ - var user = userService.GetById(id); - if (user is null) - { - return Results.NotFound(new { message = $"User with ID {id} not found" }); - } - - // ✨ Use generated mapping: Domain → DTO - var data = user.MapToUserDto(); - return Results.Ok(data); -}) -.WithName("GetUserById") -.Produces(StatusCodes.Status200OK) -.Produces(StatusCodes.Status404NotFound); - -// GET endpoint - Retrieve all users -app.MapGet("/users", (UserService userService) => -{ - // ✨ Use generated mapping: Domain → DTO - var data = userService - .GetAll() - .Select(u => u.MapToUserDto()) - .ToList(); - return Results.Ok(data); -}) -.WithName("GetAllUsers") -.Produces>(StatusCodes.Status200OK); - -await app.RunAsync(); -``` - -## 📝 Generated Code - -The generator creates extension methods for bidirectional mappings: - -```csharp -// -namespace Atc.Mapping; - -// Bidirectional mapping: User ↔ UserEntity -public static partial class UserExtensions -{ - public static UserEntity MapToUserEntity(this User source) - { - if (source is null) - { - return default!; - } - - return new UserEntity - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - Status = source.Status.MapToUserStatusEntity(), // ✨ Safe enum mapping (bidirectional) - Address = source.Address?.MapToAddressEntity()!, // ✨ Auto nested mapping - CreatedAt = source.CreatedAt, - UpdatedAt = source.UpdatedAt - }; - } - - public static UserDto MapToUserDto(this User source) - { - if (source is null) - { - return default!; - } - - return new UserDto - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - Status = source.Status.MapToUserStatusDto(), // ✨ Safe enum mapping - Address = source.Address?.MapToAddressDto()!, // ✨ Nested mapping - CreatedAt = source.CreatedAt, - UpdatedAt = source.UpdatedAt - }; - } -} - -public static partial class UserEntityExtensions -{ - // Reverse mapping (from Bidirectional = true) - public static User MapToUser(this UserEntity source) - { - if (source is null) - { - return default!; - } - - return new User - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - Status = source.Status.MapToUserStatus(), // ✨ Safe enum mapping (reverse) - Address = source.Address?.MapToAddress()!, // ✨ Auto nested mapping - CreatedAt = source.CreatedAt, - UpdatedAt = source.UpdatedAt - }; - } -} -``` - -## ✨ Key Features Demonstrated - -### 1. **Multi-Layer Mapping Chains** - -```csharp -var dto = entity - .MapToUser() // Entity → Domain - .MapToUserDto(); // Domain → DTO -``` - -### 2. **Automatic Enum Conversion** - -```csharp -// Simple cast (fallback when enums don't have [MapTo] attributes) -Status = (Domain.UserStatus)source.Status - -// For safer enum mapping with special case handling (None → Unknown, etc.), -// add [MapTo] attributes to your enums - see EnumMapping sample -``` - -### 3. **Nested Object Mapping** - -```csharp -// Automatically detects AddressEntity has MapToAddress() method -Address = source.Address?.MapToAddress()! -``` - -### 4. **Null Safety** - -```csharp -// Built-in null checks -if (source is null) return default!; - -// Null-conditional operator for nullable properties -Address = source.Address?.MapToAddressDto()! -``` - -### 5. **Convention-Based** - -- Properties are matched by name -- No manual configuration needed -- Unmapped properties are simply skipped - -### 6. **Base Class Property Inheritance** - -The sample includes an inheritance demo showing how the generator automatically includes properties from base classes: - -```csharp -// Base entity with common audit properties -public abstract partial class BaseEntity -{ - public Guid Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -// Auditable entity adds update tracking -public abstract partial class AuditableEntity : BaseEntity -{ - public DateTimeOffset? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } -} - -// Concrete entity with business properties -[MapTo(typeof(BookDto))] -public partial class Book : AuditableEntity -{ - public string Title { get; set; } = string.Empty; - public string Author { get; set; } = string.Empty; - public decimal Price { get; set; } -} -``` - -**Generated mapping includes ALL properties from the inheritance hierarchy:** - -```csharp -public static BookDto MapToBookDto(this Book source) -{ - return new BookDto - { - Id = source.Id, // ✨ From BaseEntity (2 levels up) - CreatedAt = source.CreatedAt, // ✨ From BaseEntity - UpdatedAt = source.UpdatedAt, // ✨ From AuditableEntity (1 level up) - UpdatedBy = source.UpdatedBy, // ✨ From AuditableEntity - Title = source.Title, // From Book - Author = source.Author, // From Book - Price = source.Price // From Book - }; -} -``` - -**Key Benefits:** - -- **No manual property listing** - inherited properties automatically included -- **Unlimited depth** - works with any inheritance hierarchy depth -- **Respects [MapIgnore]** - can exclude base class properties if needed -- **Perfect for entity base classes** - ideal for common audit fields (Id, CreatedAt, UpdatedAt, etc.) - -## 🎯 Benefits - -1. **Zero Boilerplate**: No manual mapping code to write or maintain -2. **Type Safety**: Compile-time errors if properties don't match -3. **Refactoring Safe**: Rename a property, mappings update automatically -4. **Performance**: Zero runtime overhead - all code generated at compile time -5. **Maintainable**: Changes to models automatically propagate through mappings -6. **Testable**: Generated methods are standard C# extension methods - -## 🔗 Related Documentation - -- [ObjectMapping Generator Guide](ObjectMappingGenerators.md) - Full generator documentation -- [DependencyRegistration Sample](DependencyRegistrationGenerators-Samples.md) - DI registration example -- [OptionsBinding Sample](OptionsBinding-Samples.md) - Configuration binding example -- [PetStore API Sample](PetStoreApi-Samples.md) - Complete application using all generators diff --git a/docs/ObjectMappingGenerators.md b/docs/ObjectMappingGenerators.md deleted file mode 100644 index 2c4bd5f..0000000 --- a/docs/ObjectMappingGenerators.md +++ /dev/null @@ -1,3923 +0,0 @@ -# 🗺️ Object Mapping Generator - -Automatically generate type-safe object-to-object mapping code using attributes. The generator creates efficient mapping extension methods at compile time, eliminating manual mapping boilerplate and reducing errors. - -**Key Benefits:** - -- 🎯 **Zero boilerplate** - No manual property copying or constructor calls -- 🔗 **Automatic chaining** - Nested objects map automatically when mappings exist -- 🧩 **Constructor support** - Maps to classes with primary constructors or parameter-based constructors -- 🛡️ **Null-safe** - Generates proper null checks for nullable properties -- ⚡ **Native AOT ready** - Pure compile-time generation with zero reflection - -**Quick Example:** - -```csharp -// Input: Decorate your domain model -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; -} - -// Generated: Extension method -public static UserDto MapToUserDto(this User source) => - new UserDto { Id = source.Id, Name = source.Name }; -``` - -## 📖 Documentation Navigation - -- **[📋 Feature Roadmap](ObjectMappingGenerators-FeatureRoadmap.md)** - See all implemented and planned features -- **[🎯 Sample Projects](ObjectMappingGenerators-Samples.md)** - Working code examples with architecture diagrams - -## 📑 Table of Contents - -- [🗺️ Object Mapping Generator](#️-object-mapping-generator) - - [📖 Documentation Navigation](#-documentation-navigation) - - [📑 Table of Contents](#-table-of-contents) - - [🚀 Get Started - Quick Guide](#-get-started---quick-guide) - - [📂 Project Structure](#-project-structure) - - [1️⃣ Setup Projects](#1️⃣-setup-projects) - - [2️⃣ Data Access Layer (`UserApp.DataAccess`)](#2️⃣-data-access-layer-userappdataaccess) - - [3️⃣ Domain Layer (`UserApp.Domain`)](#3️⃣-domain-layer-userappdomain) - - [4️⃣ API Layer (`UserApp.Api`)](#4️⃣-api-layer-userappapi) - - [🎨 What Gets Generated](#-what-gets-generated) - - [6️⃣ Testing the Application](#6️⃣-testing-the-application) - - [🔍 Viewing Generated Code (Optional)](#-viewing-generated-code-optional) - - [🎯 Key Takeaways](#-key-takeaways) - - [✨ Features](#-features) - - [📦 Installation](#-installation) - - [💡 Basic Usage](#-basic-usage) - - [1️⃣ Add Using Directives](#1️⃣-add-using-directives) - - [2️⃣ Decorate Your Classes](#2️⃣-decorate-your-classes) - - [3️⃣ Use Generated Mappings](#3️⃣-use-generated-mappings) - - [🏗️ Advanced Scenarios](#️-advanced-scenarios) - - [🔄 Enum Conversion](#-enum-conversion) - - [🎯 Safe Enum Mapping (Recommended)](#-safe-enum-mapping-recommended) - - [⚠️ Enum Cast (Fallback)](#️-enum-cast-fallback) - - [🪆 Nested Object Mapping](#-nested-object-mapping) - - [📦 Collection Mapping](#-collection-mapping) - - [🔁 Multi-Layer Mapping](#-multi-layer-mapping) - - [🚫 Excluding Properties with `[MapIgnore]`](#-excluding-properties-with-mapignore) - - [🔤 Property Name Casing Strategies](#-property-name-casing-strategies) - - [Example: Mapping to JSON API (camelCase)](#example-mapping-to-json-api-camelcase) - - [Example: Mapping to Database (snake\_case)](#example-mapping-to-database-snake_case) - - [Example: Bidirectional Mapping with Strategy](#example-bidirectional-mapping-with-strategy) - - [Example: Override with `[MapProperty]`](#example-override-with-mapproperty) - - [🏷️ Custom Property Name Mapping with `[MapProperty]`](#️-custom-property-name-mapping-with-mapproperty) - - [🔄 Property Flattening](#-property-flattening) - - [🔀 Built-in Type Conversion](#-built-in-type-conversion) - - [✅ Required Property Validation](#-required-property-validation) - - [Basic Example](#basic-example) - - [Correct Implementation](#correct-implementation) - - [Validation Behavior](#validation-behavior) - - [Elevating to Error](#elevating-to-error) - - [🌳 Polymorphic / Derived Type Mapping](#-polymorphic--derived-type-mapping) - - [Basic Example](#basic-example-1) - - [How It Works](#how-it-works) - - [Real-World Example - Notification System](#real-world-example---notification-system) - - [Key Features](#key-features) - - [🧬 Base Class Property Inheritance](#-base-class-property-inheritance) - - [Basic Example](#basic-example-2) - - [Multi-Level Inheritance](#multi-level-inheritance) - - [Property Overrides](#property-overrides) - - [Respecting \[MapIgnore\] on Base Properties](#respecting-mapignore-on-base-properties) - - [Compatibility with Other Features](#compatibility-with-other-features) - - [🏗️ Constructor Mapping](#️-constructor-mapping) - - [Simple Record Mapping](#simple-record-mapping) - - [Bidirectional Record Mapping](#bidirectional-record-mapping) - - [Mixed Constructor + Initializer](#mixed-constructor--initializer) - - [Case-Insensitive Parameter Matching](#case-insensitive-parameter-matching) - - [🪝 Before/After Mapping Hooks](#-beforeafter-mapping-hooks) - - [Basic Usage](#basic-usage) - - [Hook Signatures](#hook-signatures) - - [Execution Order](#execution-order) - - [Using Only BeforeMap](#using-only-beforemap) - - [Using Only AfterMap](#using-only-aftermap) - - [Hooks with Constructor Mapping](#hooks-with-constructor-mapping) - - [Use Cases](#use-cases) - - [Important Notes](#important-notes) - - [Hooks in Bidirectional Mappings](#hooks-in-bidirectional-mappings) - - [🏭 Object Factories](#-object-factories) - - [Basic Usage](#basic-usage-1) - - [Factory Method Signature](#factory-method-signature) - - [Execution Order](#execution-order-1) - - [Factory with Hooks](#factory-with-hooks) - - [Use Cases](#use-cases-1) - - [Important Notes](#important-notes-1) - - [Factories in Bidirectional Mappings](#factories-in-bidirectional-mappings) - - [Update Existing Target Instance](#update-existing-target-instance) - - [Basic Usage](#basic-usage-2) - - [EF Core Tracked Entities](#ef-core-tracked-entities) - - [Update with Hooks](#update-with-hooks) - - [Reduce Object Allocations](#reduce-object-allocations) - - [Important Notes](#important-notes-2) - - [When to Use UpdateTarget](#when-to-use-updatetarget) - - [Comparison with Standard Mapping](#comparison-with-standard-mapping) - - [📊 IQueryable Projections](#-iqueryable-projections) - - [When to Use IQueryable Projections](#when-to-use-iqueryable-projections) - - [Basic Example](#basic-example-3) - - [Projection Limitations](#projection-limitations) - - [What Gets Included in Projections](#what-gets-included-in-projections) - - [Comparison: Standard Mapping vs. Projection](#comparison-standard-mapping-vs-projection) - - [Performance Benefits](#performance-benefits) - - [Real-World Example: Pet Store List View](#real-world-example-pet-store-list-view) - - [Best Practices](#best-practices) - - [Troubleshooting](#troubleshooting) - - [🔐 Private Member Access](#-private-member-access) - - [When to Use Private Member Access](#when-to-use-private-member-access) - - [Basic Example](#basic-example-4) - - [How It Works](#how-it-works-1) - - [UpdateTarget with Private Members](#updatetarget-with-private-members) - - [Bidirectional Mapping with Private Members](#bidirectional-mapping-with-private-members) - - [Mixing Public and Private Properties](#mixing-public-and-private-properties) - - [Compatibility with Other Features](#compatibility-with-other-features-1) - - [Requirements](#requirements) - - [Real-World Example: Secure Domain Model](#real-world-example-secure-domain-model) - - [Performance Characteristics](#performance-characteristics) - - [Best Practices](#best-practices-1) - - [⚙️ MapToAttribute Parameters](#️-maptoattribute-parameters) - - [🛡️ Diagnostics](#️-diagnostics) - - [❌ ATCMAP001: Mapping Class Must Be Partial](#-atcmap001-mapping-class-must-be-partial) - - [❌ ATCMAP002: Target Type Must Be Class or Struct](#-atcmap002-target-type-must-be-class-or-struct) - - [❌ ATCMAP003: MapProperty Target Property Not Found](#-atcmap003-mapproperty-target-property-not-found) - - [⚠️ ATCMAP004: Required Property Not Mapped](#️-atcmap004-required-property-not-mapped) - - [🚀 Native AOT Compatibility](#-native-aot-compatibility) - - [✅ AOT-Safe Features](#-aot-safe-features) - - [🏗️ How It Works](#️-how-it-works) - - [📋 Example Generated Code](#-example-generated-code) - - [🎯 Multi-Layer AOT Support](#-multi-layer-aot-support) - - [📚 Additional Examples](#-additional-examples) - - [Example 1: Simple POCO Mapping](#example-1-simple-poco-mapping) - - [Example 2: Record Types](#example-2-record-types) - - [Example 3: Complex Nested Structure](#example-3-complex-nested-structure) - - [Example 4: Working with Collections](#example-4-working-with-collections) - ---- - -## 🚀 Get Started - Quick Guide - -This guide demonstrates a realistic 3-layer architecture for a UserApp application using minimal APIs and automatic object mapping. - -### 📂 Project Structure - -``` -UserApp.sln -├── UserApp.Api/ (Presentation layer - DTOs) -├── UserApp.Domain/ (Business logic layer - Domain models) -└── UserApp.DataAccess/ (Data access layer - Entities) -``` - -### 1️⃣ Setup Projects - -**UserApp.DataAccess.csproj** (Base layer): - -```xml - - - net10.0 - enable - enable - - - - - - - - - - - -``` - -**UserApp.Domain.csproj** (Middle layer): - -```xml - - - net10.0 - enable - enable - - - - - - - -``` - -**UserApp.Api.csproj** (Top layer): - -```xml - - - net10.0 - enable - enable - - - - - - -``` - -### 2️⃣ Data Access Layer (`UserApp.DataAccess`) - -**Entities/UserEntity.cs** - Database entity with mapping attribute: - -```csharp -using Atc.SourceGenerators.Annotations; -using UserApp.Domain; - -namespace UserApp.DataAccess.Entities; - -/// -/// Database entity for user (maps to Domain.User). -/// -[MapTo(typeof(User))] -public partial class UserEntity -{ - /// - /// Gets or sets the database ID (auto-increment). - /// - public int DatabaseId { get; set; } - - /// - /// Gets or sets the user's public unique identifier (GUID). - /// - public Guid Id { get; set; } - - /// - /// Gets or sets the user's first name. - /// - public string FirstName { get; set; } = string.Empty; - - /// - /// Gets or sets the user's last name. - /// - public string LastName { get; set; } = string.Empty; - - /// - /// Gets or sets the user's email address. - /// - public string Email { get; set; } = string.Empty; - - /// - /// Gets or sets the user's status (stored as int in DB). - /// - public UserStatusEntity Status { get; set; } - - /// - /// Gets or sets the navigation property to address. - /// - public AddressEntity? Address { get; set; } - - /// - /// Gets or sets when the user was created. - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets when the user was last updated. - /// - public DateTimeOffset? UpdatedAt { get; set; } - - /// - /// Gets or sets if the record is soft-deleted. - /// - public bool IsDeleted { get; set; } - - /// - /// Gets or sets the row version for optimistic concurrency. - /// - public byte[] RowVersion { get; set; } = Array.Empty(); -} -``` - -**Entities/AddressEntity.cs** - Nested entity with mapping: - -```csharp -using Atc.SourceGenerators.Annotations; -using UserApp.Domain; - -namespace UserApp.DataAccess.Entities; - -/// -/// Database entity for address (maps to Domain.Address). -/// -[MapTo(typeof(Address))] -public partial class AddressEntity -{ - public int Id { get; set; } - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } -} -``` - -**Entities/UserStatusEntity.cs** - Enum matching domain enum: - -```csharp -namespace UserApp.DataAccess.Entities; - -/// -/// Database representation of user status. -/// -public enum UserStatusEntity -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3, -} -``` - -### 3️⃣ Domain Layer (`UserApp.Domain`) - -**User.cs** - Domain model with mapping to DTO: - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace UserApp.Domain; - -/// -/// Domain model for user (maps to UserDto for API responses). -/// -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; init; } - public string FirstName { get; init; } = string.Empty; - public string LastName { get; init; } = string.Empty; - public string Email { get; init; } = string.Empty; - public UserStatus Status { get; init; } - public Address? Address { get; init; } - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? UpdatedAt { get; init; } -} -``` - -**Address.cs** - Nested domain model: - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace UserApp.Domain; - -[MapTo(typeof(AddressDto))] -public partial class Address -{ - public string Street { get; init; } = string.Empty; - public string City { get; init; } = string.Empty; - public string State { get; init; } = string.Empty; - public string PostalCode { get; init; } = string.Empty; - public string Country { get; init; } = string.Empty; -} -``` - -**UserStatus.cs** - Domain enum: - -```csharp -namespace UserApp.Domain; - -public enum UserStatus -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3, -} -``` - -**UserDto.cs** - API DTO: - -```csharp -namespace UserApp.Domain; - -public class UserDto -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatusDto Status { get; set; } - public AddressDto? Address { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } -} -``` - -**AddressDto.cs** - Nested DTO: - -```csharp -namespace UserApp.Domain; - -public class AddressDto -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string State { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; - public string Country { get; set; } = string.Empty; -} -``` - -**UserStatusDto.cs** - DTO enum: - -```csharp -namespace UserApp.Domain; - -public enum UserStatusDto -{ - Active = 0, - Inactive = 1, - Suspended = 2, - Deleted = 3, -} -``` - -### 4️⃣ API Layer (`UserApp.Api`) - -**Program.cs** - Using generated mappings: - -```csharp -using Atc.Mapping; -using UserApp.Domain; - -var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); - -// In-memory user repository for demo purposes -var users = new Dictionary -{ - { - Guid.Parse("550e8400-e29b-41d4-a716-446655440000"), - new User - { - Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440000"), - FirstName = "John", - LastName = "Doe", - Email = "john.doe@example.com", - Status = UserStatus.Active, - Address = new Address - { - Street = "123 Main St", - City = "Springfield", - State = "IL", - PostalCode = "62701", - Country = "USA", - }, - CreatedAt = DateTimeOffset.UtcNow.AddDays(-30), - UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1), - } - }, -}; - -// GET /users/{id} - Get user by ID -app.MapGet("/users/{id:guid}", (Guid id) => -{ - if (!users.TryGetValue(id, out var user)) - { - return Results.NotFound(new { message = $"User with ID {id} not found" }); - } - - // ✨ Use generated mapping extension method - var dto = user.MapToUserDto(); - return Results.Ok(dto); -}) -.WithName("GetUserById") -.Produces(StatusCodes.Status200OK) -.Produces(StatusCodes.Status404NotFound); - -// GET /users - Get all users -app.MapGet("/users", () => -{ - // ✨ Use generated mapping extension method - var dtos = users.Values.Select(u => u.MapToUserDto()).ToList(); - return Results.Ok(dtos); -}) -.WithName("GetAllUsers") -.Produces>(StatusCodes.Status200OK); - -app.Run(); -``` - -### 🎨 What Gets Generated - -The generator automatically creates extension methods in the `Atc.Mapping` namespace: - -**For Data Access → Domain:** - -```csharp -namespace Atc.Mapping; - -public static class ObjectMappingExtensions -{ - public static UserApp.Domain.User MapToUser(this UserApp.DataAccess.Entities.UserEntity source) - { - if (source is null) - { - return default!; - } - - return new UserApp.Domain.User - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - // ✨ Automatic enum conversion - Status = (UserApp.Domain.UserStatus)source.Status, - // ✨ Automatic nested object mapping - Address = source.Address?.MapToAddress()!, - CreatedAt = source.CreatedAt, - UpdatedAt = source.UpdatedAt - }; - } - - public static UserApp.Domain.Address MapToAddress(this UserApp.DataAccess.Entities.AddressEntity source) - { - if (source is null) - { - return default!; - } - - return new UserApp.Domain.Address - { - Street = source.Street, - City = source.City, - State = source.State, - PostalCode = source.PostalCode, - Country = source.Country - }; - } -} -``` - -**For Domain → DTOs:** - -```csharp -namespace Atc.Mapping; - -public static class ObjectMappingExtensions -{ - public static UserApp.Domain.UserDto MapToUserDto(this UserApp.Domain.User source) - { - if (source is null) - { - return default!; - } - - return new UserApp.Domain.UserDto - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - // ✨ Automatic enum conversion - Status = (UserApp.Domain.UserStatusDto)source.Status, - // ✨ Automatic nested object mapping - Address = source.Address?.MapToAddressDto()!, - CreatedAt = source.CreatedAt, - UpdatedAt = source.UpdatedAt - }; - } - - public static UserApp.Domain.AddressDto MapToAddressDto(this UserApp.Domain.Address source) - { - if (source is null) - { - return default!; - } - - return new UserApp.Domain.AddressDto - { - Street = source.Street, - City = source.City, - State = source.State, - PostalCode = source.PostalCode, - Country = source.Country - }; - } -} -``` - -### 6️⃣ Testing the Application - -```bash -# Run the application -dotnet run --project UserApp.Api - -# Test the endpoints -curl https://localhost:7000/users -curl https://localhost:7000/users/550e8400-e29b-41d4-a716-446655440000 -``` - -**Example Response:** - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@example.com", - "status": 0, - "address": { - "street": "123 Main St", - "city": "Springfield", - "state": "IL", - "postalCode": "62701", - "country": "USA" - }, - "createdAt": "2024-12-15T10:00:00Z", - "updatedAt": "2025-01-13T15:30:00Z" -} -``` - -### 🔍 Viewing Generated Code (Optional) - -To see the generated mapping code: - -```bash -dotnet build -p:EmitCompilerGeneratedFiles=true -p:CompilerGeneratedFilesOutputPath=Generated -``` - -Then look in `obj/Debug/net10.0/Atc.SourceGenerators/Atc.SourceGenerators.ObjectMappingGenerator/ObjectMappingExtensions.g.cs` - -### 🎯 Key Takeaways - -✅ **3-Layer Architecture:** - -- **Data Access Layer:** `UserEntity` (with database-specific fields) -- **Domain Layer:** `User` (clean domain model) -- **API Layer:** `UserDto` (API contract) - -✅ **Automatic Mapping Chain:** - -```text -UserEntity → User → UserDto -``` - -✅ **Features Demonstrated:** - -- Enum conversion -- Nested object mapping -- Null safety -- Multiple properties -- DateTimeOffset/DateTime handling - -✅ **Benefits:** - -- 🚀 No manual mapping code -- ✅ Compile-time type safety -- 🎯 Zero runtime overhead -- 🔧 Easy to maintain - ---- - -## ✨ Features - -🎯 **Attribute-Based Configuration** - -- Declarative mapping using `[MapTo(typeof(TargetType))]` -- Clean and readable code - -🔄 **Automatic Type Handling** - -- Direct property mapping (same name and type, case-insensitive) -- **Constructor mapping** - Automatically detects and uses constructors for records and classes with primary constructors -- Mixed initialization support (constructor + object initializer for remaining properties) -- **Property exclusion** - Use `[MapIgnore]` to exclude sensitive or internal properties -- **Custom property names** - Use `[MapProperty]` to map properties with different names -- Automatic enum conversion -- Nested object mapping -- Collection mapping with LINQ -- Null safety built-in - -⚡ **Compile-Time Generation** - -- Zero runtime reflection -- Zero performance overhead -- Type-safe extension methods - -🏗️ **Multi-Layer Support** - -- Entity → Domain → DTO chains -- Automatic chaining of nested mappings - -🛡️ **Comprehensive Diagnostics** - -- Clear error messages -- Build-time validation -- Helpful suggestions - ---- - -## 📦 Installation - -**Required:** - -```bash -dotnet add package Atc.SourceGenerators -``` - -**Optional (recommended for better IntelliSense):** - -```bash -dotnet add package Atc.SourceGenerators.Annotations -``` - -Or in your `.csproj`: - -```xml - - - - - - - -``` - -**Note:** The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: `$(NoWarn);CS0436` - ---- - -## 💡 Basic Usage - -### 1️⃣ Add Using Directives - -```csharp -using Atc.SourceGenerators.Annotations; -using Atc.Mapping; // For using generated extension methods -``` - -### 2️⃣ Decorate Your Classes - -Mark the **source** class with `[MapTo(typeof(TargetType))]`: - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace MyApp.Domain; - -// Simple mapping example -[MapTo(typeof(PersonDto))] -public partial class Person -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public int Age { get; set; } -} - -public class PersonDto -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public int Age { get; set; } -} -``` - -**Important:** The source class **must** be `partial`. - -### 3️⃣ Use Generated Mappings - -```csharp -using Atc.Mapping; - -var person = new Person { Id = 1, Name = "John", Age = 30 }; - -// ✨ Use the generated extension method -var dto = person.MapToPersonDto(); - -Console.WriteLine($"{dto.Name} is {dto.Age} years old"); -``` - ---- - -## 🏗️ Advanced Scenarios - -### 🔄 Enum Conversion - -The generator automatically converts between enums using one of two approaches: - -#### 🎯 Safe Enum Mapping (Recommended) - -When enums are decorated with `[MapTo]` attributes, the generator uses **EnumMappingGenerator** extension methods for type-safe conversion with special case handling: - -```csharp -// Source enum with MapTo attribute -[MapTo(typeof(StatusDto), Bidirectional = true)] -public enum Status -{ - None, // Maps to StatusDto.Unknown (special case) - Active, - Inactive -} - -// Target enum -public enum StatusDto -{ - Unknown, // Maps from Status.None (special case) - Active, - Inactive -} - -[MapTo(typeof(UserDto))] -public partial class User -{ - public string Name { get; set; } = string.Empty; - public Status Status { get; set; } -} - -public class UserDto -{ - public string Name { get; set; } = string.Empty; - public StatusDto Status { get; set; } -} -``` - -**Generated code:** - -```csharp -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserDto - { - Name = source.Name, - // ✨ Uses EnumMapping extension method (safe) - Status = source.Status.MapToStatusDto() - }; -} -``` - -**Benefits:** - -- ✅ Type-safe with `ArgumentOutOfRangeException` for unmapped values -- ✅ Special case handling (None → Unknown, etc.) -- ✅ Compile-time warnings for unmapped enum values -- ✅ No silent failures from incorrect casts - -#### ⚠️ Enum Cast (Fallback) - -For enums **without** `[MapTo]` attributes, the generator falls back to simple casts: - -```csharp -// Enums without MapTo attribute -public enum Priority { Low = 1, Medium = 2, High = 3 } -public enum PriorityDto { Low = 1, Medium = 2, High = 3 } - -[MapTo(typeof(TaskDto))] -public partial class Task -{ - public Priority Priority { get; set; } -} -``` - -**Generated code:** - -```csharp -public static TaskDto MapToTaskDto(this Task source) -{ - // ... - return new TaskDto - { - // ⚠️ Simple cast (less safe, no validation) - Priority = (PriorityDto)source.Priority - }; -} -``` - -**Limitations:** - -- ⚠️ No runtime validation -- ⚠️ No special case handling -- ⚠️ Silent failures if enum values don't match - -**Recommendation:** Always use `[MapTo]` on enums to enable safe mapping. See the [EnumMapping Guide](EnumMappingGenerators.md) for details. - -### 🪆 Nested Object Mapping - -The generator automatically chains mappings for nested objects: - -```csharp -[MapTo(typeof(AddressDto))] -public partial class Address -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; -} - -public class AddressDto -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; -} - -[MapTo(typeof(PersonDto))] -public partial class Person -{ - public string Name { get; set; } = string.Empty; - public Address? Address { get; set; } -} - -public class PersonDto -{ - public string Name { get; set; } = string.Empty; - public AddressDto? Address { get; set; } -} -``` - -**Generated code:** - -```csharp -public static PersonDto MapToPersonDto(this Person source) -{ - if (source is null) - { - return default!; - } - - return new PersonDto - { - Name = source.Name, - // ✨ Automatic nested object mapping - Address = source.Address?.MapToAddressDto()! - }; -} -``` - -### 📦 Collection Mapping - -The generator automatically maps collections using LINQ `.Select()` and generates appropriate conversion methods for different collection types. - -**Supported Collection Types:** - -- `List` / `IList` -- `IEnumerable` -- `ICollection` / `IReadOnlyCollection` -- `IReadOnlyList` -- `T[]` (arrays) -- `Collection` / `ReadOnlyCollection` - -```csharp -[MapTo(typeof(TagDto))] -public partial class Tag -{ - public string Name { get; set; } = string.Empty; - public string Color { get; set; } = string.Empty; -} - -public class TagDto -{ - public string Name { get; set; } = string.Empty; - public string Color { get; set; } = string.Empty; -} - -[MapTo(typeof(PostDto))] -public partial class Post -{ - public string Title { get; set; } = string.Empty; - public IList Tags { get; set; } = new List(); -} - -public class PostDto -{ - public string Title { get; set; } = string.Empty; - public IReadOnlyList Tags { get; set; } = Array.Empty(); -} -``` - -**Generated code:** - -```csharp -public static PostDto MapToPostDto(this Post source) -{ - if (source is null) - { - return default!; - } - - return new PostDto - { - Title = source.Title, - // ✨ Automatic collection mapping with element conversion - Tags = source.Tags?.Select(x => x.MapToTagDto()).ToList()! - }; -} -``` - -**Collection Conversion Rules:** - -- **`List`, `IList`, `IEnumerable`, `ICollection`, `IReadOnlyList`, `IReadOnlyCollection`** → Uses `.ToList()` -- **`T[]` (arrays)** → Uses `.ToArray()` -- **`Collection`** → Uses `new Collection(source.Items?.Select(...).ToList()!)` -- **`ReadOnlyCollection`** → Uses `new ReadOnlyCollection(source.Items?.Select(...).ToList()!)` - -**Multi-Layer Collection Example:** - -See the PetStore.Api sample which demonstrates collection mapping across 3 layers: - -```text -PetEntity (DataAccess) → ICollection Children - ↓ .MapToPet() -Pet (Domain) → IList Children - ↓ .MapToPetResponse() -PetResponse (API) → IReadOnlyList Children -``` - -Each layer automatically converts collections while preserving the element mappings. - -### 🔁 Multi-Layer Mapping - -Build complex mapping chains across multiple layers: - -```text -Database Entity → Domain Model → API DTO -``` - -**Layer 1 (Data Access):** - -```csharp -namespace DataAccess; - -[MapTo(typeof(Domain.Product))] -public partial class ProductEntity -{ - public int DatabaseId { get; set; } - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public bool IsDeleted { get; set; } // Database-specific -} -``` - -**Layer 2 (Domain):** - -```csharp -namespace Domain; - -[MapTo(typeof(ProductDto))] -public partial class Product -{ - public Guid Id { get; init; } - public string Name { get; init; } = string.Empty; - public decimal Price { get; init; } -} - -public class ProductDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } -} -``` - -**Usage:** - -```csharp -using Atc.Mapping; - -// Database to Domain -var entity = repository.GetById(1); -var domainModel = entity.MapToProduct(); - -// Domain to DTO -var dto = domainModel.MapToProductDto(); - -// Or directly in LINQ -var dtos = repository.GetAll() - .Select(e => e.MapToProduct()) - .Select(p => p.MapToProductDto()) - .ToList(); -``` - -### 🚫 Excluding Properties with `[MapIgnore]` - -Use the `[MapIgnore]` attribute to exclude specific properties from mapping. This is useful for sensitive data, internal state, or audit fields that should not be mapped to DTOs. - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - - // Sensitive data - never map to DTOs - [MapIgnore] - public byte[] PasswordHash { get; set; } = Array.Empty(); - - // Internal audit fields - excluded from mapping - [MapIgnore] - public DateTimeOffset CreatedAt { get; set; } - - [MapIgnore] - public string? ModifiedBy { get; set; } -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - // PasswordHash, CreatedAt, and ModifiedBy are NOT mapped -} - -// Generated: Only Id, Name, and Email are mapped -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserDto - { - Id = source.Id, - Name = source.Name, - Email = source.Email - }; -} -``` - -**Use Cases:** - -- **Sensitive data** - Password hashes, API keys, tokens -- **Audit fields** - CreatedAt, UpdatedAt, ModifiedBy -- **Internal state** - Cache values, computed fields, temporary flags -- **Navigation properties** - Complex relationships managed separately - -**Works with:** - -- Simple properties -- Nested objects (ignored properties in nested objects are also excluded) -- Bidirectional mappings (properties can be ignored in either direction) -- Constructor mappings (ignored properties are excluded from constructor parameters) - -### 🔤 Property Name Casing Strategies - -When integrating with external APIs or different system layers, property names often follow different naming conventions. The `PropertyNameStrategy` parameter enables automatic conversion between casing styles without manually renaming properties or using `[MapProperty]` on every field. - -**Supported Strategies:** - -- **PascalCase** (default) - `FirstName`, `LastName`, `DateOfBirth` -- **CamelCase** - `firstName`, `lastName`, `dateOfBirth` -- **SnakeCase** - `first_name`, `last_name`, `date_of_birth` -- **KebabCase** - `first-name`, `last-name`, `date-of-birth` - -#### Example: Mapping to JSON API (camelCase) - -```csharp -using Atc.SourceGenerators.Annotations; - -// Domain model (PascalCase) -[MapTo(typeof(UserApiDto), PropertyNameStrategy = PropertyNameStrategy.CamelCase)] -public partial class User -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public int Age { get; set; } -} - -// API DTO (camelCase - typical for JSON APIs) -public class UserApiDto -{ - public Guid id { get; set; } - public string firstName { get; set; } = string.Empty; - public string lastName { get; set; } = string.Empty; - public int age { get; set; } -} - -// Generated mapping code -public static UserApiDto MapToUserApiDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserApiDto - { - id = source.Id, // ✨ Id → id - firstName = source.FirstName, // ✨ FirstName → firstName - lastName = source.LastName, // ✨ LastName → lastName - age = source.Age // ✨ Age → age - }; -} -``` - -#### Example: Mapping to Database (snake_case) - -```csharp -// Domain model (PascalCase) -[MapTo(typeof(UserEntity), PropertyNameStrategy = PropertyNameStrategy.SnakeCase)] -public partial class User -{ - public Guid UserId { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public DateTimeOffset DateOfBirth { get; set; } -} - -// Database entity (snake_case - typical for PostgreSQL) -public class UserEntity -{ - public Guid user_id { get; set; } - public string first_name { get; set; } = string.Empty; - public string last_name { get; set; } = string.Empty; - public DateTimeOffset date_of_birth { get; set; } -} - -// Generated: Automatic snake_case conversion -// UserId → user_id -// FirstName → first_name -// LastName → last_name -// DateOfBirth → date_of_birth -``` - -#### Example: Bidirectional Mapping with Strategy - -PropertyNameStrategy works seamlessly with `Bidirectional = true`: - -```csharp -[MapTo(typeof(ProductDto), PropertyNameStrategy = PropertyNameStrategy.CamelCase, Bidirectional = true)] -public partial class Product -{ - public Guid ProductId { get; set; } - public string ProductName { get; set; } = string.Empty; - public decimal UnitPrice { get; set; } -} - -public partial class ProductDto -{ - public Guid productId { get; set; } - public string productName { get; set; } = string.Empty; - public decimal unitPrice { get; set; } -} - -// Generated methods: -// Product.MapToProductDto() (PascalCase → camelCase) -// ProductDto.MapToProduct() (camelCase → PascalCase) -``` - -#### Example: Override with `[MapProperty]` - -For individual properties, `[MapProperty]` always takes precedence over `PropertyNameStrategy`: - -```csharp -[MapTo(typeof(UserDto), PropertyNameStrategy = PropertyNameStrategy.SnakeCase)] -public partial class User -{ - public string FirstName { get; set; } = string.Empty; - - // Strategy would convert to last_name, but override to special_field - [MapProperty("special_field")] - public string LastName { get; set; } = string.Empty; -} - -public class UserDto -{ - public string first_name { get; set; } = string.Empty; // ✅ Auto snake_case - public string special_field { get; set; } = string.Empty; // ✅ Manual override -} - -// Generated: -// first_name = source.FirstName (PropertyNameStrategy applied) -// special_field = source.LastName (MapProperty override) -``` - -**Use Cases:** - -- 🌐 **REST APIs** - Map PascalCase domain models to camelCase JSON DTOs -- 🗄️ **PostgreSQL** - Map to snake_case column names without changing C# properties -- 🔗 **External Systems** - Integrate with kebab-case or snake_case APIs -- 🏢 **Multi-Layer Architecture** - Keep consistent casing within each layer - -**Works with:** - -- Simple properties (automatic conversion) -- Nested objects (strategy applies recursively) -- Bidirectional mappings (reverse conversion is automatic) -- All other features (collections, enums, constructors, hooks, etc.) - -**Validation:** - -- ✅ Compile-time conversion - zero runtime overhead -- ✅ Works with all MapToAttribute features -- ✅ `[MapProperty]` overrides strategy for specific properties - -### 🏷️ Custom Property Name Mapping with `[MapProperty]` - -When integrating with external APIs, legacy systems, or when property names differ between layers, use `[MapProperty]` to specify custom mappings without renaming your domain models. - -**Example:** - -```csharp -using Atc.SourceGenerators.Annotations; - -// Domain model -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - - // Maps PreferredName → DisplayName in UserDto - [MapProperty("DisplayName")] - public string PreferredName { get; set; } = string.Empty; - - // Maps YearsOld → Age in UserDto - [MapProperty("Age")] - public int YearsOld { get; set; } -} - -// DTO with different property names -public class UserDto -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public int Age { get; set; } -} - -// Generated mapping code -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserDto - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - DisplayName = source.PreferredName, // ✨ Custom mapping - Age = source.YearsOld // ✨ Custom mapping - }; -} -``` - -**Use Cases:** - -- 🔌 **API Integration** - Match external API property names without modifying your domain models -- 🏛️ **Legacy Systems** - Adapt to existing database column names or legacy DTOs -- 🌍 **Naming Conventions** - Bridge different naming conventions between layers (e.g., `firstName` ↔ `FirstName`) -- 📦 **Domain Clarity** - Keep meaningful domain property names while exposing simplified DTO names - -**Works with:** - -- Simple properties (strings, numbers, dates, etc.) -- Nested objects (custom property names on nested object references) -- Bidirectional mappings (apply `[MapProperty]` on both sides for reverse mapping) -- Constructor mappings (custom names are resolved when matching constructor parameters) - -**Validation:** - -- ✅ Compile-time validation ensures target properties exist -- ❌ `ATCMAP003` diagnostic if target property name is not found - -### 🔄 Property Flattening - -When working with nested objects that need to be flattened into a simpler DTO structure, use `EnableFlattening = true` to automatically map nested properties using a naming convention. - -**Example:** - -```csharp -using Atc.SourceGenerators.Annotations; - -// Nested object -public class Address -{ - public string Street { get; set; } = string.Empty; - public string City { get; set; } = string.Empty; - public string PostalCode { get; set; } = string.Empty; -} - -// Source with nested object -[MapTo(typeof(UserFlatDto), EnableFlattening = true)] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public Address? Address { get; set; } // Nested object -} - -// Flattened target DTO -public class UserFlatDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Flattened properties using {PropertyName}{NestedPropertyName} convention - public string? AddressStreet { get; set; } - public string? AddressCity { get; set; } - public string? AddressPostalCode { get; set; } -} - -// Generated: Automatic property flattening with null-safety -public static UserFlatDto MapToUserFlatDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserFlatDto - { - Id = source.Id, - Name = source.Name, - AddressStreet = source.Address?.Street!, // Null-safe flattening - AddressCity = source.Address?.City!, - AddressPostalCode = source.Address?.PostalCode! - }; -} -``` - -**Naming Convention:** - -- Pattern: `{PropertyName}{NestedPropertyName}` -- Examples: - - `Address.City` → `AddressCity` - - `Address.Street` → `AddressStreet` - - `HomeAddress.City` → `HomeAddressCity` - - `WorkAddress.City` → `WorkAddressCity` - -**Multiple Nested Objects:** - -```csharp -[MapTo(typeof(PersonDto), EnableFlattening = true)] -public partial class Person -{ - public int Id { get; set; } - public Address HomeAddress { get; set; } = new(); - public Address WorkAddress { get; set; } = new(); -} - -public class PersonDto -{ - public int Id { get; set; } - // Home address flattened - public string HomeAddressCity { get; set; } = string.Empty; - public string HomeAddressStreet { get; set; } = string.Empty; - // Work address flattened - public string WorkAddressCity { get; set; } = string.Empty; - public string WorkAddressStreet { get; set; } = string.Empty; -} -``` - -**Null Safety:** - -- Nullable nested objects automatically use null-conditional operator (`?.`) -- Non-nullable nested objects use direct property access -- Flattened properties are marked as nullable if the source nested object is nullable - -**Works with:** - -- One-level deep nesting (can be extended in future) -- Multiple nested objects of the same type -- Bidirectional mappings (both directions support flattening) -- Other mapping features (MapIgnore, MapProperty, etc.) - -**Use Cases:** - -- **API responses** - Simplify complex domain models for client consumption -- **Report generation** - Flatten hierarchical data for tabular export -- **Legacy integration** - Map to flat database schemas or external APIs -- **Performance optimization** - Reduce object graph complexity in data transfer - -### 🔀 Built-in Type Conversion - -The generator automatically converts between common types when property names match but types differ. This is particularly useful when mapping domain models with strongly-typed properties to DTOs that use string representations. - -**Example:** - -```csharp -using Atc.SourceGenerators.Annotations; -using System; - -// Domain model with strongly-typed properties -[MapTo(typeof(UserEventDto))] -public partial class UserEvent -{ - public Guid EventId { get; set; } - public Guid UserId { get; set; } - public DateTimeOffset Timestamp { get; set; } - public int DurationSeconds { get; set; } - public bool Success { get; set; } -} - -// DTO with string-based properties -public class UserEventDto -{ - public string EventId { get; set; } = string.Empty; - public string UserId { get; set; } = string.Empty; - public string Timestamp { get; set; } = string.Empty; - public string DurationSeconds { get; set; } = string.Empty; - public string Success { get; set; } = string.Empty; -} - -// Generated: Automatic type conversion -public static UserEventDto MapToUserEventDto(this UserEvent source) -{ - if (source is null) - { - return default!; - } - - return new UserEventDto - { - EventId = source.EventId.ToString(), // Guid → string - UserId = source.UserId.ToString(), - Timestamp = source.Timestamp.ToString("O", global::System.Globalization.CultureInfo.InvariantCulture), // DateTimeOffset → string (ISO 8601) - DurationSeconds = source.DurationSeconds.ToString(global::System.Globalization.CultureInfo.InvariantCulture), // int → string - Success = source.Success.ToString() // bool → string - }; -} -``` - -**Supported Conversions:** - -| Source Type | Target Type | Conversion Method | -|------------|-------------|-------------------| -| `DateTime` | `string` | `.ToString("O", InvariantCulture)` (ISO 8601) | -| `string` | `DateTime` | `DateTime.Parse(value, InvariantCulture)` | -| `DateTimeOffset` | `string` | `.ToString("O", InvariantCulture)` (ISO 8601) | -| `string` | `DateTimeOffset` | `DateTimeOffset.Parse(value, InvariantCulture)` | -| `Guid` | `string` | `.ToString()` | -| `string` | `Guid` | `Guid.Parse(value)` | -| Numeric types* | `string` | `.ToString(InvariantCulture)` | -| `string` | Numeric types* | `{Type}.Parse(value, InvariantCulture)` | -| `bool` | `string` | `.ToString()` | -| `string` | `bool` | `bool.Parse(value)` | - -*Numeric types: `int`, `long`, `short`, `byte`, `sbyte`, `uint`, `ulong`, `ushort`, `decimal`, `double`, `float` - -**Reverse Conversion Example:** - -```csharp -// Reverse mapping: string → strong types -[MapTo(typeof(UserEvent))] -public partial class UserEventDto -{ - public string EventId { get; set; } = string.Empty; - public string Timestamp { get; set; } = string.Empty; - public string DurationSeconds { get; set; } = string.Empty; -} - -// Generated: Parse methods for string → strong types -EventId = global::System.Guid.Parse(source.EventId), -Timestamp = global::System.DateTimeOffset.Parse(source.Timestamp, global::System.Globalization.CultureInfo.InvariantCulture), -DurationSeconds = int.Parse(source.DurationSeconds, global::System.Globalization.CultureInfo.InvariantCulture) -``` - -**Culture and Format:** - -- All numeric and DateTime conversions use `InvariantCulture` for consistency -- DateTime/DateTimeOffset use ISO 8601 format ("O") for string conversion -- This ensures the generated mappings are culture-independent and portable - -**Works with:** - -- Bidirectional mappings (automatic conversion in both directions) -- Nullable types (proper null handling for both source and target) -- Other mapping features (MapIgnore, MapProperty, constructor mapping, etc.) - -**Use Cases:** - -- **API boundaries** - Convert strongly-typed domain models to string-based JSON DTOs -- **Database mappings** - Map between typed entities and string-based legacy schemas -- **Configuration** - Convert configuration values between types -- **Export/Import** - Generate CSV or other text-based formats from typed data - -### ✅ Required Property Validation - -The generator validates at **compile time** that all `required` properties (C# 11+) on the target type have corresponding mappings from the source type. This catches missing property mappings during development instead of discovering issues at runtime. - -#### Basic Example - -```csharp -// ❌ This will generate ATCMAP004 warning at compile time -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; - // Missing: Email property (required in target) -} - -public class UserRegistrationDto -{ - public Guid Id { get; set; } - public required string Email { get; set; } // ⚠️ Required but not mapped! - public required string FullName { get; set; } // ✅ Mapped -} - -// Compiler output: -// Warning ATCMAP004: Required property 'Email' on target type 'UserRegistrationDto' has no mapping from source type 'UserRegistration' -``` - -#### Correct Implementation - -```csharp -// ✅ All required properties have mappings - no warnings -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string Email { get; set; } = string.Empty; // ✅ Maps to required property - public string FullName { get; set; } = string.Empty; // ✅ Maps to required property - public string? PhoneNumber { get; set; } // Optional property (can be omitted) -} - -public class UserRegistrationDto -{ - public Guid Id { get; set; } - public required string Email { get; set; } // ✅ Mapped from source - public required string FullName { get; set; } // ✅ Mapped from source - public string? PhoneNumber { get; set; } // Not required (can be omitted from source) -} - -// Generated mapping method: -public static UserRegistrationDto MapToUserRegistrationDto(this UserRegistration source) -{ - if (source is null) - { - return default!; - } - - return new UserRegistrationDto - { - Id = source.Id, - Email = source.Email, // ✅ Required property mapped - FullName = source.FullName, // ✅ Required property mapped - PhoneNumber = source.PhoneNumber, - }; -} -``` - -#### Validation Behavior - -**When ATCMAP004 is Generated:** - -- Target property has the `required` modifier (C# 11+) -- No corresponding property exists in the source type -- Property is not marked with `[MapIgnore]` - -**When No Warning is Generated:** - -- All required properties have mappings (by name or via `[MapProperty]`) -- Target property is NOT required (no `required` keyword) -- Target property is marked with `[MapIgnore]` - -**Diagnostic Details:** - -- **ID**: ATCMAP004 -- **Severity**: Warning (can be elevated to Error in `.editorconfig`) -- **Message**: "Required property '{PropertyName}' on target type '{TargetType}' has no mapping from source type '{SourceType}'" - -#### Elevating to Error - -You can configure the diagnostic as an error to enforce strict mapping validation: - -**.editorconfig:** - -```ini -# Treat missing required property mappings as compilation errors -dotnet_diagnostic.ATCMAP004.severity = error -``` - -**Project file:** - -```xml - - $(WarningsAsErrors);ATCMAP004 - -``` - -**Works With:** - -- Type conversions (built-in and enum mappings) -- Nested object mappings -- Collection mappings -- Custom property name mapping via `[MapProperty]` -- Bidirectional mappings -- Constructor mappings - -**Use Cases:** - -- **API contracts** - Ensure all required fields in request/response DTOs are mapped -- **Data validation** - Catch missing required properties at compile time instead of runtime -- **Refactoring safety** - Adding `required` to a DTO property immediately flags all unmapped sources -- **Team standards** - Enforce property mapping completeness across large codebases - -### 🌳 Polymorphic / Derived Type Mapping - -The generator supports polymorphic type mapping for abstract base classes and interfaces with multiple derived types. This enables runtime type discrimination using C# switch expressions and type pattern matching. - -#### Basic Example - -```csharp -// Domain layer - abstract base class -[MapTo(typeof(Contract.AnimalDto))] -[MapDerivedType(typeof(Dog), typeof(Contract.DogDto))] -[MapDerivedType(typeof(Cat), typeof(Contract.CatDto))] -public abstract partial class Animal -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; -} - -// Domain layer - derived classes -[MapTo(typeof(Contract.DogDto))] -public partial class Dog : Animal -{ - public string Breed { get; set; } = string.Empty; -} - -[MapTo(typeof(Contract.CatDto))] -public partial class Cat : Animal -{ - public int Lives { get; set; } -} - -// Contract layer - DTOs -public abstract class AnimalDto -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; -} - -public class DogDto : AnimalDto -{ - public string Breed { get; set; } = string.Empty; -} - -public class CatDto : AnimalDto -{ - public int Lives { get; set; } -} - -// Generated: Polymorphic mapping with switch expression -public static AnimalDto MapToAnimalDto(this Animal source) -{ - if (source is null) - { - return default!; - } - - return source switch - { - Dog dog => dog.MapToDogDto(), - Cat cat => cat.MapToCatDto(), - _ => throw new global::System.ArgumentException($"Unknown derived type: {source.GetType().Name}") - }; -} -``` - -#### How It Works - -1. **Base Class Attribute**: Apply `[MapDerivedType]` attributes to the abstract base class for each derived type mapping -2. **Derived Class Mappings**: Each derived class must have its own `[MapTo]` attribute mapping to the corresponding target derived type -3. **Switch Expression**: The generator creates a switch expression that performs type pattern matching -4. **Null Safety**: The generated code includes null checks for the source parameter -5. **Error Handling**: Unmapped derived types throw an `ArgumentException` with a descriptive message - -#### Real-World Example - Notification System - -```csharp -// Domain layer -[MapTo(typeof(NotificationDto))] -[MapDerivedType(typeof(EmailNotification), typeof(EmailNotificationDto))] -[MapDerivedType(typeof(SmsNotification), typeof(SmsNotificationDto))] -public abstract partial class Notification -{ - public Guid Id { get; set; } - public string Message { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } -} - -[MapTo(typeof(EmailNotificationDto))] -public partial class EmailNotification : Notification -{ - public string To { get; set; } = string.Empty; - public string Subject { get; set; } = string.Empty; -} - -[MapTo(typeof(SmsNotificationDto))] -public partial class SmsNotification : Notification -{ - public string PhoneNumber { get; set; } = string.Empty; -} - -// Usage in API endpoint -app.MapGet("/notifications", () => -{ - var notifications = new List - { - new EmailNotification - { - Id = Guid.NewGuid(), - Message = "Welcome to our service!", - CreatedAt = DateTimeOffset.UtcNow, - To = "user@example.com", - Subject = "Welcome", - }, - new SmsNotification - { - Id = Guid.NewGuid(), - Message = "Your code is 123456", - CreatedAt = DateTimeOffset.UtcNow, - PhoneNumber = "+1-555-0123", - }, - }; - - // ✨ Polymorphic mapping - automatically handles derived types - var dtos = notifications - .Select(n => n.MapToNotificationDto()) - .ToList(); - - return Results.Ok(dtos); -}); -``` - -#### Key Features - -**Compile-Time Validation:** - -- Verifies that each derived type mapping has a corresponding `MapTo` attribute -- Ensures the target types match the declared derived type mappings - -**Type Safety:** - -- All type checking happens at compile time -- No reflection or runtime type discovery -- Switch expressions provide exhaustive type coverage - -**Performance:** - -- Zero runtime overhead - pure switch expressions -- No dictionary lookups or type caching -- Native AOT compatible - -**Null Safety:** - -- Generated code includes proper null checks -- Follows nullable reference type annotations - -**Extensibility:** - -- Support for arbitrary numbers of derived types -- Works with deep inheritance hierarchies -- Can be combined with other mapping features (collections, nesting, etc.) - -**Use Cases:** - -- **Polymorphic API responses** - Return different DTO types based on domain object type -- **Notification systems** - Map different notification types (Email, SMS, Push) from domain to DTOs -- **Payment processing** - Handle different payment method types (CreditCard, PayPal, BankTransfer) -- **Document types** - Map different document formats (PDF, Word, Excel) to DTOs -- **Event sourcing** - Map different event types from domain events to event DTOs - -### 🧬 Base Class Property Inheritance - -The generator automatically includes properties from base classes when generating mappings. This eliminates the need to manually specify inherited properties and is particularly useful for entity base classes with common audit fields like `Id`, `CreatedAt`, `UpdatedAt`, etc. - -#### Basic Example - -```csharp -// Base entity class with common properties -public abstract partial class BaseEntity -{ - public Guid Id { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -// Derived entity inherits Id and CreatedAt -[MapTo(typeof(UserDto))] -public partial class User : BaseEntity -{ - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; -} - -// DTO receives properties from all levels of the hierarchy -public class UserDto -{ - public Guid Id { get; set; } // From BaseEntity - public DateTimeOffset CreatedAt { get; set; } // From BaseEntity - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; -} -``` - -**Generated mapping:** - -```csharp -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserDto - { - Id = source.Id, // ✅ Automatically included from BaseEntity - CreatedAt = source.CreatedAt, // ✅ Automatically included from BaseEntity - Name = source.Name, - Email = source.Email, - }; -} -``` - -#### Multi-Level Inheritance - -The generator traverses the entire inheritance hierarchy, supporting multiple levels of base classes: - -```csharp -// Level 1: Base entity -public abstract partial class Entity -{ - public Guid Id { get; set; } -} - -// Level 2: Auditable entity -public abstract partial class AuditableEntity : Entity -{ - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } -} - -// Level 3: Concrete entity -[MapTo(typeof(BookDto))] -public partial class Book : AuditableEntity -{ - public string Title { get; set; } = string.Empty; - public string Author { get; set; } = string.Empty; - public decimal Price { get; set; } -} - -// DTO includes properties from all three levels -public class BookDto -{ - public Guid Id { get; set; } // From Entity - public DateTimeOffset CreatedAt { get; set; } // From AuditableEntity - public DateTimeOffset? UpdatedAt { get; set; } // From AuditableEntity - public string? UpdatedBy { get; set; } // From AuditableEntity - public string Title { get; set; } = string.Empty; - public string Author { get; set; } = string.Empty; - public decimal Price { get; set; } -} -``` - -#### Property Overrides - -When a derived class overrides a base class property, only the overridden version is mapped (avoiding duplicates): - -```csharp -public abstract partial class Animal -{ - public virtual string Name { get; set; } = string.Empty; - public int Age { get; set; } -} - -[MapTo(typeof(DogDto))] -public partial class Dog : Animal -{ - public override string Name { get; set; } = "Dog"; // Overrides base property - public string Breed { get; set; } = string.Empty; -} - -public class DogDto -{ - public string Name { get; set; } = string.Empty; // Mapped from Dog.Name (override) - public int Age { get; set; } // Mapped from Animal.Age - public string Breed { get; set; } = string.Empty; -} -``` - -**Generated mapping includes only one `Name` assignment (the overridden version).** - -#### Respecting [MapIgnore] on Base Properties - -The `[MapIgnore]` attribute works on base class properties: - -```csharp -public abstract partial class BaseEntity -{ - public Guid Id { get; set; } - - [MapIgnore] // ❌ Won't be included in mappings - public DateTimeOffset InternalTimestamp { get; set; } -} - -[MapTo(typeof(UserDto))] -public partial class User : BaseEntity -{ - public string Name { get; set; } = string.Empty; -} - -public class UserDto -{ - public Guid Id { get; set; } // ✅ Included - public string Name { get; set; } = string.Empty; - // InternalTimestamp is NOT included -} -``` - -#### Compatibility with Other Features - -Base class inheritance works seamlessly with all other mapping features: - -**With PropertyNameStrategy:** - -```csharp -public abstract partial class BaseEntity -{ - public Guid EntityId { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -[MapTo(typeof(UserDto), PropertyNameStrategy = PropertyNameStrategy.CamelCase)] -public partial class User : BaseEntity -{ - public string UserName { get; set; } = string.Empty; -} - -public class UserDto -{ - #pragma warning disable IDE1006 - public Guid entityId { get; set; } // Converted to camelCase - public DateTimeOffset createdAt { get; set; } // Converted to camelCase - public string userName { get; set; } = string.Empty; - #pragma warning restore IDE1006 -} -``` - -**With Bidirectional Mapping:** - -```csharp -public abstract partial class Entity -{ - public Guid Id { get; set; } -} - -[MapTo(typeof(UserDto), Bidirectional = true)] -public partial class User : Entity -{ - public string Name { get; set; } = string.Empty; -} - -public partial class UserDto -{ - public Guid Id { get; set; } // Mapped in both directions - public string Name { get; set; } = string.Empty; -} - -// ✅ Both mappings include Id from Entity: -// user.MapToUserDto() → includes Id -// userDto.MapToUser() → includes Id -``` - -**Key Features:** - -- **Automatic traversal** - Walks up the entire inheritance hierarchy to `System.Object` -- **Multi-level support** - Handles any depth of inheritance (Entity → AuditableEntity → ConcreteEntity) -- **Override handling** - Properly handles `virtual`/`override` properties (no duplicates) -- **[MapIgnore] support** - Respects exclusion attributes on base class properties -- **Full integration** - Works with PropertyNameStrategy, Bidirectional, [MapProperty], and all other features -- **Zero boilerplate** - No need to manually specify inherited properties - -**Use Cases:** - -- **Entity base classes** - Common pattern for Id, CreatedAt, UpdatedAt audit fields -- **DDD value objects** - Base classes with common value object properties -- **Multi-tenancy** - Base classes with TenantId or OrganizationId -- **Soft delete pattern** - Base classes with IsDeleted, DeletedAt, DeletedBy -- **Versioning** - Base classes with Version or RowVersion for optimistic concurrency - -### 🏗️ Constructor Mapping - -The generator automatically detects and uses constructors when mapping to records or classes with primary constructors (C# 12+). This provides a more natural mapping approach for immutable types. - -#### Simple Record Mapping - -```csharp -// Target: Record with constructor -public record OrderDto(Guid Id, string CustomerName, decimal Total); - -// Source: Class with properties -[MapTo(typeof(OrderDto))] -public partial class Order -{ - public Guid Id { get; set; } - public string CustomerName { get; set; } = string.Empty; - public decimal Total { get; set; } -} - -// Generated: Constructor call instead of object initializer -public static OrderDto MapToOrderDto(this Order source) -{ - if (source is null) - { - return default!; - } - - return new OrderDto( - source.Id, - source.CustomerName, - source.Total); -} -``` - -#### Bidirectional Record Mapping - -```csharp -// Both sides are records with constructors -public record UserDto(Guid Id, string Name); - -[MapTo(typeof(UserDto), Bidirectional = true)] -public partial record User(Guid Id, string Name); - -// Generated: Both directions use constructors -// Forward: User → UserDto -public static UserDto MapToUserDto(this User source) => - new UserDto(source.Id, source.Name); - -// Reverse: UserDto → User -public static User MapToUser(this UserDto source) => - new User(source.Id, source.Name); -``` - -#### Mixed Constructor + Initializer - -When the target has constructor parameters AND additional settable properties, the generator uses both: - -```csharp -// Target: Constructor for required properties, settable for optional -public record ProductDto(Guid Id, string Name, decimal Price) -{ - public string Description { get; set; } = string.Empty; - public bool InStock { get; set; } -} - -// Source: All properties settable -[MapTo(typeof(ProductDto))] -public partial class Product -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public string Description { get; set; } = string.Empty; - public bool InStock { get; set; } -} - -// Generated: Constructor for primary properties, initializer for extras -public static ProductDto MapToProductDto(this Product source) -{ - if (source is null) - { - return default!; - } - - return new ProductDto( - source.Id, - source.Name, - source.Price) - { - Description = source.Description, - InStock = source.InStock - }; -} -``` - -#### Case-Insensitive Parameter Matching - -The generator matches properties to constructor parameters case-insensitively: - -```csharp -// Target: camelCase parameters (less common but supported) -public record ItemDto(int id, string name); - -// Source: PascalCase properties (standard C# convention) -[MapTo(typeof(ItemDto))] -public partial class Item -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; -} - -// Generated: Correctly matches despite casing difference -public static ItemDto MapToItemDto(this Item source) => - new ItemDto(source.Id, source.Name); -``` - -### 🪝 Before/After Mapping Hooks - -Execute custom logic before or after the mapping operation using hook methods. This feature allows you to add validation, logging, enrichment, or any other custom behavior to your mappings without writing wrapper methods. - -#### Basic Usage - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto), BeforeMap = nameof(ValidateUser), AfterMap = nameof(EnrichDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - // BeforeMap hook - called after null check, before mapping - private static void ValidateUser(User source) - { - if (string.IsNullOrWhiteSpace(source.Name)) - { - throw new ArgumentException("Name cannot be empty"); - } - } - - // AfterMap hook - called after mapping, before return - private static void EnrichDto(User source, UserDto target) - { - target.DisplayName = $"{source.Name} (ID: {source.Id})"; - } -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; -} -``` - -**Generated code:** - -```csharp -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - // BeforeMap hook - called after null check, before mapping - User.ValidateUser(source); - - var target = new UserDto - { - Id = source.Id, - Name = source.Name - }; - - // AfterMap hook - called after mapping, before return - User.EnrichDto(source, target); - - return target; -} -``` - -#### Hook Signatures - -**BeforeMap Hook:** - -- **Signature**: `static void MethodName(SourceType source)` -- **When called**: After null check, before object creation -- **Parameters**: Source object only -- **Purpose**: Validation, preprocessing, logging - -**AfterMap Hook:** - -- **Signature**: `static void MethodName(SourceType source, TargetType target)` -- **When called**: After object creation, before return -- **Parameters**: Both source and target objects -- **Purpose**: Post-processing, enrichment, computed properties - -#### Execution Order - -The mapping lifecycle follows this sequence: - -1. **Null check** on source object -2. **BeforeMap hook** (if specified) -3. **Polymorphic type check** (if derived type mappings exist) -4. **Object creation** (constructor or object initializer) -5. **AfterMap hook** (if specified) -6. **Return** target object - -#### Using Only BeforeMap - -For validation-only scenarios, use just the BeforeMap hook: - -```csharp -[MapTo(typeof(OrderDto), BeforeMap = nameof(ValidateOrder))] -public partial class Order -{ - public Guid Id { get; set; } - public decimal Total { get; set; } - public List Items { get; set; } = new(); - - private static void ValidateOrder(Order source) - { - if (source.Total <= 0) - { - throw new ArgumentException("Order total must be positive"); - } - - if (source.Items.Count == 0) - { - throw new ArgumentException("Order must have at least one item"); - } - } -} - -// Generated: Only BeforeMap is called -public static OrderDto MapToOrderDto(this Order source) -{ - if (source is null) - { - return default!; - } - - Order.ValidateOrder(source); // ✅ Validation before mapping - - return new OrderDto - { - Id = source.Id, - Total = source.Total, - Items = source.Items?.Select(x => x.MapToOrderItemDto()).ToList()! - }; -} -``` - -#### Using Only AfterMap - -For enrichment-only scenarios, use just the AfterMap hook: - -```csharp -[MapTo(typeof(ProductDto), AfterMap = nameof(CalculateDiscountPrice))] -public partial class Product -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public decimal DiscountPercentage { get; set; } - - private static void CalculateDiscountPrice(Product source, ProductDto target) - { - target.DiscountedPrice = source.Price * (1 - source.DiscountPercentage / 100); - } -} - -public class ProductDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public decimal DiscountedPrice { get; set; } // Computed in AfterMap -} - -// Generated: Only AfterMap is called -public static ProductDto MapToProductDto(this Product source) -{ - if (source is null) - { - return default!; - } - - var target = new ProductDto - { - Id = source.Id, - Name = source.Name, - Price = source.Price - }; - - Product.CalculateDiscountPrice(source, target); // ✅ Enrichment after mapping - - return target; -} -``` - -#### Hooks with Constructor Mapping - -Hooks work seamlessly with constructor-based mappings: - -```csharp -public record PersonDto(Guid Id, string FullName) -{ - public string Initials { get; set; } = string.Empty; -} - -[MapTo(typeof(PersonDto), AfterMap = nameof(SetInitials))] -public partial class Person -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; - - private static void SetInitials(Person source, PersonDto target) - { - var names = source.FullName.Split(' ', StringSplitOptions.RemoveEmptyEntries); - target.Initials = string.Join("", names.Select(n => n[0])); - } -} - -// Generated: Constructor + AfterMap hook -public static PersonDto MapToPersonDto(this Person source) -{ - if (source is null) - { - return default!; - } - - var target = new PersonDto( // Constructor call - source.Id, - source.FullName); - - Person.SetInitials(source, target); // AfterMap hook - - return target; -} -``` - -#### Use Cases - -**Validation (BeforeMap):** - -```csharp -private static void ValidateUser(User source) -{ - if (string.IsNullOrWhiteSpace(source.Email)) - { - throw new ArgumentException("Email is required"); - } - - if (!source.Email.Contains('@')) - { - throw new ArgumentException("Invalid email format"); - } -} -``` - -**Logging (BeforeMap or AfterMap):** - -```csharp -private static void LogMapping(User source, UserDto target) -{ - Console.WriteLine($"Mapped User {source.Id} to UserDto"); -} -``` - -**Enrichment (AfterMap):** - -```csharp -private static void EnrichUserDto(User source, UserDto target) -{ - target.FullName = $"{source.FirstName} {source.LastName}"; - target.Age = DateTime.UtcNow.Year - source.DateOfBirth.Year; -} -``` - -**Auditing (AfterMap):** - -```csharp -private static void AuditMapping(Order source, OrderDto target) -{ - target.MappedAt = DateTime.UtcNow; - target.MappedBy = "ObjectMappingGenerator"; -} -``` - -**Side Effects (AfterMap):** - -```csharp -private static void UpdateCache(Product source, ProductDto target) -{ - // Update cache after successful mapping - _cache.Set($"product:{source.Id}", target); -} -``` - -#### Important Notes - -- ✅ Hook methods **must be static** -- ✅ Both hooks are **optional** - use one, both, or neither -- ✅ Hooks are specified by method name (use `nameof()` for type safety) -- ✅ Hooks work with all mapping features (collections, nested objects, polymorphic types, etc.) -- ✅ **Reverse mappings** (Bidirectional = true) do NOT inherit hooks from the forward mapping -- ✅ Hooks are called via fully qualified name (e.g., `User.ValidateUser(source)`) -- ✅ Full **Native AOT compatibility** - -#### Hooks in Bidirectional Mappings - -When using bidirectional mappings, each direction can have its own hooks: - -```csharp -[MapTo(typeof(UserDto), Bidirectional = true, BeforeMap = nameof(ValidateUser), AfterMap = nameof(EnrichDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - private static void ValidateUser(User source) { /* Validation */ } - private static void EnrichDto(User source, UserDto target) { /* Enrichment */ } -} - -// Generated forward mapping: User → UserDto (includes hooks) -public static UserDto MapToUserDto(this User source) -{ - // ... includes ValidateUser and EnrichDto hooks -} - -// Generated reverse mapping: UserDto → User (NO hooks) -public static User MapToUser(this UserDto source) -{ - // ... reverse mapping does NOT call ValidateUser or EnrichDto -} -``` - -If you need hooks in the reverse direction, define them on the target type: - -```csharp -[MapTo(typeof(User), BeforeMap = nameof(ValidateDto))] -public partial class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - private static void ValidateDto(UserDto source) { /* Validation */ } -} -``` - -### 🏭 Object Factories - -Use custom factory methods to create target instances during mapping, allowing you to initialize objects with default values, use object pooling, or apply other custom creation logic. - -#### Basic Usage - -```csharp -using Atc.SourceGenerators.Annotations; - -[MapTo(typeof(UserDto), Factory = nameof(CreateUserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Factory method creates the target instance - internal static UserDto CreateUserDto() - { - return new UserDto - { - CreatedAt = DateTimeOffset.UtcNow, // Set default value - }; - } -} - -public class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } -} -``` - -**Generated code:** - -```csharp -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - var target = User.CreateUserDto(); // Factory creates instance - - target.Id = source.Id; // Property mappings applied - target.Name = source.Name; - - return target; -} -``` - -#### Factory Method Signature - -**Signature**: `static TargetType MethodName()` - -- Must be static -- Must return the target type -- Takes no parameters -- Can be `internal`, `public`, or `private` - -#### Execution Order - -When a factory is specified, the mapping lifecycle follows this sequence: - -1. **Null check** on source object -2. **BeforeMap hook** (if specified) -3. **Factory method** creates target instance -4. **Property mappings** applied to target -5. **AfterMap hook** (if specified) -6. **Return** target object - -#### Factory with Hooks - -Factories work seamlessly with BeforeMap and AfterMap hooks: - -```csharp -[MapTo(typeof(OrderDto), Factory = nameof(CreateOrderDto), BeforeMap = nameof(ValidateOrder), AfterMap = nameof(EnrichOrder))] -public partial class Order -{ - public Guid Id { get; set; } - public string OrderNumber { get; set; } = string.Empty; - public DateTimeOffset OrderDate { get; set; } - - internal static void ValidateOrder(Order source) - { - if (string.IsNullOrWhiteSpace(source.OrderNumber)) - { - throw new ArgumentException("Order number is required"); - } - } - - internal static OrderDto CreateOrderDto() - { - return new OrderDto - { - CreatedAt = DateTimeOffset.UtcNow, - Status = "Pending", // Default status - }; - } - - internal static void EnrichOrder( - Order source, - OrderDto target) - { - target.FormattedOrderNumber = $"ORD-{source.OrderNumber}"; - } -} - -public class OrderDto -{ - public Guid Id { get; set; } - public string OrderNumber { get; set; } = string.Empty; - public DateTimeOffset OrderDate { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public string Status { get; set; } = string.Empty; - public string FormattedOrderNumber { get; set; } = string.Empty; -} -``` - -**Generated code:** - -```csharp -public static OrderDto MapToOrderDto(this Order source) -{ - if (source is null) - { - return default!; - } - - Order.ValidateOrder(source); // BeforeMap hook - - var target = Order.CreateOrderDto(); // Factory creates instance - - target.Id = source.Id; // Property mappings - target.OrderNumber = source.OrderNumber; - target.OrderDate = source.OrderDate; - - Order.EnrichOrder(source, target); // AfterMap hook - - return target; -} -``` - -#### Use Cases - -**Default Values:** - -```csharp -internal static ProductDto CreateProductDto() -{ - return new ProductDto - { - CreatedAt = DateTimeOffset.UtcNow, - IsActive = true, - Version = 1, - }; -} -``` - -**Object Pooling:** - -```csharp -private static readonly ObjectPool _userDtoPool = new(); - -internal static UserDto CreateUserDto() -{ - return _userDtoPool.Get(); // Reuse objects from pool -} -``` - -**Dependency Injection (Service Locator):** - -```csharp -internal static NotificationDto CreateNotificationDto() -{ - var factory = ServiceLocator.GetService(); - return factory.Create(); -} -``` - -**Complex Initialization:** - -```csharp -internal static ReportDto CreateReportDto() -{ - var dto = new ReportDto(); - dto.Initialize(); // Custom initialization logic - dto.RegisterEventHandlers(); - return dto; -} -``` - -#### Important Notes - -- ✅ Factory method **must be static** -- ✅ Factory **replaces** `new TargetType()` for object creation -- ✅ Property mappings are **applied after** factory creates the instance -- ✅ Fully compatible with **BeforeMap/AfterMap hooks** -- ✅ Works with all mapping features (nested objects, collections, etc.) -- ✅ **Reverse mappings** (Bidirectional = true) do NOT inherit factory methods -- ✅ Full **Native AOT compatibility** -- ⚠️ **Limitation**: Factory pattern doesn't work with init-only properties (records with `init` setters) - - For init-only properties, use constructor mapping or object initializers instead - -#### Factories in Bidirectional Mappings - -When using bidirectional mappings, each direction can have its own factory: - -```csharp -[MapTo(typeof(UserDto), Bidirectional = true, Factory = nameof(CreateUserDto))] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - internal static UserDto CreateUserDto() - { - return new UserDto { CreatedAt = DateTimeOffset.UtcNow }; - } -} - -// Generated forward mapping: User → UserDto (includes factory) -public static UserDto MapToUserDto(this User source) -{ - // ... uses CreateUserDto factory -} - -// Generated reverse mapping: UserDto → User (NO factory) -public static User MapToUser(this UserDto source) -{ - // ... uses standard object initializer -} -``` - -If you need a factory in the reverse direction, define it on the target type: - -```csharp -[MapTo(typeof(User), Factory = nameof(CreateUser))] -public partial class UserDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - - internal static User CreateUser() - { - return new User(); // Custom creation logic - } -} -``` - ---- - -## Update Existing Target Instance - -The `UpdateTarget` parameter allows you to generate an additional method overload that updates an existing target instance instead of creating a new one. This is particularly useful when working with **EF Core tracked entities**, **ViewModels**, or when you want to reduce object allocations. - -### Basic Usage - -```csharp -[MapTo(typeof(UserDto), UpdateTarget = true)] -public partial class User -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; -} -``` - -**Generated Code:** - -```csharp -// Method 1: Standard method (creates new instance) -public static UserDto MapToUserDto(this User source) -{ - if (source is null) return default!; - - return new UserDto - { - Id = source.Id, - Name = source.Name, - Email = source.Email - }; -} - -// Method 2: Update method (updates existing instance) -public static void MapToUserDto(this User source, UserDto target) -{ - if (source is null) return; - if (target is null) return; - - target.Id = source.Id; - target.Name = source.Name; - target.Email = source.Email; -} -``` - -### EF Core Tracked Entities - -The primary use case for `UpdateTarget` is updating EF Core tracked entities: - -```csharp -[MapTo(typeof(PetEntity), Bidirectional = true, UpdateTarget = true)] -public partial class Pet -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; -} - -// Usage in a service: -public async Task UpdatePetAsync(Guid petId, Pet domainPet) -{ - // Fetch tracked entity from database - var existingPet = await _dbContext.Pets.FindAsync(petId); - if (existingPet is null) - { - throw new NotFoundException($"Pet with ID {petId} not found"); - } - - // Update it with new data (EF Core tracks changes) - domainPet.MapToPetEntity(existingPet); - - // Save changes (only modified properties are updated in database) - await _dbContext.SaveChangesAsync(); -} -``` - -### Update with Hooks - -The update method fully supports `BeforeMap` and `AfterMap` hooks: - -```csharp -[MapTo(typeof(OrderDto), UpdateTarget = true, BeforeMap = nameof(ValidateOrder), AfterMap = nameof(EnrichOrder))] -public partial class Order -{ - public Guid Id { get; set; } - public decimal Total { get; set; } - - internal static void ValidateOrder(Order source) - { - if (source.Total < 0) - throw new ArgumentException("Total cannot be negative"); - } - - internal static void EnrichOrder(Order source, OrderDto target) - { - // Custom enrichment logic after update - target.LastModified = DateTimeOffset.UtcNow; - } -} - -// Usage: -var existingDto = GetOrderDto(orderId); -updatedOrder.MapToOrderDto(existingDto); // Validates, updates, then enriches -``` - -**Execution Order for Update Method:** - -1. Null check for source -2. Null check for target -3. Execute `BeforeMap(source)` hook (if specified) -4. Update all properties on target -5. Execute `AfterMap(source, target)` hook (if specified) - -### Reduce Object Allocations - -Reuse DTO instances to reduce allocations in hot paths: - -```csharp -[MapTo(typeof(SettingsDto), UpdateTarget = true)] -public partial class Settings -{ - public string Theme { get; set; } = "Light"; - public bool EnableNotifications { get; set; } = true; -} - -// Reuse the same DTO instance -var settingsDto = new SettingsDto(); - -settings1.MapToSettingsDto(settingsDto); -ProcessSettings(settingsDto); - -settings2.MapToSettingsDto(settingsDto); // Reuse same instance -ProcessSettings(settingsDto); -``` - -### Important Notes - -- **Both methods are generated**: When `UpdateTarget = true`, you get both the standard method (creates new instance) and the update method (updates existing instance) -- **Null checks**: The update method checks both source and target for null -- **Void return**: The update method returns `void` (no return value) -- **No factory**: The update method does not use factory methods (factory is only for creating new instances) -- **Bidirectional support**: Works seamlessly with `Bidirectional = true` - both directions get update overloads -- **All properties updated**: All mapped properties are updated, including nullable properties - -### When to Use UpdateTarget - -✅ **Use when:** - -- Updating EF Core tracked entities -- Reducing allocations for frequently mapped objects -- Updating existing ViewModels or DTOs -- You need to preserve object identity -- Working with object pools - -❌ **Don't use when:** - -- You always need new instances -- Working with immutable types (records with init-only properties) -- Factory method is needed (factory creates new instances) -- You want the update operation to return a value - -### Comparison with Standard Mapping - -| Feature | Standard Method | Update Method | -|---------|----------------|---------------| -| **Return Type** | `TargetType` | `void` | -| **Creates New Instance** | ✅ Yes | ❌ No | -| **Updates Existing Instance** | ❌ No | ✅ Yes | -| **Target Parameter** | ❌ No | ✅ Yes (`TargetType target`) | -| **EF Core Compatible** | ⚠️ Requires attach | ✅ Yes (change tracking) | -| **Null Checks** | Source only | Source and target | -| **BeforeMap Hook** | ✅ Yes | ✅ Yes | -| **AfterMap Hook** | ✅ Yes | ✅ Yes | -| **Factory Support** | ✅ Yes | ❌ No | - ---- - -## 📊 IQueryable Projections - -Generate `Expression>` for use with EF Core `.Select()` queries to enable server-side projection. This feature optimizes database queries by selecting only the required columns instead of fetching entire entities. - -### When to Use IQueryable Projections - -✅ **Use projections when:** - -- Fetching data for list/grid views where you need minimal fields -- Optimizing database query performance -- Reducing network traffic between application and database -- Working with large datasets where full entity hydration is expensive -- Need server-side filtering and sorting with minimal data transfer - -❌ **Don't use projections when:** - -- You need BeforeMap/AfterMap hooks (not supported in expressions) -- You need Factory methods (not supported in expressions) -- You have nested objects or collections (require method calls) -- You need complex type conversions (only simple casts work) -- The mapping is used for write operations (projections are read-only) - -### Basic Example - -```csharp -using Atc.SourceGenerators.Annotations; - -// Define a lightweight DTO for list views -public class UserSummaryDto -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public UserStatusDto Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -// Enable projection with GenerateProjection = true -[MapTo(typeof(UserSummaryDto), GenerateProjection = true)] -public partial class User -{ - public Guid Id { get; set; } - public string FirstName { get; set; } = string.Empty; - public string LastName { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string PreferredName { get; set; } = string.Empty; // Not in DTO, excluded from projection - public UserStatus Status { get; set; } - public Address? Address { get; set; } // Nested object, excluded from projection - public DateTimeOffset CreatedAt { get; set; } - public byte[] PasswordHash { get; set; } = []; // Not in DTO, excluded from projection -} - -// Generated projection method -public static Expression> ProjectToUserSummaryDto() -{ - return source => new UserSummaryDto - { - Id = source.Id, - FirstName = source.FirstName, - LastName = source.LastName, - Email = source.Email, - Status = (UserStatusDto)source.Status, // Simple enum cast - CreatedAt = source.CreatedAt - }; -} - -// Usage with EF Core -var users = await dbContext.Users - .Where(u => u.IsActive) - .OrderBy(u => u.LastName) - .Select(User.ProjectToUserSummaryDto()) - .ToListAsync(); - -// SQL generated (optimized - only selected columns): -// SELECT Id, FirstName, LastName, Email, Status, CreatedAt -// FROM Users -// WHERE IsActive = 1 -// ORDER BY LastName -``` - -### Projection Limitations - -IQueryable projections have important limitations because they generate Expression trees that EF Core translates to SQL: - -| Feature | Standard Mapping | IQueryable Projection | -|---------|------------------|----------------------| -| **BeforeMap Hook** | ✅ Supported | ❌ Not supported (expressions can't call methods) | -| **AfterMap Hook** | ✅ Supported | ❌ Not supported (expressions can't call methods) | -| **Factory Method** | ✅ Supported | ❌ Not supported (must use object initializer) | -| **Nested Objects** | ✅ Supported | ❌ Not supported (would require `.MapToX()` calls) | -| **Collections** | ✅ Supported | ❌ Not supported (would require `.Select()` calls) | -| **Built-in Type Conversions** | ✅ Supported | ❌ Not supported (only simple casts work) | -| **Simple Properties** | ✅ Supported | ✅ Supported | -| **Enum Conversions** | ✅ Supported | ✅ Supported (via simple casts) | -| **UpdateTarget** | ✅ Supported | ❌ Not applicable (projections are read-only) | - -### What Gets Included in Projections - -The generator **automatically excludes** the following from projection expressions: - -1. **Nested Objects** - Properties of class/struct types (other than primitives/enums) -2. **Collections** - `IEnumerable`, `List`, arrays, etc. -3. **Properties without matching target** - Source properties not found in target DTO -4. **Properties marked with `[MapIgnore]`** - Excluded from all mappings - -**Only simple properties are included:** - -- Primitive types (`int`, `string`, `Guid`, `DateTime`, `DateTimeOffset`, etc.) -- Enums (converted via simple casts) -- Value types (`decimal`, `bool`, etc.) - -### Comparison: Standard Mapping vs. Projection - -```csharp -// Standard mapping (loads entire entity, then maps in memory) -var users = await dbContext.Users - .Where(u => u.IsActive) - .ToListAsync(); // ⚠️ Fetches ALL columns for ALL users -var dtos = users.Select(u => u.MapToUserDto()).ToList(); // ✅ Maps in-memory - -// SQL: SELECT * FROM Users WHERE IsActive = 1 (fetches all columns) - -// --- - -// Projection (maps on the database server) -var dtos = await dbContext.Users - .Where(u => u.IsActive) - .Select(User.ProjectToUserDto()) // ✅ Translates to SQL SELECT - .ToListAsync(); - -// SQL: SELECT Id, Name, Email FROM Users WHERE IsActive = 1 (only required columns) -``` - -### Performance Benefits - -**Database Query Optimization:** - -- ✅ Reduced data transfer (only selected columns) -- ✅ Smaller result sets (fewer bytes over network) -- ✅ Faster queries (database processes less data) -- ✅ Better index usage (covering indexes possible) - -**Memory Optimization:** - -- ✅ Less memory allocated (no full entity objects) -- ✅ Fewer GC collections (smaller object graphs) -- ✅ Better cache locality (smaller DTO objects) - -### Real-World Example: Pet Store List View - -```csharp -using Atc.SourceGenerators.Annotations; - -// Lightweight DTO for pet list/grid view -public class PetListItemDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } - public PetStatus Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -// Domain model with projection enabled -[MapTo(typeof(PetListItemDto), GenerateProjection = true)] -public partial class Pet -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } - public PetStatus Status { get; set; } - public Owner? Owner { get; set; } // ❌ Excluded (nested object) - public IList Children { get; set; } = new List(); // ❌ Excluded (collection) - public DateTimeOffset CreatedAt { get; set; } -} - -// API endpoint using projection -app.MapGet("/pets", async (PetDbContext db) => -{ - var pets = await db.Pets - .Where(p => p.Status == PetStatus.Available) - .OrderBy(p => p.Name) - .Select(Pet.ProjectToPetListItemDto()) // ✅ Server-side projection - .Take(100) - .ToListAsync(); - - return Results.Ok(pets); -}); - -// SQL (optimized): -// SELECT TOP(100) Id, Name, Species, Breed, Age, Status, CreatedAt -// FROM Pets -// WHERE Status = 1 -// ORDER BY Name -``` - -### Best Practices - -**1. Create Dedicated DTOs for Projections** - -```csharp -// ✅ Good: Lightweight DTO designed for projections -public class UserSummaryDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; -} - -// ❌ Bad: Heavy DTO with nested objects -public class UserDetailsDto -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public AddressDto Address { get; set; } = null!; // Won't work in projection -} -``` - -**2. Use Standard Mapping for Complex Scenarios** - -```csharp -// For read-only lists: Use projection -var summary = await db.Users.Select(User.ProjectToUserSummaryDto()).ToListAsync(); - -// For create/update: Use standard mapping with hooks -var userDto = user.MapToUserDto(); // Supports hooks, factory, etc. -``` - -**3. Combine Projections with EF Core Features** - -```csharp -// Filtering, sorting, paging - all on the server -var results = await db.Pets - .Where(p => p.Age > 2) // Server-side filter - .OrderBy(p => p.Name) // Server-side sort - .Skip(pageIndex * pageSize) // Server-side skip - .Take(pageSize) // Server-side take - .Select(Pet.ProjectToPetListItemDto()) // Server-side projection - .ToListAsync(); // Single optimized SQL query -``` - -### Troubleshooting - -**Q: Why isn't my nested object included in the projection?** - -A: Projections only support simple properties. Nested objects require method calls like `.MapToX()` which can't be translated to SQL. - -**Solution:** Either flatten the nested properties using `EnableFlattening = true` in a separate mapping, or use standard mapping instead. - -**Q: Why can't I use BeforeMap/AfterMap with projections?** - -A: Expression trees (which projections use) can only contain expressions that EF Core can translate to SQL. Method calls like hooks aren't supported. - -**Solution:** Use standard mapping (`MapToX()`) when you need hooks. Use projections only for read-only, simple scenarios. - -**Q: The generator excluded all my properties from the projection!** - -A: Check that: - -1. Target DTO properties match source properties by name (case-insensitive) -2. Properties are simple types (not classes, collections, or complex types) -3. Properties aren't marked with `[MapIgnore]` -4. Source and target types are compatible (or enum-to-enum) - ---- - -## 🔐 Private Member Access - -Access private and internal members during mapping using `UnsafeAccessor` (.NET 8+) for AOT-safe, zero-overhead access without reflection. This feature is useful when mapping between layers with encapsulated domain models or working with legacy code. - -### When to Use Private Member Access - -✅ **Use private member access when:** - -- Domain models use encapsulation with private setters -- Mapping from database entities with private fields -- Working with legacy code that uses internal properties -- Need to preserve encapsulation while enabling mapping -- Want zero-overhead access without reflection - -❌ **Don't use private member access when:** - -- Public properties are available (use standard mapping) -- Targeting .NET versions earlier than .NET 8 -- The members are truly private implementation details that shouldn't be mapped - -### Basic Example - -```csharp -using Atc.SourceGenerators.Annotations; - -// Domain model with encapsulated private members -[MapTo(typeof(AccountDto), IncludePrivateMembers = true)] -public partial class Account -{ - public Guid Id { get; set; } - public string AccountNumber { get; set; } = string.Empty; - - // Private property - only accessible via IncludePrivateMembers - private decimal Balance { get; set; } - - // Internal property - only accessible via IncludePrivateMembers - internal string InternalCode { get; set; } = string.Empty; -} - -// DTO with public properties -public class AccountDto -{ - public Guid Id { get; set; } - public string AccountNumber { get; set; } = string.Empty; - public decimal Balance { get; set; } - public string InternalCode { get; set; } = string.Empty; -} - -// Generated code uses UnsafeAccessor for private/internal members -public static AccountDto MapToAccountDto(this Account source) -{ - if (source is null) - { - return default!; - } - - return new AccountDto - { - Id = source.Id, - AccountNumber = source.AccountNumber, - Balance = UnsafeGetAccount_Balance(source), // ✅ UnsafeAccessor - InternalCode = UnsafeGetAccount_InternalCode(source) // ✅ UnsafeAccessor - }; -} - -// Generated UnsafeAccessor methods (zero overhead) -[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Balance")] -private static extern decimal UnsafeGetAccount_Balance(Account instance); - -[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_InternalCode")] -private static extern string UnsafeGetAccount_InternalCode(Account instance); -``` - -### How It Works - -The generator uses the **UnsafeAccessor attribute** (.NET 8+) to generate compile-time accessors for private/internal members: - -1. **Compile-time detection**: Analyzes property accessibility during code generation -2. **Accessor generation**: Creates `extern` methods with `[UnsafeAccessor]` attribute -3. **Zero overhead**: Direct method calls with no reflection or performance cost -4. **AOT-safe**: Fully compatible with Native AOT compilation -5. **Type-safe**: Compile-time validation of property names and types - -**Naming Convention:** - -- Getters: `UnsafeGet{TypeName}_{PropertyName}` -- Setters: `UnsafeSet{TypeName}_{PropertyName}` - -### UpdateTarget with Private Members - -When using `UpdateTarget = true` with private members, the generator creates setter accessors for target properties: - -```csharp -[MapTo(typeof(AccountEntity), IncludePrivateMembers = true, UpdateTarget = true)] -public partial class Account -{ - public Guid Id { get; set; } - private decimal Balance { get; set; } -} - -public class AccountEntity -{ - public Guid Id { get; set; } - private decimal Balance { get; set; } -} - -// Generated: Updates existing target instance -public static void MapToAccountEntity(this Account source, AccountEntity target) -{ - if (source is null || target is null) - { - return; - } - - target.Id = source.Id; - UnsafeSetAccountEntity_Balance(target, UnsafeGetAccount_Balance(source)); // ✅ Private-to-private -} - -// Generated accessors -[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Balance")] -private static extern decimal UnsafeGetAccount_Balance(Account instance); - -[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Balance")] -private static extern void UnsafeSetAccountEntity_Balance(AccountEntity instance, decimal value); -``` - -### Bidirectional Mapping with Private Members - -Combine `IncludePrivateMembers` with `Bidirectional` for two-way mapping: - -```csharp -[MapTo(typeof(UserDto), IncludePrivateMembers = true, Bidirectional = true)] -public partial class User -{ - public Guid Id { get; set; } - private string PasswordHash { get; set; } = string.Empty; -} - -public partial class UserDto -{ - public Guid Id { get; set; } - private string PasswordHash { get; set; } = string.Empty; -} - -// Generated: Both directions with UnsafeAccessor -// User.MapToUserDto() -// UserDto.MapToUser() -``` - -### Mixing Public and Private Properties - -The generator intelligently uses direct access for public properties and `UnsafeAccessor` only when needed: - -```csharp -[MapTo(typeof(ProductDto), IncludePrivateMembers = true)] -public partial class Product -{ - public Guid Id { get; set; } // ✅ Public - direct access - public string Name { get; set; } = string.Empty; // ✅ Public - direct access - private decimal Cost { get; set; } // 🔐 Private - UnsafeAccessor - internal int InternalSku { get; set; } // 🔐 Internal - UnsafeAccessor -} - -// Generated code -return new ProductDto -{ - Id = source.Id, // Direct access - Name = source.Name, // Direct access - Cost = UnsafeGetProduct_Cost(source), // UnsafeAccessor - InternalSku = UnsafeGetProduct_InternalSku(source) // UnsafeAccessor -}; -``` - -### Compatibility with Other Features - -`IncludePrivateMembers` works seamlessly with all other mapping features: - -| Feature | Compatible | Notes | -|---------|------------|-------| -| **Nested Objects** | ✅ Yes | Private nested objects map automatically | -| **Collections** | ✅ Yes | Private collection properties supported | -| **Enums** | ✅ Yes | Private enum properties with smart conversion | -| **Bidirectional** | ✅ Yes | Generates accessors for both directions | -| **UpdateTarget** | ✅ Yes | Generates setter accessors for private target properties | -| **EnableFlattening** | ✅ Yes | Flattens private nested properties | -| **BeforeMap/AfterMap** | ✅ Yes | Hooks can access mapped private properties | -| **Factory** | ✅ Yes | Factory creates instance, then maps private properties | -| **GenerateProjection** | ❌ No | EF Core projections don't support UnsafeAccessor | - -### Requirements - -- **Target Framework**: .NET 8 or later (UnsafeAccessor is a .NET 8+ feature) -- **Partial Classes**: Source class must be `partial` (standard mapping requirement) -- **Property Accessors**: Private/internal properties must have get/set methods (auto-properties work) - -### Real-World Example: Secure Domain Model - -```csharp -// Domain model with encapsulation -[MapTo(typeof(OrderDto), IncludePrivateMembers = true)] -public partial class Order -{ - // Public properties - public Guid Id { get; set; } - public string OrderNumber { get; set; } = string.Empty; - - // Private business logic properties - private decimal Subtotal { get; set; } - private decimal Tax { get; set; } - private decimal Total { get; set; } - - // Internal audit properties - internal string CreatedBy { get; set; } = string.Empty; - internal DateTimeOffset CreatedAt { get; set; } - - // Public methods that maintain invariants - public void CalculateTotals(decimal taxRate) - { - Tax = Subtotal * taxRate; - Total = Subtotal + Tax; - } -} - -// DTO with all public properties -public class OrderDto -{ - public Guid Id { get; set; } - public string OrderNumber { get; set; } = string.Empty; - public decimal Subtotal { get; set; } - public decimal Tax { get; set; } - public decimal Total { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } -} - -// Usage: Domain model preserves encapsulation, DTO exposes all data -var order = GetOrderFromDatabase(); -order.CalculateTotals(0.08m); // Business logic maintains invariants -var dto = order.MapToOrderDto(); // Mapping accesses private members for serialization -``` - -### Performance Characteristics - -**UnsafeAccessor Performance:** - -- ✅ **Zero overhead** - Direct method calls (same as public access) -- ✅ **No reflection** - Compile-time code generation -- ✅ **AOT-friendly** - Works with Native AOT compilation -- ✅ **Inlined** - JIT can inline accessor calls -- ✅ **Cache-friendly** - No dictionary lookups or metadata queries - -**Comparison:** - -``` -Direct Access (public): ~1 ns -UnsafeAccessor (private): ~1 ns ✅ Same performance! -Reflection (GetProperty): ~80 ns ❌ 80x slower -``` - -### Best Practices - -**1. Use IncludePrivateMembers Sparingly** - -```csharp -// ✅ Good: Only when needed for encapsulation -[MapTo(typeof(AccountDto), IncludePrivateMembers = true)] -public partial class Account -{ - private decimal Balance { get; set; } // Encapsulated business logic -} - -// ❌ Bad: Making everything private unnecessarily -[MapTo(typeof(UserDto), IncludePrivateMembers = true)] -public partial class User -{ - private Guid Id { get; set; } // No reason to be private - private string Name { get; set; } // No reason to be private -} -``` - -**2. Consider Encapsulation Boundaries** - -```csharp -// ✅ Good: DTOs expose data, domain preserves invariants -public partial class Order // Encapsulated -{ - private decimal Total { get; set; } - public void RecalculateTotal() { /* business logic */ } -} - -public class OrderDto // Public DTO for API -{ - public decimal Total { get; set; } -} -``` - -**3. Document Why Members Are Private** - -```csharp -[MapTo(typeof(LegacyDto), IncludePrivateMembers = true)] -public partial class LegacyEntity -{ - /// - /// Private for backward compatibility with legacy ORM. - /// Use IncludePrivateMembers to map to modern DTOs. - /// - private string InternalCode { get; set; } = string.Empty; -} -``` - ---- - -## ⚙️ MapToAttribute Parameters - -The `MapToAttribute` accepts the following parameters: - -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| `targetType` | `Type` | ✅ Yes | - | The type to map to | -| `Bidirectional` | `bool` | ❌ No | `false` | Generate bidirectional mappings (both Source → Target and Target → Source) | -| `EnableFlattening` | `bool` | ❌ No | `false` | Enable property flattening (nested properties are flattened using {PropertyName}{NestedPropertyName} convention) | -| `PropertyNameStrategy` | `PropertyNameStrategy` | ❌ No | `PascalCase` | Naming strategy for automatic property name conversion. Enables mapping between different naming conventions (PascalCase ↔ camelCase ↔ snake_case ↔ kebab-case). Use `[MapProperty]` to override for specific properties | -| `BeforeMap` | `string?` | ❌ No | `null` | Name of a static method to call before performing the mapping. Signature: `static void MethodName(SourceType source)` | -| `AfterMap` | `string?` | ❌ No | `null` | Name of a static method to call after performing the mapping. Signature: `static void MethodName(SourceType source, TargetType target)` | -| `Factory` | `string?` | ❌ No | `null` | Name of a static factory method to use for creating the target instance. Signature: `static TargetType MethodName()` | -| `UpdateTarget` | `bool` | ❌ No | `false` | Generate an additional method overload that updates an existing target instance instead of creating a new one. Generates both `MapToX()` and `MapToX(target)` methods | -| `GenerateProjection` | `bool` | ❌ No | `false` | Generate an Expression projection method for use with IQueryable (EF Core server-side projection). Generates `ProjectToX()` method that returns `Expression>`. Only simple property mappings are supported (no hooks, factory, nested objects, or collections) | -| `IncludePrivateMembers` | `bool` | ❌ No | `false` | Include private and internal members in the mapping. Uses UnsafeAccessor (.NET 8+) for AOT-safe, zero-overhead access to private members. Compatible with all other features (nested mappings, collections, enums, etc.) | - -**Example:** - -```csharp -// Basic mapping (one-way) -[MapTo(typeof(PersonDto))] -public partial class Person { } -// Generates: Person.MapToPersonDto() - -// Bidirectional mapping (two-way) -[MapTo(typeof(PersonDto), Bidirectional = true)] -public partial class Person { } -// Generates: Person.MapToPersonDto() AND PersonDto.MapToPerson() -``` - ---- - -## 🛡️ Diagnostics - -The generator provides helpful diagnostics during compilation. - -### ❌ ATCMAP001: Mapping Class Must Be Partial - -**Error:** The class decorated with `[MapTo]` is not marked as `partial`. - -**Example:** - -```csharp -[MapTo(typeof(PersonDto))] -public class Person // ❌ Missing 'partial' keyword -{ - public string Name { get; set; } = string.Empty; -} -``` - -**Fix:** - -```csharp -[MapTo(typeof(PersonDto))] -public partial class Person // ✅ Added 'partial' -{ - public string Name { get; set; } = string.Empty; -} -``` - -**Why:** The generator needs to add extension methods in a separate file, which requires the class to be `partial`. - ---- - -### ❌ ATCMAP002: Target Type Must Be Class or Struct - -**Error:** The target type specified in `[MapTo(typeof(...))]` is not a class or struct. - -**Example:** - -```csharp -[MapTo(typeof(IPerson))] // ❌ Interface -public partial class Person { } -``` - -**Fix:** - -```csharp -[MapTo(typeof(PersonDto))] // ✅ Class -public partial class Person { } -``` - -**Why:** You can only map to concrete types (classes or structs), not interfaces or abstract classes. - ---- - -### ❌ ATCMAP003: MapProperty Target Property Not Found - -**Error:** The target property specified in `[MapProperty("PropertyName")]` does not exist on the target type. - -**Example:** - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - - [MapProperty("NonExistentProperty")] // ❌ UserDto doesn't have this property - public string Name { get; set; } = string.Empty; -} - -public class UserDto -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; -} -``` - -**Fix:** - -```csharp -[MapTo(typeof(UserDto))] -public partial class User -{ - public Guid Id { get; set; } - - [MapProperty("FullName")] // ✅ UserDto has this property - public string Name { get; set; } = string.Empty; -} -``` - -**Why:** The generator validates at compile time that the target property exists to prevent runtime errors. This ensures type-safe mappings. - ---- - -### ⚠️ ATCMAP004: Required Property Not Mapped - -**Warning:** A required property on the target type has no corresponding mapping from the source type. - -**Example:** - -```csharp -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string FullName { get; set; } = string.Empty; - // Missing: Email property -} - -public class UserRegistrationDto -{ - public Guid Id { get; set; } - public required string Email { get; set; } // ⚠️ Required but not mapped! - public required string FullName { get; set; } -} - -// Warning ATCMAP004: Required property 'Email' on target type 'UserRegistrationDto' has no mapping from source type 'UserRegistration' -``` - -**Fix:** - -```csharp -[MapTo(typeof(UserRegistrationDto))] -public partial class UserRegistration -{ - public Guid Id { get; set; } - public string Email { get; set; } = string.Empty; // ✅ Added to fix ATCMAP004 - public string FullName { get; set; } = string.Empty; -} -``` - -**Why:** The generator validates at compile time that all `required` properties (C# 11+) on the target type have mappings. This catches missing required properties during development instead of discovering issues at runtime or during object initialization. - -**Elevating to Error:** You can configure this diagnostic as an error in `.editorconfig`: - -```ini -dotnet_diagnostic.ATCMAP004.severity = error -``` - ---- - -## 🚀 Native AOT Compatibility - -The Object Mapping Generator is **fully compatible with Native AOT** compilation, producing code that meets all AOT requirements: - -### ✅ AOT-Safe Features - -- **Zero reflection** - All mappings use direct property access and constructor calls -- **Compile-time generation** - Mapping code is generated during build, not at runtime -- **Trimming-safe** - No dynamic type discovery or metadata dependencies -- **Constructor detection** - Analyzes types at compile time, not runtime -- **Static analysis friendly** - All code paths are visible to the AOT compiler - -### 🏗️ How It Works - -1. **Build-time analysis**: The generator scans classes with `[MapTo]` attributes during compilation -2. **Property matching**: Creates direct property-to-property assignments without reflection -3. **Constructor detection**: Analyzes target type constructors at compile time -4. **Extension method generation**: Produces static extension methods with concrete implementations -5. **AOT compilation**: The generated code compiles to native machine code with full optimizations - -### 📋 Example Generated Code - -```csharp -// Source: [MapTo(typeof(UserDto))] public partial class User { ... } - -// Generated AOT-safe code: -public static UserDto MapToUserDto(this User source) -{ - if (source is null) - { - return default!; - } - - return new UserDto - { - Id = source.Id, - Name = source.Name, - Email = source.Email - }; -} -``` - -**Why This Is AOT-Safe:** - -- No `Activator.CreateInstance()` calls (reflection) -- No dynamic property access via `PropertyInfo` -- All property assignments are compile-time verified -- Null checks are explicit and traceable -- Constructor calls use `new` keyword, not reflection - -### 🎯 Multi-Layer AOT Support - -Even complex mapping chains remain fully AOT-compatible: - -```csharp -// Entity → Domain → DTO chain -var dto = entity - .MapToDomainModel() // ✅ AOT-safe - .MapToDto(); // ✅ AOT-safe -``` - -Each mapping method is independently generated with zero reflection, ensuring the entire chain compiles to efficient native code. - ---- - -## 📚 Additional Examples - -### Example 1: Simple POCO Mapping - -```csharp -[MapTo(typeof(ProductDto))] -public partial class Product -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } -} - -public class ProductDto -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } -} - -// Usage -var product = new Product { Id = 1, Name = "Widget", Price = 9.99m }; -var dto = product.MapToProductDto(); -``` - -### Example 2: Record Types - -```csharp -[MapTo(typeof(PersonDto))] -public partial record Person(int Id, string Name, int Age); - -public record PersonDto(int Id, string Name, int Age); - -// Usage -var person = new Person(1, "Alice", 25); -var dto = person.MapToPersonDto(); -``` - -### Example 3: Complex Nested Structure - -```csharp -[MapTo(typeof(ContactInfoDto))] -public partial class ContactInfo -{ - public string Email { get; set; } = string.Empty; - public string Phone { get; set; } = string.Empty; -} - -public class ContactInfoDto -{ - public string Email { get; set; } = string.Empty; - public string Phone { get; set; } = string.Empty; -} - -[MapTo(typeof(CompanyDto))] -public partial class Company -{ - public string Name { get; set; } = string.Empty; - public ContactInfo? Contact { get; set; } -} - -public class CompanyDto -{ - public string Name { get; set; } = string.Empty; - public ContactInfoDto? Contact { get; set; } -} - -// Usage -var company = new Company -{ - Name = "Acme Corp", - Contact = new ContactInfo { Email = "info@acme.com", Phone = "555-1234" } -}; -var dto = company.MapToCompanyDto(); -``` - -### Example 4: Working with Collections - -```csharp -[MapTo(typeof(TagDto))] -public partial class Tag -{ - public string Name { get; set; } = string.Empty; -} - -public class TagDto -{ - public string Name { get; set; } = string.Empty; -} - -// Usage with LINQ -List tags = GetTags(); -List tagDtos = tags.Select(t => t.MapToTagDto()).ToList(); -``` - ---- - -**Happy Mapping! 🗺️✨** - -For more information and examples, visit the [Atc.SourceGenerators GitHub repository](https://github.com/atc-net/atc-source-generators). diff --git a/docs/OptionsBindingGenerators-FeatureRoadmap.md b/docs/OptionsBindingGenerators-FeatureRoadmap.md deleted file mode 100644 index 9117fa6..0000000 --- a/docs/OptionsBindingGenerators-FeatureRoadmap.md +++ /dev/null @@ -1,1725 +0,0 @@ -# ⚙️ Feature Roadmap - Options Binding Generator - -This document outlines the feature roadmap for the **OptionsBindingGenerator**, based on analysis of Microsoft.Extensions.Options patterns and real-world configuration challenges. - -## 🔍 Research Sources - -This roadmap is based on comprehensive analysis of: - -1. **Microsoft.Extensions.Options** - [Official Documentation](https://learn.microsoft.com/en-us/dotnet/core/extensions/options) - - Options pattern guidance - - Validation strategies (DataAnnotations, custom, IValidateOptions) - - Lifetime management (IOptions, IOptionsSnapshot, IOptionsMonitor) - - Named options support - -2. **Configuration Binder Source Generator** - .NET 8+ AOT support - - [Configuration Generator](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-generator) - - Replace reflection-based binding with compile-time generation - - AOT-compatible, trimming-safe - -3. **GitHub Issues & Feature Requests**: - - [Issue #36015](https://github.com/dotnet/runtime/issues/36015) - Better error handling for missing configuration - - [Issue #83599](https://github.com/dotnet/runtime/issues/83599) - Extensibility for configuration binding - - Silent failures and hard-to-diagnose misconfigurations - -4. **Community Pain Points**: - - Silent binding failures (missing keys → null properties) - - Lifetime mismatches (IOptionsSnapshot in singletons) - - Change detection limitations (only file-based providers) - - Field binding doesn't work (only properties) - -### 📊 Key Insights from Options Pattern - -**What Developers Need**: - -- **Fail-fast validation** - Catch configuration errors at startup, not runtime -- **Strong typing** - Avoid string-based configuration access -- **Change notifications** - Reload configuration without restarting app -- **Scoped configuration** - Different values per HTTP request -- **Named options** - Multiple configurations for same type -- **Custom validation** - Beyond DataAnnotations - -**Common Mistakes**: - -- Using `IOptionsSnapshot` in singleton services (lifetime mismatch) -- Expecting fields to bind (only properties work) -- Missing validation (silent failures in production) -- Not using `ValidateOnStart()` for critical configuration - ---- - -## 📊 Current State - -### ✅ OptionsBindingGenerator - Implemented Features - -- **Section name resolution** - 5-level priority system (explicit → const SectionName → const NameTitle → const Name → auto-inferred) -- **Validation support** - `ValidateDataAnnotations` and `ValidateOnStart` parameters -- **Custom validation** - `IValidateOptions` for complex business rules beyond DataAnnotations -- **Named options** - Multiple configurations of the same options type with different names -- **Error on missing keys** - `ErrorOnMissingKeys` fail-fast validation when configuration sections are missing -- **Configuration change callbacks** - `OnChange` callbacks for Monitor lifetime (auto-generates IHostedService) -- **Post-configuration support** - `PostConfigure` callbacks for normalizing/transforming values after binding (e.g., path normalization, URL lowercase) -- **ConfigureAll support** - Set common defaults for all named options instances before individual binding -- **Child sections** - Simplified syntax for multiple named instances from subsections (e.g., `Email` → Primary/Secondary/Fallback) -- **Nested subsection binding** - Automatic binding of complex properties to configuration subsections (e.g., `Storage:Database:Retry`) -- **Lifetime selection** - Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), Monitor (`IOptionsMonitor`) -- **Multi-project support** - Assembly-specific extension methods with smart naming -- **Transitive registration** - 4 overloads for automatic/selective assembly registration -- **Partial class requirement** - Enforced at compile time -- **Native AOT compatible** - Zero reflection, compile-time generation -- **Compile-time diagnostics** - Validate partial class, section names, OnChange/PostConfigure/ConfigureAll callbacks, ChildSections usage (ATCOPT001-016) - ---- - -## 📋 Feature Status Overview - -| Status | Feature | Priority | -|:------:|---------|----------| -| ✅ | [Custom Validation Support (IValidateOptions)](#1-custom-validation-support-ivalidateoptions) | 🔴 High | -| ✅ | [Named Options Support](#2-named-options-support) | 🔴 High | -| ✅ | [Post-Configuration Support](#3-post-configuration-support) | 🟡 Medium-High | -| ✅ | [Error on Missing Configuration Keys](#4-error-on-missing-configuration-keys) | 🔴 High | -| ✅ | [Configuration Change Callbacks](#5-configuration-change-callbacks) | 🟡 Medium | -| ✅ | [Bind Configuration Subsections to Properties](#6-bind-configuration-subsections-to-properties) | 🟡 Medium | -| ✅ | [ConfigureAll Support](#7-configureall-support) | 🟢 Low-Medium | -| ✅ | [Child Sections (Simplified Named Options)](#8-child-sections-simplified-named-options) | 🟢 Low-Medium | -| ❌ | [Compile-Time Section Name Validation](#9-compile-time-section-name-validation) | 🟡 Medium | -| ✅ | [Early Access to Options During Service Registration](#10-early-access-to-options-during-service-registration) | 🔴 High | -| ❌ | [Auto-Generate Options Classes from appsettings.json](#11-auto-generate-options-classes-from-appsettingsjson) | 🟢 Low | -| ❌ | [Environment-Specific Validation](#12-environment-specific-validation) | 🟢 Low | -| ❌ | [Hot Reload Support with Filtering](#13-hot-reload-support-with-filtering) | 🟢 Low | -| 🚫 | [Reflection-Based Binding](#15-reflection-based-binding) | - | -| 🚫 | [JSON Schema Generation](#16-json-schema-generation) | - | -| 🚫 | [Configuration Encryption/Decryption](#17-configuration-encryptiondecryption) | - | -| 🚫 | [Dynamic Configuration Sources](#18-dynamic-configuration-sources) | - | - -**Legend:** - -- ✅ **Implemented** - Feature is complete and available -- ❌ **Not Implemented** - Feature is planned but not yet developed -- 🚫 **Not Planned** - Feature is out of scope or not aligned with project goals - ---- - -## 🎯 Need to Have (High Priority) - -These features address common pain points and align with Microsoft's Options pattern best practices. - -### 1. Custom Validation Support (IValidateOptions) - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: Microsoft.Extensions.Options.IValidateOptions - -**Description**: Support complex validation logic beyond DataAnnotations using `IValidateOptions` interface. - -**User Story**: -> "As a developer, I want to validate that `MaxConnections` is greater than `MinConnections` using custom business logic that DataAnnotations can't express." - -**Example**: - -```csharp -// Options class -[OptionsBinding("ConnectionPool", ValidateDataAnnotations = true)] -public partial class ConnectionPoolOptions -{ - [Range(1, 100)] - public int MinConnections { get; set; } = 1; - - [Range(1, 1000)] - public int MaxConnections { get; set; } = 10; - - public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30); -} - -// Custom validator -public class ConnectionPoolOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, ConnectionPoolOptions options) - { - if (options.MaxConnections <= options.MinConnections) - { - return ValidateOptionsResult.Fail( - "MaxConnections must be greater than MinConnections"); - } - - if (options.ConnectionTimeout < TimeSpan.FromSeconds(1)) - { - return ValidateOptionsResult.Fail( - "ConnectionTimeout must be at least 1 second"); - } - - return ValidateOptionsResult.Success; - } -} - -// Generated code should register the validator: -services.AddOptions() - .Bind(configuration.GetSection("ConnectionPool")) - .ValidateDataAnnotations() - .Services.AddSingleton, ConnectionPoolOptionsValidator>(); -``` - -**Implementation Notes**: - -- ✅ Added `Validator` property to `[OptionsBinding]` attribute -- ✅ Generator extracts validator type and registers it as singleton -- ✅ Generated code: `services.AddSingleton, TValidator>()` -- ✅ Works with DataAnnotations validation and ValidateOnStart -- ✅ Supports fully qualified type names -- ✅ Tested in sample projects (DatabaseOptions, PetStoreOptions) - ---- - -### 2. Named Options Support - -**Priority**: 🔴 **High** -**Status**: ✅ **Implemented** -**Inspiration**: Microsoft.Extensions.Options named options - -**Description**: Support multiple configuration sections binding to the same options class with different names. - -**User Story**: -> "As a developer building a multi-tenant application, I want to load different database configurations for each tenant using the same `DatabaseOptions` class." - -**Example**: - -```csharp -// appsettings.json -{ - "Databases": { - "Primary": { - "ConnectionString": "Server=primary;...", - "MaxRetries": 5 - }, - "Reporting": { - "ConnectionString": "Server=reporting;...", - "MaxRetries": 3 - }, - "Archive": { - "ConnectionString": "Server=archive;...", - "MaxRetries": 10 - } - } -} - -// Options class with named instances -[OptionsBinding("Databases:Primary", Name = "Primary")] -[OptionsBinding("Databases:Reporting", Name = "Reporting")] -[OptionsBinding("Databases:Archive", Name = "Archive")] -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; - public int MaxRetries { get; set; } -} - -// Generated code: -services.Configure("Primary", - configuration.GetSection("Databases:Primary")); -services.Configure("Reporting", - configuration.GetSection("Databases:Reporting")); -services.Configure("Archive", - configuration.GetSection("Databases:Archive")); - -// Usage: -public class DataService -{ - public DataService(IOptionsSnapshot options) - { - var primaryDb = options.Get("Primary"); - var reportingDb = options.Get("Reporting"); - var archiveDb = options.Get("Archive"); - } -} -``` - -**Implementation Details**: - -- ✅ `[OptionsBinding]` attribute supports `AllowMultiple = true` -- ✅ Added `Name` property to distinguish named instances -- ✅ Named options use `Configure(name, section)` pattern -- ✅ Named options accessed via `IOptionsSnapshot.Get(name)` -- ✅ Can mix named and unnamed options on the same class -- ⚠️ Named options do NOT support validation chain (ValidateDataAnnotations, ValidateOnStart, Validator) - -**Testing**: - -- ✅ 8 comprehensive unit tests covering all scenarios -- ✅ Sample project with EmailOptions demonstrating Primary/Secondary/Fallback servers -- ✅ PetStore.Api sample with NotificationOptions (Email/SMS/Push channels) - ---- - -### 3. Post-Configuration Support - -**Priority**: 🟡 **Medium-High** -**Status**: ✅ **Implemented** -**Inspiration**: `IPostConfigureOptions` pattern - -**Description**: Support post-configuration actions that run after binding and validation to apply defaults or transformations. - -**User Story**: -> "As a developer, I want to apply default values or normalize configuration after binding (e.g., ensure paths end with slash, URLs are lowercase)." - -**Example**: - -```csharp -[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))] -public partial class StorageOptions -{ - public string BasePath { get; set; } = string.Empty; - public string CachePath { get; set; } = string.Empty; - public string TempPath { get; set; } = string.Empty; - - // Post-configuration method - private static void NormalizePaths(StorageOptions options) - { - // Ensure all paths end with directory separator - options.BasePath = EnsureTrailingSlash(options.BasePath); - options.CachePath = EnsureTrailingSlash(options.CachePath); - options.TempPath = EnsureTrailingSlash(options.TempPath); - } - - private static string EnsureTrailingSlash(string path) - => path.EndsWith(Path.DirectorySeparatorChar) - ? path - : path + Path.DirectorySeparatorChar; -} - -// Generated code: -services.AddOptions() - .Bind(configuration.GetSection("Storage")) - .PostConfigure(options => StorageOptions.NormalizePaths(options)); -``` - -**Implementation Details**: - -- ✅ Added `PostConfigure` property to `[OptionsBinding]` attribute -- ✅ Generator calls `.PostConfigure()` method on the options builder -- ✅ PostConfigure method must have signature: `static void MethodName(TOptions options)` -- ✅ Runs after binding and validation -- ✅ PostConfigure method can be `internal` or `public` (not `private`) -- ⚠️ Cannot be used with named options -- ✅ Comprehensive compile-time validation with 3 diagnostic codes (ATCOPT008-010) -- ✅ Useful for normalization, defaults, computed properties - -**Diagnostics**: - -- **ATCOPT008**: PostConfigure callback not supported with named options -- **ATCOPT009**: PostConfigure callback method not found -- **ATCOPT010**: PostConfigure callback method has invalid signature - -**Testing**: - -- ✅ Unit tests covering all scenarios and error cases -- ✅ Sample project: StoragePathsOptions demonstrates path normalization (trailing slash) -- ✅ PetStore.Api sample: ExternalApiOptions demonstrates URL normalization (lowercase + trailing slash removal) - ---- - -### 4. Error on Missing Configuration Keys - -**Priority**: 🔴 **High** ⭐ *Highly requested in GitHub issues* -**Status**: ✅ **Implemented** -**Inspiration**: [GitHub Issue #36015](https://github.com/dotnet/runtime/issues/36015) - -**Description**: Throw exceptions when required configuration sections are missing instead of silently binding to null/default values. - -**User Story**: -> "As a developer, I want my application to fail at startup if critical configuration like database connection strings is missing, rather than failing in production with NullReferenceException." - -**Example**: - -```csharp -[OptionsBinding("Database", ErrorOnMissingKeys = true, ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required, MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; - - public int TimeoutSeconds { get; set; } = 30; -} - -// Generated code with section existence check: -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .Validate(options => - { - var section = configuration.GetSection("Database"); - if (!section.Exists()) - { - throw new global::System.InvalidOperationException( - "Configuration section 'Database' is missing. " + - "Ensure the section exists in your appsettings.json or other configuration sources."); - } - - return true; - }) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -**Implementation Details**: - -- ✅ Added `ErrorOnMissingKeys` boolean parameter to `[OptionsBinding]` attribute -- ✅ Generator checks `IConfigurationSection.Exists()` to detect missing sections -- ✅ Throws `InvalidOperationException` with descriptive message including section name -- ✅ Combines with `ValidateOnStart = true` for startup detection (recommended) -- ✅ Works with all validation options (DataAnnotations, custom validators) -- ✅ Section name included in error message for easy troubleshooting -- ⚠️ Named options do NOT support ErrorOnMissingKeys (named options use simpler Configure pattern) - -**Testing**: - -- ✅ 11 comprehensive unit tests covering all scenarios -- ✅ Sample project updated: DatabaseOptions demonstrates ErrorOnMissingKeys -- ✅ PetStore.Api sample: PetStoreOptions uses ErrorOnMissingKeys for critical configuration - -**Best Practices**: - -- Always combine with `ValidateOnStart = true` to catch missing configuration at startup -- Use for production-critical configuration (databases, external services, API keys) -- Avoid for optional configuration with reasonable defaults - ---- - -### 5. Configuration Change Callbacks - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** -**Inspiration**: `IOptionsMonitor.OnChange()` pattern - -**Description**: Support registering callbacks that execute when configuration changes are detected. - -**User Story**: -> "As a developer, I want to be notified when feature flags change so I can reload caches or update runtime behavior without restarting the application." - -**Example**: - -```csharp -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))] -public partial class FeaturesOptions -{ - public bool EnableNewUI { get; set; } - public bool EnableBetaFeatures { get; set; } - public int MaxUploadSizeMB { get; set; } = 10; - - // Change callback - signature: static void OnChange(TOptions options, string? name) - internal static void OnFeaturesChanged(FeaturesOptions options, string? name) - { - Console.WriteLine($"Features configuration changed: EnableNewUI={options.EnableNewUI}"); - // Clear caches, notify components, etc. - } -} - -// Generated code: -services.AddOptions() - .Bind(configuration.GetSection("Features")); - -services.AddHostedService(); - -// Generated hosted service -internal sealed class FeaturesOptionsChangeListener : IHostedService -{ - private readonly IOptionsMonitor _monitor; - private IDisposable? _changeToken; - - public FeaturesOptionsChangeListener(IOptionsMonitor monitor) - { - _monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _changeToken = _monitor.OnChange((options, name) => - FeaturesOptions.OnFeaturesChanged(options, name)); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _changeToken?.Dispose(); - return Task.CompletedTask; - } -} -``` - -**Implementation Details**: - -- ✅ Added `OnChange` property to `[OptionsBinding]` attribute -- ✅ Generator creates `IHostedService` that registers the callback via `IOptionsMonitor.OnChange()` -- ✅ Hosted service is automatically registered when application starts -- ✅ Callback signature: `static void MethodName(TOptions options, string? name)` -- ✅ Callback method can be `internal` or `public` (not `private`) -- ✅ Properly disposes change token in `StopAsync` to prevent memory leaks -- ✅ Only applicable when `Lifetime = OptionsLifetime.Monitor` -- ⚠️ Cannot be used with named options -- ✅ Comprehensive compile-time validation with 4 diagnostic codes (ATCOPT004-007) -- **Limitation**: Only works with file-based configuration providers (appsettings.json with reloadOnChange: true) - -**Diagnostics**: - -- **ATCOPT004**: OnChange callback requires Monitor lifetime -- **ATCOPT005**: OnChange callback not supported with named options -- **ATCOPT006**: OnChange callback method not found -- **ATCOPT007**: OnChange callback method has invalid signature - -**Testing**: - -- ✅ 20 comprehensive unit tests covering all scenarios and error cases -- ✅ Sample project updated: LoggingOptions demonstrates OnChange callbacks -- ✅ PetStore.Api sample: FeaturesOptions uses OnChange for feature flag changes - ---- - -### 6. Bind Configuration Subsections to Properties - -**Priority**: 🟡 **Medium** -**Status**: ✅ **Implemented** -**Inspiration**: Microsoft.Extensions.Configuration.Binder automatic subsection binding - -**Description**: Microsoft's `.Bind()` method automatically handles nested configuration subsections. Complex properties are automatically bound to their corresponding configuration subsections without any additional configuration. - -**User Story**: -> "As a developer, I want to bind nested configuration sections to nested properties without manually creating separate options classes or writing additional binding code." - -**Example**: - -```csharp -// appsettings.json -{ - "Email": { - "From": "noreply@example.com", - "Smtp": { - "Host": "smtp.gmail.com", - "Port": 587, - "UseSsl": true - }, - "Templates": { - "Welcome": "welcome.html", - "ResetPassword": "reset.html" - } - } -} - -// Options class - nested objects automatically bind! -[OptionsBinding("Email")] -public partial class EmailOptions -{ - public string From { get; set; } = string.Empty; - - // Automatically binds to "Email:Smtp" subsection - no special config needed! - public SmtpSettings Smtp { get; set; } = new(); - - // Automatically binds to "Email:Templates" subsection - public EmailTemplates Templates { get; set; } = new(); -} - -public class SmtpSettings -{ - public string Host { get; set; } = string.Empty; - public int Port { get; set; } - public bool UseSsl { get; set; } -} - -public class EmailTemplates -{ - public string Welcome { get; set; } = string.Empty; - public string ResetPassword { get; set; } = string.Empty; -} -``` - -**Real-World Example - Deeply Nested (3 Levels)**: - -```csharp -[OptionsBinding("Storage", ValidateDataAnnotations = true)] -public partial class StorageOptions -{ - // Binds to "Storage:Database" - public DatabaseSettings Database { get; set; } = new(); - - // Binds to "Storage:FileStorage" - public FileStorageSettings FileStorage { get; set; } = new(); -} - -public class DatabaseSettings -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 1000)] - public int MaxConnections { get; set; } = 100; - - // Binds to "Storage:Database:Retry" - 3 levels deep! - public DatabaseRetryPolicy Retry { get; set; } = new(); -} - -public class DatabaseRetryPolicy -{ - [Range(0, 10)] - public int MaxAttempts { get; set; } = 3; - - [Range(100, 10000)] - public int DelayMilliseconds { get; set; } = 500; -} -``` - -**Implementation Details**: - -- ✅ **Zero configuration required** - Just use complex property types and `.Bind()` handles the rest -- ✅ **Already supported by Microsoft.Extensions.Configuration.Binder** - Our generator leverages this natively -- ✅ **Automatic path construction** - "Parent:Child:GrandChild" paths are built automatically -- ✅ **Works with validation** - DataAnnotations validation applies to all nested levels -- ✅ **Unlimited depth** - Supports deeply nested structures (e.g., CloudStorage → Azure → Blob) -- ✅ **Collections supported** - List, arrays, dictionaries all work automatically -- ✅ **No breaking changes** - This feature works out-of-the-box with existing code - -**What Gets Automatically Bound**: - -- **Nested objects** - Properties with complex class types -- **Collections** - List, IEnumerable, arrays -- **Dictionaries** - Dictionary, Dictionary -- **Multiple levels** - As deeply nested as needed - -**Testing**: - -- ✅ 9 comprehensive unit tests covering all scenarios -- ✅ Sample project: CloudStorageOptions demonstrates Azure/AWS/Blob nested structure -- ✅ PetStore.Api sample: StorageOptions demonstrates Database/FileStorage/Retry 3-level nesting - -**Key Benefits**: - -- **Cleaner configuration models** - Group related settings without flat structures -- **Better organization** - Mirrors natural hierarchy of configuration -- **Type-safe all the way down** - Compile-time safety for nested properties -- **Works with existing features** - Validation, change detection, all lifetimes - ---- - -### 7. ConfigureAll Support - -**Priority**: 🟢 **Low-Medium** -**Status**: ✅ **Implemented** - -**Description**: Support configuring all named instances of an options type at once, allowing you to set common defaults that apply to all named configurations before individual settings override them. - -**Example**: - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public int MaxRetries { get; set; } - public int TimeoutSeconds { get; set; } = 30; - - internal static void SetDefaults(EmailOptions options) - { - // Set common defaults for ALL email configurations - options.MaxRetries = 3; - options.TimeoutSeconds = 30; - options.Port = 587; - } -} -``` - -**Generated Code**: - -```csharp -// Configure defaults for ALL named instances FIRST -services.ConfigureAll(options => EmailOptions.SetDefaults(options)); - -// Then configure individual instances (can override defaults) -services.Configure("Primary", config.GetSection("Email:Primary")); -services.Configure("Secondary", config.GetSection("Email:Secondary")); -services.Configure("Fallback", config.GetSection("Email:Fallback")); -``` - -**Implementation Details**: - -- ✅ **Requires multiple named instances** - Cannot be used with single unnamed instance (compile-time error) -- ✅ **Method signature validation** - Must be `static void MethodName(TOptions options)` -- ✅ **Execution order** - ConfigureAll runs BEFORE individual Configure calls -- ✅ **Flexible placement** - Can be specified on any one of the `[OptionsBinding]` attributes -- ✅ **Override support** - Individual configurations can override defaults set by ConfigureAll -- ✅ **Compile-time safety** - Diagnostics ATCOPT011-013 validate usage and method signature - -**Use Cases**: - -- **Baseline settings**: Set common retry, timeout, or connection defaults across all database connections -- **Feature flags**: Enable/disable common features for all tenant configurations -- **Security defaults**: Apply consistent security settings across all API client configurations -- **Notification channels**: Set common rate limits and retry policies for all notification providers - -**Testing**: - -- ✅ 14 comprehensive unit tests covering all scenarios -- ✅ Sample project: EmailOptions demonstrates default retry/timeout settings -- ✅ PetStore.Api sample: NotificationOptions demonstrates common defaults for Email/SMS/Push channels - ---- - -## 💡 Nice to Have (Medium Priority) - -These features would improve usability but are not critical. - -### 8. Child Sections (Simplified Named Options) - -**Priority**: 🟢 **Low-Medium** -**Status**: ✅ **Implemented** -**Inspiration**: Community feedback on reducing boilerplate for multiple named instances - -**Description**: Provide a simplified syntax for creating multiple named options instances from configuration subsections. Instead of writing multiple `[OptionsBinding]` attributes for each named instance, developers can use a single `ChildSections` property. - -**User Story**: -> "As a developer, I want to configure multiple related named options (Primary/Secondary/Fallback servers, Email/SMS/Push channels) without repeating multiple attribute declarations." - -**Example**: - -**Before (Multiple Attributes):** - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; - - internal static void SetDefaults(EmailOptions options) - { - options.Port = 587; - options.UseSsl = true; - options.MaxRetries = 3; - } -} -``` - -**After (With ChildSections):** - -```csharp -[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" }, ConfigureAll = nameof(SetDefaults))] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; - - internal static void SetDefaults(EmailOptions options) - { - options.Port = 587; - options.UseSsl = true; - options.MaxRetries = 3; - } -} -``` - -**Generated Code (Identical for Both):** - -```csharp -// Configure defaults for ALL instances FIRST -services.ConfigureAll(options => EmailOptions.SetDefaults(options)); - -// Configure individual named instances -services.Configure("Primary", configuration.GetSection("Email:Primary")); -services.Configure("Secondary", configuration.GetSection("Email:Secondary")); -services.Configure("Fallback", configuration.GetSection("Email:Fallback")); -``` - -**Real-World Example (PetStore Sample):** - -```csharp -/// -/// Notification channel options with support for multiple named configurations. -/// Demonstrates ChildSections + ConfigureAll for common defaults. -/// -[OptionsBinding("Notifications", ChildSections = new[] { "Email", "SMS", "Push" }, ConfigureAll = nameof(SetCommonDefaults))] -public partial class NotificationOptions -{ - public bool Enabled { get; set; } - public string Provider { get; set; } = string.Empty; - public string ApiKey { get; set; } = string.Empty; - public string SenderId { get; set; } = string.Empty; - public int TimeoutSeconds { get; set; } = 30; - public int MaxRetries { get; set; } = 3; - public int RateLimitPerMinute { get; set; } - - internal static void SetCommonDefaults(NotificationOptions options) - { - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.RateLimitPerMinute = 60; - options.Enabled = true; - } -} -``` - -**Configuration (appsettings.json):** - -```json -{ - "Notifications": { - "Email": { - "Enabled": true, - "Provider": "SendGrid", - "ApiKey": "your-api-key", - "SenderId": "noreply@example.com", - "TimeoutSeconds": 30, - "MaxRetries": 3 - }, - "SMS": { - "Enabled": false, - "Provider": "Twilio", - "ApiKey": "your-api-key", - "SenderId": "+1234567890", - "TimeoutSeconds": 15, - "MaxRetries": 2 - }, - "Push": { - "Enabled": true, - "Provider": "Firebase", - "ApiKey": "your-server-key", - "SenderId": "app-id", - "TimeoutSeconds": 20, - "MaxRetries": 3 - } - } -} -``` - -**Implementation Details**: - -- ✅ Added `ChildSections` string array property to `[OptionsBinding]` attribute -- ✅ Generator expands ChildSections into multiple OptionsInfo instances at extraction time -- ✅ Each child section becomes a named instance: `Parent:Child` section path with `Child` as the name -- ✅ **Works with all named options features**: ConfigureAll, validation, ErrorOnMissingKeys, custom validators -- ✅ **Validation support**: Named options with ChildSections can use fluent API for validation -- ✅ **Mutual exclusivity**: Cannot be combined with `Name` property (compile-time error ATCOPT014) -- ✅ **Minimum 2 items**: Requires at least 2 child sections (compile-time error ATCOPT015) -- ✅ **No null/empty items**: All child section names must be non-empty (compile-time error ATCOPT016) -- ✅ **Nested paths supported**: Works with paths like `"App:Services:Cache"` → `"App:Services:Cache:Redis"` - -**Diagnostics**: - -- **ATCOPT014**: ChildSections cannot be used with Name property -- **ATCOPT015**: ChildSections requires at least 2 items (found X item(s)) -- **ATCOPT016**: ChildSections array contains null or empty value at index X - -**Testing**: - -- ✅ 13 comprehensive unit tests covering all scenarios and error cases -- ✅ Sample project: EmailOptions demonstrates Primary/Secondary/Fallback with ConfigureAll -- ✅ PetStore.Api sample: NotificationOptions demonstrates Email/SMS/Push channels -- ✅ All existing tests pass (275 succeeded, 0 failed, 33 skipped) - -**Key Benefits**: - -1. **Less Boilerplate**: One attribute instead of 3+ separate declarations -2. **Clearer Intent**: Explicitly shows configurations are grouped under common parent -3. **Easier Maintenance**: Add/remove sections by updating the array -4. **Feature Complete**: Supports all named options capabilities (validation, ConfigureAll, validators) -5. **Same Power**: Generates identical code to multiple attributes approach - -**Use Cases**: - -- **Notification Channels**: Email, SMS, Push configurations (as shown in PetStore sample) -- **Database Fallback**: Primary, Secondary, Tertiary connections -- **Multi-Region APIs**: USEast, USWest, EUWest, APSouth endpoints -- **Cache Tiers**: L1, L2, L3 cache configurations -- **Multi-Tenant**: Tenant1, Tenant2, Tenant3 configurations - -**Design Decision**: - -- **Expansion at extraction time**: ChildSections array is expanded into multiple OptionsInfo instances during attribute extraction, not at code generation. This simplifies generator logic and ensures all features work consistently. -- **No OnChange support**: Like regular named options, ChildSections-based instances don't support OnChange callbacks (use `IOptionsMonitor.OnChange()` manually if needed). - ---- - -### 9. Compile-Time Section Name Validation - -**Priority**: 🟡 **Medium** -**Status**: ❌ Not Implemented - -**Description**: Validate at compile time that specified configuration section paths exist in appsettings.json. - -**Challenge**: Requires analyzing appsettings.json files during compilation, which is complex. - -**Potential approach**: - -- Use MSBuild task to read appsettings.json -- Generate diagnostics if section path doesn't exist -- May have false positives (environment-specific configs) - ---- - -### 10. Early Access to Options During Service Registration - -**Priority**: 🔴 **High** ⭐ *Avoids BuildServiceProvider anti-pattern* -**Status**: ✅ **Implemented** -**Inspiration**: [StackOverflow: Avoid BuildServiceProvider](https://stackoverflow.com/questions/66263977/how-to-avoid-using-using-buildserviceprovider-method-at-multiple-places) - -> **📝 Implementation Note:** This feature is fully implemented with three APIs: -> 1. `Get[Type]From[Assembly]()` - Reads cache, doesn't populate (efficient, no side effects) -> 2. `GetOrAdd[Type]From[Assembly]()` - Reads AND populates cache (idempotent) -> 3. `GetOptions()` - Smart dispatcher for multi-assembly projects (calls Get internally) -> -> See [OptionsBindingGenerators.md](OptionsBindingGenerators.md#-early-access-to-options-avoid-buildserviceprovider-anti-pattern) for current usage. - -**Description**: Enable access to bound and validated options instances **during** service registration without calling `BuildServiceProvider()`, which is a known anti-pattern that causes memory leaks, scope issues, and application instability. - -**User Story**: -> "As a developer, I need to access configuration values (like connection strings, API keys, or feature flags) during service registration to conditionally register services or configure dependencies, WITHOUT calling `BuildServiceProvider()` multiple times." - -**The Anti-Pattern Problem**: - -```csharp -// ❌ ANTI-PATTERN - Multiple BuildServiceProvider calls -var services = new ServiceCollection(); -services.AddOptionsFromApp(configuration); - -// Need database connection string to configure DbContext -var tempProvider = services.BuildServiceProvider(); // ⚠️ First build -var dbOptions = tempProvider.GetRequiredService>().Value; - -services.AddDbContext(options => - options.UseSqlServer(dbOptions.ConnectionString)); - -// Need API key for HttpClient -var apiOptions = tempProvider.GetRequiredService>().Value; -services.AddHttpClient("External", client => - client.DefaultRequestHeaders.Add("X-API-Key", apiOptions.ApiKey)); - -// Finally build the real provider -var provider = services.BuildServiceProvider(); // ⚠️ Second build -var app = host.Build(); -``` - -**Why This is Bad**: - -1. **Memory Leaks**: First `ServiceProvider` is never disposed properly -2. **Incomplete Services**: Services registered after first build aren't in first provider -3. **Scope/Lifetime Issues**: Transient services may be captured as singletons -4. **Production Failures**: Subtle bugs that only manifest under load -5. **Microsoft's Warning**: Official docs explicitly warn against this pattern - -**Proposed Solution - Individual Accessor Methods**: - -Generate per-options extension methods that return bound instances for early access: - -```csharp -// ✅ SOLUTION - Early access WITHOUT BuildServiceProvider -var services = new ServiceCollection(); - -// Get options instances during registration (no BuildServiceProvider!) -var dbOptions = services.GetOrAddDatabaseOptions(configuration); -var apiOptions = services.GetOrAddApiOptions(configuration); - -// Use options immediately for service configuration -services.AddDbContext(options => - options.UseSqlServer(dbOptions.ConnectionString)); - -services.AddHttpClient("External", client => - client.DefaultRequestHeaders.Add("X-API-Key", apiOptions.ApiKey)); - -// Still call the bulk method for completeness (idempotent - won't duplicate) -services.AddOptionsFromApp(configuration); - -// Build provider ONCE at the end -var provider = services.BuildServiceProvider(); // ✅ Only one build! -``` - -**Generated Code Pattern**: - -For each options class, generate an individual accessor method: - -```csharp -/// -/// Gets or adds DatabaseOptions to the service collection with configuration binding. -/// If already registered, returns the existing instance. Otherwise, creates, binds, validates, and registers the instance. -/// This method is idempotent and safe to call multiple times. -/// -/// The service collection. -/// The configuration. -/// The bound and validated DatabaseOptions instance for immediate use during service registration. -public static DatabaseOptions GetOrAddDatabaseOptions( - this IServiceCollection services, - IConfiguration configuration) -{ - // Check if already registered (idempotent) - var existingDescriptor = services.FirstOrDefault(d => - d.ServiceType == typeof(DatabaseOptions) && - d.ImplementationInstance != null); - - if (existingDescriptor != null) - { - return (DatabaseOptions)existingDescriptor.ImplementationInstance!; - } - - // Create and bind instance - var options = new DatabaseOptions(); - var section = configuration.GetSection("DatabaseOptions"); - section.Bind(options); - - // Validate immediately (DataAnnotations) - var validationContext = new ValidationContext(options); - Validator.ValidateObject(options, validationContext, validateAllProperties: true); - - // Register instance directly (singleton) - services.AddSingleton(options); - - // Register IOptions wrapper - services.AddSingleton>( - new OptionsWrapper(options)); - - // Register for IOptionsSnapshot/IOptionsMonitor (reuses same instance) - services.AddSingleton>( - sp => new OptionsWrapper(options) as IOptionsSnapshot); - - services.AddSingleton>( - sp => new OptionsMonitorWrapper(options)); - - return options; -} -``` - -**Transitive Registration Support**: - -Just like the existing `AddOptionsFromApp()` supports transitive registration, early access must work across assemblies: - -```csharp -// Current transitive registration (4 overloads): -services.AddOptionsFromApp(configuration); // Default -services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true); // Auto-detect all -services.AddOptionsFromApp(configuration, "DataAccess"); // Specific assembly -services.AddOptionsFromApp(configuration, "DataAccess", "Infrastructure"); // Multiple assemblies - -// Early access must support the same pattern: -// Approach 1: Individual accessors from specific assemblies -var dbOptions = services.GetOrAddDatabaseOptionsFromDataAccess(configuration); // From DataAccess assembly -var apiOptions = services.GetOrAddApiOptionsFromApp(configuration); // From App assembly - -// Approach 2: Generic accessor (works after AddOptionsFrom* called) -services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true); -var dbOptions = services.GetOptionInstanceOf(); // From any registered assembly -var apiOptions = services.GetOptionInstanceOf(); -``` - -**Alternative API - Extension Method Returning from Internal Registry**: - -```csharp -// User's suggestion: Retrieve from internal cache after AddOptionsFromApp -services.AddOptionsFromApp(configuration); - -// Get instance from internal registry (no BuildServiceProvider) -var dbOptions = services.GetOptionInstanceOf(); -var apiOptions = services.GetOptionInstanceOf(); - -// Works with transitive registration too: -services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true); -var domainOptions = services.GetOptionInstanceOf(); // From Domain assembly -var dataAccessOptions = services.GetOptionInstanceOf(); // From DataAccess assembly -``` - -**Generated Internal Registry** (Shared Across Assemblies): - -```csharp -// Generated ONCE in the Atc.OptionsBinding namespace (shared across all assemblies) -namespace Atc.OptionsBinding -{ - using System.Collections.Concurrent; - - internal static class OptionsInstanceCache - { - private static readonly ConcurrentDictionary instances = new(); - - internal static void Add(T instance) where T : class - => instances[typeof(T)] = instance; - - internal static T? TryGet() where T : class - => instances.TryGetValue(typeof(T), out var instance) - ? (T)instance - : null; - } -} - -// Design Decision: Use shared static class with ConcurrentDictionary -// ✅ Works across referenced assemblies (DataAccess, Domain, Infrastructure) -// ✅ GetOptionInstanceOf() can retrieve options from ANY registered assembly -// ✅ Single source of truth - no duplicate instances -// ✅ Thread-safe via ConcurrentDictionary (lock-free reads, better performance) -// ✅ Cleaner code - no manual locking required - -// Generated extension method -public static T GetOptionInstanceOf(this IServiceCollection services) - where T : class -{ - var instance = AppOptionsInstanceCache.TryGet(); - if (instance == null) - { - throw new InvalidOperationException( - $"Options instance of type '{typeof(T).Name}' not found. " + - $"Ensure AddOptionsFromApp() was called before GetOptionInstanceOf()."); - } - - return instance; -} - -// Modified AddOptionsFromApp to populate shared cache (supports transitive registration) -public static IServiceCollection AddOptionsFromApp( - this IServiceCollection services, - IConfiguration configuration) -{ - // Create, bind, and validate DatabaseOptions - var dbOptions = new DatabaseOptions(); - configuration.GetSection("DatabaseOptions").Bind(dbOptions); - - var validationContext = new ValidationContext(dbOptions); - Validator.ValidateObject(dbOptions, validationContext, validateAllProperties: true); - - // Add to SHARED cache for early access (accessible via GetOptionInstanceOf) - OptionsInstanceCache.Add(dbOptions); - - // Register in DI - services.AddSingleton(dbOptions); - services.AddSingleton>( - new OptionsWrapper(dbOptions)); - - // Repeat for all options classes in this assembly... - - return services; -} - -// Transitive registration overloads also populate the shared cache: -public static IServiceCollection AddOptionsFromApp( - this IServiceCollection services, - IConfiguration configuration, - bool includeReferencedAssemblies) -{ - // Register current assembly options first - AddOptionsFromApp(services, configuration); - - if (includeReferencedAssemblies) - { - // Call generated methods from referenced assemblies - // Each one populates the shared OptionsInstanceCache - services.AddOptionsFromDomain(configuration); // Domain options added to cache - services.AddOptionsFromDataAccess(configuration); // DataAccess options added to cache - // ... auto-detect and call all referenced assembly methods - } - - return services; -} -``` - -**Implementation Recommendations**: - -**Approach 1: Individual Accessor Methods (Recommended)** - -✅ **Pros**: - -- No global state -- Idempotent (safe to call multiple times) -- Works before or after `AddOptionsFromApp` -- Granular control (only get what you need) -- Thread-safe via IServiceCollection -- Clear intent and discoverability - -❌ **Cons**: - -- Generates more code (one method per options class) -- Two ways to register (could be confusing initially) - -**Approach 2: Internal Registry + GetOptionInstanceOf (User's Suggestion)** - -✅ **Pros**: - -- Clean API matching user's request -- Works well with existing `AddOptionsFromApp` -- Single method for all options types - -❌ **Cons**: - -- Global static state (testing concerns) -- Order dependency (must call `AddOptionsFromApp` first) -- Less discoverable (generic method) - -**Approach 3: Hybrid (Best of Both)** - -Generate BOTH approaches: - -```csharp -// Approach 1: Individual accessors (primary) -var dbOptions = services.GetOrAddDatabaseOptions(configuration); - -// Approach 2: Generic accessor (convenience, requires AddOptionsFromApp called first) -services.AddOptionsFromApp(configuration); -var apiOptions = services.GetOptionInstanceOf(); -``` - -**Implementation Details**: - -**Key Considerations**: - -1. **Validation Strategy**: - - Eager validation during `GetOrAdd*` (throws immediately on invalid config) - - Validates ONCE when instance is created - - No startup validation for early-access instances (would require ServiceProvider) - -2. **Lifetime Compatibility**: - - Early-access instances are ALWAYS registered as Singletons - - `IOptions`, `IOptionsSnapshot`, `IOptionsMonitor` all resolve to same instance - - Scoped lifetime not supported for early-access (singleton registration only) - -3. **Named Options**: - - Named options NOT supported for early access (require specific names) - - Only unnamed/default options can use `GetOrAdd*` or `GetOptionInstanceOf` - -4. **OnChange Callbacks**: - - Early-access instances do NOT support `OnChange` callbacks - - Use `IOptionsMonitor.OnChange()` manually if needed after provider built - -5. **ErrorOnMissingKeys**: - - Fully supported - throws during `GetOrAdd*` if section missing - - Combines with DataAnnotations validation - -6. **Idempotency**: - - Multiple calls to `GetOrAddDatabaseOptions` return same instance - - Multiple calls to `AddOptionsFromApp` won't duplicate registrations - - Safe to mix and match approaches - -7. **Multi-Assembly Support**: - - Individual `GetOrAdd*` methods are generated per-assembly with smart naming - - `GetOrAddDatabaseOptionsFromDataAccess()` - from DataAccess assembly - - `GetOrAddDatabaseOptionsFromApp()` - from App assembly (if App also has DatabaseOptions) - - `GetOptionInstanceOf()` works across all registered assemblies - - Internal registry is shared across all assemblies (static class in generated namespace) - -**Diagnostics**: - -Potential new diagnostic codes: - -- **ATCOPT017**: Early access not supported with named options (Error) -- **ATCOPT018**: Early access requires Singleton lifetime (Warning) -- **ATCOPT019**: OnChange callbacks not supported with early access (Warning) - -**Real-World Use Cases**: - -**Use Case 1: Conditional Service Registration (Feature Flags)** - -```csharp -var features = services.GetOrAddFeaturesOptions(configuration); - -if (features.EnableRedisCache) -{ - var redis = services.GetOrAddRedisOptions(configuration); - services.AddStackExchangeRedisCache(options => - { - options.Configuration = redis.ConnectionString; - options.InstanceName = redis.InstanceName; - }); -} -else -{ - services.AddDistributedMemoryCache(); -} -``` - -**Use Case 2: DbContext Configuration** - -```csharp -var dbOptions = services.GetOrAddDatabaseOptions(configuration); - -services.AddDbContext(options => -{ - options.UseSqlServer(dbOptions.ConnectionString, - sqlOptions => - { - sqlOptions.EnableRetryOnFailure( - maxRetryCount: dbOptions.MaxRetries, - maxRetryDelay: TimeSpan.FromSeconds(dbOptions.RetryDelaySeconds), - errorNumbersToAdd: null); - }); -}); -``` - -**Use Case 3: HttpClient Configuration** - -```csharp -var apiOptions = services.GetOrAddExternalApiOptions(configuration); - -services.AddHttpClient("ExternalAPI", client => -{ - client.BaseAddress = new Uri(apiOptions.BaseUrl); - client.DefaultRequestHeaders.Add("X-API-Key", apiOptions.ApiKey); - client.Timeout = TimeSpan.FromSeconds(apiOptions.TimeoutSeconds); -}) -.AddPolicyHandler(Policy.TimeoutAsync( - TimeSpan.FromSeconds(apiOptions.TimeoutSeconds))); -``` - -**Use Case 4: Multi-Tenant Routing** - -```csharp -var tenants = services.GetOrAddTenantOptions(configuration); - -foreach (var tenant in tenants.EnabledTenants) -{ - services.AddScoped(sp => - new TenantContext(tenant.TenantId, tenant.DatabaseName)); -} -``` - -**Testing Strategy**: - -```csharp -[Fact] -public void GetOrAddDatabaseOptions_Should_Return_Same_Instance_When_Called_Multiple_Times() -{ - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["DatabaseOptions:ConnectionString"] = "Server=test;Database=test" - }) - .Build(); - - // Act - var instance1 = services.GetOrAddDatabaseOptions(configuration); - var instance2 = services.GetOrAddDatabaseOptions(configuration); - - // Assert - Assert.Same(instance1, instance2); // Idempotent - same instance - Assert.Single(services.Where(d => d.ServiceType == typeof(DatabaseOptions))); // Only one registration -} - -[Fact] -public void GetOrAddDatabaseOptions_Should_Throw_When_Validation_Fails() -{ - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["DatabaseOptions:ConnectionString"] = "" // Empty - violates [Required] - }) - .Build(); - - // Act & Assert - var exception = Assert.Throws(() => - services.GetOrAddDatabaseOptions(configuration)); - - Assert.Contains("ConnectionString", exception.Message); -} - -[Fact] -public void GetOptionInstanceOf_Should_Throw_When_AddOptionsFromApp_Not_Called() -{ - // Arrange - var services = new ServiceCollection(); - - // Act & Assert - var exception = Assert.Throws(() => - services.GetOptionInstanceOf()); - - Assert.Contains("AddOptionsFromApp", exception.Message); -} -``` - -**Sample Project - Program.cs** (Multi-Project Scenario): - -```csharp -var builder = WebApplication.CreateBuilder(args); -var services = builder.Services; -var configuration = builder.Configuration; - -// ✅ Early access pattern - get options during registration from multiple assemblies -// Approach 1: Individual accessors (type-safe, assembly-specific) -var dbOptions = services.GetOrAddDatabaseOptionsFromDataAccess(configuration); // From DataAccess assembly -var features = services.GetOrAddFeaturesOptionsFromDomain(configuration); // From Domain assembly -var apiOptions = services.GetOrAddExternalApiOptionsFromApp(configuration); // From App assembly - -// Use options immediately for conditional registration -if (features.EnableDatabase) -{ - services.AddDbContext(options => - options.UseSqlServer(dbOptions.ConnectionString, - sqlOptions => sqlOptions.EnableRetryOnFailure( - maxRetryCount: dbOptions.MaxRetries, - maxRetryDelay: TimeSpan.FromSeconds(dbOptions.RetryDelaySeconds), - errorNumbersToAdd: null))); -} - -if (features.EnableRedisCache) -{ - var cacheOptions = services.GetOrAddCacheOptionsFromInfrastructure(configuration); - services.AddStackExchangeRedisCache(options => - { - options.Configuration = cacheOptions.ConnectionString; - options.InstanceName = cacheOptions.InstanceName; - }); -} -else -{ - services.AddDistributedMemoryCache(); -} - -// Configure HTTP clients -services.AddHttpClient("ExternalAPI", client => -{ - client.BaseAddress = new Uri(apiOptions.BaseUrl); - client.DefaultRequestHeaders.Add("X-API-Key", apiOptions.ApiKey); - client.Timeout = TimeSpan.FromSeconds(apiOptions.TimeoutSeconds); -}); - -// Register remaining options normally with transitive registration (idempotent - won't duplicate) -// This also makes options available via IOptions injection -services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true); - -// Alternative Approach 2: Generic accessor (after AddOptionsFrom* called) -// var petStoreOptions = services.GetOptionInstanceOf(); // From any registered assembly - -var app = builder.Build(); -app.Run(); -``` - -**Sample Project - 3-Layer Architecture**: - -``` -PetStore.Api (Program.cs) -├── GetOrAddExternalApiOptionsFromApp() → Use immediately for HttpClient config -├── GetOrAddFeaturesOptionsFromDomain() → Use for conditional service registration -└── GetOrAddDatabaseOptionsFromDataAccess() → Use for DbContext configuration - -PetStore.Domain -├── FeaturesOptions [OptionsBinding] -└── PetStoreOptions [OptionsBinding] - -PetStore.DataAccess -└── DatabaseOptions [OptionsBinding] -``` - -**Best Practices**: - -1. **Use for Conditional Registration**: Only use early access when you need options DURING registration -2. **Validate Eagerly**: Early access validates immediately - ensure appsettings.json is correct before deployment -3. **Single Provider Build**: Only call `BuildServiceProvider()` ONCE at the end -4. **Combine with AddOptionsFromApp**: Call both for completeness (idempotent) -5. **Avoid for Scoped Options**: Early access uses Singleton lifetime - not suitable for per-request configuration - -**Benefits**: - -✅ **Eliminates Anti-Pattern**: No more multiple `BuildServiceProvider()` calls -✅ **Production-Safe**: Prevents memory leaks and scope issues -✅ **Type-Safe**: Full compile-time validation and IntelliSense -✅ **Fail-Fast**: Validation errors caught immediately during registration -✅ **Flexible**: Use individual accessors OR generic registry method -✅ **Idempotent**: Safe to call multiple times -✅ **Zero Runtime Cost**: All code generated at compile time - ---- - -### 11. Auto-Generate Options Classes from appsettings.json - -**Priority**: 🟢 **Low** -**Status**: ❌ Not Implemented - -**Description**: Reverse the process - analyze appsettings.json and generate strongly-typed options classes. - -**Example**: - -```json -{ - "Database": { - "ConnectionString": "...", - "MaxRetries": 5 - } -} -``` - -Generates: - -```csharp -// Auto-generated -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; - public int MaxRetries { get; set; } -} -``` - -**Considerations**: - -- Requires JSON schema inference -- Type ambiguity (is "5" an int or string?) -- May conflict with user-defined classes -- Interesting but low priority - ---- - -### 12. Environment-Specific Validation - -**Priority**: 🟢 **Low** -**Status**: ❌ Not Implemented - -**Description**: Apply different validation rules based on environment (e.g., stricter in production). - -**Example**: - -```csharp -[OptionsBinding("Features", ValidateOnStart = true)] -public partial class FeaturesOptions -{ - public bool EnableDebugMode { get; set; } - - // Only validate in production - [RequiredInProduction] - public string LicenseKey { get; set; } = string.Empty; -} -``` - ---- - -### 13. Hot Reload Support with Filtering - -**Priority**: 🟢 **Low** -**Status**: ❌ Not Implemented - -**Description**: Fine-grained control over which configuration changes trigger reloads. - -**Example**: - -```csharp -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, ReloadOn = new[] { "EnableNewUI", "MaxUploadSizeMB" })] -public partial class FeaturesOptions -{ - public bool EnableNewUI { get; set; } // Triggers reload - public int MaxUploadSizeMB { get; set; } // Triggers reload - public string UITheme { get; set; } = "Light"; // Doesn't trigger reload -} -``` - ---- - -## ⛔ Do Not Need (Low Priority / Out of Scope) - -### 14. Reflection-Based Binding - -**Reason**: Defeats the purpose of compile-time source generation and breaks AOT compatibility. - -**Status**: ❌ Out of Scope - ---- - -### 15. JSON Schema Generation - -**Reason**: Out of scope for options binding. Use dedicated tools like NJsonSchema. - -**Status**: ❌ Not Planned - ---- - -### 16. Configuration Encryption/Decryption - -**Reason**: Security concern handled by configuration providers (Azure Key Vault, AWS Secrets Manager, etc.), not binding layer. - -**Status**: ❌ Out of Scope - ---- - -### 17. Dynamic Configuration Sources - -**Reason**: Configuration providers handle this. Options binding focuses on type-safe access. - -**Status**: ❌ Out of Scope - ---- - -## 📅 Proposed Implementation Order - -Based on priority, user demand, and implementation complexity: - -### Phase 1: Validation & Error Handling (v1.1 - Q1 2025) ✅ COMPLETED - -**Goal**: Fail-fast and better validation - -1. **Error on Missing Configuration Keys** 🔴 High ⭐ - Startup failures instead of silent nulls ✅ -2. **Custom Validation Support (IValidateOptions)** 🔴 High - Complex validation beyond DataAnnotations ✅ -3. **Post-Configuration Support** 🟡 Medium-High - Normalization and defaults ✅ - -**Estimated effort**: 3-4 weeks -**Impact**: Prevent production misconfigurations, better developer experience -**Status**: ✅ All features implemented and shipped - ---- - -### Phase 2: Advanced Scenarios (v1.2 - Q2 2025) ✅ COMPLETED - -**Goal**: Multi-tenant and dynamic configuration - -4. **Named Options Support** 🔴 High - Multiple configurations for same type ✅ -5. **Configuration Change Callbacks** 🟡 Medium - React to runtime changes ✅ -6. **ConfigureAll Support** 🟢 Low-Medium - Set defaults across named instances ✅ - -**Estimated effort**: 4-5 weeks -**Impact**: Multi-tenant scenarios, feature flags, runtime configuration -**Status**: ✅ All features implemented and shipped - ---- - -### Phase 3: Developer Experience (v1.3 - Q3 2025) ✅ COMPLETED - -**Goal**: Better diagnostics and usability - -7. **Bind Configuration Subsections** 🟡 Medium - Nested object binding (already worked out-of-the-box) ✅ -8. **Child Sections** 🟢 Low-Medium - Simplified named options syntax ✅ -9. **Compile-Time Section Name Validation** 🟡 Medium - Validate section paths exist ❌ Deferred - -**Estimated effort**: 3-4 weeks -**Impact**: Catch configuration errors earlier, better IDE support -**Status**: ✅ Core features implemented - ---- - -### Phase 4: Service Registration Integration (v1.4 - 2025) - -**Goal**: Eliminate BuildServiceProvider anti-pattern - -10. **Early Access to Options During Service Registration** 🔴 High ⭐ - Access options during registration without BuildServiceProvider - - Generate `GetOrAddDatabaseOptions()` individual accessor methods - - Generate `GetOptionInstanceOf()` generic accessor method - - Internal registry for option instances - - Idempotent registration - - Eager validation support - -**Estimated effort**: 3-4 weeks -**Impact**: - -- Prevents memory leaks and scope issues from multiple BuildServiceProvider calls -- Enables conditional service registration based on configuration -- Production-safe pattern for DbContext, HttpClient, and feature flag configuration -- Critical for real-world ASP.NET Core applications - -**Priority Justification**: This is a HIGH priority feature because: - -- ⚠️ Multiple BuildServiceProvider calls is a **documented anti-pattern** by Microsoft -- 🐛 Causes production bugs (memory leaks, lifetime issues) -- ⭐ Frequently requested in StackOverflow (66k+ views on related questions) -- 🔥 Blocking issue for developers needing configuration-driven service registration - ---- - -### Phase 5: Optional Enhancements (v2.0+ - 2025-2026) - -**Goal**: Nice-to-have features based on feedback - -11. **Auto-Generate Options from JSON** 🟢 Low - Reverse generation (experimental) -12. **Environment-Specific Validation** 🟢 Low - Production vs. development validation -13. **Hot Reload with Filtering** 🟢 Low - Fine-grained reload control -14. **Compile-Time Section Name Validation** 🟡 Medium - Validate section paths exist (deferred from Phase 3) - -**Estimated effort**: Variable -**Impact**: Polish and edge cases - ---- - -### Feature Prioritization Matrix - -| Feature | Priority | User Demand | Complexity | Phase | Status | -|---------|----------|-------------|------------|-------|--------| -| Error on Missing Keys | 🔴 High | ⭐⭐⭐ | Medium | 1.1 | ✅ | -| Custom Validation (IValidateOptions) | 🔴 High | ⭐⭐⭐ | Medium | 1.1 | ✅ | -| Post-Configuration | 🟡 Med-High | ⭐⭐ | Low | 1.1 | ✅ | -| Named Options | 🔴 High | ⭐⭐⭐ | Medium | 1.2 | ✅ | -| Change Callbacks | 🟡 Medium | ⭐⭐ | Medium | 1.2 | ✅ | -| ConfigureAll | 🟢 Low-Med | ⭐ | Low | 1.2 | ✅ | -| Nested Object Binding | 🟡 Medium | ⭐⭐ | Low | 1.3 | ✅ | -| Child Sections | 🟢 Low-Med | ⭐⭐ | Low | 1.3 | ✅ | -| **Early Access to Options** | 🔴 **High** | ⭐⭐⭐ | **Medium-High** | **1.4** | ✅ | -| Section Path Validation | 🟡 Medium | ⭐⭐ | High | 2.0+ | ❌ | -| Environment Validation | 🟢 Low | ⭐ | Medium | 2.0+ | ❌ | -| Hot Reload Filtering | 🟢 Low | ⭐ | Medium | 2.0+ | ❌ | -| Auto-Generate from JSON | 🟢 Low | ⭐ | High | 2.0+ | ❌ | - ---- - -## 🎯 Success Metrics - -1. **Startup Failure Rate** - Measure configuration errors caught at startup vs. runtime -2. **GitHub Issues** - Track configuration-related bug reports -3. **Validation Coverage** - % of options classes using validation -4. **Adoption Metrics** - NuGet downloads, multi-tenant usage -5. **Community Feedback** - Developer satisfaction with error messages - ---- - -## 📝 Notes - -### Design Philosophy - -- **Guiding Principle**: **Type-safe**, **fail-fast**, **AOT-compatible** configuration -- **Trade-offs**: We prioritize compile-time safety over runtime flexibility -- **Microsoft.Extensions.Options Alignment**: We follow Microsoft's patterns but generate the boilerplate -- **Configuration Binder Inspiration**: Like .NET 8's source generator, we replace reflection with compile-time code - -### Key Differences from Standard Options Pattern - -**What we do differently**: - -1. **Attribute-based** - `[OptionsBinding]` instead of manual `Configure()` calls -2. **Smart section name resolution** - 5-level priority system vs. manual specification -3. **Transitive registration** - Auto-discover options from referenced assemblies -4. **Assembly-specific methods** - `AddOptionsFromDomain()` vs. manual registration - -**What we learn from Microsoft.Extensions.Options**: - -- ⭐ **Validation** is critical - DataAnnotations, custom, and startup validation -- ⭐ **Lifetime management** matters - IOptions vs. IOptionsSnapshot vs. IOptionsMonitor -- ⭐ **Named options** enable multi-tenant scenarios -- ⭐ **Silent failures** are the #1 pain point (missing config → null → production crashes) - -### Lessons from GitHub Issues - -**From dotnet/runtime issues**: - -- **Error on missing keys** is one of the most requested features (Issue #36015) -- **Silent binding failures** cause production incidents -- **Change detection** limitations frustrate developers (file-system only) -- **Type converter limitations** in source generator approach - -### Updated Priorities Based on Community Insights - -**Originally "Nice to Have" → Elevated**: - -- ✅ **Error on Missing Keys** - Moved to "Need to Have" due to production incident prevention -- ✅ **Custom Validation (IValidateOptions)** - Essential for complex business rules - -**Recognized as Critical**: - -- 🔴 **Named Options** - Multi-tenant scenarios are common -- 🔴 **Post-Configuration** - Normalization and defaults are frequently needed - ---- - -## 🔗 Related Resources - -- **Microsoft.Extensions.Options**: -- **Configuration Binder Source Generator**: -- **GitHub Issue #36015**: (Error on missing keys) -- **Our Documentation**: See `/docs/generators/OptionsBinding.md` -- **Sample Projects**: See `/sample/PetStore.Api` for complete example - ---- - -**Last Updated**: 2025-01-20 -**Version**: 1.2 -**Research Date**: January 2025 (.NET 8/9 Options Pattern + Service Registration Anti-Patterns) -**Maintained By**: Atc.SourceGenerators Team diff --git a/docs/OptionsBindingGenerators-Samples.md b/docs/OptionsBindingGenerators-Samples.md deleted file mode 100644 index 503057c..0000000 --- a/docs/OptionsBindingGenerators-Samples.md +++ /dev/null @@ -1,1447 +0,0 @@ -# Options Binding Sample - -## 🎯 Focus - -This sample demonstrates the **OptionsBindingGenerator** in a multi-project console application with configuration management. The focus is on: - -- **Zero boilerplate configuration binding** across multiple projects -- **Automatic section name inference** from class names -- **Type-safe configuration** with validation -- **Multiple configuration sources** (appsettings.json, environment variables) -- **Options lifetime management** (IOptions, IOptionsSnapshot, IOptionsMonitor) -- **Validation at startup** with Data Annotations -- **Custom validation** using IValidateOptions for complex business rules -- **Configuration change callbacks** - Automatic OnChange notifications with Monitor lifetime -- **Child sections** - Simplified syntax for multiple named configurations (Email → Primary/Secondary/Fallback) -- **Nested subsection binding** - Automatic binding of complex properties to configuration subsections -- **Early access to options** - Retrieve options during service registration without BuildServiceProvider() anti-pattern - -## 📁 Sample Projects - -- **Atc.SourceGenerators.OptionsBinding** - Console application (entry point) -- **Atc.SourceGenerators.OptionsBinding.Domain** - Domain layer with options classes - -## 🏗️ Architecture - -```mermaid -graph TB - subgraph "Configuration Sources" - JSON[appsettings.json] - ENV[Environment Variables] - CMD[Command Line Args] - end - - subgraph "Configuration Pipeline" - CB[ConfigurationBuilder] - CONF[IConfiguration] - end - - subgraph "Options Classes" - DO[DatabaseOptions] - CO[CacheOptions] - AO[ApiOptions] - LO[LoggingOptions] - end - - subgraph "Generated Extension Methods" - EXT1["AddOptionsFromAtcSourceGeneratorsOptionsBinding()"] - EXT2["AddOptionsFromAtcSourceGeneratorsOptionsBindingDomain()"] - end - - subgraph "Dependency Injection" - SC[ServiceCollection] - SP[ServiceProvider] - end - - subgraph "Consumption" - SVC[Services] - IOPT["IOptions<T>"] - ISNAP["IOptionsSnapshot<T>"] - IMON["IOptionsMonitor<T>"] - end - - JSON --> CB - ENV --> CB - CMD --> CB - CB --> CONF - - CONF --> EXT1 - CONF --> EXT2 - - EXT1 --> SC - EXT2 --> SC - - SC --> SP - - SP --> IOPT - SP --> ISNAP - SP --> IMON - - IOPT --> SVC - ISNAP --> SVC - IMON --> SVC - - DO -.->|bound to| IOPT - CO -.->|bound to| ISNAP - AO -.->|bound to| IMON - LO -.->|bound to| IOPT - - style EXT1 fill:#2ea44f - style EXT2 fill:#2ea44f - style DO fill:#0969da - style CO fill:#0969da - style AO fill:#0969da - style LO fill:#0969da -``` - -## 🔄 Binding Flow - -```mermaid -sequenceDiagram - participant App as Program.cs - participant Gen as Source Generator - participant Config as IConfiguration - participant DI as ServiceCollection - participant Svc as MyService - - Note over Gen: Build Time - Gen->>Gen: Scan for [OptionsBinding] attributes - Gen->>Gen: Resolve section names (priority) - Gen->>Gen: Generate AddOptionsFromXXX() - - Note over App,Svc: Runtime - App->>Config: Load appsettings.json - App->>DI: AddOptionsFromDomain(configuration) - - DI->>Config: GetSection("Database") - Config-->>DI: IConfigurationSection - DI->>DI: Bind to DatabaseOptions - DI->>DI: ValidateDataAnnotations() - DI->>DI: ValidateOnStart() - - DI->>Config: GetSection("ApplicationCache") - Config-->>DI: IConfigurationSection - DI->>DI: Bind to CacheOptions - - App->>DI: BuildServiceProvider() - DI-->>App: ServiceProvider - - App->>Svc: Resolve MyService - Note over Svc: Constructor injection - Svc->>DI: IOptions - DI-->>Svc: DatabaseOptions instance -``` - -## 💻 Code Example - -### Configuration File (appsettings.json) - -```json -{ - "Database": { - "ConnectionString": "Server=localhost;Database=MyApp;", - "MaxRetries": 3, - "CommandTimeout": 30 - }, - "ApplicationCache": { - "MaxSize": 1000, - "ExpirationMinutes": 60, - "SlidingExpiration": true - }, - "App": { - "Api": { - "BaseUrl": "https://api.example.com", - "Timeout": 30, - "MaxRetries": 3 - } - }, - "Logging": { - "Level": "Information", - "IncludeScopes": true - } -} -``` - -### Domain Layer Options - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace Atc.SourceGenerators.OptionsBinding.Domain; - -// Explicit section name (highest priority) -// Demonstrates fail-fast validation when configuration section is missing -[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true, ErrorOnMissingKeys = true)] -public partial class DatabaseOptions -{ - [Required] - [MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; - - [Range(1, 300)] - public int CommandTimeout { get; set; } = 30; -} - -// Using const SectionName (2nd priority) -[OptionsBinding(ValidateDataAnnotations = true)] -public partial class CacheOptions -{ - public const string SectionName = "ApplicationCache"; - - [Range(100, 10000)] - public int MaxSize { get; set; } = 1000; - - [Range(1, 1440)] - public int ExpirationMinutes { get; set; } = 60; - - public bool SlidingExpiration { get; set; } -} - -// Nested section path -[OptionsBinding("App:Api")] -public partial class ApiOptions -{ - [Required] - [Url] - public string BaseUrl { get; set; } = string.Empty; - - [Range(1, 300)] - public int Timeout { get; set; } = 30; - - public int MaxRetries { get; set; } = 3; -} - -// Auto-inferred section name from class name (lowest priority) -// Binds to "Logging" section -// Demonstrates configuration change callbacks with Monitor lifetime -[OptionsBinding("Logging", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnLoggingChanged))] -public partial class LoggingOptions -{ - public string Level { get; set; } = "Information"; - public bool EnableConsole { get; set; } = true; - public bool EnableFile { get; set; } - public string? FilePath { get; set; } - - /// - /// Called automatically when the Logging configuration section changes. - /// Requires appsettings.json to have reloadOnChange: true. - /// - internal static void OnLoggingChanged( - LoggingOptions options, - string? name) - { - Console.WriteLine($"[OnChange Callback] Logging configuration changed:"); - Console.WriteLine($" Level: {options.Level}"); - Console.WriteLine($" EnableConsole: {options.EnableConsole}"); - Console.WriteLine($" EnableFile: {options.EnableFile}"); - Console.WriteLine($" FilePath: {options.FilePath ?? \"(not set)\"}"); - Console.WriteLine(); - } -} -``` - -### Application Setup - -**Option 1 - Manual Registration:** - -```csharp -using Atc.DependencyInjection; -using Atc.SourceGenerators.Annotations; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -// Build configuration -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false) - .AddEnvironmentVariables() - .Build(); - -var services = new ServiceCollection(); - -// One line per project - that's it! -services.AddOptionsFromAtcSourceGeneratorsOptionsBinding(configuration); -services.AddOptionsFromAtcSourceGeneratorsOptionsBindingDomain(configuration); - -// Register your services -services.AddSingleton(); -services.AddScoped(); - -var provider = services.BuildServiceProvider(); - -// Options are validated at startup due to ValidateOnStart = true -// If validation fails, this will throw -``` - -**Option 2 - Transitive Registration (Recommended):** - -```csharp -using Atc.DependencyInjection; -using Atc.SourceGenerators.Annotations; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -// Build configuration -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false) - .AddEnvironmentVariables() - .Build(); - -var services = new ServiceCollection(); - -// Single call automatically registers options from both projects! -services.AddOptionsFromAtcSourceGeneratorsOptionsBinding(configuration, includeReferencedAssemblies: true); - -// Register your services -services.AddSingleton(); -services.AddScoped(); - -var provider = services.BuildServiceProvider(); - -// Options are validated at startup due to ValidateOnStart = true -// If validation fails, this will throw -``` - -**All Available Overloads:** - -```csharp -// Overload 1: Default (no transitive registration) -services.AddOptionsFromYourProject(configuration); - -// Overload 2: Auto-detect ALL referenced assemblies recursively -services.AddOptionsFromYourProject(configuration, includeReferencedAssemblies: true); - -// Overload 3: Register specific referenced assembly (short or full name) -services.AddOptionsFromYourProject(configuration, "Domain"); -services.AddOptionsFromYourProject(configuration, "MyApp.Domain"); - -// Overload 4: Register multiple specific assemblies -services.AddOptionsFromYourProject(configuration, "Domain", "DataAccess", "Infrastructure"); -``` - -### Service Consumption - -```csharp -using Microsoft.Extensions.Options; - -// Singleton service with IOptions (best for singleton lifetime) -public class MyDatabaseService -{ - private readonly DatabaseOptions _options; - - public MyDatabaseService(IOptions options) - { - _options = options.Value; - Console.WriteLine($"DB: {_options.ConnectionString}"); - } -} - -// Scoped service with IOptionsSnapshot (reloads per request) -public class MyCacheService -{ - private readonly CacheOptions _options; - - public MyCacheService(IOptionsSnapshot options) - { - _options = options.Value; - Console.WriteLine($"Cache max size: {_options.MaxSize}"); - } -} - -// Service with IOptionsMonitor (live reload support) -public class MyLoggingService -{ - private readonly IOptionsMonitor _monitor; - - public MyLoggingService(IOptionsMonitor monitor) - { - _monitor = monitor; - - // React to configuration changes - _monitor.OnChange(options => - { - Console.WriteLine($"Logging level changed to: {options.Level}"); - }); - } - - public void Log(string message) - { - var currentOptions = _monitor.CurrentValue; - Console.WriteLine($"[{currentOptions.Level}] {message}"); - } -} -``` - -## 📝 Generated Code - -The generator creates this extension method in each project: - -```csharp -// -namespace Atc.SourceGenerators.Annotations; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddOptionsFromAtcSourceGeneratorsOptionsBindingDomain( - this IServiceCollection services, - IConfiguration configuration) - { - // DatabaseOptions - explicit section name - services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); - - // CacheOptions - const SectionName - services.AddOptions() - .Bind(configuration.GetSection("ApplicationCache")) - .ValidateDataAnnotations(); - - // ApiOptions - nested section path - services.AddOptions() - .Bind(configuration.GetSection("App:Api")); - - // LoggingOptions - auto-inferred name + Monitor lifetime - services.Configure( - configuration.GetSection("Logging")); - services.AddSingleton>( - new ConfigurationChangeTokenSource( - configuration.GetSection("Logging"))); - - return services; - } -} -``` - -## ✨ Section Name Resolution Priority - -The generator resolves section names in this order: - -1. **Explicit constructor parameter**: `[OptionsBinding("Database")]` → `"Database"` -2. **`const string SectionName`**: Binds to the value of this constant -3. **`const string NameTitle`**: Fallback if SectionName doesn't exist -4. **`const string Name`**: Fallback if NameTitle doesn't exist -5. **Auto-inferred from class name**: `LoggingOptions` → `"Logging"` (removes "Options" suffix) - -### Example - -```csharp -[OptionsBinding] // Explicit parameter wins if provided -public partial class MyOptions -{ - public const string SectionName = "Section1"; // 2nd priority - USED - public const string NameTitle = "Section2"; // 3rd priority - public const string Name = "Section3"; // 4th priority - // Auto-inferred would be "My" (5th priority) -} -// Binds to "Section1" -``` - -## 🎯 Options Lifetime Comparison - -| Lifetime | Interface | Use Case | Reload Behavior | -|----------|-----------|----------|----------------| -| **Singleton** | `IOptions` | Static configuration that never changes | No reload | -| **Scoped** | `IOptionsSnapshot` | Per-request configuration (web apps) | Reloads per scope | -| **Monitor** | `IOptionsMonitor` | Real-time configuration changes | Live reload with notifications | - -### When to Use Each - -```csharp -// Singleton - For services that don't need config updates -[OptionsBinding(Lifetime = OptionsLifetime.Singleton)] -public partial class DatabaseOptions { } -// Inject: IOptions - -// Scoped - For web apps that need fresh config per request -[OptionsBinding(Lifetime = OptionsLifetime.Scoped)] -public partial class FeatureFlags { } -// Inject: IOptionsSnapshot - -// Monitor - For services that need real-time updates -[OptionsBinding(Lifetime = OptionsLifetime.Monitor)] -public partial class LoggingOptions { } -// Inject: IOptionsMonitor -``` - -## 🔔 Configuration Change Callbacks - -The **OptionsBindingGenerator** can automatically generate IHostedService classes to register callbacks that fire when configuration changes are detected. This is perfect for reacting to runtime configuration updates without restarting your application. - -### Requirements - -1. **Monitor Lifetime**: `Lifetime = OptionsLifetime.Monitor` (required) -2. **OnChange Parameter**: Specify the callback method name via `OnChange = nameof(YourCallback)` -3. **Callback Signature**: Static method with signature `void MethodName(TOptions options, string? name)` -4. **Configuration Reload**: Ensure `appsettings.json` is loaded with `reloadOnChange: true` - -### Example 1: Logging Configuration Changes - -**LoggingOptions.cs** (from `Atc.SourceGenerators.OptionsBinding` sample): - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace Atc.SourceGenerators.OptionsBinding.Options; - -/// -/// Logging configuration options. -/// Explicitly binds to "Logging" section in appsettings.json. -/// Demonstrates configuration change callbacks with Monitor lifetime. -/// -[OptionsBinding("Logging", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnLoggingChanged))] -public partial class LoggingOptions -{ - public string Level { get; set; } = "Information"; - - public bool EnableConsole { get; set; } = true; - - public bool EnableFile { get; set; } - - public string? FilePath { get; set; } - - /// - /// Called automatically when the Logging configuration section changes. - /// Requires appsettings.json to have reloadOnChange: true. - /// - internal static void OnLoggingChanged( - LoggingOptions options, - string? name) - { - Console.WriteLine($"[OnChange Callback] Logging configuration changed:"); - Console.WriteLine($" Level: {options.Level}"); - Console.WriteLine($" EnableConsole: {options.EnableConsole}"); - Console.WriteLine($" EnableFile: {options.EnableFile}"); - Console.WriteLine($" FilePath: {options.FilePath ?? \"(not set)\"}"); - Console.WriteLine(); - } -} -``` - -### Example 2: Feature Flag Changes - -**FeaturesOptions.cs** (from `PetStore.Domain` sample): - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace PetStore.Domain.Options; - -/// -/// Feature toggle configuration options. -/// Demonstrates configuration change callbacks with Monitor lifetime. -/// Changes to feature flags in appsettings.json are detected automatically. -/// -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))] -public partial class FeaturesOptions -{ - /// - /// Gets or sets a value indicating whether the new UI is enabled. - /// - public bool EnableNewUI { get; set; } - - /// - /// Gets or sets a value indicating whether advanced search is enabled. - /// - public bool EnableAdvancedSearch { get; set; } = true; - - /// - /// Gets or sets a value indicating whether pet recommendations are enabled. - /// - public bool EnableRecommendations { get; set; } = true; - - /// - /// Gets or sets a value indicating whether beta features are enabled. - /// - public bool EnableBetaFeatures { get; set; } - - /// - /// Called automatically when the Features configuration section changes. - /// Requires appsettings.json to have reloadOnChange: true. - /// - internal static void OnFeaturesChanged( - FeaturesOptions options, - string? name) - { - Console.WriteLine("[OnChange Callback] Feature flags changed:"); - Console.WriteLine($" EnableNewUI: {options.EnableNewUI}"); - Console.WriteLine($" EnableAdvancedSearch: {options.EnableAdvancedSearch}"); - Console.WriteLine($" EnableRecommendations: {options.EnableRecommendations}"); - Console.WriteLine($" EnableBetaFeatures: {options.EnableBetaFeatures}"); - Console.WriteLine(); - } -} -``` - -### Generated Code - -The generator automatically creates an internal `IHostedService` class to register the callback: - -```csharp -// -namespace PetStore.Domain.Options; - -[global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.OptionsBindingGenerator", "1.0.0")] -[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -[global::System.Runtime.CompilerServices.CompilerGenerated] -[global::System.Diagnostics.DebuggerNonUserCode] -[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -internal sealed class FeaturesOptionsMonitorService : Microsoft.Extensions.Hosting.IHostedService, global::System.IDisposable -{ - private readonly Microsoft.Extensions.Options.IOptionsMonitor _monitor; - private global::System.IDisposable? _changeToken; - - public FeaturesOptionsMonitorService(Microsoft.Extensions.Options.IOptionsMonitor monitor) - { - _monitor = monitor; - } - - public global::System.Threading.Tasks.Task StartAsync(global::System.Threading.CancellationToken cancellationToken) - { - _changeToken = _monitor.OnChange(FeaturesOptions.OnFeaturesChanged); - return global::System.Threading.Tasks.Task.CompletedTask; - } - - public global::System.Threading.Tasks.Task StopAsync(global::System.Threading.CancellationToken cancellationToken) - { - return global::System.Threading.Tasks.Task.CompletedTask; - } - - public void Dispose() - { - _changeToken?.Dispose(); - } -} -``` - -The registration extension method also includes: - -```csharp -// Register the IHostedService -services.AddHostedService(); - -// Register IOptionsMonitor -services.AddSingleton>( - new ConfigurationChangeTokenSource( - configuration.GetSection("Features"))); -services.Configure(configuration.GetSection("Features")); -``` - -### Configuration Setup - -To enable configuration reload, ensure your `ConfigurationBuilder` uses `reloadOnChange: true`: - -```csharp -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) // ← Enable reload - .Build(); -``` - -**appsettings.json:** - -```json -{ - "Features": { - "EnableNewUI": false, - "EnableAdvancedSearch": true, - "EnableRecommendations": true, - "EnableBetaFeatures": false - }, - "Logging": { - "Level": "Information", - "EnableConsole": true, - "EnableFile": false, - "FilePath": null - } -} -``` - -### How It Works - -1. **Build Time**: Generator detects `OnChange` parameter and validates callback signature -2. **Application Startup**: IHostedService is registered and started -3. **Runtime**: When `appsettings.json` changes on disk, the file watcher triggers a reload -4. **Callback Execution**: Your callback method is automatically invoked with the new values - -### Use Cases - -- **Feature Flags**: Toggle features on/off without restarting -- **Logging Levels**: Adjust log verbosity in production -- **Rate Limits**: Update throttling thresholds dynamically -- **API Endpoints**: Switch between failover endpoints -- **Cache Sizes**: Adjust memory limits based on load - -### Validation Diagnostics - -The generator enforces these rules at compile time: - -| Diagnostic | Severity | Description | -|------------|----------|-------------| -| **ATCOPT004** | Error | OnChange requires `Lifetime = OptionsLifetime.Monitor` | -| **ATCOPT005** | Error | OnChange is not supported with named options | -| **ATCOPT006** | Error | Callback method not found in options class | -| **ATCOPT007** | Error | Callback must be static void with signature `(TOptions, string?)` | - -### Important Notes - -- **Thread Safety**: Callbacks may fire on background threads - ensure thread-safe access to shared state -- **Performance**: Keep callbacks lightweight - they block configuration updates -- **Disposal**: The generated IHostedService properly disposes change tokens on shutdown -- **Named Options**: OnChange callbacks are NOT supported with named options (use `IOptionsMonitor.OnChange()` manually instead) - -## 📛 Named Options Support - -**Named Options** allow multiple configurations of the same options type with different names - perfect for fallback scenarios, multi-tenant applications, or multi-region deployments. You can also use **ConfigureAll** to set common defaults for all named instances. - -### 🎯 Example: Email Server Fallback with ConfigureAll - -**Options Class:** - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace Atc.SourceGenerators.OptionsBinding.Options; - -[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; - public string FromAddress { get; set; } = string.Empty; - public int TimeoutSeconds { get; set; } = 30; - public int MaxRetries { get; set; } - - internal static void SetDefaults(EmailOptions options) - { - // Set common defaults for ALL email configurations - options.UseSsl = true; - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.Port = 587; - } -} -``` - -**Configuration (appsettings.json):** - -```json -{ - "Email": { - "Primary": { - "SmtpServer": "smtp.primary.example.com", - "Port": 587, - "UseSsl": true, - "FromAddress": "noreply@primary.example.com", - "TimeoutSeconds": 30 - }, - "Secondary": { - "SmtpServer": "smtp.secondary.example.com", - "Port": 587, - "UseSsl": true, - "FromAddress": "noreply@secondary.example.com", - "TimeoutSeconds": 45 - }, - "Fallback": { - "SmtpServer": "smtp.fallback.example.com", - "Port": 25, - "UseSsl": false, - "FromAddress": "noreply@fallback.example.com", - "TimeoutSeconds": 60 - } - } -} -``` - -**Generated Code:** - -```csharp -// -namespace Atc.SourceGenerators.Annotations; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddOptionsFromOptionsBinding( - this IServiceCollection services, - IConfiguration configuration) - { - // Configure defaults for ALL named instances of EmailOptions - services.ConfigureAll(options => EmailOptions.SetDefaults(options)); - - // Configure EmailOptions (Named: "Primary") - services.Configure("Primary", configuration.GetSection("Email:Primary")); - - // Configure EmailOptions (Named: "Secondary") - services.Configure("Secondary", configuration.GetSection("Email:Secondary")); - - // Configure EmailOptions (Named: "Fallback") - services.Configure("Fallback", configuration.GetSection("Email:Fallback")); - - return services; - } -} -``` - -**Usage in Services:** - -```csharp -public class EmailService -{ - private readonly IOptionsSnapshot _emailSnapshot; - - public EmailService(IOptionsSnapshot emailSnapshot) - { - _emailSnapshot = emailSnapshot; - } - - public async Task SendAsync(string to, string body) - { - // Try primary first - var primaryEmail = _emailSnapshot.Get("Primary"); - if (await TrySendAsync(primaryEmail, to, body)) - return; - - // Fallback to secondary - var secondaryEmail = _emailSnapshot.Get("Secondary"); - if (await TrySendAsync(secondaryEmail, to, body)) - return; - - // Last resort: fallback server - var fallbackEmail = _emailSnapshot.Get("Fallback"); - await TrySendAsync(fallbackEmail, to, body); - } - - private async Task TrySendAsync(EmailOptions options, string to, string body) - { - try - { - // Send email using options.SmtpServer, options.Port, etc. - return true; - } - catch - { - return false; - } - } -} -``` - -### ⚠️ Important Notes - -- **Use `IOptionsSnapshot`**: Named options are accessed via `IOptionsSnapshot.Get(name)`, not `IOptions.Value` -- **No Validation Chain**: Named options use the simpler `Configure(name, section)` pattern without validation support -- **AllowMultiple**: The `[OptionsBinding]` attribute supports multiple instances on the same class - -### 🎯 Common Use Cases - -- **Fallback Servers**: Primary/Secondary/Fallback email or database servers -- **Multi-Region**: Different API endpoints for US, EU, Asia regions -- **Multi-Tenant**: Tenant-specific configurations -- **Environment Tiers**: Production, Staging, Development endpoints - -## 🎯 Child Sections (Simplified Named Options) - -**Child Sections** provide a simplified syntax for creating multiple named options instances from configuration subsections. This feature dramatically reduces boilerplate when you have multiple related configurations under a common parent section. - -### Before vs After - -**Before (Multiple Attributes):** - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -**After (With ChildSections):** - -```csharp -[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" }, ConfigureAll = nameof(SetDefaults))] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -**Both generate identical code** - one attribute instead of three! - -### 📋 Real-World Example: Notification Channels (PetStore Sample) - -**NotificationOptions.cs** (from `PetStore.Domain` sample): - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace PetStore.Domain.Options; - -/// -/// Notification channel options with support for multiple named configurations. -/// This class demonstrates the ChildSections feature which provides a concise way to create -/// multiple named instances from child configuration sections. -/// It also demonstrates the ConfigureAll feature which sets default values for ALL named instances. -/// -/// -/// Using ChildSections = new[] { "Email", "SMS", "Push" } is equivalent to: -/// [OptionsBinding("Notifications:Email", Name = "Email")] -/// [OptionsBinding("Notifications:SMS", Name = "SMS")] -/// [OptionsBinding("Notifications:Push", Name = "Push")] -/// -[OptionsBinding("Notifications", ChildSections = new[] { "Email", "SMS", "Push" }, ConfigureAll = nameof(SetCommonDefaults))] -public partial class NotificationOptions -{ - /// - /// Gets or sets a value indicating whether this notification channel is enabled. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the provider name for this notification channel. - /// - public string Provider { get; set; } = string.Empty; - - /// - /// Gets or sets the API key or credential for the provider. - /// - public string ApiKey { get; set; } = string.Empty; - - /// - /// Gets or sets the sender identifier (email address, phone number, or app ID). - /// - public string SenderId { get; set; } = string.Empty; - - /// - /// Gets or sets the timeout in seconds for notification delivery. - /// - public int TimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets the maximum retry attempts. - /// - public int MaxRetries { get; set; } = 3; - - /// - /// Gets or sets the rate limit (max notifications per minute). - /// - public int RateLimitPerMinute { get; set; } - - /// - /// Sets common default values for ALL notification channels. - /// This method runs BEFORE individual configuration binding, ensuring all channels - /// have consistent baseline settings that can be overridden per channel. - /// - internal static void SetCommonDefaults(NotificationOptions options) - { - // Set common defaults for all notification channels - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.RateLimitPerMinute = 60; - options.Enabled = true; - } -} -``` - -**appsettings.json (PetStore.Api):** - -```json -{ - "Notifications": { - "Email": { - "Enabled": true, - "Provider": "SendGrid", - "ApiKey": "your-sendgrid-api-key", - "SenderId": "noreply@petstoredemo.com", - "TimeoutSeconds": 30, - "MaxRetries": 3 - }, - "SMS": { - "Enabled": false, - "Provider": "Twilio", - "ApiKey": "your-twilio-api-key", - "SenderId": "+1234567890", - "TimeoutSeconds": 15, - "MaxRetries": 2 - }, - "Push": { - "Enabled": true, - "Provider": "Firebase", - "ApiKey": "your-firebase-server-key", - "SenderId": "petstore-app", - "TimeoutSeconds": 20, - "MaxRetries": 3 - } - } -} -``` - -### Generated Code - -```csharp -// -namespace Atc.SourceGenerators.Annotations; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddOptionsFromDomain( - this IServiceCollection services, - IConfiguration configuration) - { - // Configure defaults for ALL notification channels FIRST - services.ConfigureAll( - options => global::PetStore.Domain.Options.NotificationOptions.SetCommonDefaults(options)); - - // Configure NotificationOptions (Named: "Email") - Inject using IOptionsSnapshot.Get("Email") - services.Configure( - "Email", configuration.GetSection("Notifications:Email")); - - // Configure NotificationOptions (Named: "SMS") - Inject using IOptionsSnapshot.Get("SMS") - services.Configure( - "SMS", configuration.GetSection("Notifications:SMS")); - - // Configure NotificationOptions (Named: "Push") - Inject using IOptionsSnapshot.Get("Push") - services.Configure( - "Push", configuration.GetSection("Notifications:Push")); - - return services; - } -} -``` - -### Usage in Services - -```csharp -public class NotificationService -{ - private readonly IOptionsSnapshot _notificationOptions; - private readonly ILogger _logger; - - public NotificationService( - IOptionsSnapshot notificationOptions, - ILogger logger) - { - _notificationOptions = notificationOptions; - _logger = logger; - } - - public async Task SendAsync(string channel, string message) - { - // Get the specific channel configuration - var options = _notificationOptions.Get(channel); - - if (!options.Enabled) - { - _logger.LogInformation("Notification channel {Channel} is disabled", channel); - return; - } - - _logger.LogInformation( - "Sending notification via {Provider} (timeout: {Timeout}s, retries: {MaxRetries})", - options.Provider, - options.TimeoutSeconds, - options.MaxRetries); - - // Send notification using the configured provider - await SendViaProvider(options, message); - } - - public async Task SendWithFallbackAsync(string message) - { - // Try Email first - if (await TrySendAsync("Email", message)) - return; - - // Fallback to SMS - if (await TrySendAsync("SMS", message)) - return; - - // Last resort: Push notification - await TrySendAsync("Push", message); - } - - private async Task TrySendAsync(string channel, string message) - { - try - { - await SendAsync(channel, message); - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to send via {Channel}", channel); - return false; - } - } - - private async Task SendViaProvider(NotificationOptions options, string message) - { - // Implement provider-specific sending logic - await Task.CompletedTask; - } -} -``` - -### ✨ Key Benefits - -1. **Less Boilerplate**: One attribute line instead of many -2. **Clearer Intent**: Explicitly shows related configurations are grouped under a parent -3. **Easier Maintenance**: Add/remove channels by updating the array -4. **Works with All Features**: ConfigureAll, validation, ErrorOnMissingKeys all supported -5. **Same Power**: Generates identical code to multiple attributes approach - -### 🎯 Common Use Cases - -- **Notification Channels**: Email, SMS, Push configurations -- **Database Fallback**: Primary, Secondary, Tertiary database connections -- **Multi-Region APIs**: USEast, USWest, EUWest, APSouth endpoints -- **Cache Tiers**: L1, L2, L3 cache configurations -- **Environment Tiers**: Dev, Staging, Prod configurations - -### ⚠️ Validation Rules - -- **Requires at least 2 child sections** (use regular attributes for single instances) -- **Cannot be combined with `Name` property** (mutually exclusive) -- **Child section names cannot be null or empty** -- **Works seamlessly with ConfigureAll** to set baseline defaults for all instances - -## 📂 Nested Subsection Binding (Feature #6) - -The **OptionsBindingGenerator** automatically handles nested configuration subsections through Microsoft's `.Bind()` method. Complex properties are automatically bound to their corresponding configuration subsections without any additional configuration. - -### 🎯 How It Works - -When you have properties that are complex types (not primitives like string, int, etc.), the configuration binder automatically: - -1. Detects the property is a complex type -2. Looks for a subsection with the same name -3. Recursively binds that subsection to the property - -This works for: - -- **Nested objects** - Properties with custom class types -- **Collections** - List, IEnumerable, arrays -- **Dictionaries** - Dictionary, Dictionary -- **Multiple levels** - Deeply nested structures (3+ levels) - -### 📋 Example 1: Simple Nested Objects (Email Service) - -**EmailServiceOptions.cs:** - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace Atc.SourceGenerators.OptionsBinding.Options; - -[OptionsBinding("EmailService")] -public partial class EmailServiceOptions -{ - [Required] - [EmailAddress] - public string From { get; set; } = string.Empty; - - // Automatically binds to "EmailService:Smtp" subsection - public SmtpSettings Smtp { get; set; } = new(); - - // Automatically binds to "EmailService:Templates" subsection - public EmailTemplates Templates { get; set; } = new(); -} - -public class SmtpSettings -{ - [Required] - public string Host { get; set; } = string.Empty; - - [Range(1, 65535)] - public int Port { get; set; } = 587; - - public bool UseSsl { get; set; } = true; -} - -public class EmailTemplates -{ - public string Welcome { get; set; } = "welcome.html"; - public string ResetPassword { get; set; } = "reset.html"; - public string VerifyEmail { get; set; } = "verify.html"; -} -``` - -**appsettings.json:** - -```json -{ - "EmailService": { - "From": "noreply@example.com", - "Smtp": { - "Host": "smtp.example.com", - "Port": 587, - "UseSsl": true - }, - "Templates": { - "Welcome": "welcome-template.html", - "ResetPassword": "reset-template.html", - "VerifyEmail": "verify-template.html" - } - } -} -``` - -### 📋 Example 2: Deeply Nested Objects (Cloud Storage - 3 Levels) - -**CloudStorageOptions.cs** (from `Atc.SourceGenerators.OptionsBinding` sample): - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace Atc.SourceGenerators.OptionsBinding.Options; - -[OptionsBinding("CloudStorage", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class CloudStorageOptions -{ - [Required] - public string Provider { get; set; } = string.Empty; - - // Binds to "CloudStorage:Azure" subsection - public AzureStorageSettings Azure { get; set; } = new(); - - // Binds to "CloudStorage:Aws" subsection - public AwsS3Settings Aws { get; set; } = new(); - - // Binds to "CloudStorage:RetryPolicy" subsection - public RetryPolicy RetryPolicy { get; set; } = new(); -} - -public class AzureStorageSettings -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - public string ContainerName { get; set; } = string.Empty; - - // Binds to "CloudStorage:Azure:Blob" subsection - 3 levels deep! - public BlobSettings Blob { get; set; } = new(); -} - -public class BlobSettings -{ - public int MaxBlockSize { get; set; } = 4194304; // 4 MB - public int ParallelOperations { get; set; } = 8; -} - -public class AwsS3Settings -{ - [Required] - public string AccessKey { get; set; } = string.Empty; - - [Required] - public string SecretKey { get; set; } = string.Empty; - - public string Region { get; set; } = "us-east-1"; - public string BucketName { get; set; } = string.Empty; -} - -public class RetryPolicy -{ - [Range(0, 10)] - public int MaxRetries { get; set; } = 3; - - [Range(100, 60000)] - public int DelayMilliseconds { get; set; } = 1000; - - public bool UseExponentialBackoff { get; set; } = true; -} -``` - -**appsettings.json:** - -```json -{ - "CloudStorage": { - "Provider": "Azure", - "Azure": { - "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey;", - "ContainerName": "my-container", - "Blob": { - "MaxBlockSize": 4194304, - "ParallelOperations": 8 - } - }, - "Aws": { - "AccessKey": "AKIAIOSFODNN7EXAMPLE", - "SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Region": "us-west-2", - "BucketName": "my-bucket" - }, - "RetryPolicy": { - "MaxRetries": 3, - "DelayMilliseconds": 1000, - "UseExponentialBackoff": true - } - } -} -``` - -### 🎯 Real-World Example: Storage Options (PetStore Sample) - -**StorageOptions.cs** (from `PetStore.Domain` sample): - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace PetStore.Domain.Options; - -[OptionsBinding("Storage", ValidateDataAnnotations = true)] -public partial class StorageOptions -{ - // Automatically binds to "Storage:Database" subsection - public DatabaseSettings Database { get; set; } = new(); - - // Automatically binds to "Storage:FileStorage" subsection - public FileStorageSettings FileStorage { get; set; } = new(); -} - -public class DatabaseSettings -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 1000)] - public int MaxConnections { get; set; } = 100; - - public int CommandTimeout { get; set; } = 30; - - // Binds to "Storage:Database:Retry" - 3 levels deep! - public DatabaseRetryPolicy Retry { get; set; } = new(); -} - -public class DatabaseRetryPolicy -{ - [Range(0, 10)] - public int MaxAttempts { get; set; } = 3; - - [Range(100, 10000)] - public int DelayMilliseconds { get; set; } = 500; -} - -public class FileStorageSettings -{ - [Required] - public string BasePath { get; set; } = string.Empty; - - [Range(1, 100)] - public int MaxFileSizeMB { get; set; } = 10; - - public IList AllowedExtensions { get; set; } = new List { ".jpg", ".png", ".pdf" }; -} -``` - -**appsettings.json (PetStore.Api):** - -```json -{ - "Storage": { - "Database": { - "ConnectionString": "Server=localhost;Database=PetStoreDb;Integrated Security=true;", - "MaxConnections": 100, - "CommandTimeout": 30, - "Retry": { - "MaxAttempts": 3, - "DelayMilliseconds": 500 - } - }, - "FileStorage": { - "BasePath": "C:\\PetStore\\Files", - "MaxFileSizeMB": 10, - "AllowedExtensions": [ ".jpg", ".png", ".pdf", ".docx" ] - } - } -} -``` - -### ✨ Key Benefits - -- **Zero Configuration** - Just declare properties with complex types and `.Bind()` handles the rest -- **Automatic Path Construction** - "Parent:Child:GrandChild" paths are built automatically -- **Works with Validation** - DataAnnotations validation applies to all nested levels -- **Unlimited Depth** - Supports deeply nested structures (3, 4, 5+ levels) -- **Collections Supported** - List, arrays, dictionaries all work automatically -- **Type-Safe All the Way Down** - Compile-time safety for nested properties -- **No Breaking Changes** - This feature works out-of-the-box with existing code - -### 🎯 Usage in Services - -```csharp -using Microsoft.Extensions.Options; - -public class CloudStorageService -{ - private readonly CloudStorageOptions _options; - - public CloudStorageService(IOptions options) - { - _options = options.Value; - - // Access nested properties directly - Console.WriteLine($"Provider: {_options.Provider}"); - Console.WriteLine($"Azure Container: {_options.Azure.ContainerName}"); - Console.WriteLine($"Azure Blob MaxBlockSize: {_options.Azure.Blob.MaxBlockSize}"); - Console.WriteLine($"Retry MaxAttempts: {_options.RetryPolicy.MaxRetries}"); - } -} -``` - -### Generated Code - -The generator creates the standard binding code - Microsoft's `.Bind()` method handles all nested subsection binding automatically: - -```csharp -// -services.AddOptions() - .Bind(configuration.GetSection("CloudStorage")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -// .Bind() automatically: -// - Binds CloudStorage:Azure to Azure property -// - Binds CloudStorage:Azure:Blob to Azure.Blob property -// - Binds CloudStorage:Aws to Aws property -// - Binds CloudStorage:RetryPolicy to RetryPolicy property -``` - -## ✨ Key Takeaways - -1. **Zero Boilerplate**: No manual `AddOptions().Bind()` code to write -2. **Auto-Inference**: Section names can be inferred from class names -3. **Validation**: Built-in support for Data Annotations validation -4. **Type Safety**: Compile-time errors if configuration doesn't match -5. **Multi-Project**: Each project gets its own `AddOptionsFromXXX()` method -6. **Flexible Lifetimes**: Choose between Singleton, Scoped, or Monitor -7. **Startup Validation**: Catch configuration errors before runtime -8. **Error on Missing Keys**: Fail-fast validation when configuration sections are missing -9. **Named Options**: Multiple configurations of the same type for fallback/multi-tenant scenarios -10. **Child Sections**: Simplified syntax for creating multiple named instances from configuration subsections -11. **Configuration Change Callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime -12. **Nested Subsection Binding**: Automatic binding of complex properties to configuration subsections (unlimited depth) - -## 🔗 Related Documentation - -- [OptionsBinding Generator Guide](OptionsBindingGenerators.md) - Full generator documentation -- [DependencyRegistration Sample](DependencyRegistrationGenerators-Samples.md) - DI registration example -- [Mapping Sample](ObjectMappingGenerators-Samples.md) - Object mapping example -- [PetStore API Sample](PetStoreApi-Samples.md) - Complete application using all generators diff --git a/docs/OptionsBindingGenerators.md b/docs/OptionsBindingGenerators.md deleted file mode 100644 index 49e8e7a..0000000 --- a/docs/OptionsBindingGenerators.md +++ /dev/null @@ -1,3017 +0,0 @@ -# ⚙️ Options Binding Source Generator - -Automatically bind configuration sections to strongly-typed options classes with compile-time code generation. - -**Key Benefits:** - -- 🎯 **Zero boilerplate** - No manual `AddOptions().Bind()` calls needed -- 🧠 **Smart section inference** - Auto-detects section names from class names or constants -- 🛡️ **Built-in validation** - Automatic DataAnnotations validation and startup checks -- 🔧 **Multi-project support** - Smart naming for assembly-specific registration methods -- ⚡ **Native AOT ready** - Pure compile-time generation with zero reflection - -**Quick Example:** - -```csharp -// Input: Decorate your options class -[OptionsBinding("Database")] -public partial class DatabaseOptions -{ - [Required] public string ConnectionString { get; set; } = string.Empty; -} - -// Generated: Registration extension method -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -## 📖 Documentation Navigation - -- **[📋 Feature Roadmap](OptionsBindingGenerators-FeatureRoadmap.md)** - See all implemented and planned features -- **[🎯 Sample Projects](OptionsBindingGenerators-Samples.md)** - Working code examples with architecture diagrams - -## 📑 Table of Contents - -- [⚙️ Options Binding Source Generator](#️-options-binding-source-generator) - - [📖 Documentation Navigation](#-documentation-navigation) - - [📑 Table of Contents](#-table-of-contents) - - [📖 Overview](#-overview) - - [😫 Before (Manual Approach)](#-before-manual-approach) - - [✨ After (With Source Generator)](#-after-with-source-generator) - - [🚀 Quick Start](#-quick-start) - - [1️⃣ Install the Package](#1️⃣-install-the-package) - - [2️⃣ Create Your Options Class](#2️⃣-create-your-options-class) - - [3️⃣ Configure Your appsettings.json](#3️⃣-configure-your-appsettingsjson) - - [4️⃣ Register Options in Program.cs](#4️⃣-register-options-in-programcs) - - [📋 Configuration Examples](#-configuration-examples) - - [🎯 Base JSON Configuration](#-base-json-configuration) - - [📚 All Configuration Patterns](#-all-configuration-patterns) - - [1️⃣ Explicit Section Name (Highest Priority)](#1️⃣-explicit-section-name-highest-priority) - - [2️⃣ Using `const string SectionName` (2nd Priority)](#2️⃣-using-const-string-sectionname-2nd-priority) - - [3️⃣ Using `const string NameTitle` (3rd Priority)](#3️⃣-using-const-string-nametitle-3rd-priority) - - [4️⃣ Using `const string Name` (4th Priority)](#4️⃣-using-const-string-name-4th-priority) - - [5️⃣ Auto-Inferred from Class Name (Lowest Priority)](#5️⃣-auto-inferred-from-class-name-lowest-priority) - - [🔒 Validation Examples](#-validation-examples) - - [With Data Annotations Only](#with-data-annotations-only) - - [With Validation On Start](#with-validation-on-start) - - [With Both Validations (Recommended)](#with-both-validations-recommended) - - [⏱️ Lifetime Examples](#️-lifetime-examples) - - [Singleton (Default - IOptions)](#singleton-default---ioptions) - - [Scoped (IOptionsSnapshot)](#scoped-ioptionssnapshot) - - [Monitor (IOptionsMonitor)](#monitor-ioptionsmonitor) - - [🎯 Complete Example - All Features Combined](#-complete-example---all-features-combined) - - [📊 Priority Summary Table](#-priority-summary-table) - - [🔄 Mapping Both Base JSON Examples](#-mapping-both-base-json-examples) - - [✨ Features](#-features) - - [📦 Installation](#-installation) - - [📋 Package Reference](#-package-reference) - - [💡 Usage](#-usage) - - [🔰 Basic Options Binding](#-basic-options-binding) - - [📍 Explicit Section Names](#-explicit-section-names) - - [✅ Validation](#-validation) - - [🏷️ Data Annotations Validation](#️-data-annotations-validation) - - [🚀 Validate on Startup](#-validate-on-startup) - - [🔗 Combined Validation](#-combined-validation) - - [🎯 Custom Validation (IValidateOptions)](#-custom-validation-ivalidateoptions) - - [🚨 Error on Missing Configuration Keys](#-error-on-missing-configuration-keys) - - [⏱️ Options Lifetimes](#️-options-lifetimes) - - [🔔 Configuration Change Callbacks](#-configuration-change-callbacks) - - [🔧 Post-Configuration Support](#-post-configuration-support) - - [🎛️ ConfigureAll Support](#️-configureall-support) - - [🔧 How It Works](#-how-it-works) - - [1️⃣ Attribute Detection](#1️⃣-attribute-detection) - - [2️⃣ Section Name Resolution](#2️⃣-section-name-resolution) - - [3️⃣ Code Generation](#3️⃣-code-generation) - - [4️⃣ Compile-Time Safety](#4️⃣-compile-time-safety) - - [🎯 Advanced Scenarios](#-advanced-scenarios) - - [🏢 Multiple Assemblies](#-multiple-assemblies) - - [✨ Smart Naming](#-smart-naming) - - [📂 Nested Configuration (Feature #6: Bind Configuration Subsections to Properties)](#-nested-configuration-feature-6-bind-configuration-subsections-to-properties) - - [🎯 How It Works](#-how-it-works-1) - - [📋 Example 1: Simple Nested Objects](#-example-1-simple-nested-objects) - - [📋 Example 2: Deeply Nested Objects (3 Levels)](#-example-2-deeply-nested-objects-3-levels) - - [📋 Example 3: Real-World Scenario (Cloud Storage)](#-example-3-real-world-scenario-cloud-storage) - - [🎯 Key Points](#-key-points) - - [📍 Explicit Nested Paths](#-explicit-nested-paths) - - [⚡ Early Access to Options (Avoid BuildServiceProvider Anti-Pattern)](#-early-access-to-options-avoid-buildserviceprovider-anti-pattern) - - [🎯 Key Features](#-key-features) - - [📋 API Approaches](#-api-approaches) - - [⚖️ When to Use Which API](#️-when-to-use-which-api) - - [📋 Basic Usage](#-basic-usage) - - [🔄 Idempotency Example](#-idempotency-example) - - [🛡️ Validation Example](#️-validation-example) - - [🚀 Real-World Use Cases](#-real-world-use-cases) - - [⚙️ How It Works](#️-how-it-works) - - [⚠️ Limitations](#️-limitations) - - [🎓 Best Practices](#-best-practices) - - [🌍 Environment-Specific Configuration](#-environment-specific-configuration) - - [📛 Named Options (Multiple Configurations)](#-named-options-multiple-configurations) - - [✨ Use Cases](#-use-cases) - - [🎯 Basic Example](#-basic-example) - - [🔧 Generated Code](#-generated-code) - - [⚠️ Important Notes](#️-important-notes) - - [🎯 Mixing Named and Unnamed Options](#-mixing-named-and-unnamed-options) - - [🎯 Child Sections (Simplified Named Options)](#-child-sections-simplified-named-options) - - [✨ Use Cases](#-use-cases-1) - - [🎯 Basic Example](#-basic-example-1) - - [📋 Configuration Structure](#-configuration-structure) - - [🔧 Advanced Features](#-advanced-features) - - [📍 Nested Paths](#-nested-paths) - - [🚨 Validation Rules](#-validation-rules) - - [💡 Key Benefits](#-key-benefits) - - [📊 ChildSections vs Multiple Attributes](#-childsections-vs-multiple-attributes) - - [🎯 Real-World Example](#-real-world-example) - - [🛡️ Diagnostics](#️-diagnostics) - - [❌ ATCOPT001: Options class must be partial](#-atcopt001-options-class-must-be-partial) - - [❌ ATCOPT002: Section name cannot be null or empty](#-atcopt002-section-name-cannot-be-null-or-empty) - - [⚠️ ATCOPT003: Invalid options binding configuration](#️-atcopt003-invalid-options-binding-configuration) - - [❌ ATCOPT003: Const section name cannot be null or empty](#-atcopt003-const-section-name-cannot-be-null-or-empty) - - [❌ ATCOPT004-007: OnChange Callback Diagnostics](#-atcopt004-007-onchange-callback-diagnostics) - - [❌ ATCOPT008-010: PostConfigure Callback Diagnostics](#-atcopt008-010-postconfigure-callback-diagnostics) - - [❌ ATCOPT011-013: ConfigureAll Callback Diagnostics](#-atcopt011-013-configureall-callback-diagnostics) - - [❌ ATCOPT014-016: ChildSections Diagnostics](#-atcopt014-016-childsections-diagnostics) - - [🚀 Native AOT Compatibility](#-native-aot-compatibility) - - [✅ AOT-Safe Features](#-aot-safe-features) - - [🏗️ How It Works](#️-how-it-works-1) - - [📋 Example Generated Code](#-example-generated-code) - - [🎯 Multi-Project AOT Support](#-multi-project-aot-support) - - [📚 Examples](#-examples) - - [📝 Example 1: Simple Configuration](#-example-1-simple-configuration) - - [🔒 Example 2: Validated Database Options](#-example-2-validated-database-options) - - [🏗️ Example 3: Multi-Layer Application](#️-example-3-multi-layer-application) - - [🔗 Additional Resources](#-additional-resources) - - [❓ FAQ](#-faq) - - [📄 License](#-license) - ---- - -## 📖 Overview - -The Options Binding Source Generator eliminates the boilerplate code required to bind configuration sections to options classes. Simply decorate your options class with `[OptionsBinding]`, and the generator creates the necessary registration code at compile time. - -### 😫 Before (Manual Approach) - -```csharp -// appsettings.json -{ - "Database": { - "ConnectionString": "Server=localhost;...", - "MaxRetries": 5 - } -} - -// DatabaseOptions.cs -public class DatabaseOptions -{ - public string ConnectionString { get; set; } - public int MaxRetries { get; set; } -} - -// Program.cs - Manual binding -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -### ✨ After (With Source Generator) - -```csharp -// DatabaseOptions.cs -[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required] - public string ConnectionString { get; set; } - - [Range(1, 10)] - public int MaxRetries { get; set; } -} - -// Program.cs - Generated extension method -services.AddOptionsFromApp(configuration); -``` - ---- - -## 🚀 Quick Start - -### 1️⃣ Install the Package - -```bash -dotnet add package Atc.SourceGenerators -``` - -### 2️⃣ Create Your Options Class - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace MyApp.Configuration; - -[OptionsBinding("Database", ValidateDataAnnotations = true)] -public partial class DatabaseOptions -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; -} -``` - -### 3️⃣ Configure Your appsettings.json - -```json -{ - "Database": { - "ConnectionString": "Server=localhost;Database=MyDb;", - "MaxRetries": 5 - } -} -``` - -### 4️⃣ Register Options in Program.cs - -```csharp -var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .Build(); - -var services = new ServiceCollection(); - -// Use the generated extension method -services.AddOptionsFromApp(configuration); - -var serviceProvider = services.BuildServiceProvider(); - -// Access your options -var dbOptions = serviceProvider.GetRequiredService>(); -Console.WriteLine(dbOptions.Value.ConnectionString); -``` - ---- - -## 📋 Configuration Examples - -This section demonstrates all possible ways to create options classes and map them to `appsettings.json` sections. - -### 🎯 Base JSON Configuration - -We'll use these two JSON sections throughout the examples: - -**appsettings.json:** - -```json -{ - "PetMaintenanceService": { - "RepeatIntervalInSeconds": 10, - "EnableAutoCleanup": true, - "MaxPetsPerBatch": 50 - }, - - "PetOtherServiceOptions": { - "RepeatIntervalInSeconds": 10, - "EnableAutoCleanup": true, - "MaxPetsPerBatch": 50 - } -} -``` - -**Two different scenarios:** - -- **`"PetMaintenanceService"`** - Section name that doesn't match any class name (requires explicit mapping) -- **`"PetOtherServiceOptions"`** - Section name that exactly matches a class name (can use auto-inference) - -### 📚 All Configuration Patterns - -#### 1️⃣ Explicit Section Name (Highest Priority) - -Use when you want full control over the section name: - -```csharp -// Maps to "PetMaintenanceService" section -[OptionsBinding("PetMaintenanceService")] -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**When to use:** - -- ✅ When section name doesn't match class name -- ✅ When using nested configuration paths (e.g., `"App:Services:Database"`) -- ✅ When you want explicit, readable code - -#### 2️⃣ Using `const string SectionName` (2nd Priority) - -Use when you want the section name defined as a constant in the class: - -```csharp -// Maps to "PetMaintenanceService" section -[OptionsBinding] -public partial class PetMaintenanceServiceOptions -{ - public const string SectionName = "PetMaintenanceService"; - - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**When to use:** - -- ✅ When you want the section name accessible as a constant -- ✅ When other code needs to reference the same section name -- ✅ When building configuration paths dynamically - -#### 3️⃣ Using `const string NameTitle` (3rd Priority) - -Use as an alternative to `SectionName`: - -```csharp -// Maps to "PetMaintenanceService" section -[OptionsBinding] -public partial class PetMaintenanceServiceOptions -{ - public const string NameTitle = "PetMaintenanceService"; - - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**When to use:** - -- ✅ When following specific naming conventions -- ✅ When `SectionName` is not preferred in your codebase - -#### 4️⃣ Using `const string Name` (4th Priority) - -Another alternative for section name definition: - -```csharp -// Maps to "PetMaintenanceService" section -[OptionsBinding] -public partial class PetMaintenanceServiceOptions -{ - public const string Name = "PetMaintenanceService"; - - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**When to use:** - -- ✅ When following specific naming conventions -- ✅ When `Name` fits your code style better - -#### 5️⃣ Auto-Inferred from Class Name (Lowest Priority) - -The generator uses the full class name as-is: - -```csharp -// Maps to "PetOtherServiceOptions" section (full class name) -[OptionsBinding] -public partial class PetOtherServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**When to use:** - -- ✅ When section name matches class name exactly -- ✅ When you want minimal code -- ✅ When following convention-over-configuration - -**Important:** The class name is used **as-is** - no suffix removal or transformation: - -- `DatabaseOptions` → `"DatabaseOptions"` (NOT `"Database"`) -- `ApiSettings` → `"ApiSettings"` (NOT `"Api"`) -- `CacheConfig` → `"CacheConfig"` (NOT `"Cache"`) -- `PetOtherServiceOptions` → `"PetOtherServiceOptions"` ✅ (Matches our JSON section!) - -### 🔒 Validation Examples - -#### With Data Annotations Only - -```csharp -using System.ComponentModel.DataAnnotations; - -// Maps to "PetMaintenanceService" section -[OptionsBinding("PetMaintenanceService", ValidateDataAnnotations = true)] -public partial class PetMaintenanceServiceOptions -{ - [Range(1, 3600)] - public int RepeatIntervalInSeconds { get; set; } - - public bool EnableAutoCleanup { get; set; } - - [Range(1, 1000)] - public int MaxPetsPerBatch { get; set; } -} -``` - -**Generated code includes:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("PetMaintenanceService")) - .ValidateDataAnnotations(); -``` - -#### With Validation On Start - -```csharp -// Maps to "PetMaintenanceService" section -[OptionsBinding("PetMaintenanceService", ValidateOnStart = true)] -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**Generated code includes:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("PetMaintenanceService")) - .ValidateOnStart(); -``` - -#### With Both Validations (Recommended) - -```csharp -using System.ComponentModel.DataAnnotations; - -// Maps to "PetMaintenanceService" section -[OptionsBinding("PetMaintenanceService", - ValidateDataAnnotations = true, - ValidateOnStart = true)] -public partial class PetMaintenanceServiceOptions -{ - [Required] - [Range(1, 3600, ErrorMessage = "Interval must be between 1 and 3600 seconds")] - public int RepeatIntervalInSeconds { get; set; } - - public bool EnableAutoCleanup { get; set; } - - [Range(1, 1000)] - public int MaxPetsPerBatch { get; set; } -} -``` - -**Generated code includes:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("PetMaintenanceService")) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -### ⏱️ Lifetime Examples - -#### Singleton (Default - IOptions) - -Best for options that don't change during application lifetime: - -```csharp -// Default: Lifetime = OptionsLifetime.Singleton -[OptionsBinding("PetMaintenanceService")] -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } -} - -// Usage: -public class PetMaintenanceService -{ - public PetMaintenanceService(IOptions options) - { - var config = options.Value; // Cached singleton value - } -} -``` - -**Generated code comment:** - -```csharp -// Configure PetMaintenanceServiceOptions - Inject using IOptions -``` - -#### Scoped (IOptionsSnapshot) - -Best for options that may change per request/scope: - -```csharp -[OptionsBinding("PetMaintenanceService", Lifetime = OptionsLifetime.Scoped)] -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } -} - -// Usage: -public class PetRequestHandler -{ - public PetRequestHandler(IOptionsSnapshot options) - { - var config = options.Value; // Fresh value per scope/request - } -} -``` - -**Generated code comment:** - -```csharp -// Configure PetMaintenanceServiceOptions - Inject using IOptionsSnapshot -``` - -#### Monitor (IOptionsMonitor) - -Best for options that need change notifications and hot-reload: - -```csharp -[OptionsBinding("PetMaintenanceService", Lifetime = OptionsLifetime.Monitor)] -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } -} - -// Usage: -public class PetMaintenanceService -{ - public PetMaintenanceService(IOptionsMonitor options) - { - var config = options.CurrentValue; // Always current value - - // Subscribe to configuration changes - options.OnChange(newConfig => - { - Console.WriteLine($"Configuration changed! New interval: {newConfig.RepeatIntervalInSeconds}"); - }); - } -} -``` - -**Generated code comment:** - -```csharp -// Configure PetMaintenanceServiceOptions - Inject using IOptionsMonitor -``` - -### 🎯 Complete Example - All Features Combined - -Here's an example using all features together: - -**appsettings.json:** - -```json -{ - "PetMaintenanceService": { - "RepeatIntervalInSeconds": 10, - "EnableAutoCleanup": true, - "MaxPetsPerBatch": 50, - "NotificationEmail": "admin@petstore.com" - } -} -``` - -**Options class:** - -```csharp -using System.ComponentModel.DataAnnotations; -using Atc.SourceGenerators.Annotations; - -namespace PetStore.Domain.Options; - -/// -/// Configuration options for the pet maintenance service. -/// -[OptionsBinding("PetMaintenanceService", - ValidateDataAnnotations = true, - ValidateOnStart = true, - Lifetime = OptionsLifetime.Monitor)] -public partial class PetMaintenanceServiceOptions -{ - /// - /// The interval in seconds between maintenance runs. - /// - [Required] - [Range(1, 3600, ErrorMessage = "Interval must be between 1 and 3600 seconds")] - public int RepeatIntervalInSeconds { get; set; } - - /// - /// Whether to enable automatic cleanup of old records. - /// - public bool EnableAutoCleanup { get; set; } - - /// - /// Maximum number of pets to process in a single batch. - /// - [Range(1, 1000)] - public int MaxPetsPerBatch { get; set; } = 50; - - /// - /// Email address for maintenance notifications. - /// - [Required] - [EmailAddress] - public string NotificationEmail { get; set; } = string.Empty; -} -``` - -**Program.cs:** - -```csharp -var builder = WebApplication.CreateBuilder(args); - -// Register all options from Domain assembly -builder.Services.AddOptionsFromDomain(builder.Configuration); - -var app = builder.Build(); -app.Run(); -``` - -**Usage in service:** - -```csharp -public class PetMaintenanceService : BackgroundService -{ - private readonly IOptionsMonitor _options; - private readonly ILogger _logger; - - public PetMaintenanceService( - IOptionsMonitor options, - ILogger logger) - { - _options = options; - _logger = logger; - - // React to configuration changes - _options.OnChange(newOptions => - { - _logger.LogInformation( - "Configuration updated! New interval: {Interval}s", - newOptions.RepeatIntervalInSeconds); - }); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var config = _options.CurrentValue; - - _logger.LogInformation( - "Running maintenance with interval {Interval}s, batch size {BatchSize}", - config.RepeatIntervalInSeconds, - config.MaxPetsPerBatch); - - // Perform maintenance... - - await Task.Delay( - TimeSpan.FromSeconds(config.RepeatIntervalInSeconds), - stoppingToken); - } - } -} -``` - -### 📊 Priority Summary Table - -When multiple section name sources are present, the generator uses this priority: - -| Priority | Source | Example | -|----------|--------|---------| -| 1️⃣ **Highest** | Attribute parameter | `[OptionsBinding("Database")]` | -| 2️⃣ | `const string SectionName` | `public const string SectionName = "DB";` | -| 3️⃣ | `const string NameTitle` | `public const string NameTitle = "DB";` | -| 4️⃣ | `const string Name` | `public const string Name = "DB";` | -| 5️⃣ **Lowest** | Auto-inferred from class name | Class `DatabaseOptions` → `"DatabaseOptions"` | - -**Example showing priority:** - -```csharp -// This maps to "ExplicitSection" (priority 1 wins) -[OptionsBinding("ExplicitSection")] -public partial class MyOptions -{ - public const string SectionName = "SectionNameConst"; // Ignored (priority 2) - public const string NameTitle = "NameTitleConst"; // Ignored (priority 3) - public const string Name = "NameConst"; // Ignored (priority 4) - // Class name "MyOptions" would be used if no explicit section (priority 5) -} -``` - -### 🔄 Mapping Both Base JSON Examples - -Here's how to map both JSON sections from our base configuration: - -**appsettings.json:** - -```json -{ - "PetMaintenanceService": { - "RepeatIntervalInSeconds": 10, - "EnableAutoCleanup": true, - "MaxPetsPerBatch": 50 - }, - "PetOtherServiceOptions": { - "RepeatIntervalInSeconds": 10, - "EnableAutoCleanup": true, - "MaxPetsPerBatch": 50 - } -} -``` - -**Options classes:** - -```csharp -// Case 1: Section name doesn't match class name - Use explicit mapping -[OptionsBinding("PetMaintenanceService")] // ✅ Explicit section name required -public partial class PetMaintenanceServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} - -// Case 2: Section name matches class name exactly - Auto-inference works! -[OptionsBinding] // ✅ No section name needed - infers "PetOtherServiceOptions" -public partial class PetOtherServiceOptions -{ - public int RepeatIntervalInSeconds { get; set; } - public bool EnableAutoCleanup { get; set; } - public int MaxPetsPerBatch { get; set; } -} -``` - -**Program.cs:** - -```csharp -// Both registered with a single call -services.AddOptionsFromYourProject(configuration); - -// Use the options -var maintenanceOptions = provider.GetRequiredService>(); -var otherOptions = provider.GetRequiredService>(); - -Console.WriteLine($"Maintenance interval: {maintenanceOptions.Value.RepeatIntervalInSeconds}s"); -Console.WriteLine($"Other interval: {otherOptions.Value.RepeatIntervalInSeconds}s"); -``` - ---- - -## ✨ Features - -- **🧠 Automatic section name inference** - Smart resolution from explicit names, const fields (`SectionName`, `NameTitle`, `Name`), or auto-inferred from class names -- **🔒 Built-in validation** - Integrated DataAnnotations validation (`ValidateDataAnnotations`) and startup validation (`ValidateOnStart`) -- **🎯 Custom validation** - Support for `IValidateOptions` for complex business rules beyond DataAnnotations -- **🚨 Error on missing keys** - Fail-fast validation when configuration sections are missing (`ErrorOnMissingKeys`) to catch deployment issues at startup -- **🔔 Configuration change callbacks** - Automatically respond to configuration changes at runtime with `OnChange` callbacks (requires Monitor lifetime) -- **🔧 Post-configuration support** - Normalize or transform values after binding with `PostConfigure` callbacks (e.g., ensure paths have trailing slashes, lowercase URLs) -- **🎛️ ConfigureAll support** - Set common default values for all named options instances before individual binding with `ConfigureAll` callbacks (e.g., baseline retry/timeout settings) -- **📛 Named options** - Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers) -- **🎯 Child sections** - Simplified syntax for creating multiple named instances from configuration subsections (e.g., Email → Primary/Secondary/Fallback) -- **⚡ Early access to options** - Access bound and validated options during service registration without BuildServiceProvider() anti-pattern (via `GetOrAdd*` methods) -- **🎯 Explicit section paths** - Support for nested sections like `"App:Database"` or `"Services:Email"` -- **📂 Nested subsection binding** - Automatically bind complex properties to configuration subsections (e.g., `StorageOptions.Database.Retry` → `"Storage:Database:Retry"`) -- **📦 Multiple options classes** - Register multiple configuration sections in a single assembly with one method call -- **🏗️ Multi-project support** - Smart naming generates assembly-specific extension methods (e.g., `AddOptionsFromDomain()`, `AddOptionsFromDataAccess()`) -- **🔗 Transitive registration** - Automatically discover and register options from referenced assemblies (4 overloads: default, auto-detect all, selective by name, selective multiple) -- **⏱️ Flexible lifetimes** - Choose between Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), or Monitor (`IOptionsMonitor`) patterns -- **⚡ Native AOT ready** - Pure compile-time code generation with zero reflection, fully trimming-safe for modern .NET deployments -- **🛡️ Compile-time safety** - Catch configuration errors during build, not at runtime -- **🔧 Partial class requirement** - Simple `partial` keyword enables seamless extension method generation - ---- - -**Section Name Resolution Priority:** - -1. Explicit attribute parameter: `[OptionsBinding("SectionName")]` -2. Const field: `public const string SectionName = "...";` -3. Const field: `public const string NameTitle = "...";` -4. Const field: `public const string Name = "...";` -5. Auto-inferred from class name - ---- - -**Transitive Registration Overloads:** - -```csharp -// Overload 1: Base (current assembly only) -services.AddOptionsFrom{Assembly}(configuration); - -// Overload 2: Auto-detect all referenced assemblies -services.AddOptionsFrom{Assembly}(configuration, includeReferencedAssemblies: true); - -// Overload 3: Register specific referenced assembly -services.AddOptionsFrom{Assembly}(configuration, "DataAccess"); - -// Overload 4: Register multiple specific assemblies -services.AddOptionsFrom{Assembly}(configuration, "DataAccess", "Infrastructure"); -``` - ---- - -## 📦 Installation - -### 📋 Package Reference - -**Required:** - -```bash -dotnet add package Atc.SourceGenerators -``` - -**Optional (recommended for better IntelliSense):** - -```bash -dotnet add package Atc.SourceGenerators.Annotations -``` - -Or in your `.csproj`: - -```xml - - - - - - - -``` - -**Note:** The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: `$(NoWarn);CS0436` - ---- - -## 💡 Usage - -### 🔰 Basic Options Binding - -The simplest usage with automatic section name inference: - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace MyApp.Options; - -[OptionsBinding] // Section name inferred as "Database" -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; - public int MaxRetries { get; set; } = 3; - public int TimeoutSeconds { get; set; } = 30; -} -``` - -**appsettings.json:** - -```json -{ - "Database": { - "ConnectionString": "Server=localhost;Database=MyDb;", - "MaxRetries": 5, - "TimeoutSeconds": 60 - } -} -``` - -**Generated Code:** - -```csharp -public static class OptionsBindingExtensions -{ - public static IServiceCollection AddOptionsFromApp( - this IServiceCollection services, - IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration.GetSection("Database")) - ; - - return services; - } -} -``` - -### 📍 Explicit Section Names - -Specify the exact configuration path: - -```csharp -[OptionsBinding("App:ExternalServices:PaymentGateway")] -public partial class PaymentOptions -{ - public string ApiKey { get; set; } = string.Empty; - public string BaseUrl { get; set; } = string.Empty; -} -``` - -**appsettings.json:** - -```json -{ - "App": { - "ExternalServices": { - "PaymentGateway": { - "ApiKey": "your-api-key", - "BaseUrl": "https://payment-api.com" - } - } - } -} -``` - -### ✅ Validation - -#### 🏷️ Data Annotations Validation - -```csharp -using System.ComponentModel.DataAnnotations; - -[OptionsBinding("Email", ValidateDataAnnotations = true)] -public partial class EmailOptions -{ - [Required, EmailAddress] - public string SmtpServer { get; set; } = string.Empty; - - [Range(1, 65535)] - public int Port { get; set; } = 587; - - [Required] - public string Username { get; set; } = string.Empty; -} -``` - -**Generated Code:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("Email")) - .ValidateDataAnnotations() - ; -``` - -#### 🚀 Validate on Startup - -Ensure options are valid when the application starts: - -```csharp -[OptionsBinding("Database", ValidateOnStart = true)] -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; -} -``` - -**Generated Code:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateOnStart() - ; -``` - -#### 🔗 Combined Validation - -```csharp -[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required, MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; -} -``` - -#### 🎯 Custom Validation (IValidateOptions) - -For complex validation logic that goes beyond DataAnnotations, use custom validators implementing `IValidateOptions`: - -```csharp -using Microsoft.Extensions.Options; - -// Options class with custom validator -[OptionsBinding("Database", - ValidateDataAnnotations = true, - ValidateOnStart = true, - Validator = typeof(DatabaseOptionsValidator))] -public partial class DatabaseOptions -{ - [Required, MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; - - public int TimeoutSeconds { get; set; } = 30; -} - -// Custom validator with complex business rules -public class DatabaseOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, DatabaseOptions options) - { - var failures = new List(); - - // Custom validation: timeout must be at least 10 seconds - if (options.TimeoutSeconds < 10) - { - failures.Add("TimeoutSeconds must be at least 10 seconds for reliable operations"); - } - - // Custom validation: connection string must contain Server or Data Source - if (!string.IsNullOrWhiteSpace(options.ConnectionString)) - { - var connStr = options.ConnectionString.ToLowerInvariant(); - if (!connStr.Contains("server=") && !connStr.Contains("data source=")) - { - failures.Add("ConnectionString must contain 'Server=' or 'Data Source=' parameter"); - } - } - - return failures.Count > 0 - ? ValidateOptionsResult.Fail(failures) - : ValidateOptionsResult.Success; - } -} -``` - -**Generated Code:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -services.AddSingleton, - global::MyApp.Options.DatabaseOptionsValidator>(); -``` - -**Key Features:** - -- Supports complex validation logic beyond DataAnnotations -- Validator is automatically registered as a singleton -- Runs during options validation pipeline -- Can validate cross-property dependencies -- Returns detailed failure messages - -#### 🚨 Error on Missing Configuration Keys - -The `ErrorOnMissingKeys` feature provides fail-fast validation when configuration sections are missing, preventing runtime errors from invalid or missing configuration. - -**When to use:** - -- Critical configuration that must be present (database connections, API keys, etc.) -- Detect configuration issues at application startup instead of later at runtime -- Ensure deployment validation catches missing configuration files or sections - -**Example:** - -```csharp -using System.ComponentModel.DataAnnotations; - -[OptionsBinding("Database", - ValidateDataAnnotations = true, - ValidateOnStart = true, - ErrorOnMissingKeys = true)] -public partial class DatabaseOptions -{ - [Required, MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; - - public int TimeoutSeconds { get; set; } = 30; -} -``` - -**Generated Code:** - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("Database")) - .Validate(options => - { - var section = configuration.GetSection("Database"); - if (!section.Exists()) - { - throw new global::System.InvalidOperationException( - "Configuration section 'Database' is missing. " + - "Ensure the section exists in your appsettings.json or other configuration sources."); - } - - return true; - }) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -**Behavior:** - -- Validates that the configuration section exists using `IConfigurationSection.Exists()` -- Throws `InvalidOperationException` with descriptive message if section is missing -- Combines with `ValidateOnStart = true` to fail at startup (recommended) -- Error message includes the section name for easy troubleshooting - -**Best Practices:** - -- Always combine with `ValidateOnStart = true` to catch missing configuration at startup -- Use for production-critical configuration (databases, external services, etc.) -- Avoid for optional configuration with reasonable defaults -- Ensure deployment processes validate configuration files exist - -**Example Error Message:** - -``` -System.InvalidOperationException: Configuration section 'Database' is missing. -Ensure the section exists in your appsettings.json or other configuration sources. -``` - -### ⏱️ Options Lifetimes - -Control which options interface consumers should inject. **All three interfaces are always available**, but the `Lifetime` property indicates the recommended interface for your use case: - -```csharp -// Singleton lifetime - Use IOptions -// Best for: Options that don't change during app lifetime -[OptionsBinding("Logging", Lifetime = OptionsLifetime.Singleton)] -public partial class LoggingOptions { } - -// Scoped lifetime - Use IOptionsSnapshot -// Best for: Options that may change per request/scope, supports reloading -[OptionsBinding("Request", Lifetime = OptionsLifetime.Scoped)] -public partial class RequestOptions { } - -// Monitor lifetime - Use IOptionsMonitor -// Best for: Options that need change notifications and hot-reload support -[OptionsBinding("Feature", Lifetime = OptionsLifetime.Monitor)] -public partial class FeatureOptions { } -``` - -**How to consume:** - -```csharp -// Singleton - IOptions (default, most common) -public class MyService -{ - public MyService(IOptions options) - { - var logOptions = options.Value; - } -} - -// Scoped - IOptionsSnapshot (reloads per request) -public class RequestHandler -{ - public RequestHandler(IOptionsSnapshot options) - { - var reqOptions = options.Value; // Fresh value per request - } -} - -// Monitor - IOptionsMonitor (supports change notifications) -public class FeatureManager -{ - public FeatureManager(IOptionsMonitor options) - { - var features = options.CurrentValue; // Current value - - // Subscribe to changes - options.OnChange(newOptions => - { - // Handle configuration changes - }); - } -} -``` - -**Important Notes:** - -- `AddOptions()` registers **all three interfaces** automatically -- The `Lifetime` property is a **recommendation** for which interface to inject -- Default is `Singleton` (IOptions) if not specified -- The generated code includes comments indicating the recommended interface - ---- - -### 🔔 Configuration Change Callbacks - -Automatically respond to configuration changes at runtime using the `OnChange` property. This feature enables hot-reload of configuration without restarting your application. - -**Requirements:** - -- Must use `Lifetime = OptionsLifetime.Monitor` -- Requires appsettings.json with `reloadOnChange: true` -- Cannot be used with named options -- Callback method must have signature: `static void MethodName(TOptions options, string? name)` - -**Basic Example:** - -```csharp -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))] -public partial class FeaturesOptions -{ - public bool EnableNewUI { get; set; } - public bool EnableBetaFeatures { get; set; } - - internal static void OnFeaturesChanged(FeaturesOptions options, string? name) - { - Console.WriteLine("[OnChange] Feature flags changed:"); - Console.WriteLine($" EnableNewUI: {options.EnableNewUI}"); - Console.WriteLine($" EnableBetaFeatures: {options.EnableBetaFeatures}"); - } -} -``` - -**Generated Code:** - -The generator automatically creates an `IHostedService` that registers the callback: - -```csharp -// Registration in extension method -services.AddOptions() - .Bind(configuration.GetSection("Features")); - -services.AddHostedService(); - -// Generated hosted service -internal sealed class FeaturesOptionsChangeListener : IHostedService -{ - private readonly IOptionsMonitor _monitor; - private IDisposable? _changeToken; - - public FeaturesOptionsChangeListener(IOptionsMonitor monitor) - { - _monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _changeToken = _monitor.OnChange((options, name) => - FeaturesOptions.OnFeaturesChanged(options, name)); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _changeToken?.Dispose(); - return Task.CompletedTask; - } -} -``` - -**Usage Scenarios:** - -```csharp -// ✅ Feature toggles that change without restart -[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))] -public partial class FeaturesOptions -{ - public bool EnableNewUI { get; set; } - - internal static void OnFeaturesChanged(FeaturesOptions options, string? name) - { - // Update feature flag cache, notify observers, etc. - } -} - -// ✅ Logging configuration changes -[OptionsBinding("Logging", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnLoggingChanged))] -public partial class LoggingOptions -{ - public string Level { get; set; } = "Information"; - - internal static void OnLoggingChanged(LoggingOptions options, string? name) - { - // Reconfigure logging providers with new level - } -} - -// ✅ Combined with validation -[OptionsBinding("Database", - Lifetime = OptionsLifetime.Monitor, - ValidateDataAnnotations = true, - ValidateOnStart = true, - OnChange = nameof(OnDatabaseChanged))] -public partial class DatabaseOptions -{ - [Required] public string ConnectionString { get; set; } = string.Empty; - - internal static void OnDatabaseChanged(DatabaseOptions options, string? name) - { - // Refresh connection pools, update database context, etc. - } -} -``` - -**Validation Errors:** - -The generator performs compile-time validation of OnChange callbacks: - -- **ATCOPT004**: OnChange callback requires Monitor lifetime - - ```csharp - // ❌ Error: Must use Lifetime = OptionsLifetime.Monitor - [OptionsBinding("Settings", OnChange = nameof(OnChanged))] - public partial class Settings { } - ``` - -- **ATCOPT005**: OnChange callback not supported with named options - - ```csharp - // ❌ Error: Named options don't support OnChange - [OptionsBinding("Email", Name = "Primary", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnChanged))] - public partial class EmailOptions { } - ``` - -- **ATCOPT006**: OnChange callback method not found - - ```csharp - // ❌ Error: Method 'OnSettingsChanged' does not exist - [OptionsBinding("Settings", Lifetime = OptionsLifetime.Monitor, OnChange = "OnSettingsChanged")] - public partial class Settings { } - ``` - -- **ATCOPT007**: OnChange callback method has invalid signature - - ```csharp - // ❌ Error: Must be static void with (TOptions, string?) parameters - [OptionsBinding("Settings", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnChanged))] - public partial class Settings - { - private void OnChanged(Settings options) { } // Wrong: not static, missing 2nd parameter - } - ``` - -**Important Notes:** - -- Change detection only works with file-based configuration providers (e.g., appsettings.json with `reloadOnChange: true`) -- The callback is invoked whenever the configuration file changes and is reloaded -- The hosted service is automatically registered when the application starts -- Callback method can be `internal` or `public` (not `private`) -- The `name` parameter is useful when dealing with named options in other scenarios (always null for unnamed options) - ---- - -### 🔧 Post-Configuration Support - -Automatically normalize, validate, or transform configuration values after binding using the `PostConfigure` property. This feature enables applying defaults, normalizing paths, lowercasing URLs, or computing derived properties. - -**Requirements:** - -- Cannot be used with named options -- Callback method must have signature: `static void MethodName(TOptions options)` -- Runs after binding and validation - -**Basic Example:** - -```csharp -[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))] -public partial class StoragePathsOptions -{ - public string BasePath { get; set; } = string.Empty; - public string CachePath { get; set; } = string.Empty; - public string TempPath { get; set; } = string.Empty; - - private static void NormalizePaths(StoragePathsOptions options) - { - // Ensure all paths end with directory separator - options.BasePath = EnsureTrailingSlash(options.BasePath); - options.CachePath = EnsureTrailingSlash(options.CachePath); - options.TempPath = EnsureTrailingSlash(options.TempPath); - } - - private static string EnsureTrailingSlash(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return path; - - return path.EndsWith(Path.DirectorySeparatorChar) - ? path - : path + Path.DirectorySeparatorChar; - } -} -``` - -**Generated Code:** - -The generator automatically calls `.PostConfigure()` after binding: - -```csharp -services.AddOptions() - .Bind(configuration.GetSection("Storage")) - .PostConfigure(options => StoragePathsOptions.NormalizePaths(options)); -``` - -**Usage Scenarios:** - -```csharp -// Path normalization - ensure trailing slashes -[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))] -public partial class StoragePathsOptions -{ - public string BasePath { get; set; } = string.Empty; - public string CachePath { get; set; } = string.Empty; - - private static void NormalizePaths(StoragePathsOptions options) - { - options.BasePath = EnsureTrailingSlash(options.BasePath); - options.CachePath = EnsureTrailingSlash(options.CachePath); - } - - private static string EnsureTrailingSlash(string path) - => string.IsNullOrWhiteSpace(path) || path.EndsWith(Path.DirectorySeparatorChar) - ? path - : path + Path.DirectorySeparatorChar; -} - -// URL normalization - lowercase and remove trailing slashes -[OptionsBinding("ExternalApi", PostConfigure = nameof(NormalizeUrls))] -public partial class ExternalApiOptions -{ - public string BaseUrl { get; set; } = string.Empty; - public string CallbackUrl { get; set; } = string.Empty; - - private static void NormalizeUrls(ExternalApiOptions options) - { - options.BaseUrl = NormalizeUrl(options.BaseUrl); - options.CallbackUrl = NormalizeUrl(options.CallbackUrl); - } - - private static string NormalizeUrl(string url) - { - if (string.IsNullOrWhiteSpace(url)) - return url; - - // Lowercase and remove trailing slash - return url.ToLowerInvariant().TrimEnd('/'); - } -} - -// Combined with validation -[OptionsBinding("Database", - ValidateDataAnnotations = true, - ValidateOnStart = true, - PostConfigure = nameof(ApplyDefaults))] -public partial class DatabaseOptions -{ - [Required] public string ConnectionString { get; set; } = string.Empty; - public int CommandTimeout { get; set; } - - private static void ApplyDefaults(DatabaseOptions options) - { - // Apply default timeout if not set - if (options.CommandTimeout <= 0) - { - options.CommandTimeout = 30; - } - } -} -``` - -**Validation Errors:** - -The generator performs compile-time validation of PostConfigure callbacks: - -- **ATCOPT008**: PostConfigure callback not supported with named options - - ```csharp - // Error: Named options don't support PostConfigure - [OptionsBinding("Email", Name = "Primary", PostConfigure = nameof(Normalize))] - public partial class EmailOptions { } - ``` - -- **ATCOPT009**: PostConfigure callback method not found - - ```csharp - // Error: Method 'ApplyDefaults' does not exist - [OptionsBinding("Settings", PostConfigure = "ApplyDefaults")] - public partial class Settings { } - ``` - -- **ATCOPT010**: PostConfigure callback method has invalid signature - - ```csharp - // Error: Must be static void with (TOptions) parameter - [OptionsBinding("Settings", PostConfigure = nameof(Configure))] - public partial class Settings - { - private void Configure() { } // Wrong: not static, missing parameter - } - ``` - -**Important Notes:** - -- PostConfigure runs **after** binding and validation -- Callback method can be `internal` or `public` (not `private`) -- Cannot be combined with named options (use manual `.PostConfigure()` if needed) -- Perfect for normalizing user input, applying business rules, or computed properties -- Order of execution: Bind → Validate → PostConfigure - ---- - -### 🎛️ ConfigureAll Support - -Set default values for **all named options instances** before individual configuration binding. This feature is perfect for establishing common baseline settings across multiple named configurations that can then be selectively overridden. - -**Requirements:** - -- Requires multiple named instances (at least 2) -- Callback method must have signature: `static void MethodName(TOptions options)` -- Runs **before** individual `Configure()` calls - -**Basic Example:** - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; - public int TimeoutSeconds { get; set; } = 30; - public int MaxRetries { get; set; } - - internal static void SetDefaults(EmailOptions options) - { - // Set common defaults for ALL email configurations - options.UseSsl = true; - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.Port = 587; - } -} -``` - -**Generated Code:** - -The generator automatically calls `.ConfigureAll()` **before** individual configurations: - -```csharp -// Configure defaults for ALL named instances FIRST -services.ConfigureAll(options => EmailOptions.SetDefaults(options)); - -// Then configure individual instances (can override defaults) -services.Configure("Primary", configuration.GetSection("Email:Primary")); -services.Configure("Secondary", configuration.GetSection("Email:Secondary")); -services.Configure("Fallback", configuration.GetSection("Email:Fallback")); -``` - -**Usage Scenarios:** - -```csharp -// Notification channels - common defaults for all channels -[OptionsBinding("Notifications:Email", Name = "Email", ConfigureAll = nameof(SetCommonDefaults))] -[OptionsBinding("Notifications:SMS", Name = "SMS")] -[OptionsBinding("Notifications:Push", Name = "Push")] -public partial class NotificationOptions -{ - public bool Enabled { get; set; } - public int TimeoutSeconds { get; set; } = 30; - public int MaxRetries { get; set; } = 3; - public int RateLimitPerMinute { get; set; } - - internal static void SetCommonDefaults(NotificationOptions options) - { - // All notification channels start with these defaults - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.RateLimitPerMinute = 60; - options.Enabled = true; - } -} - -// Database connections - common retry and timeout defaults -[OptionsBinding("Database:Primary", Name = "Primary", ConfigureAll = nameof(SetConnectionDefaults))] -[OptionsBinding("Database:ReadReplica", Name = "ReadReplica")] -[OptionsBinding("Database:Analytics", Name = "Analytics")] -public partial class DatabaseConnectionOptions -{ - public string ConnectionString { get; set; } = string.Empty; - public int MaxRetries { get; set; } - public int CommandTimeoutSeconds { get; set; } - public bool EnableRetry { get; set; } - - internal static void SetConnectionDefaults(DatabaseConnectionOptions options) - { - // All database connections start with these baseline settings - options.MaxRetries = 3; - options.CommandTimeoutSeconds = 30; - options.EnableRetry = true; - } -} -``` - -**Validation Errors:** - -The generator performs compile-time validation of ConfigureAll callbacks: - -- **ATCOPT011**: ConfigureAll requires multiple named options - - ```csharp - // Error: ConfigureAll needs at least 2 named instances - [OptionsBinding("Settings", Name = "Default", ConfigureAll = nameof(SetDefaults))] - public partial class Settings { } - ``` - -- **ATCOPT012**: ConfigureAll callback method not found - - ```csharp - // Error: Method 'SetDefaults' does not exist - [OptionsBinding("Email", Name = "Primary", ConfigureAll = "SetDefaults")] - [OptionsBinding("Email", Name = "Secondary")] - public partial class EmailOptions { } - ``` - -- **ATCOPT013**: ConfigureAll callback method has invalid signature - - ```csharp - // Error: Must be static void with (TOptions) parameter - [OptionsBinding("Email", Name = "Primary", ConfigureAll = nameof(Configure))] - [OptionsBinding("Email", Name = "Secondary")] - public partial class EmailOptions - { - private void Configure() { } // Wrong: not static, missing parameter - } - ``` - -**Important Notes:** - -- ConfigureAll runs **before** individual named instance configurations -- Individual configurations can override defaults set by ConfigureAll -- Callback method can be `internal` or `public` (not `private`) -- **Requires multiple named instances** - cannot be used with single unnamed instance -- Perfect for establishing baseline settings across multiple configurations -- Order of execution: ConfigureAll → Configure("Name1") → Configure("Name2") → ... -- Can be specified on any one of the `[OptionsBinding]` attributes (only processed once) - ---- - -## 🔧 How It Works - -### 1️⃣ Attribute Detection - -The generator scans your code for classes decorated with `[OptionsBinding]`: - -```csharp -[OptionsBinding("Database")] -public partial class DatabaseOptions { } -``` - -### 2️⃣ Section Name Resolution - -The generator resolves section names in the following priority order: - -1. **Explicit section name** - Provided in the attribute constructor parameter - - ```csharp - [OptionsBinding("App:Database")] - public partial class DatabaseOptions { } // Uses "App:Database" - ``` - -2. **`public const string SectionName`** - Defined in the options class (2nd highest priority) - - ```csharp - [OptionsBinding] - public partial class DatabaseOptions - { - public const string SectionName = "CustomDatabase"; // Uses "CustomDatabase" - } - ``` - -3. **`public const string NameTitle`** - Defined in the options class (takes priority over `Name`) - - ```csharp - [OptionsBinding] - public partial class CacheOptions - { - public const string NameTitle = "MyCache"; // Uses "MyCache" - } - ``` - -4. **`public const string Name`** - Defined in the options class - - ```csharp - [OptionsBinding] - public partial class EmailOptions - { - public const string Name = "EmailConfig"; // Uses "EmailConfig" - } - ``` - -5. **Auto-inferred** - Uses the full class name as-is: - - `DatabaseOptions` → `"DatabaseOptions"` - - `ApiSettings` → `"ApiSettings"` - - `LoggingConfig` → `"LoggingConfig"` - - `CacheConfiguration` → `"CacheConfiguration"` - -### 3️⃣ Code Generation - -Generates an extension method for your assembly: - -```csharp -public static IServiceCollection AddOptionsFrom{AssemblyName}( - this IServiceCollection services, - IConfiguration configuration) -{ - // Registration code for each options class -} -``` - -### 4️⃣ Compile-Time Safety - -All code is generated at compile time, ensuring: - -- ✅ Type safety -- ✅ No runtime reflection -- ✅ IntelliSense support -- ✅ Easy debugging - ---- - -## 🎯 Advanced Scenarios - -### 🏢 Multiple Assemblies - -Each assembly gets its own extension method: - -**MyApp.Core:** - -```csharp -[OptionsBinding("Database")] -public partial class DatabaseOptions { } - -// Generated: AddOptionsFromAppCore(configuration) -``` - -**MyApp.Api:** - -```csharp -[OptionsBinding("Api")] -public partial class ApiOptions { } - -// Generated: AddOptionsFromAppApi(configuration) -``` - -**Program.cs:** - -```csharp -services.AddOptionsFromCore(configuration); -services.AddOptionsFromApi(configuration); -``` - -### ✨ Smart Naming - -The generator uses **smart suffix-based naming** to create cleaner, more readable method names: - -**How it works:** - -- ✅ If the assembly suffix (last segment after final dot) is **unique** among all assemblies → use short suffix -- ⚠️ If multiple assemblies have the **same suffix** → use full sanitized name to avoid conflicts - -**Examples:** - -```csharp -// ✅ Unique suffixes (cleaner names) -PetStore.Domain → AddOptionsFromDomain(configuration) -PetStore.DataAccess → AddOptionsFromDataAccess(configuration) -PetStore.Api → AddOptionsFromApi(configuration) - -// ⚠️ Conflicting suffixes (full names prevent collisions) -PetStore.Domain → AddOptionsFromPetStoreDomain(configuration) -AnotherApp.Domain → AddOptionsFromAnotherAppDomain(configuration) -``` - -**Benefits:** - -- 🎯 **Cleaner API**: Shorter method names when there are no conflicts -- 🛡️ **Automatic Conflict Prevention**: Fallback to full names prevents naming collisions -- ⚡ **Zero Configuration**: Works automatically based on compilation context -- 🔄 **Context-Aware**: Method names adapt to the assemblies in your solution - -### 📂 Nested Configuration (Feature #6: Bind Configuration Subsections to Properties) - -The generator automatically handles nested configuration subsections through Microsoft's `.Bind()` method. Complex properties are automatically bound to their corresponding configuration subsections. - -#### 🎯 How It Works - -When you have properties that are complex types (not primitives like string, int, etc.), the configuration binder automatically: - -1. Detects the property is a complex type -2. Looks for a subsection with the same name -3. Recursively binds that subsection to the property - -This works for: - -- **Nested objects** - Properties with custom class types -- **Collections** - List, IEnumerable, arrays -- **Dictionaries** - Dictionary, Dictionary -- **Multiple levels** - Deeply nested structures (e.g., CloudStorage → Azure → Blob) - -#### 📋 Example 1: Simple Nested Objects - -```csharp -[OptionsBinding("Email")] -public partial class EmailOptions -{ - public string From { get; set; } = string.Empty; - - // Automatically binds to "Email:Smtp" subsection - public SmtpSettings Smtp { get; set; } = new(); -} - -public class SmtpSettings -{ - public string Host { get; set; } = string.Empty; - public int Port { get; set; } - public bool UseSsl { get; set; } -} -``` - -```json -{ - "Email": { - "From": "noreply@example.com", - "Smtp": { - "Host": "smtp.example.com", - "Port": 587, - "UseSsl": true - } - } -} -``` - -#### 📋 Example 2: Deeply Nested Objects (3 Levels) - -```csharp -[OptionsBinding("Storage", ValidateDataAnnotations = true)] -public partial class StorageOptions -{ - // Automatically binds to "Storage:Database" subsection - public DatabaseSettings Database { get; set; } = new(); -} - -public class DatabaseSettings -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 1000)] - public int MaxConnections { get; set; } = 100; - - // Automatically binds to "Storage:Database:Retry" subsection (3 levels deep!) - public DatabaseRetryPolicy Retry { get; set; } = new(); -} - -public class DatabaseRetryPolicy -{ - [Range(0, 10)] - public int MaxAttempts { get; set; } = 3; - - [Range(100, 10000)] - public int DelayMilliseconds { get; set; } = 500; -} -``` - -```json -{ - "Storage": { - "Database": { - "ConnectionString": "Server=localhost;Database=PetStoreDb;", - "MaxConnections": 100, - "Retry": { - "MaxAttempts": 3, - "DelayMilliseconds": 500 - } - } - } -} -``` - -#### 📋 Example 3: Real-World Scenario (Cloud Storage) - -```csharp -[OptionsBinding("CloudStorage", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class CloudStorageOptions -{ - [Required] - public string Provider { get; set; } = string.Empty; - - // Binds to "CloudStorage:Azure" - public AzureStorageSettings Azure { get; set; } = new(); - - // Binds to "CloudStorage:Aws" - public AwsS3Settings Aws { get; set; } = new(); - - // Binds to "CloudStorage:RetryPolicy" - public RetryPolicy RetryPolicy { get; set; } = new(); -} - -public class AzureStorageSettings -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - public string ContainerName { get; set; } = string.Empty; - - // Binds to "CloudStorage:Azure:Blob" (deeply nested!) - public BlobSettings Blob { get; set; } = new(); -} - -public class BlobSettings -{ - public int MaxBlockSize { get; set; } = 4194304; // 4 MB - public int ParallelOperations { get; set; } = 8; -} -``` - -```json -{ - "CloudStorage": { - "Provider": "Azure", - "Azure": { - "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=myaccount;", - "ContainerName": "my-container", - "Blob": { - "MaxBlockSize": 4194304, - "ParallelOperations": 8 - } - }, - "Aws": { - "AccessKey": "AKIAIOSFODNN7EXAMPLE", - "SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Region": "us-west-2", - "BucketName": "my-bucket" - }, - "RetryPolicy": { - "MaxRetries": 3, - "DelayMilliseconds": 1000, - "UseExponentialBackoff": true - } - } -} -``` - -#### 🎯 Key Points - -- **Zero extra configuration** - Just declare properties with complex types -- **Automatic path construction** - "Parent:Child:GrandChild" paths are built automatically -- **Works with validation** - DataAnnotations validation applies to all nested levels -- **Unlimited depth** - Support for deeply nested structures -- **Collections supported** - List, arrays, dictionaries all work automatically - -#### 📍 Explicit Nested Paths - -You can also explicitly specify the full nested path in the attribute: - -```csharp -[OptionsBinding("App:Services:Email:Smtp")] -public partial class SmtpOptions -{ - public string Host { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -```json -{ - "App": { - "Services": { - "Email": { - "Smtp": { - "Host": "smtp.example.com", - "Port": 587 - } - } - } - } -} -``` - -### ⚡ Early Access to Options (Avoid BuildServiceProvider Anti-Pattern) - -**Problem:** Sometimes you need to access options values **during service registration** to make conditional decisions, but calling `BuildServiceProvider()` in the middle of registration is an anti-pattern that causes: - -- ❌ Memory leaks -- ❌ Scope issues -- ❌ Application instability - -**Solution:** The generator provides three APIs for early access to bound and validated options without building the service provider: - -| Method | Reads Cache | Writes Cache | Use Case | -|--------|-------------|--------------|----------| -| `Get[Type]From[Assembly]()` | ✅ Yes | ❌ No | Efficient retrieval (uses cached if available, no side effects) | -| `GetOrAdd[Type]From[Assembly]()` | ✅ Yes | ✅ Yes | Early access with caching for idempotency | -| `GetOptions()` | ✅ Yes | ❌ No | Smart dispatcher (calls Get internally, multi-assembly support) | - -#### 🎯 Key Features - -- ✅ **Efficient caching** - Get methods read from cache when available (no unnecessary instance creation) -- ✅ **No side effects** - Get and GetOptions don't populate cache (only GetOrAdd does) -- ✅ **Idempotent GetOrAdd** - Safe to call multiple times, returns the same instance -- ✅ **Smart dispatcher** - GetOptions() works in multi-assembly projects (routes to correct Get method) -- ✅ **Immediate validation** - DataAnnotations and ErrorOnMissingKeys validation happens immediately -- ✅ **PostConfigure support** - Applies PostConfigure callbacks before returning -- ✅ **Full integration** - Works seamlessly with normal `AddOptionsFrom*` methods -- ✅ **Unnamed options only** - Named options are excluded (use standard Options pattern for those) - -#### 📋 API Approaches - -**Approach 1: Get Methods (Pure Retrieval)** - -```csharp -// Reads cache but doesn't populate - efficient, no side effects -var dbOptions1 = services.GetDatabaseOptionsFromDomain(configuration); -var dbOptions2 = services.GetDatabaseOptionsFromDomain(configuration); -// If GetOrAdd was never called: dbOptions1 != dbOptions2 (creates fresh instances) -// If GetOrAdd was called first: dbOptions1 == dbOptions2 (returns cached) - -if (dbOptions1.EnableFeatureX) -{ - services.AddScoped(); -} -``` - -**Approach 2: GetOrAdd Methods (With Caching)** - -```csharp -// Populates cache on first call - use for idempotency -var dbOptions1 = services.GetOrAddDatabaseOptionsFromDomain(configuration); -var dbOptions2 = services.GetOrAddDatabaseOptionsFromDomain(configuration); -// dbOptions1 == dbOptions2 (always true - cached and reused) - -if (dbOptions1.EnableFeatureX) -{ - services.AddScoped(); -} -``` - -**Approach 3: Generic Smart Dispatcher (Multi-Assembly)** - -```csharp -// Convenience method - routes to Get method internally (no caching side effects) -// Works in multi-assembly projects! No CS0121 ambiguity -var dbOptions = services.GetOptions(configuration); - -if (dbOptions.EnableFeatureX) -{ - services.AddScoped(); -} -``` - -#### ⚖️ When to Use Which API - -**Use `Get[Type]...` when:** - -- ✅ You want efficient retrieval (benefits from cache if available) -- ✅ You don't want side effects (no cache population) -- ✅ You're okay with fresh instances if cache is empty - -**Use `GetOrAdd[Type]...` when:** - -- ✅ You need idempotency (same instance on repeated calls) -- ✅ You want to explicitly populate cache for later use -- ✅ You prefer explicit cache management - -**Use `GetOptions()` when:** - -- ✅ You want concise, generic syntax -- ✅ Working in multi-assembly projects (smart dispatcher routes correctly) -- ✅ You want same behavior as Get (efficient, no side effects) - -#### 📋 Basic Usage - -```csharp -// Step 1: Get options early during service registration -// Option A: Assembly-specific (always works, recommended) -var dbOptions = services.GetOrAddDatabaseOptionsFromDomain(configuration); - -// Option B: Generic (only in single-assembly projects) -// var dbOptions = services.GetOptions(configuration); - -// Step 2: Use options to make conditional registration decisions -if (dbOptions.EnableFeatureX) -{ - services.AddScoped(); -} - -if (dbOptions.MaxRetries > 0) -{ - services.AddSingleton(new RetryPolicy(dbOptions.MaxRetries)); -} - -// Step 3: Continue with normal registration (idempotent, no duplication) -services.AddOptionsFromDomain(configuration); - -// The options are now available via IOptions, IOptionsSnapshot, IOptionsMonitor -``` - -#### 🔄 Idempotency Example - -```csharp -// First call - creates, binds, validates, and caches -var dbOptions1 = services.GetOrAddDatabaseOptionsFromDomain(configuration); -Console.WriteLine($"MaxRetries: {dbOptions1.MaxRetries}"); - -// Second call - returns cached instance (no re-binding, no re-validation) -var dbOptions2 = services.GetOrAddDatabaseOptionsFromDomain(configuration); - -// Both references point to the same instance -Console.WriteLine($"Same instance? {ReferenceEquals(dbOptions1, dbOptions2)}"); // True -``` - -#### 🛡️ Validation Example - -```csharp -// Options class with validation -[OptionsBinding("Database", ValidateDataAnnotations = true, ErrorOnMissingKeys = true)] -public partial class DatabaseOptions -{ - [Required] - [MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } -} - -// Early access - validation happens immediately -try -{ - var dbOptions = services.GetOrAddDatabaseOptionsFromDomain(configuration); - // Validation passed - options are ready to use -} -catch (ValidationException ex) -{ - // Validation failed - caught at registration time, not runtime! - Console.WriteLine($"Configuration error: {ex.Message}"); -} -``` - -#### 🚀 Real-World Use Cases - -**1. Conditional Feature Registration:** - -```csharp -var featuresOptions = services.GetOrAddFeaturesOptionsFromDomain(configuration); - -if (featuresOptions.EnableRedisCache) -{ - services.AddStackExchangeRedisCache(options => - { - options.Configuration = featuresOptions.RedisCacheConnectionString; - }); -} -else -{ - services.AddDistributedMemoryCache(); -} -``` - -**2. Dynamic Service Configuration:** - -```csharp -var storageOptions = services.GetOrAddStorageOptionsFromDomain(configuration); - -services.AddScoped(sp => -{ - return storageOptions.Provider switch - { - "Azure" => new AzureBlobStorage(storageOptions.AzureConnectionString), - "AWS" => new S3Storage(storageOptions.AwsAccessKey, storageOptions.AwsSecretKey), - _ => new LocalFileStorage(storageOptions.LocalPath) - }; -}); -``` - -**3. Validation-Based Registration:** - -```csharp -var apiOptions = services.GetOrAddApiOptionsFromDomain(configuration); - -// Only register rate limiting if enabled in config -if (apiOptions.EnableRateLimiting && apiOptions.RateLimitPerMinute > 0) -{ - services.AddRateLimiting(options => - { - options.RequestsPerMinute = apiOptions.RateLimitPerMinute; - }); -} -``` - -#### ⚙️ How It Works - -1. **First Call** to `GetOrAdd{OptionsName}()`: - - Creates new options instance - - Binds from configuration section - - Validates (DataAnnotations, ErrorOnMissingKeys) - - Applies PostConfigure callbacks - - Adds to internal cache - - Registers via `services.Configure()` - - Returns bound instance - -2. **Subsequent Calls**: - - Checks internal cache - - Returns existing instance (no re-binding, no re-validation) - -3. **Normal `AddOptionsFrom*` Call**: - - Automatically populates cache via `.PostConfigure()` - - Options available via `IOptions`, `IOptionsSnapshot`, `IOptionsMonitor` - -#### ⚠️ Limitations - -- **Unnamed options only** - Named options (`Name` property) do not generate `GetOrAdd*` methods -- **Singleton lifetime** - Early access options are registered as singleton -- **No OnChange support** - Early access is for registration-time decisions only - -#### 🎓 Best Practices - -✅ **DO:** - -- Use early access for conditional service registration -- Use early access for dynamic service configuration -- Call `GetOrAdd*` methods before `AddOptionsFrom*` -- Validate options at registration time - -❌ **DON'T:** - -- Use early access for runtime decisions (use `IOptions` instead) -- Call `BuildServiceProvider()` during registration -- Mix early access with named options (not supported) - -### 🌍 Environment-Specific Configuration - -```csharp -var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{environment}.json", optional: true) - .AddEnvironmentVariables() - .Build(); - -services.AddOptionsFromApp(configuration); -``` - -### 📛 Named Options (Multiple Configurations) - -**Named Options** allow you to have multiple configurations of the same options type with different names. This is useful when you need different configurations for the same logical service (e.g., Primary/Secondary email servers, Production/Staging databases). - -#### ✨ Use Cases - -- **🔄 Fallback Servers**: Primary, Secondary, and Fallback email/database servers -- **🌍 Multi-Region**: Different API endpoints for different regions (US, EU, Asia) -- **🎯 Multi-Tenant**: Tenant-specific configurations -- **🔧 Environment Tiers**: Production, Staging, Development endpoints - -#### 🎯 Basic Example - -**Define options with multiple named instances:** - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary")] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; - public bool UseSsl { get; set; } = true; - public string FromAddress { get; set; } = string.Empty; -} -``` - -**Configure appsettings.json:** - -```json -{ - "Email": { - "Primary": { - "SmtpServer": "smtp.primary.example.com", - "Port": 587, - "UseSsl": true, - "FromAddress": "noreply@primary.example.com" - }, - "Secondary": { - "SmtpServer": "smtp.secondary.example.com", - "Port": 587, - "UseSsl": true, - "FromAddress": "noreply@secondary.example.com" - }, - "Fallback": { - "SmtpServer": "smtp.fallback.example.com", - "Port": 25, - "UseSsl": false, - "FromAddress": "noreply@fallback.example.com" - } - } -} -``` - -**Access named options using IOptionsSnapshot:** - -```csharp -public class EmailService -{ - private readonly IOptionsSnapshot _emailOptionsSnapshot; - - public EmailService(IOptionsSnapshot emailOptionsSnapshot) - { - _emailOptionsSnapshot = emailOptionsSnapshot; - } - - public async Task SendAsync(string to, string body) - { - // Try primary first - var primaryOptions = _emailOptionsSnapshot.Get("Primary"); - if (await TrySendAsync(primaryOptions, to, body)) - return; - - // Fallback to secondary - var secondaryOptions = _emailOptionsSnapshot.Get("Secondary"); - if (await TrySendAsync(secondaryOptions, to, body)) - return; - - // Last resort: fallback server - var fallbackOptions = _emailOptionsSnapshot.Get("Fallback"); - await TrySendAsync(fallbackOptions, to, body); - } -} -``` - -#### 🔧 Generated Code - -```csharp -// Generated registration methods -services.Configure("Primary", configuration.GetSection("Email:Primary")); -services.Configure("Secondary", configuration.GetSection("Email:Secondary")); -services.Configure("Fallback", configuration.GetSection("Email:Fallback")); -``` - -#### ⚠️ Important Notes - -- **📝 Use `IOptionsSnapshot`**: Named options are accessed via `IOptionsSnapshot.Get(name)`, not `IOptions.Value` -- **🚫 No Validation Chain**: Named options use the simpler `Configure(name, section)` pattern without validation support -- **🔄 AllowMultiple**: The `[OptionsBinding]` attribute supports `AllowMultiple = true` to enable multiple configurations - -#### 🎯 Mixing Named and Unnamed Options - -You can have both named and unnamed options on the same class: - -```csharp -// Default unnamed instance -[OptionsBinding("Email")] - -// Named instances for specific use cases -[OptionsBinding("Email:Backup", Name = "Backup")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -```csharp -// Access default (unnamed) instance -var defaultEmail = serviceProvider.GetRequiredService>(); - -// Access named instances -var emailSnapshot = serviceProvider.GetRequiredService>(); -var backupEmail = emailSnapshot.Get("Backup"); -``` - ---- - -### 🎯 Child Sections (Simplified Named Options) - -**Child Sections** provide a concise syntax for creating multiple named options instances from configuration subsections. Instead of writing multiple `[OptionsBinding]` attributes for each named instance, you can use a single `ChildSections` property. - -#### ✨ Use Cases - -- **🔄 Fallback Servers**: Automatically create Primary/Secondary/Fallback email configurations -- **📢 Notification Channels**: Email, SMS, Push notification configurations from a single attribute -- **🌍 Multi-Region**: Different regional configurations (USEast, USWest, EUWest) -- **🔧 Multi-Tenant**: Tenant-specific configurations (Tenant1, Tenant2, Tenant3) - -#### 🎯 Basic Example - -**Before (Multiple Attributes):** - -```csharp -[OptionsBinding("Email:Primary", Name = "Primary")] -[OptionsBinding("Email:Secondary", Name = "Secondary")] -[OptionsBinding("Email:Fallback", Name = "Fallback")] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -**After (With ChildSections):** - -```csharp -[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" })] -public partial class EmailOptions -{ - public string SmtpServer { get; set; } = string.Empty; - public int Port { get; set; } = 587; -} -``` - -**Both generate identical code:** - -```csharp -services.Configure("Primary", configuration.GetSection("Email:Primary")); -services.Configure("Secondary", configuration.GetSection("Email:Secondary")); -services.Configure("Fallback", configuration.GetSection("Email:Fallback")); -``` - -#### 📋 Configuration Structure - -```json -{ - "Email": { - "Primary": { - "SmtpServer": "smtp.primary.example.com", - "Port": 587 - }, - "Secondary": { - "SmtpServer": "smtp.secondary.example.com", - "Port": 587 - }, - "Fallback": { - "SmtpServer": "smtp.fallback.example.com", - "Port": 25 - } - } -} -``` - -#### 🔧 Advanced Features - -**With Validation:** - -```csharp -[OptionsBinding("Database", - ChildSections = new[] { "Primary", "Secondary" }, - ValidateDataAnnotations = true, - ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; -} -``` - -**Generated Code:** - -```csharp -services.AddOptions("Primary") - .Bind(configuration.GetSection("Database:Primary")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -services.AddOptions("Secondary") - .Bind(configuration.GetSection("Database:Secondary")) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -**With ConfigureAll (Common Defaults):** - -```csharp -[OptionsBinding("Notifications", - ChildSections = new[] { "Email", "SMS", "Push" }, - ConfigureAll = nameof(SetCommonDefaults))] -public partial class NotificationOptions -{ - public bool Enabled { get; set; } - public int TimeoutSeconds { get; set; } = 30; - public int MaxRetries { get; set; } = 3; - - internal static void SetCommonDefaults(NotificationOptions options) - { - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.Enabled = true; - } -} -``` - -**Generated Code:** - -```csharp -// Set defaults for ALL notification channels FIRST -services.ConfigureAll(options => - NotificationOptions.SetCommonDefaults(options)); - -// Then configure individual channels -services.Configure("Email", configuration.GetSection("Notifications:Email")); -services.Configure("SMS", configuration.GetSection("Notifications:SMS")); -services.Configure("Push", configuration.GetSection("Notifications:Push")); -``` - -#### 📍 Nested Paths - -ChildSections works with nested configuration paths: - -```csharp -[OptionsBinding("App:Services:Cache", ChildSections = new[] { "Redis", "Memory" })] -public partial class CacheOptions -{ - public string Provider { get; set; } = string.Empty; - public int ExpirationMinutes { get; set; } -} -``` - -**Generated paths:** - -- `"App:Services:Cache:Redis"` -- `"App:Services:Cache:Memory"` - -#### 🚨 Validation Rules - -The generator performs compile-time validation: - -- **ATCOPT014**: ChildSections cannot be used with `Name` property - - ```csharp - // ❌ Error: Cannot use both ChildSections and Name - [OptionsBinding("Email", Name = "Primary", ChildSections = new[] { "A", "B" })] - public partial class EmailOptions { } - ``` - -- **ATCOPT015**: ChildSections requires at least 2 items - - ```csharp - // ❌ Error: Must have at least 2 child sections - [OptionsBinding("Email", ChildSections = new[] { "Primary" })] - public partial class EmailOptions { } - - // ❌ Error: Empty array not allowed - [OptionsBinding("Email", ChildSections = new string[] { })] - public partial class EmailOptions { } - ``` - -- **ATCOPT016**: ChildSections items cannot be null or empty - - ```csharp - // ❌ Error: Array contains empty string - [OptionsBinding("Email", ChildSections = new[] { "Primary", "", "Secondary" })] - public partial class EmailOptions { } - ``` - -#### 💡 Key Benefits - -1. **Less Boilerplate**: One attribute instead of many -2. **Clearer Intent**: Explicitly shows related configurations are grouped -3. **Easier Maintenance**: Add/remove sections by updating the array -4. **Same Features**: Supports all named options features (validation, ConfigureAll, etc.) - -#### 📊 ChildSections vs Multiple Attributes - -| Feature | Multiple `[OptionsBinding]` | `ChildSections` | -|---------|----------------------------|----------------| -| Verbosity | 3+ attributes | 1 attribute | -| Named instances | ✅ Yes | ✅ Yes | -| Validation | ✅ Yes | ✅ Yes | -| ConfigureAll | ✅ Yes | ✅ Yes | -| Custom validators | ✅ Yes | ✅ Yes | -| ErrorOnMissingKeys | ✅ Yes | ✅ Yes | -| Clarity | Explicit | Concise | -| Use case | Few instances | Many instances | - -**Recommendation:** - -- Use `ChildSections` when you have 2+ related configurations under a common parent section -- Use multiple `[OptionsBinding]` attributes when configurations come from different sections - -#### 🎯 Real-World Example - -**Notification system with multiple channels:** - -```csharp -/// -/// Notification channel options with support for multiple named configurations. -/// -[OptionsBinding("Notifications", - ChildSections = new[] { "Email", "SMS", "Push" }, - ConfigureAll = nameof(SetCommonDefaults), - ValidateDataAnnotations = true, - ValidateOnStart = true)] -public partial class NotificationOptions -{ - [Required] - public string Provider { get; set; } = string.Empty; - - public bool Enabled { get; set; } - - [Range(1, 300)] - public int TimeoutSeconds { get; set; } = 30; - - [Range(0, 10)] - public int MaxRetries { get; set; } = 3; - - internal static void SetCommonDefaults(NotificationOptions options) - { - options.TimeoutSeconds = 30; - options.MaxRetries = 3; - options.RateLimitPerMinute = 60; - options.Enabled = true; - } -} -``` - -**appsettings.json:** - -```json -{ - "Notifications": { - "Email": { - "Provider": "SendGrid", - "Enabled": true, - "TimeoutSeconds": 30, - "MaxRetries": 3 - }, - "SMS": { - "Provider": "Twilio", - "Enabled": false, - "TimeoutSeconds": 15, - "MaxRetries": 2 - }, - "Push": { - "Provider": "Firebase", - "Enabled": true, - "TimeoutSeconds": 20, - "MaxRetries": 3 - } - } -} -``` - -**Usage:** - -```csharp -public class NotificationService -{ - private readonly IOptionsSnapshot _notificationOptions; - - public NotificationService(IOptionsSnapshot notificationOptions) - { - _notificationOptions = notificationOptions; - } - - public async Task SendAsync(string channel, string message) - { - var options = _notificationOptions.Get(channel); - - if (!options.Enabled) - { - return; // Channel disabled - } - - // Send using the configured provider - await SendViaProvider(options.Provider, message, options.TimeoutSeconds); - } -} -``` - ---- - -## 🛡️ Diagnostics - -The generator provides helpful compile-time diagnostics: - -### ❌ ATCOPT001: Options class must be partial - -**Error:** - -```csharp -[OptionsBinding("Database")] -public class DatabaseOptions { } // ❌ Missing 'partial' keyword -``` - -**Fix:** - -```csharp -[OptionsBinding("Database")] -public partial class DatabaseOptions { } // ✅ Correct -``` - -### ❌ ATCOPT002: Section name cannot be null or empty - -**Error:** - -```csharp -[OptionsBinding("")] // ❌ Empty section name -public partial class DatabaseOptions { } -``` - -**Fix:** - -```csharp -[OptionsBinding("Database")] // ✅ Provide section name -public partial class DatabaseOptions { } - -// Or let it be inferred -[OptionsBinding] // ✅ Inferred as "Database" -public partial class DatabaseOptions { } -``` - -### ⚠️ ATCOPT003: Invalid options binding configuration - -**Warning:** - -General warning for invalid configuration scenarios. - -### ❌ ATCOPT003: Const section name cannot be null or empty - -**Error:** - -```csharp -[OptionsBinding] -public partial class DatabaseOptions -{ - public const string Name = ""; // ❌ Empty const value -} -``` - -```csharp -[OptionsBinding] -public partial class ApiOptions -{ - public const string NameTitle = null; // ❌ Null const value -} -``` - -**Fix:** - -```csharp -// Provide a valid const value -[OptionsBinding] -public partial class DatabaseOptions -{ - public const string Name = "MyDatabase"; // ✅ Valid const value -} - -// Or remove the const field to use auto-inference -[OptionsBinding] -public partial class DatabaseOptions // ✅ Inferred as "Database" -{ - // No const Name/NameTitle field -} -``` - -### ❌ ATCOPT004-007: OnChange Callback Diagnostics - -See [Configuration Change Callbacks](#-configuration-change-callbacks) section for details. - -### ❌ ATCOPT008-010: PostConfigure Callback Diagnostics - -See [Post-Configuration Support](#-post-configuration-support) section for details. - ---- - -### ❌ ATCOPT011-013: ConfigureAll Callback Diagnostics - -See [ConfigureAll Support](#️-configureall-support) section for details. - ---- - -### ❌ ATCOPT014-016: ChildSections Diagnostics - -See [Child Sections (Simplified Named Options)](#-child-sections-simplified-named-options) section for details. - -**Quick reference:** - -- **ATCOPT014**: ChildSections cannot be used with Name property -- **ATCOPT015**: ChildSections requires at least 2 items -- **ATCOPT016**: ChildSections items cannot be null or empty - ---- - -## 🚀 Native AOT Compatibility - -The Options Binding Generator is **fully compatible with Native AOT** compilation, producing code that meets all AOT requirements: - -### ✅ AOT-Safe Features - -- **Zero reflection** - All options binding uses `IConfiguration.Bind()` without reflection-based discovery -- **Compile-time generation** - Binding code is generated during build, not at runtime -- **Trimming-safe** - No dynamic type discovery or metadata dependencies -- **Static method calls** - All registration uses concrete extension method calls -- **Static analysis friendly** - All code paths are visible to the AOT compiler - -### 🏗️ How It Works - -1. **Build-time analysis**: The generator scans classes with `[OptionsBinding]` attributes during compilation -2. **Method generation**: Creates static extension methods with concrete `IConfiguration.GetSection()` and `Bind()` calls -3. **Options API integration**: Uses standard .NET Options pattern (`AddOptions()`, `Bind()`, `Validate()`) -4. **AOT compilation**: The generated code compiles to native machine code with full optimizations - -### 📋 Example Generated Code - -```csharp -// Source: [OptionsBinding("Database")] public partial class DatabaseOptions { ... } - -// Generated AOT-safe code: -public static IServiceCollection AddOptionsFromYourProject( - this IServiceCollection services, - IConfiguration configuration) -{ - services.AddOptions() - .Bind(configuration.GetSection("Database")) - .ValidateDataAnnotations() - .ValidateOnStart(); - - return services; -} -``` - -**Why This Is AOT-Safe:** - -- No `Activator.CreateInstance()` calls (reflection) -- No dynamic assembly scanning -- All types resolved at compile time via generic parameters -- Configuration binding uses built-in AOT-compatible `IConfiguration.Bind()` -- Validation uses standard DataAnnotations attributes - -### 🎯 Multi-Project AOT Support - -Even transitive options registration remains fully AOT-compatible: - -```csharp -// Auto-detect and register referenced assemblies - still AOT-safe! -services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true); -``` - -The generator produces concrete method calls to each referenced assembly's registration method, ensuring the entire dependency chain compiles to efficient native code. - ---- - -## 📚 Examples - -### 📝 Example 1: Simple Configuration - -```csharp -// Options class -[OptionsBinding] -public partial class AppOptions -{ - public string Name { get; set; } = string.Empty; - public string Version { get; set; } = string.Empty; -} - -// appsettings.json -{ - "App": { - "Name": "My Application", - "Version": "1.0.0" - } -} - -// Program.cs -services.AddOptionsFromApp(configuration); -var appOptions = serviceProvider.GetRequiredService>(); -Console.WriteLine($"{appOptions.Value.Name} v{appOptions.Value.Version}"); -``` - -### 🔒 Example 2: Validated Database Options - -```csharp -[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class DatabaseOptions -{ - [Required, MinLength(10)] - public string ConnectionString { get; set; } = string.Empty; - - [Range(1, 10)] - public int MaxRetries { get; set; } = 3; - - [Range(1, 300)] - public int TimeoutSeconds { get; set; } = 30; -} -``` - -### 🏗️ Example 3: Multi-Layer Application - -**appsettings.json:** - -```json -{ - "Database": { - "ConnectionString": "Server=localhost;Database=MyDb;" - }, - "Api": { - "BaseUrl": "https://api.example.com", - "Timeout": 30 - }, - "Logging": { - "Level": "Information", - "EnableConsole": true - } -} -``` - -**Options Classes:** - -```csharp -[OptionsBinding("Database")] -public partial class DatabaseOptions -{ - public string ConnectionString { get; set; } = string.Empty; -} - -[OptionsBinding("Api")] -public partial class ApiOptions -{ - public string BaseUrl { get; set; } = string.Empty; - public int Timeout { get; set; } = 30; -} - -[OptionsBinding("Logging")] -public partial class LoggingOptions -{ - public string Level { get; set; } = "Information"; - public bool EnableConsole { get; set; } = true; -} -``` - -**Program.cs:** - -```csharp -// Single call registers all options -services.AddOptionsFromApp(configuration); - -// Use options -var dbOpts = provider.GetRequiredService>(); -var apiOpts = provider.GetRequiredService>(); -var logOpts = provider.GetRequiredService>(); -``` - ---- - -## 🔗 Additional Resources - -- **[Sample Project](../sample/Atc.SourceGenerators.OptionsBinding)** - Working example -- **[Source Generator Documentation](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview)** - Microsoft Docs -- **[Options Pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options)** - Microsoft Docs - ---- - -## ❓ FAQ - -**Q: Do I need to make my options class partial?** - -A: Yes, the `partial` keyword is required for source generators to add generated code to your class. - -**Q: Can I use this with ASP.NET Core?** - -A: Absolutely! It works with any .NET application that uses `Microsoft.Extensions.Options`. - -**Q: What if I don't specify a section name?** - -A: The generator infers the section name by removing common suffixes (`Options`, `Settings`, `Config`, `Configuration`) from your class name. - -**Q: Can I validate options at startup?** - -A: Yes, use `ValidateOnStart = true` in the attribute. Combined with `ValidateDataAnnotations = true`, your options will be validated when the application starts. - -**Q: Does this work with reloadable configuration?** - -A: Yes, when you use `reloadOnChange: true` with your configuration source, the bound options will reflect changes automatically. - ---- - -## 📄 License - -[License information here] diff --git a/docs/PetStoreApi-Samples.md b/docs/PetStoreApi-Samples.md deleted file mode 100644 index ada3be3..0000000 --- a/docs/PetStoreApi-Samples.md +++ /dev/null @@ -1,895 +0,0 @@ -# PetStore API Sample - -## 🎯 Focus - -This sample demonstrates **all three generators working together** in a realistic ASP.NET Core 10.0 application with OpenAPI/Scalar documentation. The focus is on: - -- **Complete 3-layer architecture** (API → Domain → DataAccess) -- **All generators in harmony** (DependencyRegistration + OptionsBinding + Mapping) -- **OpenAPI/Swagger integration** with XML documentation -- **Type-safe configuration** with validation -- **Zero boilerplate** across all layers -- **Production-ready patterns** for modern .NET applications - -## 📁 Sample Projects - -- **PetStore.Api** - ASP.NET Core Minimal API with OpenAPI/Scalar -- **PetStore.Domain** - Domain layer (services, validation, mappings) -- **PetStore.DataAccess** - Data access layer (repositories, entities) -- **PetStore.Api.Contract** - API contracts (DTOs, requests/responses) - -## 🏗️ Architecture - -```mermaid -graph TB - subgraph "Client" - HTTP[HTTP Client] - SCALAR[Scalar UI] - end - - subgraph "PetStore.Api (ASP.NET Core 10.0)" - EP[Endpoints] - OPENAPI[OpenAPI Generator] - PROG[Program.cs] - APIENUM["PetStatus (API)"] - end - - subgraph "PetStore.Domain" - PS["PetService - @Registration"] - VS["ValidationService - @Registration"] - BG["PetMaintenanceService - @Registration (BackgroundService)"] - OPT["PetStoreOptions - @OptionsBinding"] - OPT2["PetMaintenanceServiceOptions - @OptionsBinding"] - PET["Pet - @MapTo(PetResponse)"] - PET2["Pet - @MapTo(PetEntity, Bidirectional=true)"] - DOMENUM["PetStatus (Domain)"] - end - - subgraph "PetStore.DataAccess" - PR["PetRepository - @Registration"] - ENT["PetEntity"] - ENTENUM["PetStatusEntity"] - end - - subgraph "PetStore.Api.Contract" - DTO[DTOs: PetResponse, CreatePetRequest] - end - - subgraph "Generated Code" - DI1["AddDependencyRegistrationsFromPetStoreApi()"] - DI2["AddDependencyRegistrationsFromPetStoreDomain()"] - DI3["AddDependencyRegistrationsFromPetStoreDataAccess()"] - CFG["AddOptionsFromPetStoreDomain(config)"] - M1["Pet.MapToPetResponse()"] - M2["Pet.MapToPetEntity() [Bidirectional]"] - M3["PetEntity.MapToPet() [Bidirectional]"] - end - - HTTP -->|POST /pets| EP - HTTP -->|GET /pets/:id| EP - SCALAR -->|Browse API| OPENAPI - - EP --> PS - PS --> VS - PS --> PR - PS --> OPT - - PR --> ENT - ENT --> ENTENUM - PET --> DOMENUM - EP --> APIENUM - - PROG --> DI2 - PROG --> CFG - - PET --> M1 - PET2 -.->|Bidirectional| M2 - PET2 -.->|Bidirectional| M3 - - style PS fill:#0969da - style VS fill:#0969da - style BG fill:#0969da - style PR fill:#0969da - style OPT fill:#0969da - style OPT2 fill:#0969da - style DI1 fill:#2ea44f - style DI2 fill:#2ea44f - style DI3 fill:#2ea44f - style CFG fill:#2ea44f - style M1 fill:#2ea44f - style M2 fill:#2ea44f - style M3 fill:#2ea44f - style ENTENUM fill:#d73a4a - style DOMENUM fill:#d73a4a - style APIENUM fill:#d73a4a -``` - -### Project References (Clean Architecture) - -```text -PetStore.Api -├── PetStore.Domain -│ ├── PetStore.DataAccess (NO Api.Contract reference) -│ └── PetStore.Api.Contract -└── PetStore.Api.Contract - -Key: DataAccess has NO upward dependencies, maintaining clean architecture -``` - -## 🔄 Request Flow: Creating a Pet - -```mermaid -sequenceDiagram - participant Client - participant API as API Endpoint - participant PetService as PetService [Registration] - participant Options as PetStoreOptions [OptionsBinding] - participant Repository as PetRepository [Registration] - participant Mapping as Generated Mappings - - Note over Client,Mapping: POST /pets - - Client->>API: HTTP POST /pets - Note over API: CreatePetRequest DTO - - API->>PetService: CreatePet(request) - PetService->>Options: Get MaxPetsPerPage - Options-->>PetService: 100 - - Note over PetService: Create Pet with Status = Models.PetStatus.Available - PetService->>PetService: new Pet { Status = PetStatus.Available } - - Note right of Mapping: Bidirectional Generated Code - PetService->>Mapping: pet.MapToPetEntity() - Note right of Mapping: Enum cast: PetStatus → PetStatusEntity - Mapping-->>PetService: PetEntity - - PetService->>Repository: Create(entity) - Repository->>Repository: Save to storage - Note right of Mapping: Bidirectional Generated Code - Repository->>Mapping: entity.MapToPet() - Note right of Mapping: Enum cast: PetStatusEntity → PetStatus - Mapping-->>Repository: Pet - Repository-->>PetService: Pet - - Note right of Mapping: Generated Code - PetService->>Mapping: pet.MapToPetResponse() - Mapping-->>PetService: PetResponse - PetService-->>API: PetResponse - - API-->>Client: 200 OK + PetResponse -``` - -## 🔄 Request Flow: Getting a Pet - -```mermaid -sequenceDiagram - participant Client - participant API as API Endpoint - participant PetService as PetService [Registration] - participant Repository as PetRepository [Registration] - participant Mapping as Generated Mappings - - Note over Client,Mapping: GET /pets/{id} - - Client->>API: HTTP GET /pets/{id} - API->>PetService: GetById(id) - PetService->>Repository: GetById(id) - - Note right of Mapping: Bidirectional Generated Code - Repository->>Repository: Retrieve PetEntity from storage - Repository->>Mapping: entity.MapToPet() - Note right of Mapping: Enum cast: PetStatusEntity → PetStatus - Mapping-->>Repository: Pet - Repository-->>PetService: Pet - - Note right of Mapping: Generated Code - PetService->>Mapping: pet.MapToPetResponse() - Mapping-->>PetService: PetResponse - PetService-->>API: PetResponse - - API-->>Client: 200 OK + PetResponse -``` - -## 🔄 Request Flow: Getting Pets by Status - -```mermaid -sequenceDiagram - participant Client - participant API as API Endpoint - participant PetService as PetService [Registration] - participant Repository as PetRepository [Registration] - participant Mapping as Generated Mappings - - Note over Client,Mapping: GET /pets/status/Available - - Client->>API: HTTP GET /pets/status/Available - Note over API: PetStatus (Api.Contract enum) - API->>API: Cast to Domain.Models.PetStatus - API->>PetService: GetByStatus(status) - Note over PetService: Cast to PetStatusEntity - PetService->>Repository: GetByStatus((PetStatusEntity)status) - - Note right of Mapping: Bidirectional Generated Code - Repository->>Repository: Filter by PetStatusEntity - loop For each PetEntity - Repository->>Mapping: entity.MapToPet() - Note right of Mapping: Enum cast: PetStatusEntity → PetStatus - Mapping-->>Repository: Pet - end - Repository-->>PetService: IEnumerable - - Note right of Mapping: Generated Code - loop For each Pet - PetService->>Mapping: pet.MapToPetResponse() - Mapping-->>PetService: PetResponse - end - PetService-->>API: IEnumerable - - API-->>Client: 200 OK + PetResponse[] -``` - -## 💻 Code Example - -### Configuration (appsettings.json) - -```json -{ - "PetStore": { - "MaxPetsPerPage": 100 - }, - "Logging": { - "LogLevel": { - "Default": "Information" - } - } -} -``` - -### PetStore.Api.Contract (DTOs) - -```csharp -namespace PetStore.Api.Contract; - -/// -/// Request to create a new pet. -/// -public class CreatePetRequest -{ - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } -} - -/// -/// Pet response DTO. -/// -public class PetResponse -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } - public PetStatus Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } -} - -/// -/// Represents the status of a pet (API layer). -/// -public enum PetStatus -{ - Available = 0, - Pending = 1, - Adopted = 2, -} -``` - -### PetStore.Domain (Services & Models) - -```csharp -using Atc.SourceGenerators.Annotations; -using System.ComponentModel.DataAnnotations; - -namespace PetStore.Domain; - -// ✨ Options bound automatically -[OptionsBinding("PetStore", ValidateDataAnnotations = true, ValidateOnStart = true)] -public partial class PetStoreOptions -{ - [Range(1, 100)] - public int MaxPetsPerPage { get; set; } = 20; - - [Required] - [StringLength(100, MinimumLength = 1)] - public string StoreName { get; set; } = "Furry Friends Pet Store"; - - public bool EnableAutoStatusUpdates { get; set; } = true; -} - -// ✨ Auto-registered as IPetService (default: Singleton) -[Registration] -public class PetService : IPetService -{ - private readonly IPetRepository repository; - private readonly PetStoreOptions options; - - public PetService( - IPetRepository repository, - IOptions options) - { - ArgumentNullException.ThrowIfNull(options); - this.repository = repository; - this.options = options.Value; - } - - public Pet? GetById(Guid id) - { - var entity = repository.GetById(id); - return entity?.MapToPet(); // ✨ Bidirectional mapping (PetEntity → Pet) - } - - public IEnumerable GetAll() => - repository - .GetAll() - .Select(e => e.MapToPet()) // ✨ Bidirectional mapping - .Take(options.MaxPetsPerPage); - - public IEnumerable GetByStatus(Models.PetStatus status) => - repository - .GetByStatus((PetStatusEntity)status) // ✨ Enum cast: Domain → DataAccess - .Select(e => e.MapToPet()); // ✨ Bidirectional mapping - - public Pet CreatePet(CreatePetRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var pet = new Pet - { - Id = Guid.NewGuid(), - Name = request.Name, - Species = request.Species, - Breed = request.Breed, - Age = request.Age, - Status = Models.PetStatus.Available, // ✨ Domain enum - CreatedAt = DateTimeOffset.UtcNow, - }; - - var entity = pet.MapToPetEntity(); // ✨ Bidirectional mapping (Pet → PetEntity) - var createdEntity = repository.Create(entity); - return createdEntity.MapToPet(); // ✨ Bidirectional mapping (PetEntity → Pet) - } -} - -// ✨ Domain model with bidirectional mapping -[MapTo(typeof(PetResponse))] -[MapTo(typeof(PetEntity), Bidirectional = true)] // ✨ Generates both Pet→PetEntity AND PetEntity→Pet -public partial class Pet -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } - public PetStatus Status { get; set; } // ✨ Domain enum - public DateTimeOffset CreatedAt { get; set; } -} - -// ✨ Domain-layer enum (separate from API and DataAccess) -public enum PetStatus -{ - Available = 0, - Pending = 1, - Adopted = 2, -} -``` - -### PetStore.DataAccess (Repository & Entities) - -```csharp -using Atc.SourceGenerators.Annotations; - -namespace PetStore.DataAccess; - -// ✨ Auto-registered as IPetRepository (Singleton lifetime) -[Registration(Lifetime.Singleton)] -public class PetRepository : IPetRepository -{ - private readonly Dictionary pets; - - public PetRepository() - { - // Initialize with sample data - var pet1Id = Guid.Parse("11111111-1111-1111-1111-111111111111"); - var pet2Id = Guid.Parse("22222222-2222-2222-2222-222222222222"); - - pets = new Dictionary - { - [pet1Id] = new PetEntity - { - Id = pet1Id, - Name = "Buddy", - Species = "Dog", - Breed = "Golden Retriever", - Age = 3, - Status = Entities.PetStatusEntity.Available, // ✨ DataAccess enum - CreatedAt = DateTimeOffset.UtcNow.AddDays(-30), - }, - [pet2Id] = new PetEntity - { - Id = pet2Id, - Name = "Whiskers", - Species = "Cat", - Breed = "Siamese", - Age = 2, - Status = Entities.PetStatusEntity.Adopted, // ✨ DataAccess enum - CreatedAt = DateTimeOffset.UtcNow.AddDays(-45), - }, - }; - } - - public PetEntity? GetById(Guid id) => - !pets.TryGetValue(id, out var entity) ? null : entity; - - public IEnumerable GetAll() => - pets.Values; - - public IEnumerable GetByStatus(Entities.PetStatusEntity status) => - pets.Values.Where(e => e.Status == status); - - public PetEntity Create(PetEntity pet) - { - pets[pet.Id] = pet; - return pet; - } -} - -// ✨ Entity - no MapTo attribute needed (Pet has Bidirectional = true) -public class PetEntity -{ - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Species { get; set; } = string.Empty; - public string Breed { get; set; } = string.Empty; - public int Age { get; set; } - public PetStatusEntity Status { get; set; } // ✨ DataAccess enum - public DateTimeOffset CreatedAt { get; set; } -} - -// ✨ DataAccess-layer enum (separate from API and Domain) -public enum PetStatusEntity -{ - Available = 0, - Pending = 1, - Adopted = 2, -} -``` - -### PetStore.Api (Application Setup) - -```csharp -using Microsoft.AspNetCore.Mvc; - -var builder = WebApplication.CreateBuilder(args); - -// ✨ Scenario B: Register all services transitively (Domain + DataAccess) -// This single call registers: -// - PetService from PetStore.Domain -// - PetRepository from PetStore.DataAccess (auto-detected as referenced assembly) -builder.Services.AddDependencyRegistrationsFromPetStoreDomain( - includeReferencedAssemblies: true); - -// ✨ Register configuration options automatically via [OptionsBinding] attribute -// This single call registers options from PetStore.Domain (PetStoreOptions) -builder.Services.AddOptionsFromPetStoreDomain( - builder.Configuration, - includeReferencedAssemblies: true); - -// Add OpenAPI -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); - app.MapScalarApiReference(); -} - -app.UseHttpsRedirection(); - -// ✨ API Endpoints -app - .MapPost("/pets", ([FromBody] CreatePetRequest request, IPetService petService) => - { - var pet = petService.CreatePet(request); - - // Use generated mapping: Pet → PetResponse - var response = pet.MapToPetResponse(); - - return Results.Created($"/pets/{response.Id}", response); - }) - .WithName("CreatePet") - .WithDescription("Creates a new pet") - .Produces(StatusCodes.Status201Created) - .WithOpenApi(); - -app - .MapGet("/pets/{id}", ([FromRoute] Guid id, IPetService petService) => - { - var pet = petService.GetById(id); - - if (pet is null) - { - return Results.NotFound(); - } - - // Use generated mapping: Pet → PetResponse - var response = pet.MapToPetResponse(); - - return Results.Ok(response); - }) - .WithName("GetPetById") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); - -app - .MapGet("/pets/status/{status}", ([FromRoute] PetStatus status, IPetService petService) => - { - // ✨ Enum cast: API PetStatus → Domain PetStatus - var pets = petService.GetByStatus((PetStore.Domain.Models.PetStatus)status); - - // Use generated mapping: Pet → PetResponse - var response = pets.Select(p => p.MapToPetResponse()); - - return Results.Ok(response); - }) - .WithName("GetPetsByStatus") - .Produces>(StatusCodes.Status200OK); - -app - .MapGet("/pets", (IPetService petService) => - { - var pets = petService.GetAll(); - - // Use generated mapping: Pet → PetResponse - var response = pets.Select(p => p.MapToPetResponse()); - - return Results.Ok(response); - }) - .WithName("GetAllPets") - .Produces>(StatusCodes.Status200OK); - -app.Run(); -``` - -## 📝 Generated Code Summary - -### DependencyRegistration Generator - -Creates extension methods with transitive registration: - -```csharp -// From PetStore.Domain (with includeReferencedAssemblies: true) -services.AddSingleton(); -services.AddHostedService(); // ✨ Automatic BackgroundService registration -services.AddSingleton(); // From referenced PetStore.DataAccess -``` - -### OptionsBinding Generator - -Creates configuration binding: - -```csharp -// From PetStore.Domain -services.AddOptions() - .Bind(configuration.GetSection("PetStore")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -services.AddOptions() - .Bind(configuration.GetSection("PetMaintenanceService")) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -### Mapping Generator - -Creates 3 extension methods: - -```csharp -// Pet → PetResponse (one-way) -public static PetResponse MapToPetResponse(this Pet source) -{ - return new PetResponse - { - Id = source.Id, - Name = source.Name, - Species = source.Species, - Breed = source.Breed, - Age = source.Age, - Status = (PetStatus)source.Status, // ✨ Enum cast: Domain → API - CreatedAt = source.CreatedAt, - }; -} - -// Pet → PetEntity (bidirectional, generated from [MapTo(typeof(PetEntity), Bidirectional = true)]) -public static PetEntity MapToPetEntity(this Pet source) -{ - return new PetEntity - { - Id = source.Id, - Name = source.Name, - Species = source.Species, - Breed = source.Breed, - Age = source.Age, - Status = (PetStatusEntity)source.Status, // ✨ Enum cast: Domain → DataAccess - CreatedAt = source.CreatedAt, - }; -} - -// PetEntity → Pet (bidirectional, reverse mapping generated automatically) -public static Pet MapToPet(this PetEntity source) -{ - return new Pet - { - Id = source.Id, - Name = source.Name, - Species = source.Species, - Breed = source.Breed, - Age = source.Age, - Status = (Models.PetStatus)source.Status, // ✨ Enum cast: DataAccess → Domain - CreatedAt = source.CreatedAt, - }; -} -``` - -**Key Feature:** The `Bidirectional = true` parameter on Pet's `[MapTo(typeof(PetEntity), Bidirectional = true)]` generates **both** `Pet.MapToPetEntity()` and `PetEntity.MapToPet()` from a single attribute, eliminating the need for manual reverse mapping code. - -## ✨ Key Features Demonstrated - -### 1. **Complete Integration** - -All three generators work seamlessly together: - -- Services auto-registered via `[Registration]` -- Background services auto-registered with `AddHostedService()` via `[Registration]` -- Configuration auto-bound via `[OptionsBinding]` -- Objects auto-mapped via `[MapTo]` with bidirectional support - -### 2. **Clean Architecture with Enum Separation** - -Each layer has its own enum to maintain proper separation of concerns: - -- **API Layer**: `PetStatus` (Api.Contract) - exposed to clients -- **Domain Layer**: `PetStatus` (Domain.Models) - business logic -- **DataAccess Layer**: `PetStatusEntity` - database persistence - -The MappingGenerator automatically handles enum conversions via casting at layer boundaries: - -```csharp -// API → Domain -var pets = petService.GetByStatus((PetStore.Domain.Models.PetStatus)status); - -// Domain → DataAccess -repository.GetByStatus((PetStatusEntity)status) - -// DataAccess → Domain (in generated mapping) -Status = (Models.PetStatus)source.Status -``` - -**Why simple casting instead of EnumMapping extension methods?** - -The PetStore sample uses simple enum casts because the enum values have identical names and underlying values across all layers. The MappingGenerator automatically uses: - -- **Simple casts** `(TargetEnum)source.Value` when enums don't have `[MapTo]` attributes (like in this sample) -- **EnumMapping extension methods** `source.Value.MapToTargetEnum()` when enums have `[MapTo]` attributes (for special case handling like None → Unknown) - -For advanced enum mapping with special case detection, see the [EnumMapping sample](EnumMappingGenerators-Samples.md). - -**Benefit:** DataAccess layer has **NO** dependency on Api.Contract, maintaining clean architecture principles. - -### 3. **Bidirectional Mapping** - -Single attribute generates both forward and reverse mappings: - -```csharp -[MapTo(typeof(PetEntity), Bidirectional = true)] -public partial class Pet { ... } -``` - -Generates: - -- `Pet.MapToPetEntity()` - forward mapping -- `PetEntity.MapToPet()` - reverse mapping (automatically!) - -**Benefit:** Eliminates manual reverse mapping code like `PetEntityExtensions.cs`. - -### 4. **Transitive Registration** - -Single registration call includes all referenced assemblies: - -```csharp -builder.Services.AddDependencyRegistrationsFromPetStoreDomain( - includeReferencedAssemblies: true); // Also registers from PetStore.DataAccess -``` - -**Benefit:** One line registers services from multiple projects. - -### 5. **Zero Boilerplate** - -Compare traditional approach vs generator approach: - -**Traditional (150+ lines)**: - -```csharp -// Manual DI -services.AddSingleton(); -services.AddSingleton(); - -// Manual options -services.AddOptions() - .Bind(configuration.GetSection("PetStore")) - .ValidateDataAnnotations() - .ValidateOnStart(); - -// Manual bidirectional mapping -public static PetEntity MapToPetEntity(Pet pet) { ... } -public static Pet MapToPet(PetEntity entity) { ... } -public static PetResponse MapToPetResponse(Pet pet) { ... } -// ... 50+ lines of mapping code -``` - -**With Generators (2 lines)**: - -```csharp -services.AddDependencyRegistrationsFromPetStoreDomain(includeReferencedAssemblies: true); -services.AddOptionsFromPetStoreDomain(configuration, includeReferencedAssemblies: true); -``` - -### 6. **OpenAPI Integration** - -XML documentation works correctly with proper project configuration: - -- Library projects: `GenerateDocumentationFile=false` -- API project: `GenerateDocumentationFile=true` -- No duplicate key errors from generated attributes - -### 7. **Type Safety** - -Compile-time validation catches errors: - -- Missing interface implementations -- Invalid configuration bindings -- Mapping mismatches -- Enum conversion errors - -### 8. **Production Ready** - -- Validation with Data Annotations -- Structured logging ready -- Separation of concerns (3-layer architecture) -- Testable services -- Clean architecture (no upward dependencies) -- Enum separation across layers - -## 🎯 Benefits Summary - -| Generator | Without | With | Savings | -|-----------|---------|------|---------| -| **DependencyRegistration** | ~20 lines (manual per-service registration) | 1 line (transitive) | 95% less code | -| **OptionsBinding** | ~5 lines per options class | 1 line (transitive) | 80% less code | -| **Mapping** | ~30 lines (manual forward + reverse) | 1 attribute (bidirectional) | 97% less code | - -**Total**: From ~200 lines of boilerplate to ~2 lines - -### Architecture Benefits - -| Feature | Traditional | With Generators | -|---------|------------|-----------------| -| **Enum Separation** | Manual conversions + tight coupling | Automatic enum casting, clean architecture | -| **Layer Dependencies** | DataAccess → Domain → API (circular) | DataAccess ← Domain ← API (clean) | -| **Bidirectional Mapping** | Write both directions manually | Single attribute generates both | -| **Multi-Project DI** | Register each project separately | Single transitive call | - -## 🚀 Running the Sample - -```bash -cd sample/PetStore.Api -dotnet run -``` - -Then open your browser to: - -- **Scalar UI**: -- **OpenAPI JSON**: - -### Try It Out - -**Create a pet:** - -```bash -curl -X POST https://localhost:42616/pets \ - -H "Content-Type: application/json" \ - -d '{"name":"Charlie","species":"Dog","breed":"Labrador","age":4}' -``` - -**Get all pets:** - -```bash -curl https://localhost:42616/pets -``` - -**Get a specific pet:** - -```bash -curl https://localhost:42616/pets/11111111-1111-1111-1111-111111111111 -``` - -**Get pets by status:** - -```bash -curl https://localhost:42616/pets/status/Available -curl https://localhost:42616/pets/status/Pending -curl https://localhost:42616/pets/status/Adopted -``` - -## 📊 Project Configuration - -### Project References (Clean Architecture) - -```xml - - - - - - - - - - - - - - - - - - - - - -``` - -**Key Point:** DataAccess layer has **NO** dependency on Api.Contract, ensuring proper separation of concerns. Each layer has its own enum types (`PetStatusEntity`, `PetStatus`, `PetStatus`) with automatic conversions via casting. - -### XML Documentation Settings - -```xml - - - false - $(NoWarn);CS0436;SA0001;CS1591;IDE0005 - - - - - true - $(NoWarn);CS0436;SA0001;CS1591;IDE0005 - -``` - -This configuration prevents OpenAPI duplicate key errors while maintaining API documentation. - -## 🔗 Related Documentation - -- [DependencyRegistration Sample](DependencyRegistrationGenerators-Samples.md) - DI registration deep dive -- [OptionsBinding Sample](OptionsBinding-Samples.md) - Configuration binding deep dive -- [Mapping Sample](ObjectMappingGenerators-Samples.md) - Object mapping deep dive -- [DependencyRegistration Generator Guide](DependencyRegistrationGenerators.md) -- [OptionsBinding Generator Guide](OptionsBindingGenerators.md) -- [ObjectMapping Generator Guide](ObjectMappingGenerators.md) diff --git a/sample/.editorconfig b/sample/.editorconfig index 02cf372..732d3dc 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -55,14 +55,17 @@ dotnet_diagnostic.CA1056.severity = none # dotnet_diagnostic.CA1303.severity = none # dotnet_diagnostic.CA1716.severity = none # Rename virtual/interface member dotnet_diagnostic.CA1848.severity = none # For improved performance +dotnet_diagnostic.CA1873.severity = none # Use 'Regex.Count' or avoid expensive logging arguments dotnet_diagnostic.CA1819.severity = none # Properties should not return arrays dotnet_diagnostic.CA2227.severity = none # Change 'Children' to be read-only by removing the property setter dotnet_diagnostic.MA0084.severity = none # Local variable 'smtpHost' should not hide field +dotnet_diagnostic.SA1512.severity = none # Single-line comments should not be followed by blank line dotnet_diagnostic.SA1611.severity = none # The documentation for parameter 'pet' is missing dotnet_diagnostic.SA1615.severity = none # Element return value should be documented +dotnet_diagnostic.S6418.severity = none # Hard-coded secret detected dotnet_diagnostic.S6580.severity = none # Use a format provider when parsing date and time. dotnet_diagnostic.S6667.severity = none # Logging in a catch clause should pass the caught exception as a parameter. dotnet_diagnostic.S6672.severity = none # Update this logger to use its enclosing type \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj b/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj index a1ef7fa..7f9ed87 100644 --- a/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj @@ -14,7 +14,7 @@ - + diff --git a/sample/Atc.SourceGenerators.DependencyRegistration.Domain/Atc.SourceGenerators.DependencyRegistration.Domain.csproj b/sample/Atc.SourceGenerators.DependencyRegistration.Domain/Atc.SourceGenerators.DependencyRegistration.Domain.csproj index 173229c..ce0e8ea 100644 --- a/sample/Atc.SourceGenerators.DependencyRegistration.Domain/Atc.SourceGenerators.DependencyRegistration.Domain.csproj +++ b/sample/Atc.SourceGenerators.DependencyRegistration.Domain/Atc.SourceGenerators.DependencyRegistration.Domain.csproj @@ -9,7 +9,7 @@ - + diff --git a/sample/Atc.SourceGenerators.DependencyRegistration/Atc.SourceGenerators.DependencyRegistration.csproj b/sample/Atc.SourceGenerators.DependencyRegistration/Atc.SourceGenerators.DependencyRegistration.csproj index 74c2b82..09f535e 100644 --- a/sample/Atc.SourceGenerators.DependencyRegistration/Atc.SourceGenerators.DependencyRegistration.csproj +++ b/sample/Atc.SourceGenerators.DependencyRegistration/Atc.SourceGenerators.DependencyRegistration.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/sample/Atc.SourceGenerators.Mapping.DataAccess/Atc.SourceGenerators.Mapping.DataAccess.csproj b/sample/Atc.SourceGenerators.Mapping.DataAccess/Atc.SourceGenerators.Mapping.DataAccess.csproj index a6da9ce..ed3b01f 100644 --- a/sample/Atc.SourceGenerators.Mapping.DataAccess/Atc.SourceGenerators.Mapping.DataAccess.csproj +++ b/sample/Atc.SourceGenerators.Mapping.DataAccess/Atc.SourceGenerators.Mapping.DataAccess.csproj @@ -1,15 +1,15 @@ - + net10.0 enable enable - $(NoWarn);CS0436;IDE0005 + $(NoWarn);CS0436;EnableGenerateDocumentationFile false - + diff --git a/sample/Atc.SourceGenerators.Mapping.Domain/Atc.SourceGenerators.Mapping.Domain.csproj b/sample/Atc.SourceGenerators.Mapping.Domain/Atc.SourceGenerators.Mapping.Domain.csproj index 4332e79..904eb51 100644 --- a/sample/Atc.SourceGenerators.Mapping.Domain/Atc.SourceGenerators.Mapping.Domain.csproj +++ b/sample/Atc.SourceGenerators.Mapping.Domain/Atc.SourceGenerators.Mapping.Domain.csproj @@ -1,20 +1,20 @@ - + net10.0 enable enable - $(NoWarn);CS0436;IDE0005 + $(NoWarn);CS0436;EnableGenerateDocumentationFile false - - - - - - + + + + + + diff --git a/sample/Atc.SourceGenerators.Mapping/Atc.SourceGenerators.Mapping.csproj b/sample/Atc.SourceGenerators.Mapping/Atc.SourceGenerators.Mapping.csproj index 6b850fe..2017e40 100644 --- a/sample/Atc.SourceGenerators.Mapping/Atc.SourceGenerators.Mapping.csproj +++ b/sample/Atc.SourceGenerators.Mapping/Atc.SourceGenerators.Mapping.csproj @@ -4,12 +4,12 @@ net10.0 enable enable - $(NoWarn);CS0436;IDE0005 + $(NoWarn);CS0436 - + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/ActivitySeverity.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/ActivitySeverity.cs new file mode 100644 index 0000000..5123027 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/ActivitySeverity.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Contract; + +public enum ActivitySeverity +{ + Debug, + Information, + Warning, + Error, + Critical, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/AnalyticsEventDto.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/AnalyticsEventDto.cs new file mode 100644 index 0000000..76cb029 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/AnalyticsEventDto.cs @@ -0,0 +1,9 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Contract; + +public record AnalyticsEventDto( + Guid Id, + string Name, + ActivitySeverity Severity, + DateTimeOffset Timestamp, + string? UserId, + string? Metadata); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/Atc.SourceGenerators.MappingCombinedConfiguration.Contract.csproj b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/Atc.SourceGenerators.MappingCombinedConfiguration.Contract.csproj new file mode 100644 index 0000000..cd4833c --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/Atc.SourceGenerators.MappingCombinedConfiguration.Contract.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/NotificationDto.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/NotificationDto.cs new file mode 100644 index 0000000..6f05b82 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/NotificationDto.cs @@ -0,0 +1,18 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Contract; + +public class NotificationDto +{ + public int Id { get; set; } + + public string Recipient { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public UrgencyLevel Urgency { get; set; } + + public DateTime SentAt { get; set; } + + public bool Delivered { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/SessionDto.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/SessionDto.cs new file mode 100644 index 0000000..57bcbdc --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/SessionDto.cs @@ -0,0 +1,8 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Contract; + +public record SessionDto( + Guid Id, + string UserId, + DateTimeOffset StartTime, + DateTimeOffset? EndTime, + int PageViews); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/UrgencyLevel.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/UrgencyLevel.cs new file mode 100644 index 0000000..375ac3e --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/UrgencyLevel.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Contract; + +public enum UrgencyLevel +{ + Unknown, + Low, + Medium, + High, + Urgent, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivityEvent.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivityEvent.cs new file mode 100644 index 0000000..9010f15 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivityEvent.cs @@ -0,0 +1,16 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +public class ActivityEvent +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ActivitySeverity Severity { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + public string? UserId { get; set; } + + public string? Metadata { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivitySeverity.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivitySeverity.cs new file mode 100644 index 0000000..fdec920 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ActivitySeverity.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +public enum ActivitySeverity +{ + Debug, + Information, + Warning, + Error, + Critical, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Atc.SourceGenerators.MappingCombinedConfiguration.Domain.csproj b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Atc.SourceGenerators.MappingCombinedConfiguration.Domain.csproj new file mode 100644 index 0000000..63e21bf --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Atc.SourceGenerators.MappingCombinedConfiguration.Domain.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + + + + + + + + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/BrowsingSession.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/BrowsingSession.cs new file mode 100644 index 0000000..a013c6a --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/BrowsingSession.cs @@ -0,0 +1,14 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +public class BrowsingSession +{ + public Guid Id { get; set; } + + public string UserId { get; set; } = string.Empty; + + public DateTimeOffset StartTime { get; set; } + + public DateTimeOffset? EndTime { get; set; } + + public int PageViews { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ClassLevelMappings.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ClassLevelMappings.cs new file mode 100644 index 0000000..d54ab51 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ClassLevelMappings.cs @@ -0,0 +1,5 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +// Approach 3: Class-level [MapTypes] shorthand for simple type mappings +[MapTypes(typeof(DeliveryReport), typeof(DeliveryStatus))] +internal static class ClassLevelMappings; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/DeliveryStatus.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/DeliveryStatus.cs new file mode 100644 index 0000000..14e3260 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/DeliveryStatus.cs @@ -0,0 +1,12 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +public class DeliveryStatus +{ + public int ReportId { get; set; } + + public int NotificationId { get; set; } + + public DateTime? DeliveredAt { get; set; } + + public string? FailureReason { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ExternalMappings.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ExternalMappings.cs new file mode 100644 index 0000000..0600b8a --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/ExternalMappings.cs @@ -0,0 +1,22 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +[MappingConfiguration] +public static partial class ExternalMappings +{ + [MapConfigProperty("NotificationId", "Id")] + [MapConfigProperty("RecipientToken", "Recipient")] + [MapConfigProperty("Body", "Message")] + [MapConfigProperty("IsDelivered", "Delivered")] + [MapConfigProperty("Priority", "Urgency")] + public static partial Notification MapToNotification( + this PushNotification source); + + [MapConfigProperty("EventId", "Id")] + [MapConfigProperty("EventName", "Name")] + public static partial ActivityEvent MapToActivityEvent( + this AnalyticsEvent source); + + [MapConfigProperty("SessionId", "Id")] + public static partial BrowsingSession MapToBrowsingSession( + this UserSession source); +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/GlobalUsings.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/GlobalUsings.cs new file mode 100644 index 0000000..0426954 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Atc.SourceGenerators.Annotations; +global using Atc.SourceGenerators.MappingCombinedConfiguration.Contract; +global using Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics; +global using Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/InlineMappings.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/InlineMappings.cs new file mode 100644 index 0000000..8f9f3f8 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/InlineMappings.cs @@ -0,0 +1,19 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +// Approach 4: Inline registration using MappingBuilder.Configure() +// The lambda body is analyzed at compile time to generate mapping extension methods. +// At runtime, MappingBuilder.Configure() is a no-op. +internal static class InlineMappings +{ + internal static void Register() + { + MappingBuilder.Configure(map => + { + // Map domain ActivityEvent -> contract AnalyticsEventDto (record with constructor) + map.Map(); + + // Map domain BrowsingSession -> contract SessionDto (record with constructor) + map.Map(); + }); + } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Notification.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Notification.cs new file mode 100644 index 0000000..459412e --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Notification.cs @@ -0,0 +1,19 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +[MapTo(typeof(NotificationDto))] +public partial class Notification +{ + public int Id { get; set; } + + public string Recipient { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public Urgency Urgency { get; set; } + + public DateTime SentAt { get; set; } + + public bool Delivered { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Urgency.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Urgency.cs new file mode 100644 index 0000000..eb3b9f4 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Urgency.cs @@ -0,0 +1,11 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.Domain; + +[MapTo(typeof(UrgencyLevel))] +public enum Urgency +{ + Unknown, + Low, + Medium, + High, + Urgent, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/AnalyticsEvent.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/AnalyticsEvent.cs new file mode 100644 index 0000000..ec4c2e7 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/AnalyticsEvent.cs @@ -0,0 +1,16 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics; + +public class AnalyticsEvent +{ + public Guid EventId { get; set; } + + public string EventName { get; set; } = string.Empty; + + public EventSeverity Severity { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + public string? UserId { get; set; } + + public string? Metadata { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics.csproj b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics.csproj new file mode 100644 index 0000000..cd4833c --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/EventSeverity.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/EventSeverity.cs new file mode 100644 index 0000000..1b6da05 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/EventSeverity.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics; + +public enum EventSeverity +{ + Debug, + Information, + Warning, + Error, + Critical, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/UserSession.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/UserSession.cs new file mode 100644 index 0000000..7e81cb4 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/UserSession.cs @@ -0,0 +1,8 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics; + +public record UserSession( + Guid SessionId, + string UserId, + DateTimeOffset StartTime, + DateTimeOffset? EndTime, + int PageViews); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications.csproj b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications.csproj new file mode 100644 index 0000000..cd4833c --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/DeliveryReport.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/DeliveryReport.cs new file mode 100644 index 0000000..fd6e1b7 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/DeliveryReport.cs @@ -0,0 +1,7 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications; + +public record DeliveryReport( + int ReportId, + int NotificationId, + DateTime? DeliveredAt, + string? FailureReason); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/NotificationPriority.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/NotificationPriority.cs new file mode 100644 index 0000000..6211006 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/NotificationPriority.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications; + +public enum NotificationPriority +{ + Unknown, + Low, + Medium, + High, + Urgent, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/PushNotification.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/PushNotification.cs new file mode 100644 index 0000000..c98832e --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/PushNotification.cs @@ -0,0 +1,18 @@ +namespace Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications; + +public class PushNotification +{ + public int NotificationId { get; set; } + + public string RecipientToken { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public NotificationPriority Priority { get; set; } + + public DateTime SentAt { get; set; } + + public bool IsDelivered { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Atc.SourceGenerators.MappingCombinedConfiguration.csproj b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Atc.SourceGenerators.MappingCombinedConfiguration.csproj new file mode 100644 index 0000000..1b8beda --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Atc.SourceGenerators.MappingCombinedConfiguration.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);CS0436 + + + + + + + + + + diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration/GlobalUsings.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/GlobalUsings.cs new file mode 100644 index 0000000..950ae71 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Atc.Mapping; +global using Atc.SourceGenerators.MappingCombinedConfiguration.Domain; +global using Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics; +global using Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Program.cs b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Program.cs new file mode 100644 index 0000000..41bb6b1 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingCombinedConfiguration/Program.cs @@ -0,0 +1,291 @@ +// ============================================================================= +// Section 1: Attribute-Based Mapping (Notification -> NotificationDto) +// ============================================================================= + +Console.WriteLine("=== Section 1: Attribute-Based Mapping ==="); +Console.WriteLine(); + +var notification = new Notification +{ + Id = 1, + Recipient = "user@example.com", + Title = "Welcome", + Message = "Welcome to our platform!", + Urgency = Urgency.High, + SentAt = new DateTime(2025, 6, 15, 10, 30, 0, DateTimeKind.Utc), + Delivered = true, +}; + +Console.WriteLine("--- Source: Domain Notification ---"); +Console.WriteLine($" Id: {notification.Id}"); +Console.WriteLine($" Recipient: {notification.Recipient}"); +Console.WriteLine($" Title: {notification.Title}"); +Console.WriteLine($" Message: {notification.Message}"); +Console.WriteLine($" Urgency: {notification.Urgency}"); +Console.WriteLine($" SentAt: {notification.SentAt}"); +Console.WriteLine($" Delivered: {notification.Delivered}"); +Console.WriteLine(); + +var notificationDto = notification.MapToNotificationDto(); + +Console.WriteLine("--- Target: NotificationDto ---"); +Console.WriteLine($" Id: {notificationDto.Id}"); +Console.WriteLine($" Recipient: {notificationDto.Recipient}"); +Console.WriteLine($" Title: {notificationDto.Title}"); +Console.WriteLine($" Message: {notificationDto.Message}"); +Console.WriteLine($" Urgency: {notificationDto.Urgency}"); +Console.WriteLine($" SentAt: {notificationDto.SentAt}"); +Console.WriteLine($" Delivered: {notificationDto.Delivered}"); +Console.WriteLine(); + +// ============================================================================= +// Section 2: Configuration-Based Mapping (External -> Domain) +// ============================================================================= + +Console.WriteLine("=== Section 2: Configuration-Based Mapping ==="); +Console.WriteLine(); + +// PushNotification -> Notification +var pushNotification = new PushNotification +{ + NotificationId = 42, + RecipientToken = "device-token-abc123", + Title = "New Message", + Body = "You have a new message from support.", + Priority = NotificationPriority.High, + SentAt = new DateTime(2025, 7, 1, 14, 0, 0, DateTimeKind.Utc), + IsDelivered = false, +}; + +Console.WriteLine("--- Source: PushNotification (External SDK) ---"); +Console.WriteLine($" NotificationId: {pushNotification.NotificationId}"); +Console.WriteLine($" RecipientToken: {pushNotification.RecipientToken}"); +Console.WriteLine($" Title: {pushNotification.Title}"); +Console.WriteLine($" Body: {pushNotification.Body}"); +Console.WriteLine($" Priority: {pushNotification.Priority}"); +Console.WriteLine($" SentAt: {pushNotification.SentAt}"); +Console.WriteLine($" IsDelivered: {pushNotification.IsDelivered}"); +Console.WriteLine(); + +var mappedNotification = pushNotification.MapToNotification(); + +Console.WriteLine("--- Target: Domain Notification (via config mapping) ---"); +Console.WriteLine($" Id: {mappedNotification.Id}"); +Console.WriteLine($" Recipient: {mappedNotification.Recipient}"); +Console.WriteLine($" Title: {mappedNotification.Title}"); +Console.WriteLine($" Message: {mappedNotification.Message}"); +Console.WriteLine($" Urgency: {mappedNotification.Urgency}"); +Console.WriteLine($" SentAt: {mappedNotification.SentAt}"); +Console.WriteLine($" Delivered: {mappedNotification.Delivered}"); +Console.WriteLine(); + +// AnalyticsEvent -> ActivityEvent +var analyticsEvent = new AnalyticsEvent +{ + EventId = Guid.NewGuid(), + EventName = "UserLogin", + Severity = EventSeverity.Information, + Timestamp = DateTimeOffset.UtcNow, + UserId = "user-456", + Metadata = "{\"ip\":\"192.168.1.1\"}", +}; + +Console.WriteLine("--- Source: AnalyticsEvent (External SDK) ---"); +Console.WriteLine($" EventId: {analyticsEvent.EventId}"); +Console.WriteLine($" EventName: {analyticsEvent.EventName}"); +Console.WriteLine($" Severity: {analyticsEvent.Severity}"); +Console.WriteLine($" Timestamp: {analyticsEvent.Timestamp}"); +Console.WriteLine($" UserId: {analyticsEvent.UserId}"); +Console.WriteLine($" Metadata: {analyticsEvent.Metadata}"); +Console.WriteLine(); + +var activityEvent = analyticsEvent.MapToActivityEvent(); + +Console.WriteLine("--- Target: Domain ActivityEvent (via config mapping) ---"); +Console.WriteLine($" Id: {activityEvent.Id}"); +Console.WriteLine($" Name: {activityEvent.Name}"); +Console.WriteLine($" Severity: {activityEvent.Severity}"); +Console.WriteLine($" Timestamp: {activityEvent.Timestamp}"); +Console.WriteLine($" UserId: {activityEvent.UserId}"); +Console.WriteLine($" Metadata: {activityEvent.Metadata}"); +Console.WriteLine(); + +// UserSession -> BrowsingSession +var userSession = new UserSession( + SessionId: Guid.NewGuid(), + UserId: "user-789", + StartTime: DateTimeOffset.UtcNow.AddHours(-2), + EndTime: DateTimeOffset.UtcNow, + PageViews: 15); + +Console.WriteLine("--- Source: UserSession (External SDK) ---"); +Console.WriteLine($" SessionId: {userSession.SessionId}"); +Console.WriteLine($" UserId: {userSession.UserId}"); +Console.WriteLine($" StartTime: {userSession.StartTime}"); +Console.WriteLine($" EndTime: {userSession.EndTime}"); +Console.WriteLine($" PageViews: {userSession.PageViews}"); +Console.WriteLine(); + +var browsingSession = userSession.MapToBrowsingSession(); + +Console.WriteLine("--- Target: Domain BrowsingSession (via config mapping) ---"); +Console.WriteLine($" Id: {browsingSession.Id}"); +Console.WriteLine($" UserId: {browsingSession.UserId}"); +Console.WriteLine($" StartTime: {browsingSession.StartTime}"); +Console.WriteLine($" EndTime: {browsingSession.EndTime}"); +Console.WriteLine($" PageViews: {browsingSession.PageViews}"); +Console.WriteLine(); + +// ============================================================================= +// Section 3: Multi-Hop Mapping Chain (PushNotification -> Notification -> NotificationDto) +// ============================================================================= + +Console.WriteLine("=== Section 3: Multi-Hop Mapping Chain ==="); +Console.WriteLine(); + +var externalPush = new PushNotification +{ + NotificationId = 99, + RecipientToken = "device-token-xyz789", + Title = "Order Shipped", + Body = "Your order #12345 has been shipped!", + Priority = NotificationPriority.Medium, + SentAt = new DateTime(2025, 8, 10, 9, 15, 0, DateTimeKind.Utc), + IsDelivered = true, +}; + +Console.WriteLine("--- Source: PushNotification (External SDK) ---"); +Console.WriteLine($" NotificationId: {externalPush.NotificationId}"); +Console.WriteLine($" Body: {externalPush.Body}"); +Console.WriteLine($" Priority: {externalPush.Priority}"); +Console.WriteLine(); + +// Chain: PushNotification -> Notification -> NotificationDto +var domainNotification = externalPush.MapToNotification(); +var dto = domainNotification.MapToNotificationDto(); + +Console.WriteLine("--- Intermediate: Domain Notification ---"); +Console.WriteLine($" Id: {domainNotification.Id}"); +Console.WriteLine($" Message: {domainNotification.Message}"); +Console.WriteLine($" Urgency: {domainNotification.Urgency}"); +Console.WriteLine(); + +Console.WriteLine("--- Final: NotificationDto ---"); +Console.WriteLine($" Id: {dto.Id}"); +Console.WriteLine($" Message: {dto.Message}"); +Console.WriteLine($" Urgency: {dto.Urgency}"); +Console.WriteLine(); + +// ============================================================================= +// Section 4: Enum Auto-Detection Demonstrations +// ============================================================================= + +Console.WriteLine("=== Section 4: Enum Auto-Detection ==="); +Console.WriteLine(); + +Console.WriteLine("--- Attribute-Based Enum Mapping (Urgency -> UrgencyLevel) ---"); +Console.WriteLine($" Urgency.Unknown -> UrgencyLevel.{Urgency.Unknown.MapToUrgencyLevel()}"); +Console.WriteLine($" Urgency.Low -> UrgencyLevel.{Urgency.Low.MapToUrgencyLevel()}"); +Console.WriteLine($" Urgency.Medium -> UrgencyLevel.{Urgency.Medium.MapToUrgencyLevel()}"); +Console.WriteLine($" Urgency.High -> UrgencyLevel.{Urgency.High.MapToUrgencyLevel()}"); +Console.WriteLine($" Urgency.Urgent -> UrgencyLevel.{Urgency.Urgent.MapToUrgencyLevel()}"); +Console.WriteLine(); + +Console.WriteLine("--- Config-Based Enum Auto-Detection (NotificationPriority -> Urgency) ---"); +Console.WriteLine(" (Enum values are auto-detected when mapping PushNotification -> Notification)"); +Console.WriteLine($" PushNotification with Priority=Low -> Urgency.{new PushNotification { Priority = NotificationPriority.Low }.MapToNotification().Urgency}"); +Console.WriteLine($" PushNotification with Priority=High -> Urgency.{new PushNotification { Priority = NotificationPriority.High }.MapToNotification().Urgency}"); +Console.WriteLine(); + +Console.WriteLine("--- Config-Based Enum Auto-Detection (EventSeverity -> ActivitySeverity) ---"); +Console.WriteLine(" (Enum values are auto-detected when mapping AnalyticsEvent -> ActivityEvent)"); +Console.WriteLine($" AnalyticsEvent with Severity=Warning -> ActivitySeverity.{new AnalyticsEvent { Severity = EventSeverity.Warning }.MapToActivityEvent().Severity}"); +Console.WriteLine($" AnalyticsEvent with Severity=Error -> ActivitySeverity.{new AnalyticsEvent { Severity = EventSeverity.Error }.MapToActivityEvent().Severity}"); +Console.WriteLine(); + +// ============================================================================= +// Section 5: Class-Level [MapTypes] Mapping (DeliveryReport -> DeliveryStatus) +// ============================================================================= + +Console.WriteLine("=== Section 5: Class-Level [MapTypes] Mapping ==="); +Console.WriteLine(); + +var deliveryReport = new DeliveryReport( + ReportId: 101, + NotificationId: 42, + DeliveredAt: new DateTime(2025, 7, 1, 14, 30, 0, DateTimeKind.Utc), + FailureReason: null); + +Console.WriteLine("--- Source: DeliveryReport (External SDK) ---"); +Console.WriteLine($" ReportId: {deliveryReport.ReportId}"); +Console.WriteLine($" NotificationId: {deliveryReport.NotificationId}"); +Console.WriteLine($" DeliveredAt: {deliveryReport.DeliveredAt}"); +Console.WriteLine($" FailureReason: {deliveryReport.FailureReason ?? "(null)"}"); +Console.WriteLine(); + +var deliveryStatus = deliveryReport.MapToDeliveryStatus(); + +Console.WriteLine("--- Target: DeliveryStatus (via class-level [MapTypes]) ---"); +Console.WriteLine($" ReportId: {deliveryStatus.ReportId}"); +Console.WriteLine($" NotificationId: {deliveryStatus.NotificationId}"); +Console.WriteLine($" DeliveredAt: {deliveryStatus.DeliveredAt}"); +Console.WriteLine($" FailureReason: {deliveryStatus.FailureReason ?? "(null)"}"); +Console.WriteLine(); + +// ============================================================================= +// Section 6: Inline MappingBuilder.Configure() Registration +// ============================================================================= + +Console.WriteLine("=== Section 6: Inline MappingBuilder.Configure() Registration ==="); +Console.WriteLine(); + +// ActivityEvent -> AnalyticsEventDto (configured via MappingBuilder.Configure in InlineMappings.cs) +var activityEventForDto = new ActivityEvent +{ + Id = Guid.NewGuid(), + Name = "PageView", + Severity = Atc.SourceGenerators.MappingCombinedConfiguration.Domain.ActivitySeverity.Information, + Timestamp = DateTimeOffset.UtcNow, + UserId = "user-123", + Metadata = "{\"page\":\"/home\"}", +}; + +Console.WriteLine("--- Source: Domain ActivityEvent ---"); +Console.WriteLine($" Id: {activityEventForDto.Id}"); +Console.WriteLine($" Name: {activityEventForDto.Name}"); +Console.WriteLine($" Severity: {activityEventForDto.Severity}"); +Console.WriteLine(); + +var analyticsEventDto = activityEventForDto.MapToAnalyticsEventDto(); + +Console.WriteLine("--- Target: Contract AnalyticsEventDto (via MappingBuilder.Configure) ---"); +Console.WriteLine($" Id: {analyticsEventDto.Id}"); +Console.WriteLine($" Name: {analyticsEventDto.Name}"); +Console.WriteLine($" Severity: {analyticsEventDto.Severity}"); +Console.WriteLine(); + +// BrowsingSession -> SessionDto (configured via MappingBuilder.Configure in InlineMappings.cs) +var browsingSessionForDto = new BrowsingSession +{ + Id = Guid.NewGuid(), + UserId = "user-456", + StartTime = DateTimeOffset.UtcNow.AddHours(-1), + EndTime = DateTimeOffset.UtcNow, + PageViews = 42, +}; + +Console.WriteLine("--- Source: Domain BrowsingSession ---"); +Console.WriteLine($" Id: {browsingSessionForDto.Id}"); +Console.WriteLine($" UserId: {browsingSessionForDto.UserId}"); +Console.WriteLine($" PageViews: {browsingSessionForDto.PageViews}"); +Console.WriteLine(); + +var sessionDto = browsingSessionForDto.MapToSessionDto(); + +Console.WriteLine("--- Target: Contract SessionDto (via MappingBuilder.Configure) ---"); +Console.WriteLine($" Id: {sessionDto.Id}"); +Console.WriteLine($" UserId: {sessionDto.UserId}"); +Console.WriteLine($" PageViews: {sessionDto.PageViews}"); +Console.WriteLine(); + +Console.WriteLine("=== Demo Complete ==="); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Address.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Address.cs new file mode 100644 index 0000000..ea25ee3 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Address.cs @@ -0,0 +1,17 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.Domain; + +/// +/// Domain address model. +/// +public class Address +{ + public string Street { get; set; } = string.Empty; + + public string City { get; set; } = string.Empty; + + public string State { get; set; } = string.Empty; + + public string PostalCode { get; set; } = string.Empty; + + public string Country { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Atc.SourceGenerators.MappingOnlyConfiguration.Domain.csproj b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Atc.SourceGenerators.MappingOnlyConfiguration.Domain.csproj new file mode 100644 index 0000000..a812284 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Atc.SourceGenerators.MappingOnlyConfiguration.Domain.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + + + + + + diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CrmMappings.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CrmMappings.cs new file mode 100644 index 0000000..b779e95 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CrmMappings.cs @@ -0,0 +1,29 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.Domain; + +/// +/// Configuration-based mapping for CRM SDK types. +/// These are third-party types that cannot be decorated with [MapTo] attributes. +/// +[MappingConfiguration] +public static partial class CrmMappings +{ + /// + /// Maps CRM Contact to domain Customer with property renaming. + /// + [MapConfigProperty("FullName", "Name")] + [MapConfigProperty("EmailAddress", "Email")] + [MapConfigProperty("PhoneNumber", "Phone")] + [MapConfigProperty("PrimaryAddress", "Address")] + [MapConfigProperty("CreatedDate", "CreatedAt")] + [MapConfigProperty("ModifiedDate", "UpdatedAt")] + [MapConfigProperty("Type", "Category")] + [MapConfigIgnore("ContactId")] + [MapConfigIgnore("InternalTrackingCode")] + public static partial Customer MapToCustomer(this Contact source); + + /// + /// Maps CRM ContactAddress to domain Address with ZipCode -> PostalCode rename. + /// + [MapConfigProperty("ZipCode", "PostalCode")] + public static partial Address MapToAddress(this ContactAddress source); +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Customer.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Customer.cs new file mode 100644 index 0000000..ba75f7c --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Customer.cs @@ -0,0 +1,23 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.Domain; + +/// +/// Domain model for a customer - our own type that we control. +/// +public class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + + public Address? Address { get; set; } + + public CustomerCategory Category { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CustomerCategory.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CustomerCategory.cs new file mode 100644 index 0000000..67c31e2 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/CustomerCategory.cs @@ -0,0 +1,13 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.Domain; + +/// +/// Domain customer category - note different values from CRM SDK's ContactType. +/// +public enum CustomerCategory +{ + Unknown, + Individual, + Company, + Government, + NonProfit, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/GlobalUsings.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/GlobalUsings.cs new file mode 100644 index 0000000..72af738 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Atc.SourceGenerators.Annotations; +global using Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm.csproj b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm.csproj new file mode 100644 index 0000000..cd4833c --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + $(NoWarn);CS0436;EnableGenerateDocumentationFile + false + + + diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Contact.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Contact.cs new file mode 100644 index 0000000..ea0ac29 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Contact.cs @@ -0,0 +1,25 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm; + +/// +/// Simulates a third-party CRM SDK type that cannot be decorated with mapping attributes. +/// +public class Contact +{ + public int ContactId { get; set; } + + public string FullName { get; set; } = string.Empty; + + public string EmailAddress { get; set; } = string.Empty; + + public string PhoneNumber { get; set; } = string.Empty; + + public ContactAddress? PrimaryAddress { get; set; } + + public ContactType Type { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime ModifiedDate { get; set; } + + public string InternalTrackingCode { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactAddress.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactAddress.cs new file mode 100644 index 0000000..051a7af --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactAddress.cs @@ -0,0 +1,17 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm; + +/// +/// Address type from the CRM SDK. +/// +public class ContactAddress +{ + public string Street { get; set; } = string.Empty; + + public string City { get; set; } = string.Empty; + + public string State { get; set; } = string.Empty; + + public string ZipCode { get; set; } = string.Empty; + + public string Country { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactType.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactType.cs new file mode 100644 index 0000000..83ab146 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/ContactType.cs @@ -0,0 +1,13 @@ +namespace Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm; + +/// +/// Contact type enum from the CRM SDK. +/// +public enum ContactType +{ + None, + Individual, + Company, + Government, + NonProfit, +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Atc.SourceGenerators.MappingOnlyConfiguration.csproj b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Atc.SourceGenerators.MappingOnlyConfiguration.csproj new file mode 100644 index 0000000..aa66a37 --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Atc.SourceGenerators.MappingOnlyConfiguration.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);CS0436 + + + + + + + + diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration/GlobalUsings.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/GlobalUsings.cs new file mode 100644 index 0000000..2d68e6f --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Atc.SourceGenerators.MappingOnlyConfiguration.Domain; +global using Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Program.cs b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Program.cs new file mode 100644 index 0000000..a9df53f --- /dev/null +++ b/sample/Atc.SourceGenerators.MappingOnlyConfiguration/Program.cs @@ -0,0 +1,66 @@ +// ============================================================================= +// Configuration-Based Mapping Demo +// Maps third-party CRM SDK types to domain models WITHOUT attributes on source types. +// ============================================================================= + +Console.WriteLine("=== Configuration-Based Mapping Demo ==="); +Console.WriteLine(); + +// Simulate receiving data from a CRM SDK +var crmContact = new Contact +{ + ContactId = 42, + FullName = "Jane Smith", + EmailAddress = "jane.smith@example.com", + PhoneNumber = "+1-555-0123", + Type = ContactType.Company, + CreatedDate = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc), + ModifiedDate = new DateTime(2024, 6, 20, 14, 45, 0, DateTimeKind.Utc), + InternalTrackingCode = "CRM-INTERNAL-12345", + PrimaryAddress = new ContactAddress + { + Street = "123 Business Ave", + City = "Seattle", + State = "WA", + ZipCode = "98101", + Country = "US", + }, +}; + +Console.WriteLine("--- Source: CRM Contact ---"); +Console.WriteLine($" ContactId: {crmContact.ContactId}"); +Console.WriteLine($" FullName: {crmContact.FullName}"); +Console.WriteLine($" EmailAddress: {crmContact.EmailAddress}"); +Console.WriteLine($" PhoneNumber: {crmContact.PhoneNumber}"); +Console.WriteLine($" Type: {crmContact.Type}"); +Console.WriteLine($" CreatedDate: {crmContact.CreatedDate}"); +Console.WriteLine($" ModifiedDate: {crmContact.ModifiedDate}"); +Console.WriteLine($" InternalTrackingCode: {crmContact.InternalTrackingCode}"); +Console.WriteLine($" PrimaryAddress.ZipCode: {crmContact.PrimaryAddress?.ZipCode}"); +Console.WriteLine(); + +// Map using the configuration-based mapping (generated by MappingConfigurationGenerator) +var customer = crmContact.MapToCustomer(); + +Console.WriteLine("--- Target: Domain Customer ---"); +Console.WriteLine($" Id: {customer.Id}"); +Console.WriteLine($" Name: {customer.Name}"); +Console.WriteLine($" Email: {customer.Email}"); +Console.WriteLine($" Phone: {customer.Phone}"); +Console.WriteLine($" Category: {customer.Category}"); +Console.WriteLine($" CreatedAt: {customer.CreatedAt}"); +Console.WriteLine($" UpdatedAt: {customer.UpdatedAt}"); +Console.WriteLine($" Address.PostalCode: {customer.Address?.PostalCode}"); +Console.WriteLine(); + +Console.WriteLine("--- Feature Highlights ---"); +Console.WriteLine($" Property renamed: FullName -> Name = '{customer.Name}'"); +Console.WriteLine($" Property renamed: EmailAddress -> Email = '{customer.Email}'"); +Console.WriteLine($" Property renamed: ZipCode -> PostalCode = '{customer.Address?.PostalCode}'"); +Console.WriteLine($" Property ignored: ContactId (not mapped)"); +Console.WriteLine($" Property ignored: InternalTrackingCode (not mapped)"); +Console.WriteLine($" Enum auto-detected: ContactType.{crmContact.Type} -> CustomerCategory.{customer.Category}"); +Console.WriteLine($" Nested mapping: ContactAddress -> Address (via CrmMappings.MapToAddress)"); + +Console.WriteLine(); +Console.WriteLine("=== Demo Complete ==="); \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.OptionsBinding.Domain/Atc.SourceGenerators.OptionsBinding.Domain.csproj b/sample/Atc.SourceGenerators.OptionsBinding.Domain/Atc.SourceGenerators.OptionsBinding.Domain.csproj index ca704b9..2b2ee38 100644 --- a/sample/Atc.SourceGenerators.OptionsBinding.Domain/Atc.SourceGenerators.OptionsBinding.Domain.csproj +++ b/sample/Atc.SourceGenerators.OptionsBinding.Domain/Atc.SourceGenerators.OptionsBinding.Domain.csproj @@ -9,11 +9,11 @@ - - - - - + + + + + diff --git a/sample/Atc.SourceGenerators.OptionsBinding/Atc.SourceGenerators.OptionsBinding.csproj b/sample/Atc.SourceGenerators.OptionsBinding/Atc.SourceGenerators.OptionsBinding.csproj index 86ab81e..2cdf09e 100644 --- a/sample/Atc.SourceGenerators.OptionsBinding/Atc.SourceGenerators.OptionsBinding.csproj +++ b/sample/Atc.SourceGenerators.OptionsBinding/Atc.SourceGenerators.OptionsBinding.csproj @@ -10,12 +10,12 @@ - - - - - - + + + + + + diff --git a/sample/PetStore.Api/GlobalUsings.cs b/sample/PetStore.Api/GlobalUsings.cs index 4db38c4..b3173ee 100644 --- a/sample/PetStore.Api/GlobalUsings.cs +++ b/sample/PetStore.Api/GlobalUsings.cs @@ -4,7 +4,6 @@ global using Microsoft.AspNetCore.Mvc; global using PetStore.Api.Contract; -global using PetStore.Domain; -global using PetStore.Domain.BackgroundServices; global using PetStore.Domain.Services; + global using Scalar.AspNetCore; \ No newline at end of file diff --git a/sample/PetStore.Api/PetStore.Api.csproj b/sample/PetStore.Api/PetStore.Api.csproj index 4802d06..7aeaf93 100644 --- a/sample/PetStore.Api/PetStore.Api.csproj +++ b/sample/PetStore.Api/PetStore.Api.csproj @@ -4,12 +4,12 @@ net10.0 enable enable - $(NoWarn);CS0436;IDE0005 + $(NoWarn);CS0436 - + diff --git a/sample/PetStore.DataAccess/PetStore.DataAccess.csproj b/sample/PetStore.DataAccess/PetStore.DataAccess.csproj index 13994bd..a74c6dc 100644 --- a/sample/PetStore.DataAccess/PetStore.DataAccess.csproj +++ b/sample/PetStore.DataAccess/PetStore.DataAccess.csproj @@ -9,7 +9,7 @@ - + diff --git a/sample/PetStore.Domain/PetStore.Domain.csproj b/sample/PetStore.Domain/PetStore.Domain.csproj index f55e86b..05db56e 100644 --- a/sample/PetStore.Domain/PetStore.Domain.csproj +++ b/sample/PetStore.Domain/PetStore.Domain.csproj @@ -9,14 +9,14 @@ - - - - - - - - + + + + + + + + diff --git a/src/Atc.SourceGenerators.Annotations/MapConfigIgnoreAttribute.cs b/src/Atc.SourceGenerators.Annotations/MapConfigIgnoreAttribute.cs new file mode 100644 index 0000000..bba1332 --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MapConfigIgnoreAttribute.cs @@ -0,0 +1,43 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Specifies a property to exclude from configuration-based mapping. +/// Applied to partial methods in a class. +/// +/// +/// +/// Multiple instances can be applied to a single method +/// to exclude multiple properties from the mapping. +/// +/// +/// The property name refers to a property on the source type (the method parameter type). +/// +/// +/// +/// +/// [MappingConfiguration] +/// public static partial class ExternalMappings +/// { +/// [MapConfigIgnore("InternalId")] +/// [MapConfigIgnore("PasswordHash")] +/// public static partial UserDto MapToUserDto(this ThirdParty.User source); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] +public sealed class MapConfigIgnoreAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the source property to exclude from mapping. + public MapConfigIgnoreAttribute(string propertyName) + { + PropertyName = propertyName; + } + + /// + /// Gets the name of the source property to exclude from mapping. + /// + public string PropertyName { get; } +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators.Annotations/MapConfigOptionsAttribute.cs b/src/Atc.SourceGenerators.Annotations/MapConfigOptionsAttribute.cs new file mode 100644 index 0000000..8fa1c1d --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MapConfigOptionsAttribute.cs @@ -0,0 +1,45 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Configures advanced mapping options for a configuration-based mapping method. +/// Applied to partial methods in a class. +/// +/// +/// +/// This attribute is optional. When not specified, default mapping behavior is used. +/// +/// +/// +/// +/// [MappingConfiguration] +/// public static partial class ExternalMappings +/// { +/// [MapConfigOptions(Bidirectional = true)] +/// public static partial OrderDto MapToOrderDto(this ExternalLib.Order source); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] +public sealed class MapConfigOptionsAttribute : Attribute +{ + /// + /// Gets or sets a value indicating whether to generate bidirectional mappings + /// (both Source -> Target and Target -> Source). + /// Default is false. + /// + public bool Bidirectional { get; set; } + + /// + /// Gets or sets a value indicating whether to enable property flattening. + /// When enabled, nested object properties are flattened using naming convention {PropertyName}{NestedPropertyName}. + /// Default is false. + /// + public bool EnableFlattening { get; set; } + + /// + /// Gets or sets the naming strategy for property name conversion during mapping. + /// Allows automatic mapping between different naming conventions. + /// Default is PascalCase (no transformation). + /// + public PropertyNameStrategy PropertyNameStrategy { get; set; } = PropertyNameStrategy.PascalCase; +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators.Annotations/MapConfigPropertyAttribute.cs b/src/Atc.SourceGenerators.Annotations/MapConfigPropertyAttribute.cs new file mode 100644 index 0000000..9ed4fa7 --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MapConfigPropertyAttribute.cs @@ -0,0 +1,49 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Specifies a property name mapping between source and target types in configuration-based mapping. +/// Applied to partial methods in a class. +/// +/// +/// +/// Multiple instances can be applied to a single method +/// to rename multiple properties during mapping. +/// +/// +/// +/// +/// [MappingConfiguration] +/// public static partial class ExternalMappings +/// { +/// [MapConfigProperty("FullName", "DisplayName")] +/// [MapConfigProperty("EmailAddress", "Email")] +/// public static partial CustomerDto MapToCustomerDto(this ThirdParty.Contact source); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] +public sealed class MapConfigPropertyAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the property on the source type. + /// The name of the property on the target type. + public MapConfigPropertyAttribute( + string sourcePropertyName, + string targetPropertyName) + { + SourcePropertyName = sourcePropertyName; + TargetPropertyName = targetPropertyName; + } + + /// + /// Gets the name of the property on the source type. + /// + public string SourcePropertyName { get; } + + /// + /// Gets the name of the property on the target type. + /// + public string TargetPropertyName { get; } +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators.Annotations/MapTypesAttribute.cs b/src/Atc.SourceGenerators.Annotations/MapTypesAttribute.cs new file mode 100644 index 0000000..46930a7 --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MapTypesAttribute.cs @@ -0,0 +1,79 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Defines a mapping between two types at the assembly level. +/// This is a shorthand alternative to for simple mapping scenarios. +/// +/// +/// +/// Use this attribute when you need to map types from external assemblies without creating +/// a full mapping configuration class. For more advanced scenarios (property ignoring, +/// renaming, etc.), use instead. +/// +/// +/// +/// +/// [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer), +/// PropertyMap = new[] { "EmailAddress:Email", "PhoneNumber:Phone" }, +/// IgnoreSourceProperties = new[] { "InternalId" }, +/// Bidirectional = true)] +/// +/// +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = true)] +public sealed class MapTypesAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The source type to map from. + /// The target type to map to. + public MapTypesAttribute( + Type sourceType, + Type targetType) + { + SourceType = sourceType; + TargetType = targetType; + } + + /// + /// Gets the source type to map from. + /// + public Type SourceType { get; } + + /// + /// Gets the target type to map to. + /// + public Type TargetType { get; } + + /// + /// Gets or sets the property name mappings in "SourceProperty:TargetProperty" format. + /// + /// + /// + /// PropertyMap = new[] { "EmailAddress:Email", "PhoneNumber:Phone" } + /// + /// + public string[]? PropertyMap { get; set; } + + /// + /// Gets or sets the source property names to exclude from mapping. + /// + public string[]? IgnoreSourceProperties { get; set; } + + /// + /// Gets or sets the target property names to exclude from mapping. + /// + public string[]? IgnoreTargetProperties { get; set; } + + /// + /// Gets or sets a value indicating whether to generate bidirectional mappings. + /// Default is false. + /// + public bool Bidirectional { get; set; } + + /// + /// Gets or sets the naming strategy for property name conversion. + /// Default is PascalCase (no transformation). + /// + public PropertyNameStrategy PropertyNameStrategy { get; set; } = PropertyNameStrategy.PascalCase; +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators.Annotations/MappingBuilder.cs b/src/Atc.SourceGenerators.Annotations/MappingBuilder.cs new file mode 100644 index 0000000..967d60e --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MappingBuilder.cs @@ -0,0 +1,65 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Provides a fluent API for configuring type mappings inline. +/// The Map() calls are analyzed at compile time by the source generator to produce mapping extension methods. +/// At runtime, this class is a no-op. +/// +public sealed class MappingBuilder +{ + internal MappingBuilder() + { + } + + /// + /// Registers a mapping between two types using typeof() expressions. + /// + /// The source type to map from. + /// The target type to map to. + /// Whether to generate bidirectional mappings. + /// Property name mappings in "SourceProperty:TargetProperty" format. + /// Source property names to exclude from mapping. + /// Target property names to exclude from mapping. + /// The naming strategy for property name conversion. + /// This builder for fluent chaining. + public MappingBuilder Map( + Type sourceType, + Type targetType, + bool bidirectional = false, + string[]? propertyMap = null, + string[]? ignoreSourceProperties = null, + string[]? ignoreTargetProperties = null, + PropertyNameStrategy propertyNameStrategy = PropertyNameStrategy.PascalCase) + => this; + + /// + /// Registers a mapping between two types using generic type parameters. + /// + /// The source type to map from. + /// The target type to map to. + /// Whether to generate bidirectional mappings. + /// Property name mappings in "SourceProperty:TargetProperty" format. + /// Source property names to exclude from mapping. + /// Target property names to exclude from mapping. + /// The naming strategy for property name conversion. + /// This builder for fluent chaining. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "S2326:Unused type parameters should be removed", Justification = "Type parameters are analyzed at compile time by the source generator")] + public MappingBuilder Map( + bool bidirectional = false, + string[]? propertyMap = null, + string[]? ignoreSourceProperties = null, + string[]? ignoreTargetProperties = null, + PropertyNameStrategy propertyNameStrategy = PropertyNameStrategy.PascalCase) + => this; + + /// + /// Configures mapping registrations without requiring dependency injection. + /// The lambda body is analyzed at compile time by the source generator. + /// At runtime, this is a no-op. + /// + /// The configuration action. + public static void Configure(Action configure) + { + // No-op — mappings are generated at compile time + } +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators.Annotations/MappingConfigurationAttribute.cs b/src/Atc.SourceGenerators.Annotations/MappingConfigurationAttribute.cs new file mode 100644 index 0000000..1b69941 --- /dev/null +++ b/src/Atc.SourceGenerators.Annotations/MappingConfigurationAttribute.cs @@ -0,0 +1,36 @@ +namespace Atc.SourceGenerators.Annotations; + +/// +/// Marks a static partial class as a mapping configuration container. +/// Methods in this class define mapping operations for types that cannot be decorated with [MapTo] attributes, +/// such as third-party types from external assemblies. +/// +/// +/// +/// The decorated class must be declared as both static and partial. +/// Each partial method in the class defines a mapping from the parameter type (source) to the return type (target). +/// Methods must be extension methods (first parameter uses this keyword). +/// +/// +/// Use to exclude specific properties from mapping. +/// Use to rename properties during mapping. +/// Use to configure advanced mapping options. +/// +/// +/// +/// +/// [MappingConfiguration] +/// public static partial class ExternalMappings +/// { +/// public static partial CustomerDto MapToCustomerDto(this ExternalLib.Contact source); +/// +/// [MapConfigIgnore("InternalId")] +/// [MapConfigProperty("FullName", "DisplayName")] +/// public static partial UserDto MapToUserDto(this ThirdParty.User source); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class MappingConfigurationAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md index c903787..046137e 100644 --- a/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -2,3 +2,20 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +ATCMCF001 | MappingConfiguration | Error | Mapping configuration class must be declared as static +ATCMCF002 | MappingConfiguration | Error | Mapping configuration class must be declared as partial +ATCMCF004 | MappingConfiguration | Error | Mapping method must be an extension method +ATCMCF005 | MappingConfiguration | Warning | Ignored property not found on source type +ATCMCF006 | MappingConfiguration | Error | Renamed source property not found +ATCMCF007 | MappingConfiguration | Error | Renamed target property not found +ATCMCF008 | MappingConfiguration | Info | Configuration class has no partial extension methods +ATCMCF010 | MappingConfiguration | Error | Method return type must be a class, record, or struct +ATCMAP005 | ObjectMapping | Warning | Type has both attribute and configuration-based mapping +ATCMAP006 | ObjectMapping | Warning | Type pair is configured multiple times +ATCMAP010 | ObjectMapping | Error | Configuration target type must be a class or struct +ATCENUM004 | EnumMapping | Warning | Auto-detected enum mapping has partial match +ATCENUM005 | EnumMapping | Warning | Enum types have no matching values +ATCMCF011 | MappingConfiguration | Error | Map() requires exactly two type arguments +ATCMCF013 | MappingConfiguration | Error | AddMappings() requires a lambda expression argument +ATCENUM006 | EnumMapping | Warning | Flag enum mapping may produce incorrect results for combined values +ATCMAP011 | ObjectMapping | Warning | Collection element type has no mapping method diff --git a/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs b/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs index d944dc0..f535b74 100644 --- a/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs @@ -17,44 +17,47 @@ public class AnnotationConstantsGenerator : IIncrementalGenerator private const string ConfigKeyPrefix = "build_property.atc_annotation_constants."; private const string IncludeUnannotatedKey = ConfigKeyPrefix + "include_unannotated_properties"; - // Supported Microsoft DataAnnotation attribute full names + private const string DataAnnotationsNamespace = "System.ComponentModel.DataAnnotations"; + private const string AtcNamespace = "Atc"; + + // Supported Microsoft DataAnnotation attribute metadata names private static readonly HashSet SupportedMicrosoftAnnotations = new(StringComparer.Ordinal) { - "System.ComponentModel.DataAnnotations.DisplayAttribute", - "System.ComponentModel.DataAnnotations.RequiredAttribute", - "System.ComponentModel.DataAnnotations.StringLengthAttribute", - "System.ComponentModel.DataAnnotations.RangeAttribute", - "System.ComponentModel.DataAnnotations.MinLengthAttribute", - "System.ComponentModel.DataAnnotations.MaxLengthAttribute", - "System.ComponentModel.DataAnnotations.RegularExpressionAttribute", - "System.ComponentModel.DataAnnotations.EmailAddressAttribute", - "System.ComponentModel.DataAnnotations.PhoneAttribute", - "System.ComponentModel.DataAnnotations.UrlAttribute", - "System.ComponentModel.DataAnnotations.CreditCardAttribute", - "System.ComponentModel.DataAnnotations.DataTypeAttribute", - "System.ComponentModel.DataAnnotations.CompareAttribute", - "System.ComponentModel.DataAnnotations.KeyAttribute", - "System.ComponentModel.DataAnnotations.EditableAttribute", - "System.ComponentModel.DataAnnotations.ScaffoldColumnAttribute", - "System.ComponentModel.DataAnnotations.TimestampAttribute", + "DisplayAttribute", + "RequiredAttribute", + "StringLengthAttribute", + "RangeAttribute", + "MinLengthAttribute", + "MaxLengthAttribute", + "RegularExpressionAttribute", + "EmailAddressAttribute", + "PhoneAttribute", + "UrlAttribute", + "CreditCardAttribute", + "DataTypeAttribute", + "CompareAttribute", + "KeyAttribute", + "EditableAttribute", + "ScaffoldColumnAttribute", + "TimestampAttribute", }; - // Supported Atc attribute full names (from Atc namespace) + // Supported Atc attribute metadata names (from Atc namespace) private static readonly HashSet SupportedAtcAnnotations = new(StringComparer.Ordinal) { - "Atc.IgnoreDisplayAttribute", - "Atc.EnumGuidAttribute", - "Atc.CasingStyleDescriptionAttribute", + "IgnoreDisplayAttribute", + "EnumGuidAttribute", + "CasingStyleDescriptionAttribute", }; - // Supported Atc validation attribute full names (from System.ComponentModel.DataAnnotations namespace, but in Atc assembly) + // Supported Atc validation attribute metadata names (from System.ComponentModel.DataAnnotations namespace, but in Atc assembly) private static readonly HashSet SupportedAtcValidationAnnotations = new(StringComparer.Ordinal) { - "System.ComponentModel.DataAnnotations.IPAddressAttribute", - "System.ComponentModel.DataAnnotations.IsoCurrencySymbolAttribute", - "System.ComponentModel.DataAnnotations.StringAttribute", - "System.ComponentModel.DataAnnotations.KeyStringAttribute", - "System.ComponentModel.DataAnnotations.UriAttribute", + "IPAddressAttribute", + "IsoCurrencySymbolAttribute", + "StringAttribute", + "KeyStringAttribute", + "UriAttribute", }; public void Initialize(IncrementalGeneratorInitializationContext context) @@ -185,8 +188,9 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida foreach (var attribute in propertySymbol.GetAttributes()) { - var attrFullName = attribute.AttributeClass?.ToDisplayString(); - if (attrFullName is null || !SupportedMicrosoftAnnotations.Contains(attrFullName)) + var attrName = attribute.AttributeClass?.MetadataName; + var attrNs = attribute.AttributeClass?.ContainingNamespace?.ToDisplayString(); + if (attrName is null || attrNs != DataAnnotationsNamespace || !SupportedMicrosoftAnnotations.Contains(attrName)) { continue; } @@ -194,19 +198,19 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida hasAnyAnnotation = true; // Extract based on attribute type - switch (attrFullName) + switch (attrName) { - case "System.ComponentModel.DataAnnotations.DisplayAttribute": + case "DisplayAttribute": display = ExtractDisplayAttribute(attribute); break; - case "System.ComponentModel.DataAnnotations.RequiredAttribute": + case "RequiredAttribute": validation.IsRequired = true; validation.AllowEmptyStrings = GetNamedArg(attribute, "AllowEmptyStrings"); validation.RequiredErrorMessage = GetNamedArg(attribute, "ErrorMessage"); break; - case "System.ComponentModel.DataAnnotations.StringLengthAttribute": + case "StringLengthAttribute": // First constructor argument is MaximumLength if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is int maxLen) @@ -218,11 +222,11 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida validation.StringLengthErrorMessage = GetNamedArg(attribute, "ErrorMessage"); break; - case "System.ComponentModel.DataAnnotations.RangeAttribute": + case "RangeAttribute": ExtractRangeAttribute(attribute, validation); break; - case "System.ComponentModel.DataAnnotations.MinLengthAttribute": + case "MinLengthAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is int minLenVal) { @@ -231,7 +235,7 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.MaxLengthAttribute": + case "MaxLengthAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is int maxLenVal) { @@ -240,7 +244,7 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.RegularExpressionAttribute": + case "RegularExpressionAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is string pattern) { @@ -250,23 +254,23 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida validation.RegularExpressionErrorMessage = GetNamedArg(attribute, "ErrorMessage"); break; - case "System.ComponentModel.DataAnnotations.EmailAddressAttribute": + case "EmailAddressAttribute": validation.IsEmailAddress = true; break; - case "System.ComponentModel.DataAnnotations.PhoneAttribute": + case "PhoneAttribute": validation.IsPhone = true; break; - case "System.ComponentModel.DataAnnotations.UrlAttribute": + case "UrlAttribute": validation.IsUrl = true; break; - case "System.ComponentModel.DataAnnotations.CreditCardAttribute": + case "CreditCardAttribute": validation.IsCreditCard = true; break; - case "System.ComponentModel.DataAnnotations.DataTypeAttribute": + case "DataTypeAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is int dataType) { @@ -275,7 +279,7 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.CompareAttribute": + case "CompareAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is string otherProp) { @@ -284,11 +288,11 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.KeyAttribute": + case "KeyAttribute": validation.IsKey = true; break; - case "System.ComponentModel.DataAnnotations.EditableAttribute": + case "EditableAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is bool editable) { @@ -297,7 +301,7 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.ScaffoldColumnAttribute": + case "ScaffoldColumnAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is bool scaffold) { @@ -306,7 +310,7 @@ private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Valida break; - case "System.ComponentModel.DataAnnotations.TimestampAttribute": + case "TimestampAttribute": validation.IsTimestamp = true; break; } @@ -324,29 +328,31 @@ private static (AtcAnnotationInfo? Annotation, bool HasAny) ExtractPropertyAtcAn foreach (var attribute in propertySymbol.GetAttributes()) { - var attrFullName = attribute.AttributeClass?.ToDisplayString(); - if (attrFullName is null) + var attrName = attribute.AttributeClass?.MetadataName; + if (attrName is null) { continue; } + var attrNs = attribute.AttributeClass?.ContainingNamespace?.ToDisplayString(); + // Check if it's an Atc namespace attribute - if (SupportedAtcAnnotations.Contains(attrFullName)) + if (attrNs == AtcNamespace && SupportedAtcAnnotations.Contains(attrName)) { hasAnyAnnotation = true; - ExtractAtcNamespaceAttribute(attribute, attrFullName, atc); + ExtractAtcNamespaceAttribute(attribute, attrName, atc); continue; } // Check if it's an Atc validation attribute (in System.ComponentModel.DataAnnotations namespace but from Atc assembly) - if (SupportedAtcValidationAnnotations.Contains(attrFullName)) + if (attrNs == DataAnnotationsNamespace && SupportedAtcValidationAnnotations.Contains(attrName)) { // Verify it's from the Atc assembly, not Microsoft's var assemblyName = attribute.AttributeClass?.ContainingAssembly?.Name; if (assemblyName is not null && assemblyName.StartsWith("Atc", StringComparison.Ordinal)) { hasAnyAnnotation = true; - ExtractAtcValidationAttribute(attribute, attrFullName, atc); + ExtractAtcValidationAttribute(attribute, attrName, atc); } } } @@ -356,16 +362,16 @@ private static (AtcAnnotationInfo? Annotation, bool HasAny) ExtractPropertyAtcAn private static void ExtractAtcNamespaceAttribute( AttributeData attribute, - string attrFullName, + string attrName, AtcAnnotationBuilder atc) { - switch (attrFullName) + switch (attrName) { - case "Atc.IgnoreDisplayAttribute": + case "IgnoreDisplayAttribute": atc.IsIgnoreDisplay = true; break; - case "Atc.EnumGuidAttribute": + case "EnumGuidAttribute": if (attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is string guidStr) { @@ -374,7 +380,7 @@ private static void ExtractAtcNamespaceAttribute( break; - case "Atc.CasingStyleDescriptionAttribute": + case "CasingStyleDescriptionAttribute": atc.CasingStyleDefault = GetNamedArg(attribute, "Default")?.ToString(); atc.CasingStylePrefix = GetNamedArg(attribute, "Prefix"); break; @@ -384,34 +390,34 @@ private static void ExtractAtcNamespaceAttribute( [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - attribute extraction logic")] private static void ExtractAtcValidationAttribute( AttributeData attribute, - string attrFullName, + string attrName, AtcAnnotationBuilder atc) { - switch (attrFullName) + switch (attrName) { - case "System.ComponentModel.DataAnnotations.IPAddressAttribute": + case "IPAddressAttribute": atc.IsIPAddress = true; atc.IPAddressRequired = GetBoolConstructorArgOrNamedArg(attribute, "Required"); break; - case "System.ComponentModel.DataAnnotations.IsoCurrencySymbolAttribute": + case "IsoCurrencySymbolAttribute": atc.IsIsoCurrencySymbol = true; atc.IsoCurrencySymbolRequired = GetNamedArg(attribute, "Required"); atc.IsoCurrencySymbols = GetNamedArgStringArray(attribute, "IsoCurrencySymbols"); break; - case "System.ComponentModel.DataAnnotations.StringAttribute": + case "StringAttribute": atc.IsAtcString = true; ExtractAtcStringAttributeProperties(attribute, atc); break; - case "System.ComponentModel.DataAnnotations.KeyStringAttribute": + case "KeyStringAttribute": atc.IsKeyString = true; atc.IsAtcString = true; ExtractAtcStringAttributeProperties(attribute, atc); break; - case "System.ComponentModel.DataAnnotations.UriAttribute": + case "UriAttribute": atc.IsAtcUri = true; ExtractAtcUriAttributeProperties(attribute, atc); break; diff --git a/src/Atc.SourceGenerators/Generators/DependencyRegistrationGenerator.cs b/src/Atc.SourceGenerators/Generators/DependencyRegistrationGenerator.cs index 618355f..85f3d12 100644 --- a/src/Atc.SourceGenerators/Generators/DependencyRegistrationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/DependencyRegistrationGenerator.cs @@ -16,9 +16,7 @@ public class DependencyRegistrationGenerator : IIncrementalGenerator { private const string AttributeNamespace = "Atc.DependencyInjection"; private const string AttributeName = "RegistrationAttribute"; - private const string AttributeFullName = $"{AttributeNamespace}.{AttributeName}"; private const string FilterAttributeName = "RegistrationFilterAttribute"; - private const string FilterAttributeFullName = $"{AttributeNamespace}.{FilterAttributeName}"; private static readonly DiagnosticDescriptor AsTypeMustBeInterfaceDescriptor = new( id: RuleIdentifierConstants.DependencyInjection.AsTypeMustBeInterface, @@ -108,16 +106,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("RegistrationAttribute.g.cs", SourceText.From(GenerateAttributeCode(), Encoding.UTF8)); }); - // Find all classes with the [Registration] attribute - var classDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => IsCandidateClass(node), - transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)) + // Find all classes with the [Registration] attribute using ForAttributeWithMetadataName + // for better Roslyn-level caching + var classSymbols = context.SyntaxProvider + .ForAttributeWithMetadataName( + AttributeNamespace + "." + AttributeName, + predicate: static (s, _) => s is ClassDeclarationSyntax, + transform: static (ctx, _) => GetRegistrationInfoForAttribute(ctx)) .Where(static m => m is not null) .Collect(); // Combine with compilation to generate the registration code - var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations); + var compilationAndClasses = context.CompilationProvider.Combine(classSymbols); context.RegisterSourceOutput(compilationAndClasses, (spc, source) => { @@ -125,8 +125,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - private static bool IsCandidateClass(SyntaxNode node) - => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + private static bool IsAttributeMatch( + INamedTypeSymbol? attributeClass, + string expectedMetadataName, + string expectedNamespace) + { + if (attributeClass is null) + { + return false; + } + + return attributeClass.MetadataName == expectedMetadataName && + attributeClass.ContainingNamespace?.ToDisplayString() == expectedNamespace; + } private static bool IsSystemInterface(ITypeSymbol interfaceSymbol) { @@ -172,127 +183,126 @@ private static bool IsHostedService(INamedTypeSymbol classSymbol) return false; } - private static ServiceRegistrationInfo? GetSemanticTargetForGeneration( - GeneratorSyntaxContext context) + private static ServiceRegistrationInfo? GetRegistrationInfoForAttribute( + GeneratorAttributeSyntaxContext context) { - var classDeclaration = (ClassDeclarationSyntax)context.Node; - var classSymbol = context.SemanticModel.GetDeclaredSymbol(classDeclaration); + var classSymbol = (INamedTypeSymbol)context.TargetSymbol; - if (classSymbol is null) + // Find the matching [Registration] attribute from the attributes provided by the context + AttributeData? attributeData = null; + foreach (var attr in context.Attributes) { - return null; + if (IsAttributeMatch(attr.AttributeClass, AttributeName, AttributeNamespace)) + { + attributeData = attr; + break; + } } - // Look for the [Registration] attribute - foreach (var attributeData in classSymbol.GetAttributes()) + if (attributeData is null) { - if (attributeData.AttributeClass?.ToDisplayString() != AttributeFullName) - { - continue; - } + return null; + } - // Parse attribute arguments - var lifetime = ServiceLifetime.Singleton; // default - ITypeSymbol? explicitAsType = null; - var asSelf = false; - object? key = null; - string? factoryMethodName = null; - var tryAdd = false; - var decorator = false; - string? instanceMemberName = null; - string? condition = null; - - // Constructor argument (lifetime) - if (attributeData.ConstructorArguments.Length > 0) + // Parse attribute arguments + var lifetime = ServiceLifetime.Singleton; // default + ITypeSymbol? explicitAsType = null; + var asSelf = false; + object? key = null; + string? factoryMethodName = null; + var tryAdd = false; + var decorator = false; + string? instanceMemberName = null; + string? condition = null; + + // Constructor argument (lifetime) + if (attributeData.ConstructorArguments.Length > 0) + { + var lifetimeValue = attributeData.ConstructorArguments[0].Value; + if (lifetimeValue is int intValue) { - var lifetimeValue = attributeData.ConstructorArguments[0].Value; - if (lifetimeValue is int intValue) - { - lifetime = (ServiceLifetime)intValue; - } + lifetime = (ServiceLifetime)intValue; } + } - // Named arguments (As, AsSelf, Key, Factory, TryAdd, Decorator, Instance, Condition) - foreach (var namedArg in attributeData.NamedArguments) + // Named arguments (As, AsSelf, Key, Factory, TryAdd, Decorator, Instance, Condition) + foreach (var namedArg in attributeData.NamedArguments) + { + switch (namedArg.Key) { - switch (namedArg.Key) - { - case "As": - explicitAsType = namedArg.Value.Value as ITypeSymbol; - break; - case "AsSelf": - if (namedArg.Value.Value is bool selfValue) - { - asSelf = selfValue; - } - - break; - case "Key": - key = namedArg.Value.Value; - break; - case "Factory": - factoryMethodName = namedArg.Value.Value as string; - break; - case "TryAdd": - if (namedArg.Value.Value is bool tryAddValue) - { - tryAdd = tryAddValue; - } + case "As": + explicitAsType = namedArg.Value.Value as ITypeSymbol; + break; + case "AsSelf": + if (namedArg.Value.Value is bool selfValue) + { + asSelf = selfValue; + } - break; - case "Decorator": - if (namedArg.Value.Value is bool decoratorValue) - { - decorator = decoratorValue; - } + break; + case "Key": + key = namedArg.Value.Value; + break; + case "Factory": + factoryMethodName = namedArg.Value.Value as string; + break; + case "TryAdd": + if (namedArg.Value.Value is bool tryAddValue) + { + tryAdd = tryAddValue; + } - break; - case "Instance": - instanceMemberName = namedArg.Value.Value as string; - break; - case "Condition": - condition = namedArg.Value.Value as string; - break; - } - } + break; + case "Decorator": + if (namedArg.Value.Value is bool decoratorValue) + { + decorator = decoratorValue; + } - // Determine which types to register against - ImmutableArray asTypes; - if (explicitAsType is not null) - { - // Explicit As parameter takes precedence - asTypes = [explicitAsType]; + break; + case "Instance": + instanceMemberName = namedArg.Value.Value as string; + break; + case "Condition": + condition = namedArg.Value.Value as string; + break; } - else - { - // Auto-detect interfaces - get all non-system interfaces - var interfaces = classSymbol.AllInterfaces - .Where(i => !IsSystemInterface(i)) - .Cast() - .ToImmutableArray(); + } - asTypes = interfaces; - } + // Determine which types to register against + ImmutableArray asTypes; + if (explicitAsType is not null) + { + // Explicit As parameter takes precedence + asTypes = [explicitAsType]; + } + else + { + // Auto-detect interfaces - get all non-system interfaces + var interfaces = classSymbol.AllInterfaces + .Where(i => !IsSystemInterface(i)) + .Cast() + .ToImmutableArray(); - // Check if this is a hosted service - var isHostedService = IsHostedService(classSymbol); - - return new ServiceRegistrationInfo( - classSymbol, - lifetime, - asTypes, - asSelf, - isHostedService, - key, - factoryMethodName, - tryAdd, - decorator, - instanceMemberName, - condition, - classDeclaration.GetLocation()); + asTypes = interfaces; } - return null; + // Check if this is a hosted service + var isHostedService = IsHostedService(classSymbol); + + return new ServiceRegistrationInfo( + classSymbol, + lifetime, + asTypes, + asSelf, + isHostedService, + key, + factoryMethodName, + tryAdd, + decorator, + instanceMemberName, + condition, + context.TargetNode.GetLocation()); } private static string GetAssemblyPrefix(string assemblyName) @@ -329,8 +339,7 @@ private static ImmutableArray GetReferencedAssembliesWit // Extract assembly information var assemblyName = assemblySymbol.Name; var sanitizedName = SanitizeAssemblyName(assemblyName); - var parts = assemblyName.Split('.'); - var shortName = parts[parts.Length - 1]; + var shortName = GetAssemblySuffix(assemblyName); result.Add(new ReferencedAssemblyInfo(assemblyName, sanitizedName, shortName)); } @@ -347,7 +356,7 @@ private static FilterRules ParseFilterRules(IAssemblySymbol assemblySymbol) // Get all RegistrationFilter attributes on the assembly foreach (var attribute in assemblySymbol.GetAttributes()) { - if (attribute.AttributeClass?.ToDisplayString() != FilterAttributeFullName) + if (!IsAttributeMatch(attribute.AttributeClass, FilterAttributeName, AttributeNamespace)) { continue; } @@ -425,7 +434,7 @@ private static bool HasRegistrationAttributeInNamespace( // Check if this type has the [Registration] attribute foreach (var attribute in typeSymbol.GetAttributes()) { - if (attribute.AttributeClass?.ToDisplayString() == AttributeFullName && + if (IsAttributeMatch(attribute.AttributeClass, AttributeName, AttributeNamespace) && !filterRules.ShouldExclude(typeSymbol)) { // Check if this type should be excluded by filter rules @@ -455,6 +464,10 @@ private static void GenerateServiceRegistrations( return; } + // Deduplicate by fully qualified class name since ForAttributeWithMetadataName + // may emit once per attribute instance + var seen = new HashSet(StringComparer.Ordinal); + // Parse filter rules from the current assembly var filterRules = ParseFilterRules(compilation.Assembly); @@ -463,6 +476,11 @@ private static void GenerateServiceRegistrations( // Validate and emit diagnostics, and apply filters foreach (var service in services) { + if (!seen.Add(service.ClassSymbol.ToDisplayString())) + { + continue; + } + // Check if service should be excluded by filter rules if (filterRules.ShouldExclude(service.ClassSymbol)) { @@ -542,10 +560,11 @@ private static bool ValidateService( { // Original interface check logic // For generic types, we need to compare the original definitions + var allInterfaces = service.ClassSymbol.AllInterfaces; if (asType is INamedTypeSymbol { IsGenericType: true } asNamedType) { var asTypeOriginal = asNamedType.OriginalDefinition; - implementsInterfaceOrInheritsAbstractClass = service.ClassSymbol.AllInterfaces.Any(i => + implementsInterfaceOrInheritsAbstractClass = allInterfaces.Any(i => { if (i is INamedTypeSymbol { IsGenericType: true } iNamedType) { @@ -557,7 +576,7 @@ private static bool ValidateService( } else { - implementsInterfaceOrInheritsAbstractClass = service.ClassSymbol.AllInterfaces.Any(i => + implementsInterfaceOrInheritsAbstractClass = allInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, asType)); } } @@ -825,16 +844,14 @@ private static string GetSmartMethodSuffixFromContext( List allAssembliesInContext) { // Get the suffix (last segment after final dot) of the target assembly - var parts = assemblyName.Split('.'); - var suffix = parts[parts.Length - 1]; + var suffix = GetAssemblySuffix(assemblyName); // Check how many assemblies in the context have this same suffix int count = 0; foreach (var asmName in allAssembliesInContext) { - var asmParts = asmName.Split('.'); - var asmSuffix = asmParts[asmParts.Length - 1]; + var asmSuffix = GetAssemblySuffix(asmName); if (asmSuffix.Equals(suffix, StringComparison.OrdinalIgnoreCase)) { @@ -852,6 +869,12 @@ private static string GetSmartMethodSuffixFromContext( return SanitizeAssemblyName(assemblyName); } + private static string GetAssemblySuffix(string assemblyName) + { + var lastDotIndex = assemblyName.LastIndexOf('.'); + return lastDotIndex >= 0 ? assemblyName.Substring(lastDotIndex + 1) : assemblyName; + } + private static string GenerateExtensionMethod( List services, string assemblyName, @@ -1246,8 +1269,7 @@ private static void GenerateSpecificAssemblyOverload( // Generate if-blocks for each referenced assembly (with prefix filtering) var filteredAssemblies = referencedAssemblies - .Where(a => a.AssemblyName.StartsWith(assemblyPrefix, StringComparison.Ordinal)) - .ToList(); + .Where(a => a.AssemblyName.StartsWith(assemblyPrefix, StringComparison.Ordinal)); foreach (var refAssembly in filteredAssemblies) { @@ -1358,10 +1380,8 @@ private static void GenerateServiceRegistrationCalls( bool includeRuntimeFiltering = false) { // Separate decorators from base services - var nonDecoratorServices = services.Where(s => !s.Decorator); - var baseServices = nonDecoratorServices.ToList(); - var decoratorServices = services.Where(s => s.Decorator); - var decorators = decoratorServices.ToList(); + var baseServices = services.Where(s => !s.Decorator); + var decorators = services.Where(s => s.Decorator); // Determine base indentation level based on runtime filtering var baseIndent = includeRuntimeFiltering ? " " : " "; @@ -1733,23 +1753,27 @@ private static bool IsReturnTypeValid( // Check if expectedType is an interface and returnType implements it if (expectedType.TypeKind == TypeKind.Interface) { + if (returnType is not INamedTypeSymbol returnNamedType) + { + return false; + } + + var allInterfaces = returnNamedType.AllInterfaces; if (expectedType is INamedTypeSymbol { IsGenericType: true } expectedNamedType) { var expectedTypeOriginal = expectedNamedType.OriginalDefinition; - return returnType is INamedTypeSymbol returnNamedType && - returnNamedType.AllInterfaces.Any(i => - { - if (i is INamedTypeSymbol { IsGenericType: true } iNamedType) - { - return SymbolEqualityComparer.Default.Equals(iNamedType.OriginalDefinition, expectedTypeOriginal); - } - - return SymbolEqualityComparer.Default.Equals(i, expectedType); - }); + return allInterfaces.Any(i => + { + if (i is INamedTypeSymbol { IsGenericType: true } iNamedType) + { + return SymbolEqualityComparer.Default.Equals(iNamedType.OriginalDefinition, expectedTypeOriginal); + } + + return SymbolEqualityComparer.Default.Equals(i, expectedType); + }); } - return returnType is INamedTypeSymbol returnTypeNamed && - returnTypeNamed.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, expectedType)); + return allInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, expectedType)); } // Check if expectedType is an abstract class and returnType inherits from it (or is the same abstract class) diff --git a/src/Atc.SourceGenerators/Generators/EnumMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/EnumMappingGenerator.cs index 290efac..ef80b89 100644 --- a/src/Atc.SourceGenerators/Generators/EnumMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/EnumMappingGenerator.cs @@ -9,8 +9,9 @@ namespace Atc.SourceGenerators.Generators; public class EnumMappingGenerator : IIncrementalGenerator { private const string AttributeNamespace = "Atc.SourceGenerators.Annotations"; - private const string AttributeName = "MapToAttribute"; - private const string FullAttributeName = $"{AttributeNamespace}.{AttributeName}"; + private const string AttributeMetadataName = "MapToAttribute"; + private const string FlagsAttributeMetadataName = "FlagsAttribute"; + private const string SystemNamespace = "System"; private static readonly DiagnosticDescriptor TargetTypeMustBeEnumDescriptor = new( id: RuleIdentifierConstants.EnumMapping.TargetTypeMustBeEnum, @@ -28,59 +29,33 @@ public class EnumMappingGenerator : IIncrementalGenerator DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor FlagEnumMappingWarningDescriptor = new( + id: RuleIdentifierConstants.EnumMapping.FlagEnumMappingWarning, + title: "Flag enum mapping warning", + messageFormat: "Enum '{0}' has the [Flags] attribute and generated switch-based mapping may produce incorrect results for combined flag values", + category: RuleCategoryConstants.EnumMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Note: MapToAttribute is generated by ObjectMappingGenerator in PostInitialization // Both generators share the same attribute, so it's only emitted once - // Find enums with MapTo attribute - var enumDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (s, _) => IsSyntaxTargetForGeneration(s), - transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)) - .Where(static m => m is not null) + // Find enums with MapTo attribute using ForAttributeWithMetadataName for optimal caching + var enumSymbols = context.SyntaxProvider + .ForAttributeWithMetadataName( + $"{AttributeNamespace}.{AttributeMetadataName}", + predicate: static (s, _) => s is EnumDeclarationSyntax, + transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol) .Collect(); - // Combine with compilation - var compilationAndEnums = context.CompilationProvider.Combine(enumDeclarations); - // Generate source - context.RegisterSourceOutput(compilationAndEnums, static (spc, source) => Execute(source.Left, source.Right!, spc)); - } - - private static bool IsSyntaxTargetForGeneration(SyntaxNode node) - => node is EnumDeclarationSyntax { AttributeLists.Count: > 0 }; - - private static EnumDeclarationSyntax? GetSemanticTargetForGeneration( - GeneratorSyntaxContext context) - { - var enumDeclaration = (EnumDeclarationSyntax)context.Node; - - foreach (var attributeListSyntax in enumDeclaration.AttributeLists) - { - foreach (var attributeSyntax in attributeListSyntax.Attributes) - { - if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol) - { - continue; - } - - var attributeContainingTypeSymbol = attributeSymbol.ContainingType; - var fullName = attributeContainingTypeSymbol.ToDisplayString(); - - if (fullName == FullAttributeName) - { - return enumDeclaration; - } - } - } - - return null; + context.RegisterSourceOutput(enumSymbols, static (spc, enums) => Execute(enums, spc)); } private static void Execute( - Compilation compilation, - ImmutableArray enums, + ImmutableArray enums, SourceProductionContext context) { if (enums.IsDefaultOrEmpty) @@ -89,13 +64,14 @@ private static void Execute( } var mappingsToGenerate = new List(); + var seen = new HashSet(StringComparer.Ordinal); - foreach (var enumDeclaration in enums.Distinct()) + foreach (var enumSymbol in enums) { context.CancellationToken.ThrowIfCancellationRequested(); - var semanticModel = compilation.GetSemanticModel(enumDeclaration.SyntaxTree); - if (semanticModel.GetDeclaredSymbol(enumDeclaration) is not { } enumSymbol) + // Deduplicate (ForAttributeWithMetadataName emits once per attribute instance) + if (!seen.Add(enumSymbol.ToDisplayString())) { continue; } @@ -127,7 +103,7 @@ private static void Execute( // Get all MapTo attributes var attributes = enumSymbol .GetAttributes() - .Where(a => a.AttributeClass?.ToDisplayString() == FullAttributeName); + .Where(a => IsAttributeMatch(a.AttributeClass, AttributeMetadataName, AttributeNamespace)); foreach (var attribute in attributes) { @@ -148,11 +124,30 @@ private static void Execute( context.ReportDiagnostic( Diagnostic.Create( TargetTypeMustBeEnumDescriptor, - enumSymbol.Locations.First(), + enumSymbol.Locations.FirstOrDefault() ?? Location.None, targetType.Name)); continue; } + // Warn if either enum has [Flags] attribute — switch expressions can't handle combined flag values + if (HasFlagsAttribute(enumSymbol)) + { + context.ReportDiagnostic( + Diagnostic.Create( + FlagEnumMappingWarningDescriptor, + enumSymbol.Locations.FirstOrDefault() ?? Location.None, + enumSymbol.Name)); + } + + if (HasFlagsAttribute(targetType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + FlagEnumMappingWarningDescriptor, + enumSymbol.Locations.FirstOrDefault() ?? Location.None, + targetType.Name)); + } + // Extract Bidirectional property var bidirectional = false; foreach (var namedArg in attribute.NamedArguments) @@ -173,7 +168,7 @@ private static void Execute( context.ReportDiagnostic( Diagnostic.Create( UnmappedEnumValueDescriptor, - enumSymbol.Locations.First(), + enumSymbol.Locations.FirstOrDefault() ?? Location.None, enumSymbol.Name, mapping.SourceValue, targetType.Name)); @@ -251,11 +246,38 @@ private static string GenerateMappingExtensions( return sb.ToString(); } + private static bool HasFlagsAttribute(ITypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + if (IsAttributeMatch(attr.AttributeClass, FlagsAttributeMetadataName, SystemNamespace)) + { + return true; + } + } + + return false; + } + + private static bool IsAttributeMatch( + INamedTypeSymbol? attributeClass, + string expectedMetadataName, + string expectedNamespace) + { + if (attributeClass is null) + { + return false; + } + + return attributeClass.MetadataName == expectedMetadataName && + attributeClass.ContainingNamespace?.ToDisplayString() == expectedNamespace; + } + private static void GenerateMappingMethod( StringBuilder sb, EnumMappingInfo mapping) { - var methodName = $"MapTo{mapping.TargetEnum.Name}"; + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(mapping.TargetEnum.Name)}"; sb.AppendLineLf(" /// "); sb.AppendLineLf($" /// Maps to ."); diff --git a/src/Atc.SourceGenerators/Generators/Internal/AutoDetectedEnumMapping.cs b/src/Atc.SourceGenerators/Generators/Internal/AutoDetectedEnumMapping.cs new file mode 100644 index 0000000..40a19fc --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/AutoDetectedEnumMapping.cs @@ -0,0 +1,6 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +internal sealed record AutoDetectedEnumMapping( + INamedTypeSymbol SourceEnum, + INamedTypeSymbol TargetEnum, + List ValueMappings); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs new file mode 100644 index 0000000..68455a3 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs @@ -0,0 +1,15 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +internal sealed record ConfiguredMappingInfo( + INamedTypeSymbol SourceType, + INamedTypeSymbol TargetType, + IMethodSymbol? Method, + INamedTypeSymbol? ContainingClass, + List PropertyMappings, + bool Bidirectional, + bool EnableFlattening, + IMethodSymbol? Constructor, + List ConstructorParameterNames, + PropertyNameStrategy PropertyNameStrategy, + List IgnoredProperties, + List<(string Source, string Target)> PropertyRenames); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/FilterRules.cs b/src/Atc.SourceGenerators/Generators/Internal/FilterRules.cs index 2c6a719..84a0110 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/FilterRules.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/FilterRules.cs @@ -3,11 +3,35 @@ namespace Atc.SourceGenerators.Generators.Internal; /// /// Represents filtering rules for excluding types from registration. /// -internal sealed record FilterRules( - ImmutableArray ExcludedNamespaces, - ImmutableArray ExcludedPatterns, - ImmutableArray ExcludedInterfaces) +internal sealed class FilterRules { + private static readonly System.Collections.Concurrent.ConcurrentDictionary PatternCache = new(StringComparer.Ordinal); + + public ImmutableArray ExcludedNamespaces { get; } + + public ImmutableArray ExcludedPatterns { get; } + + public ImmutableArray ExcludedInterfaces { get; } + + private readonly HashSet excludedInterfaceSet; + + public FilterRules( + ImmutableArray excludedNamespaces, + ImmutableArray excludedPatterns, + ImmutableArray excludedInterfaces) + { + ExcludedNamespaces = excludedNamespaces; + ExcludedPatterns = excludedPatterns; + ExcludedInterfaces = excludedInterfaces; + + // Pre-build HashSet for O(1) interface lookups instead of O(n×m) nested loops + excludedInterfaceSet = new HashSet(SymbolEqualityComparer.Default); + foreach (var iface in excludedInterfaces) + { + excludedInterfaceSet.Add(iface.OriginalDefinition); + } + } + /// /// Gets an empty filter rules instance with no exclusions. /// @@ -29,7 +53,9 @@ public bool ShouldExclude(INamedTypeSymbol typeSymbol) { // Exact match or sub-namespace match if (typeNamespace == excludedNs || - typeNamespace.StartsWith($"{excludedNs}.", StringComparison.Ordinal)) + (typeNamespace.StartsWith(excludedNs, StringComparison.Ordinal) && + typeNamespace.Length > excludedNs.Length && + typeNamespace[excludedNs.Length] == '.')) { return true; } @@ -46,21 +72,14 @@ public bool ShouldExclude(INamedTypeSymbol typeSymbol) } } - // Check interface exclusion - if (!ExcludedInterfaces.IsEmpty) + // Check interface exclusion using HashSet for O(1) lookup + if (excludedInterfaceSet.Count > 0) { - var implementedInterfaces = typeSymbol.AllInterfaces; - foreach (var excludedInterface in ExcludedInterfaces) + foreach (var implementedInterface in typeSymbol.AllInterfaces) { - foreach (var implementedInterface in implementedInterfaces) + if (excludedInterfaceSet.Contains(implementedInterface.OriginalDefinition)) { - // Use SymbolEqualityComparer to properly compare generic types - if (SymbolEqualityComparer.Default.Equals( - implementedInterface.OriginalDefinition, - excludedInterface.OriginalDefinition)) - { - return true; - } + return true; } } } @@ -71,21 +90,25 @@ public bool ShouldExclude(INamedTypeSymbol typeSymbol) /// /// Matches a string against a wildcard pattern. /// Supports * (any characters) and ? (single character). + /// Uses a static cache to avoid recompiling regex patterns. /// private static bool MatchesPattern( string value, string pattern) { - // Convert wildcard pattern to regex - var escapedPattern = System.Text.RegularExpressions.Regex.Escape(pattern); - var replacedStars = escapedPattern.Replace("\\*", ".*"); - var replacedQuestions = replacedStars.Replace("\\?", "."); - var regexPattern = $"^{replacedQuestions}$"; + var regex = PatternCache.GetOrAdd(pattern, static p => + { + var escapedPattern = System.Text.RegularExpressions.Regex.Escape(p); + var replacedStars = escapedPattern.Replace("\\*", ".*"); + var replacedQuestions = replacedStars.Replace("\\?", "."); + var regexPattern = $"^{replacedQuestions}$"; + + return new System.Text.RegularExpressions.Regex( + regexPattern, + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + }); - return System.Text.RegularExpressions.Regex.IsMatch( - value, - regexPattern, - System.Text.RegularExpressions.RegexOptions.IgnoreCase, - TimeSpan.FromSeconds(1)); + return regex.IsMatch(value); } } \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs new file mode 100644 index 0000000..2c5b01b --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs @@ -0,0 +1,192 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Shared helper methods for analyzing types during mapping code generation. +/// Used by both ObjectMappingGenerator and MappingConfigurationGenerator. +/// +internal static class MappingTypeAnalyzer +{ + /// + /// Checks if an attribute class matches the expected name and namespace + /// using MetadataName comparison instead of expensive ToDisplayString(). + /// + internal static bool IsAttributeMatch( + INamedTypeSymbol? attributeClass, + string expectedName, + string expectedNamespace = "Atc.SourceGenerators.Annotations") + { + if (attributeClass is null) + { + return false; + } + + return attributeClass.MetadataName == expectedName && + attributeClass.ContainingNamespace?.ToDisplayString() == expectedNamespace; + } + + internal static bool IsEnumConversion( + ITypeSymbol sourceType, + ITypeSymbol targetType) + => sourceType.TypeKind == TypeKind.Enum && + targetType.TypeKind == TypeKind.Enum; + + internal static bool IsNestedMapping( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + var st = sourceType.SpecialType.ToString(); + var tt = targetType.SpecialType.ToString(); + + return sourceType.TypeKind is TypeKind.Class or TypeKind.Struct && + targetType.TypeKind is TypeKind.Class or TypeKind.Struct && + !st.StartsWith("System", StringComparison.Ordinal) && + !tt.StartsWith("System", StringComparison.Ordinal); + } + + internal static bool IsBuiltInTypeConversion( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + var sourceTypeName = sourceType.ToDisplayString(); + var targetTypeName = targetType.ToDisplayString(); + + // DateTime/DateTimeOffset -> string + if ((sourceTypeName is "System.DateTime" or "System.DateTimeOffset") && + targetTypeName == "string") + { + return true; + } + + // string -> DateTime/DateTimeOffset + if (sourceTypeName == "string" && + (targetTypeName is "System.DateTime" or "System.DateTimeOffset")) + { + return true; + } + + // Guid -> string + if (sourceTypeName == "System.Guid" && targetTypeName == "string") + { + return true; + } + + // string -> Guid + if (sourceTypeName == "string" && targetTypeName == "System.Guid") + { + return true; + } + + // Numeric types -> string + if (IsNumericType(sourceTypeName) && targetTypeName == "string") + { + return true; + } + + // string -> Numeric types + if (sourceTypeName == "string" && IsNumericType(targetTypeName)) + { + return true; + } + + // bool -> string + if (sourceTypeName == "bool" && targetTypeName == "string") + { + return true; + } + + // string -> bool + if (sourceTypeName == "string" && targetTypeName == "bool") + { + return true; + } + + return false; + } + + internal static bool IsNumericType(string typeName) + => typeName is "int" or "long" or "short" or "byte" or "sbyte" or + "uint" or "ulong" or "ushort" or + "decimal" or "double" or "float"; + + internal static bool IsCollectionType( + ITypeSymbol type, + out ITypeSymbol? elementType) + { + elementType = null; + + // Handle arrays first + if (type is IArrayTypeSymbol arrayType) + { + elementType = arrayType.ElementType; + return true; + } + + if (type is not INamedTypeSymbol namedType) + { + return false; + } + + // Handle generic collections: List, IEnumerable, ICollection, IReadOnlyList, etc. + if (namedType is { IsGenericType: true, TypeArguments.Length: 1 } && + IsKnownCollectionType(namedType.ConstructedFrom)) + { + elementType = namedType.TypeArguments[0]; + return true; + } + + return false; + } + + internal static bool IsKnownCollectionType( + INamedTypeSymbol originalDefinition) + { + var name = originalDefinition.MetadataName; + var ns = originalDefinition.ContainingNamespace?.ToDisplayString() ?? string.Empty; + + return (ns == "System.Collections.Generic" && + name is "List`1" or "IEnumerable`1" or "ICollection`1" or + "IList`1" or "IReadOnlyList`1" or "IReadOnlyCollection`1") + || (ns == "System.Collections.ObjectModel" && + name is "Collection`1" or "ReadOnlyCollection`1"); + } + + internal static string GetCollectionTargetType( + ITypeSymbol targetPropertyType) + { + if (targetPropertyType is IArrayTypeSymbol) + { + return "Array"; + } + + if (targetPropertyType is not INamedTypeSymbol namedType) + { + return "List"; + } + + var originalDef = namedType.ConstructedFrom; + var name = originalDef.MetadataName; + var ns = originalDef.ContainingNamespace?.ToDisplayString() ?? string.Empty; + + if (ns == "System.Collections.ObjectModel") + { + if (name == "Collection`1") + { + return "Collection"; + } + + if (name == "ReadOnlyCollection`1") + { + return "ReadOnlyCollection"; + } + } + + return "List"; + } + + internal static bool HasMapIgnoreAttribute(IPropertySymbol property) + { + var attributes = property.GetAttributes(); + return attributes.Any(attr => + IsAttributeMatch(attr.AttributeClass, "MapIgnoreAttribute")); + } +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/PropertyNameUtility.cs b/src/Atc.SourceGenerators/Generators/Internal/PropertyNameUtility.cs index 27a9b3b..a63ad27 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/PropertyNameUtility.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/PropertyNameUtility.cs @@ -41,7 +41,7 @@ private static string ToCamelCase(string input) return input; } - return char.ToLowerInvariant(input[0]) + input.Substring(1); + return string.Concat(char.ToLowerInvariant(input[0]).ToString(), input.Substring(1)); } /// diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs new file mode 100644 index 0000000..4611129 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -0,0 +1,2271 @@ +// ReSharper disable ConvertIfStatementToReturnStatement +// ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator +// ReSharper disable InvertIf +// ReSharper disable DuplicatedStatements +namespace Atc.SourceGenerators.Generators; + +/// +/// Source generator for configuration-based object-to-object mapping. +/// Supports mapping third-party types without attributes via [MappingConfiguration] classes +/// and [assembly: MapTypes] attributes. +/// +[SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK")] +[Generator] +public class MappingConfigurationGenerator : IIncrementalGenerator +{ + private const string AttributeNamespace = "Atc.SourceGenerators.Annotations"; + private const string MappingConfigurationAttributeName = "MappingConfigurationAttribute"; + private const string MapConfigIgnoreAttributeName = "MapConfigIgnoreAttribute"; + private const string MapConfigPropertyAttributeName = "MapConfigPropertyAttribute"; + private const string MapConfigOptionsAttributeName = "MapConfigOptionsAttribute"; + private const string MapTypesAttributeName = "MapTypesAttribute"; + + private static readonly DiagnosticDescriptor ConfigClassMustBeStaticDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.ConfigClassMustBeStatic, + title: "Configuration class must be static", + messageFormat: "Mapping configuration class '{0}' must be declared as static", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ConfigClassMustBePartialDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.ConfigClassMustBePartial, + title: "Configuration class must be partial", + messageFormat: "Mapping configuration class '{0}' must be declared as partial", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MethodMustBeExtensionMethodDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.MethodMustBeExtensionMethod, + title: "Method must be extension method", + messageFormat: "Mapping method '{0}' must be an extension method (first parameter must use 'this')", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor IgnoredPropertyNotFoundDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.IgnoredPropertyNotFound, + title: "Ignored property not found", + messageFormat: "Ignored property '{0}' not found on source type '{1}'", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor RenamedSourcePropertyNotFoundDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.RenamedSourcePropertyNotFound, + title: "Renamed source property not found", + messageFormat: "Source property '{0}' specified in MapConfigProperty not found on type '{1}'", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor RenamedTargetPropertyNotFoundDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.RenamedTargetPropertyNotFound, + title: "Renamed target property not found", + messageFormat: "Target property '{0}' specified in MapConfigProperty not found on type '{1}'", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor EmptyConfigurationClassDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.EmptyConfigurationClass, + title: "Empty configuration class", + messageFormat: "Mapping configuration class '{0}' has no partial extension methods", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MethodReturnTypeInvalidDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.MethodReturnTypeInvalid, + title: "Return type invalid", + messageFormat: "Mapping method '{0}' return type must be a class, record, or struct", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateAttributeAndConfigurationDescriptor = new( + id: RuleIdentifierConstants.ObjectMappingExtended.DuplicateAttributeAndConfiguration, + title: "Duplicate attribute and configuration mapping", + messageFormat: "Type '{0}' has both [MapTo(typeof({1}))] attribute and configuration-based mapping. Attribute takes precedence.", + category: RuleCategoryConstants.ObjectMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateConfigurationMappingDescriptor = new( + id: RuleIdentifierConstants.ObjectMappingExtended.DuplicateConfigurationMapping, + title: "Duplicate configuration mapping", + messageFormat: "Type pair '{0}' -> '{1}' is configured multiple times. First configuration is used.", + category: RuleCategoryConstants.ObjectMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ConfigTargetTypeMustBeClassOrStructDescriptor = new( + id: RuleIdentifierConstants.ObjectMappingExtended.ConfigTargetTypeMustBeClassOrStruct, + title: "Target type must be a class or struct", + messageFormat: "Configuration target type '{0}' must be a class or struct", + category: RuleCategoryConstants.ObjectMapping, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AutoDetectedEnumPartialMatchDescriptor = new( + id: RuleIdentifierConstants.EnumMapping.AutoDetectedEnumPartialMatch, + title: "Partial enum match", + messageFormat: "Auto-detected enum mapping '{0}' -> '{1}': {2} of {3} values unmatched: {4}", + category: RuleCategoryConstants.EnumMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AutoDetectedEnumNoMatchDescriptor = new( + id: RuleIdentifierConstants.EnumMapping.AutoDetectedEnumNoMatch, + title: "No enum match", + messageFormat: "Enum types '{0}' and '{1}' have no matching values; falling back to cast", + category: RuleCategoryConstants.EnumMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MapCallRequiresTypeArgumentsDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.MapCallRequiresTypeArguments, + title: "Map() requires type arguments", + messageFormat: "Map() requires exactly two type arguments (source and target type)", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AddMappingsLambdaRequiredDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.AddMappingsLambdaRequired, + title: "Lambda required", + messageFormat: "AddMappings() requires a lambda expression argument", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Generate fallback attribute definitions + context.RegisterPostInitializationOutput(ctx => + { + ctx.AddSource("MappingConfigurationAttribute.g.cs", SourceText.From(GenerateMappingConfigurationAttributeSource(), Encoding.UTF8)); + ctx.AddSource("MapConfigIgnoreAttribute.g.cs", SourceText.From(GenerateMapConfigIgnoreAttributeSource(), Encoding.UTF8)); + ctx.AddSource("MapConfigPropertyAttribute.g.cs", SourceText.From(GenerateMapConfigPropertyAttributeSource(), Encoding.UTF8)); + ctx.AddSource("MapConfigOptionsAttribute.g.cs", SourceText.From(GenerateMapConfigOptionsAttributeSource(), Encoding.UTF8)); + ctx.AddSource("MapTypesAttribute.g.cs", SourceText.From(GenerateMapTypesAttributeSource(), Encoding.UTF8)); + ctx.AddSource("MappingBuilder.g.cs", SourceText.From(GenerateMappingBuilderSource(), Encoding.UTF8)); + }); + + // Find classes with [MappingConfiguration] or [MapTypes] attributes + var classDeclarations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (s, _) => IsSyntaxTargetForGeneration(s), + transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)) + .Where(static m => m is not null) + .Collect(); + + // Find AddMappings() / MappingBuilder.Configure() invocations + var inlineInvocations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (s, _) => IsInlineMappingInvocation(s), + transform: static (ctx, _) => GetInlineMappingInvocation(ctx)) + .Where(static m => m is not null) + .Collect(); + + // Combine with compilation + var compilationAndClasses = context.CompilationProvider + .Combine(classDeclarations) + .Combine(inlineInvocations); + + // Generate source + context.RegisterSourceOutput(compilationAndClasses, static (spc, source) => + Execute(source.Left.Left, source.Left.Right!, source.Right!, spc)); + } + + private static bool IsSyntaxTargetForGeneration(SyntaxNode node) + => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; + + private static ClassDeclarationSyntax? GetSemanticTargetForGeneration( + GeneratorSyntaxContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + foreach (var attributeListSyntax in classDeclaration.AttributeLists) + { + foreach (var attributeSyntax in attributeListSyntax.Attributes) + { + if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol) + { + continue; + } + + var attrType = attributeSymbol.ContainingType; + if (MappingTypeAnalyzer.IsAttributeMatch(attrType, MappingConfigurationAttributeName) || + MappingTypeAnalyzer.IsAttributeMatch(attrType, MapTypesAttributeName)) + { + return classDeclaration; + } + } + } + + return null; + } + + private static bool IsInlineMappingInvocation(SyntaxNode node) + { + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + var methodName = invocation.Expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, + _ => string.Empty, + }; + + return methodName is "AddMappings" or "Configure"; + } + + private static InvocationExpressionSyntax? GetInlineMappingInvocation( + GeneratorSyntaxContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is IMethodSymbol methodSymbol) + { + var containingType = methodSymbol.ContainingType?.ToDisplayString() ?? string.Empty; + + // Match MappingBuilder.Configure() or *.AddMappings() + if (containingType == $"{AttributeNamespace}.MappingBuilder" && methodSymbol.Name == "Configure") + { + return invocation; + } + + // AddMappings is an extension method on IServiceCollection + // We match by name and parameter type: Action + if (methodSymbol.Name == "AddMappings" && + methodSymbol.Parameters.Length >= 1 && + methodSymbol.Parameters.Last().Type is INamedTypeSymbol paramType && + paramType.Name == "Action" && + paramType.TypeArguments.Length == 1 && + paramType.TypeArguments[0].Name == "MappingBuilder") + { + return invocation; + } + } + + // Fallback: if the method is not yet resolved (common for generated methods), + // check syntax pattern + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var name = memberAccess.Name.Identifier.Text; + if (name is "AddMappings" or "Configure" && + invocation.ArgumentList.Arguments.Count >= 1) + { + var arg = invocation.ArgumentList.Arguments.Last().Expression; + if (arg is SimpleLambdaExpressionSyntax or ParenthesizedLambdaExpressionSyntax) + { + return invocation; + } + } + } + + return null; + } + + private static void Execute( + Compilation compilation, + ImmutableArray classes, + ImmutableArray inlineInvocations, + SourceProductionContext context) + { + var configuredMappings = new List(); + var autoDetectedEnums = new List(); + + // Process [MappingConfiguration] classes and class-level [MapTypes] classes + if (!classes.IsDefaultOrEmpty) + { + // Deduplicate by fully qualified symbol name to handle partial classes across multiple files + var seenClasses = new HashSet(StringComparer.Ordinal); + + foreach (var classDeclaration in classes) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + var semanticModel = compilation.GetSemanticModel(classDeclaration.SyntaxTree); + if (semanticModel.GetDeclaredSymbol(classDeclaration) is not { } classSymbol) + { + continue; + } + + if (!seenClasses.Add(classSymbol.ToDisplayString())) + { + continue; + } + + // Check if this is a [MappingConfiguration] class + var hasMappingConfigAttr = classSymbol.GetAttributes() + .Any(a => MappingTypeAnalyzer.IsAttributeMatch(a.AttributeClass, MappingConfigurationAttributeName)); + + if (hasMappingConfigAttr) + { + var mappings = ProcessMappingConfigurationClass(classSymbol, classDeclaration, context, autoDetectedEnums); + if (mappings is not null) + { + configuredMappings.AddRange(mappings); + } + } + + // Check if this is a class with [MapTypes] attributes + var classMapTypesAttrs = classSymbol.GetAttributes() + .Where(a => MappingTypeAnalyzer.IsAttributeMatch(a.AttributeClass, MapTypesAttributeName)) + .ToList(); + + if (classMapTypesAttrs.Count > 0) + { + var mappings = ProcessClassLevelMapTypesAttributes(classMapTypesAttrs, context, autoDetectedEnums); + if (mappings is not null) + { + configuredMappings.AddRange(mappings); + } + } + } + } + + // Process [assembly: MapTypes] attributes + var assemblyMappings = ProcessAssemblyMapTypesAttributes(compilation, context, autoDetectedEnums); + if (assemblyMappings is not null) + { + configuredMappings.AddRange(assemblyMappings); + } + + // Process AddMappings() / MappingBuilder.Configure() inline invocations + if (!inlineInvocations.IsDefaultOrEmpty) + { + foreach (var invocation in inlineInvocations.Distinct()) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + var mappings = ProcessInlineMappingInvocation(invocation, compilation, context, autoDetectedEnums); + if (mappings is not null) + { + configuredMappings.AddRange(mappings); + } + } + } + + // Generate AddMappings extension method if IServiceCollection is available + GenerateAddMappingsExtension(compilation, context); + + if (configuredMappings.Count == 0) + { + return; + } + + // Check for duplicates with attribute-based mappings + DetectDuplicatesWithAttributeMappings(compilation, configuredMappings, context); + + // Deduplicate within config-based mappings + DeduplicateConfigMappings(configuredMappings, context); + + // Generate code for mapper class pattern (partial classes) + var mapperClassMappings = configuredMappings.Where(m => m.ContainingClass is not null).ToList(); + if (mapperClassMappings.Count > 0) + { + GenerateMapperClassCode(mapperClassMappings, autoDetectedEnums, context); + } + + // Generate code for assembly-level pattern + var assemblyLevelMappings = configuredMappings.Where(m => m.ContainingClass is null).ToList(); + if (assemblyLevelMappings.Count > 0) + { + GenerateAssemblyLevelCode(assemblyLevelMappings, autoDetectedEnums, context); + } + } + + private static List? ProcessMappingConfigurationClass( + INamedTypeSymbol classSymbol, + ClassDeclarationSyntax classDeclaration, + SourceProductionContext context, + List autoDetectedEnums) + { + // Validate class is static + if (!classSymbol.IsStatic) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConfigClassMustBeStaticDescriptor, + classSymbol.Locations.FirstOrDefault() ?? Location.None, + classSymbol.Name)); + return null; + } + + // Validate class is partial + if (!classDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConfigClassMustBePartialDescriptor, + classSymbol.Locations.FirstOrDefault() ?? Location.None, + classSymbol.Name)); + return null; + } + + // Find partial methods + var partialMethods = classSymbol + .GetMembers() + .OfType() + .Where(m => m.IsPartialDefinition && !m.IsImplicitlyDeclared) + .ToList(); + + if (partialMethods.Count == 0) + { + context.ReportDiagnostic( + Diagnostic.Create( + EmptyConfigurationClassDescriptor, + classSymbol.Locations.FirstOrDefault() ?? Location.None, + classSymbol.Name)); + return null; + } + + var mappings = new List(); + + foreach (var method in partialMethods) + { + var mapping = ProcessMappingMethod(method, classSymbol, context, autoDetectedEnums); + if (mapping is not null) + { + mappings.Add(mapping); + } + } + + return mappings.Count > 0 ? mappings : null; + } + + private static ConfiguredMappingInfo? ProcessMappingMethod( + IMethodSymbol method, + INamedTypeSymbol containingClass, + SourceProductionContext context, + List autoDetectedEnums) + { + // Validate extension method + if (!method.IsExtensionMethod) + { + context.ReportDiagnostic( + Diagnostic.Create( + MethodMustBeExtensionMethodDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + method.Name)); + return null; + } + + // Validate return type + if (method.ReturnsVoid || + method.ReturnType is not INamedTypeSymbol targetType || + (targetType.TypeKind != TypeKind.Class && targetType.TypeKind != TypeKind.Struct) || + targetType.SpecialType != SpecialType.None) + { + context.ReportDiagnostic( + Diagnostic.Create( + MethodReturnTypeInvalidDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + method.Name)); + return null; + } + + // Get source type from first parameter + var sourceType = method.Parameters[0].Type as INamedTypeSymbol; + if (sourceType is null) + { + return null; + } + + // Extract attributes + var ignoredProperties = new List(); + var propertyRenames = new List<(string Source, string Target)>(); + var bidirectional = false; + var enableFlattening = false; + var propertyNameStrategy = PropertyNameStrategy.PascalCase; + + foreach (var attr in method.GetAttributes()) + { + if (MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, MapConfigIgnoreAttributeName)) + { + if (attr.ConstructorArguments.Length > 0 && + attr.ConstructorArguments[0].Value is string propName) + { + ignoredProperties.Add(propName); + } + } + else if (MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, MapConfigPropertyAttributeName)) + { + if (attr.ConstructorArguments.Length >= 2 && + attr.ConstructorArguments[0].Value is string sourcePropName && + attr.ConstructorArguments[1].Value is string targetPropName) + { + propertyRenames.Add((sourcePropName, targetPropName)); + } + } + else if (MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, MapConfigOptionsAttributeName)) + { + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Key == "Bidirectional") + { + bidirectional = namedArg.Value.Value as bool? ?? false; + } + else if (namedArg.Key == "EnableFlattening") + { + enableFlattening = namedArg.Value.Value as bool? ?? false; + } + else if (namedArg.Key == "PropertyNameStrategy" && + namedArg.Value.Value is int strategyValue) + { + propertyNameStrategy = (PropertyNameStrategy)strategyValue; + } + } + } + } + + // Validate ignored properties exist on source type + var sourceProperties = GetAllProperties(sourceType, includePrivateMembers: false); + foreach (var ignoredProp in ignoredProperties) + { + if (!sourceProperties.Any(p => string.Equals(p.Name, ignoredProp, StringComparison.OrdinalIgnoreCase))) + { + context.ReportDiagnostic( + Diagnostic.Create( + IgnoredPropertyNotFoundDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + ignoredProp, + sourceType.Name)); + } + } + + // Validate renamed properties exist + var targetProperties = GetAllProperties(targetType, includePrivateMembers: false, requireSetter: true); + foreach (var rename in propertyRenames) + { + if (!sourceProperties.Any(p => string.Equals(p.Name, rename.Source, StringComparison.OrdinalIgnoreCase))) + { + context.ReportDiagnostic( + Diagnostic.Create( + RenamedSourcePropertyNotFoundDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + rename.Source, + sourceType.Name)); + } + + if (!targetProperties.Any(p => string.Equals(p.Name, rename.Target, StringComparison.OrdinalIgnoreCase))) + { + context.ReportDiagnostic( + Diagnostic.Create( + RenamedTargetPropertyNotFoundDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + rename.Target, + targetType.Name)); + } + } + + // Get property mappings with ignores and renames applied + var propertyMappings = GetPropertyMappingsWithConfig( + sourceType, + targetType, + enableFlattening, + propertyNameStrategy, + ignoredProperties, + propertyRenames, + autoDetectedEnums, + context); + + // Find best matching constructor + var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + + return new ConfiguredMappingInfo( + SourceType: sourceType, + TargetType: targetType, + Method: method, + ContainingClass: containingClass, + PropertyMappings: propertyMappings, + Bidirectional: bidirectional, + EnableFlattening: enableFlattening, + Constructor: constructor, + ConstructorParameterNames: constructorParameterNames, + PropertyNameStrategy: propertyNameStrategy, + IgnoredProperties: ignoredProperties, + PropertyRenames: propertyRenames); + } + + private static List? ProcessClassLevelMapTypesAttributes( + List mapTypesAttrs, + SourceProductionContext context, + List autoDetectedEnums) + { + var mappings = new List(); + + foreach (var attr in mapTypesAttrs) + { + var mapping = ExtractMapTypesMapping(attr, context, autoDetectedEnums); + if (mapping is not null) + { + mappings.Add(mapping); + } + } + + return mappings.Count > 0 ? mappings : null; + } + + private static List? ProcessInlineMappingInvocation( + InvocationExpressionSyntax invocation, + Compilation compilation, + SourceProductionContext context, + List autoDetectedEnums) + { + // Extract the lambda argument + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count == 0) + { + context.ReportDiagnostic( + Diagnostic.Create( + AddMappingsLambdaRequiredDescriptor, + invocation.GetLocation())); + return null; + } + + var lambdaArg = arguments.Last().Expression; + SyntaxNode? lambdaBody = lambdaArg switch + { + SimpleLambdaExpressionSyntax simpleLambda => simpleLambda.Body, + ParenthesizedLambdaExpressionSyntax parenLambda => parenLambda.Body, + _ => null, + }; + + if (lambdaBody is null) + { + context.ReportDiagnostic( + Diagnostic.Create( + AddMappingsLambdaRequiredDescriptor, + invocation.GetLocation())); + return null; + } + + var semanticModel = compilation.GetSemanticModel(invocation.SyntaxTree); + var mappings = new List(); + + // Find all Map() calls inside the lambda body + var mapCalls = lambdaBody.DescendantNodes().OfType(); + + foreach (var mapCall in mapCalls) + { + var methodName = mapCall.Expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name, + _ => null, + }; + + if (methodName is null || methodName.Identifier.Text != "Map") + { + continue; + } + + var mapping = ParseMapCallInvocation(mapCall, methodName, semanticModel, context, autoDetectedEnums); + if (mapping is not null) + { + mappings.Add(mapping); + } + } + + return mappings.Count > 0 ? mappings : null; + } + + private static ConfiguredMappingInfo? ParseMapCallInvocation( + InvocationExpressionSyntax mapCall, + SimpleNameSyntax methodName, + SemanticModel semanticModel, + SourceProductionContext context, + List autoDetectedEnums) + { + INamedTypeSymbol? sourceType = null; + INamedTypeSymbol? targetType = null; + + // Check for generic Map() syntax + if (methodName is GenericNameSyntax genericName && genericName.TypeArgumentList.Arguments.Count == 2) + { + var sourceTypeInfo = semanticModel.GetTypeInfo(genericName.TypeArgumentList.Arguments[0]); + var targetTypeInfo = semanticModel.GetTypeInfo(genericName.TypeArgumentList.Arguments[1]); + + sourceType = sourceTypeInfo.Type as INamedTypeSymbol; + targetType = targetTypeInfo.Type as INamedTypeSymbol; + } + else + { + // Check for Map(typeof(Source), typeof(Target)) syntax + var args = mapCall.ArgumentList.Arguments; + var positionalArgs = args.Where(a => a.NameColon is null).ToList(); + + if (positionalArgs.Count >= 2) + { + sourceType = ExtractTypeFromTypeOfExpression(positionalArgs[0].Expression, semanticModel); + targetType = ExtractTypeFromTypeOfExpression(positionalArgs[1].Expression, semanticModel); + } + } + + if (sourceType is null || targetType is null) + { + context.ReportDiagnostic( + Diagnostic.Create( + MapCallRequiresTypeArgumentsDescriptor, + mapCall.GetLocation())); + return null; + } + + // Validate target type + if (targetType.TypeKind != TypeKind.Class && targetType.TypeKind != TypeKind.Struct) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConfigTargetTypeMustBeClassOrStructDescriptor, + mapCall.GetLocation(), + targetType.Name)); + return null; + } + + // Extract named arguments + var bidirectional = false; + var propertyNameStrategy = PropertyNameStrategy.PascalCase; + var ignoredProperties = new List(); + var propertyRenames = new List<(string Source, string Target)>(); + + foreach (var arg in mapCall.ArgumentList.Arguments) + { + if (arg.NameColon is null) + { + continue; + } + + var argName = arg.NameColon.Name.Identifier.Text; + + if (argName == "bidirectional" && arg.Expression is LiteralExpressionSyntax bidirectionalLiteral) + { + bidirectional = bidirectionalLiteral.IsKind(SyntaxKind.TrueLiteralExpression); + } + else if (argName == "propertyMap") + { + var values = ExtractStringArrayFromExpression(arg.Expression); + foreach (var mapping in values) + { + var parts = mapping.Split(':'); + if (parts.Length == 2) + { + propertyRenames.Add((parts[0].Trim(), parts[1].Trim())); + } + } + } + else if (argName == "ignoreSourceProperties") + { + ignoredProperties.AddRange(ExtractStringArrayFromExpression(arg.Expression)); + } + else if (argName == "propertyNameStrategy" && arg.Expression is MemberAccessExpressionSyntax memberAccess) + { + var strategyName = memberAccess.Name.Identifier.Text; + if (Enum.TryParse(strategyName, out var strategy)) + { + propertyNameStrategy = strategy; + } + } + } + + // Get property mappings + var propertyMappings = GetPropertyMappingsWithConfig( + sourceType, + targetType, + enableFlattening: false, + propertyNameStrategy, + ignoredProperties, + propertyRenames, + autoDetectedEnums, + context); + + // Find best matching constructor + var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + + return new ConfiguredMappingInfo( + SourceType: sourceType, + TargetType: targetType, + Method: null, + ContainingClass: null, + PropertyMappings: propertyMappings, + Bidirectional: bidirectional, + EnableFlattening: false, + Constructor: constructor, + ConstructorParameterNames: constructorParameterNames, + PropertyNameStrategy: propertyNameStrategy, + IgnoredProperties: ignoredProperties, + PropertyRenames: propertyRenames); + } + + private static INamedTypeSymbol? ExtractTypeFromTypeOfExpression( + ExpressionSyntax expression, + SemanticModel semanticModel) + { + if (expression is TypeOfExpressionSyntax typeOfExpression) + { + var typeInfo = semanticModel.GetTypeInfo(typeOfExpression.Type); + return typeInfo.Type as INamedTypeSymbol; + } + + return null; + } + + private static List ExtractStringArrayFromExpression( + ExpressionSyntax expression) + { + var values = new List(); + + // Handle: new[] { "A", "B" } or new string[] { "A", "B" } + if (expression is ImplicitArrayCreationExpressionSyntax implicitArray) + { + foreach (var expr in implicitArray.Initializer.Expressions) + { + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + values.Add(literal.Token.ValueText); + } + } + } + else if (expression is ArrayCreationExpressionSyntax arrayCreation && arrayCreation.Initializer is not null) + { + foreach (var expr in arrayCreation.Initializer.Expressions) + { + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + values.Add(literal.Token.ValueText); + } + } + } + else if (expression is CollectionExpressionSyntax collectionExpression) + { + foreach (var element in collectionExpression.Elements) + { + if (element is ExpressionElementSyntax exprElement && + exprElement.Expression is LiteralExpressionSyntax literal && + literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + values.Add(literal.Token.ValueText); + } + } + } + + return values; + } + + private static ConfiguredMappingInfo? ExtractMapTypesMapping( + AttributeData attr, + SourceProductionContext context, + List autoDetectedEnums) + { + if (attr.ConstructorArguments.Length < 2) + { + return null; + } + + if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol sourceType) + { + return null; + } + + if (attr.ConstructorArguments[1].Value is not INamedTypeSymbol targetType) + { + return null; + } + + // Validate target type + if (targetType.TypeKind != TypeKind.Class && targetType.TypeKind != TypeKind.Struct) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConfigTargetTypeMustBeClassOrStructDescriptor, + attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None, + targetType.Name)); + return null; + } + + // Extract named arguments + var ignoredProperties = new List(); + var propertyRenames = new List<(string Source, string Target)>(); + var bidirectional = false; + var propertyNameStrategy = PropertyNameStrategy.PascalCase; + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Key == "IgnoreSourceProperties" && namedArg.Value.Values.Length > 0) + { + foreach (var val in namedArg.Value.Values) + { + if (val.Value is string propName) + { + ignoredProperties.Add(propName); + } + } + } + else if (namedArg.Key == "PropertyMap" && namedArg.Value.Values.Length > 0) + { + foreach (var val in namedArg.Value.Values) + { + if (val.Value is string mapping) + { + var parts = mapping.Split(':'); + if (parts.Length == 2) + { + propertyRenames.Add((parts[0].Trim(), parts[1].Trim())); + } + } + } + } + else if (namedArg.Key == "Bidirectional") + { + bidirectional = namedArg.Value.Value as bool? ?? false; + } + else if (namedArg.Key == "PropertyNameStrategy" && + namedArg.Value.Value is int strategyValue) + { + propertyNameStrategy = (PropertyNameStrategy)strategyValue; + } + } + + // Get property mappings + var propertyMappings = GetPropertyMappingsWithConfig( + sourceType, + targetType, + enableFlattening: false, + propertyNameStrategy, + ignoredProperties, + propertyRenames, + autoDetectedEnums, + context); + + // Find best matching constructor + var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + + return new ConfiguredMappingInfo( + SourceType: sourceType, + TargetType: targetType, + Method: null, + ContainingClass: null, + PropertyMappings: propertyMappings, + Bidirectional: bidirectional, + EnableFlattening: false, + Constructor: constructor, + ConstructorParameterNames: constructorParameterNames, + PropertyNameStrategy: propertyNameStrategy, + IgnoredProperties: ignoredProperties, + PropertyRenames: propertyRenames); + } + + private static void GenerateAddMappingsExtension( + Compilation compilation, + SourceProductionContext context) + { + // Only generate if IServiceCollection is available in the compilation + var serviceCollectionType = compilation.GetTypeByMetadataName( + "Microsoft.Extensions.DependencyInjection.IServiceCollection"); + + if (serviceCollectionType is null) + { + return; + } + + var sb = new StringBuilder(); + sb.AppendLineLf("// "); + sb.AppendLineLf("#nullable enable"); + sb.AppendLineLf(); + sb.AppendLineLf("namespace Microsoft.Extensions.DependencyInjection;"); + sb.AppendLineLf(); + sb.AppendLineLf("[global::System.CodeDom.Compiler.GeneratedCode(\"Atc.SourceGenerators.MappingConfiguration\", \"1.0.0\")]"); + sb.AppendLineLf("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLineLf("[global::System.Runtime.CompilerServices.CompilerGenerated]"); + sb.AppendLineLf("[global::System.Diagnostics.DebuggerNonUserCode]"); + sb.AppendLineLf("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + sb.AppendLineLf("public static class AtcMappingServiceCollectionExtensions"); + sb.AppendLineLf("{"); + sb.AppendLineLf(" /// "); + sb.AppendLineLf(" /// Registers compile-time mapping configurations. The lambda body is analyzed by the source generator"); + sb.AppendLineLf(" /// to produce mapping extension methods. At runtime, this is a no-op."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf(" public static IServiceCollection AddMappings("); + sb.AppendLineLf(" this IServiceCollection services,"); + sb.AppendLineLf(" global::System.Action configure)"); + sb.AppendLineLf(" => services;"); + sb.AppendLineLf("}"); + + context.AddSource("AtcMappingServiceCollectionExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static List? ProcessAssemblyMapTypesAttributes( + Compilation compilation, + SourceProductionContext context, + List autoDetectedEnums) + { + var mappings = new List(); + + var assemblyAttributes = compilation.Assembly + .GetAttributes() + .Where(a => MappingTypeAnalyzer.IsAttributeMatch(a.AttributeClass, MapTypesAttributeName)); + + foreach (var attr in assemblyAttributes) + { + var mapping = ExtractMapTypesMapping(attr, context, autoDetectedEnums); + if (mapping is not null) + { + mappings.Add(mapping); + } + } + + return mappings.Count > 0 ? mappings : null; + } + + private static List GetPropertyMappingsWithConfig( + INamedTypeSymbol sourceType, + INamedTypeSymbol targetType, + bool enableFlattening, + PropertyNameStrategy propertyNameStrategy, + List ignoredProperties, + List<(string Source, string Target)> propertyRenames, + List autoDetectedEnums, + SourceProductionContext context) + { + var mappings = new List(); + + var sourceProperties = GetAllProperties(sourceType, includePrivateMembers: false); + var targetProperties = GetAllProperties(targetType, includePrivateMembers: false, requireSetter: true); + + foreach (var sourceProp in sourceProperties) + { + // Check if property is ignored + if (ignoredProperties.Any(ip => + string.Equals(ip, sourceProp.Name, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + // Check if property has custom rename + var rename = propertyRenames.FirstOrDefault(r => + string.Equals(r.Source, sourceProp.Name, StringComparison.OrdinalIgnoreCase)); + + var targetPropertyName = rename.Target ?? + PropertyNameUtility.ConvertPropertyName(sourceProp.Name, propertyNameStrategy); + + // Find target property + var targetProp = targetProperties.FirstOrDefault(t => + string.Equals(t.Name, targetPropertyName, StringComparison.OrdinalIgnoreCase) && + SymbolEqualityComparer.Default.Equals(t.Type, sourceProp.Type)); + + if (targetProp is not null) + { + // Direct type match + mappings.Add(new PropertyMapping( + SourceProperty: sourceProp, + TargetProperty: targetProp, + RequiresConversion: false, + IsNested: false, + HasEnumMapping: false, + IsCollection: false, + CollectionElementType: null, + CollectionTargetType: null, + IsFlattened: false, + FlattenedNestedProperty: null, + IsBuiltInTypeConversion: false, + SourceRequiresUnsafeAccessor: false, + TargetRequiresUnsafeAccessor: false)); + } + else + { + // Check for different but mappable types + targetProp = targetProperties.FirstOrDefault(t => + string.Equals(t.Name, targetPropertyName, StringComparison.OrdinalIgnoreCase)); + + if (targetProp is not null) + { + // Check for collection mapping + var isSourceCollection = MappingTypeAnalyzer.IsCollectionType(sourceProp.Type, out var sourceElementType); + var isTargetCollection = MappingTypeAnalyzer.IsCollectionType(targetProp.Type, out var targetElementType); + + if (isSourceCollection && isTargetCollection && sourceElementType is not null && targetElementType is not null) + { + mappings.Add(new PropertyMapping( + SourceProperty: sourceProp, + TargetProperty: targetProp, + RequiresConversion: false, + IsNested: false, + HasEnumMapping: false, + IsCollection: true, + CollectionElementType: targetElementType, + CollectionTargetType: MappingTypeAnalyzer.GetCollectionTargetType(targetProp.Type), + IsFlattened: false, + FlattenedNestedProperty: null, + IsBuiltInTypeConversion: false, + SourceRequiresUnsafeAccessor: false, + TargetRequiresUnsafeAccessor: false)); + } + else + { + var requiresConversion = MappingTypeAnalyzer.IsEnumConversion(sourceProp.Type, targetProp.Type); + var isNested = MappingTypeAnalyzer.IsNestedMapping(sourceProp.Type, targetProp.Type); + var isBuiltInTypeConversion = MappingTypeAnalyzer.IsBuiltInTypeConversion(sourceProp.Type, targetProp.Type); + + // Auto-detect enum mapping + var hasEnumMapping = false; + if (requiresConversion) + { + hasEnumMapping = HasEnumMappingAttribute(sourceProp.Type, targetProp.Type); + + // If no explicit mapping, try auto-detection + if (!hasEnumMapping && + sourceProp.Type is INamedTypeSymbol sourceEnumType && + targetProp.Type is INamedTypeSymbol targetEnumType) + { + var autoMapping = TryAutoDetectEnumMapping( + sourceEnumType, targetEnumType, autoDetectedEnums, context); + hasEnumMapping = autoMapping; + } + } + + if (requiresConversion || isNested || isBuiltInTypeConversion) + { + mappings.Add(new PropertyMapping( + SourceProperty: sourceProp, + TargetProperty: targetProp, + RequiresConversion: requiresConversion, + IsNested: isNested, + HasEnumMapping: hasEnumMapping, + IsCollection: false, + CollectionElementType: null, + CollectionTargetType: null, + IsFlattened: false, + FlattenedNestedProperty: null, + IsBuiltInTypeConversion: isBuiltInTypeConversion, + SourceRequiresUnsafeAccessor: false, + TargetRequiresUnsafeAccessor: false)); + } + } + } + } + } + + // Handle flattening if enabled + if (enableFlattening) + { + foreach (var sourceProp in sourceProperties) + { + if (ignoredProperties.Any(ip => + string.Equals(ip, sourceProp.Name, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + if (sourceProp.Type is not INamedTypeSymbol namedType || + (namedType.TypeKind != TypeKind.Class && namedType.TypeKind != TypeKind.Struct)) + { + continue; + } + + var typeStr = namedType.SpecialType.ToString(); + if (typeStr.StartsWith("System", StringComparison.Ordinal)) + { + continue; + } + + var nestedProperties = namedType + .GetMembers() + .OfType() + .Where(p => p.GetMethod is not null) + .ToList(); + + foreach (var nestedProp in nestedProperties) + { + var flattenedName = $"{sourceProp.Name}{nestedProp.Name}"; + var targetProp = targetProperties.FirstOrDefault(t => + string.Equals(t.Name, flattenedName, StringComparison.OrdinalIgnoreCase) && + SymbolEqualityComparer.Default.Equals(t.Type, nestedProp.Type)); + + if (targetProp is not null) + { + mappings.Add(new PropertyMapping( + SourceProperty: sourceProp, + TargetProperty: targetProp, + RequiresConversion: false, + IsNested: false, + HasEnumMapping: false, + IsCollection: false, + CollectionElementType: null, + CollectionTargetType: null, + IsFlattened: true, + FlattenedNestedProperty: nestedProp, + IsBuiltInTypeConversion: false, + SourceRequiresUnsafeAccessor: false, + TargetRequiresUnsafeAccessor: false)); + } + } + } + } + + return mappings; + } + + private static bool TryAutoDetectEnumMapping( + INamedTypeSymbol sourceEnum, + INamedTypeSymbol targetEnum, + List autoDetectedEnums, + SourceProductionContext context) + { + // Check if already auto-detected + if (autoDetectedEnums.Any(m => + SymbolEqualityComparer.Default.Equals(m.SourceEnum, sourceEnum) && + SymbolEqualityComparer.Default.Equals(m.TargetEnum, targetEnum))) + { + return true; + } + + var valueMappings = EnumMappingHelper.GetEnumValueMappings(sourceEnum, targetEnum); + var mappedCount = valueMappings.Count(m => m.IsMapped); + var totalCount = valueMappings.Count; + + if (mappedCount == 0) + { + // No matches at all - fall back to cast + context.ReportDiagnostic( + Diagnostic.Create( + AutoDetectedEnumNoMatchDescriptor, + sourceEnum.Locations.FirstOrDefault() ?? Location.None, + sourceEnum.Name, + targetEnum.Name)); + return false; + } + + if (mappedCount < totalCount) + { + // Partial match + var unmappedValues = valueMappings + .Where(m => !m.IsMapped) + .Select(m => m.SourceValue); + var unmappedCount = totalCount - mappedCount; + + context.ReportDiagnostic( + Diagnostic.Create( + AutoDetectedEnumPartialMatchDescriptor, + sourceEnum.Locations.FirstOrDefault() ?? Location.None, + sourceEnum.Name, + targetEnum.Name, + unmappedCount, + totalCount, + string.Join(", ", unmappedValues))); + } + + // Add to auto-detected list + autoDetectedEnums.Add(new AutoDetectedEnumMapping( + SourceEnum: sourceEnum, + TargetEnum: targetEnum, + ValueMappings: valueMappings)); + + return true; + } + + private static List GetAllProperties( + INamedTypeSymbol type, + bool includePrivateMembers, + bool requireSetter = false) + { + var properties = new List(); + var propertyNames = new HashSet(StringComparer.Ordinal); + var currentType = type; + + while (currentType is not null && + currentType.SpecialType != SpecialType.System_Object && + currentType.ToDisplayString() != "object") + { + var typeProperties = currentType + .GetMembers() + .OfType() + .Where(p => + { + if (p.GetMethod is null) + { + return false; + } + + if (requireSetter && p.SetMethod is null && currentType.TypeKind != TypeKind.Struct) + { + return false; + } + + if (MappingTypeAnalyzer.HasMapIgnoreAttribute(p)) + { + return false; + } + + if (!includePrivateMembers && p.DeclaredAccessibility != Accessibility.Public) + { + return false; + } + + return true; + }) + .ToList(); + + foreach (var prop in typeProperties) + { + if (!propertyNames.Contains(prop.Name)) + { + properties.Add(prop); + propertyNames.Add(prop.Name); + } + } + + currentType = currentType.BaseType; + } + + return properties; + } + + private static bool HasEnumMappingAttribute( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + if (sourceType is not INamedTypeSymbol sourceEnum || targetType is not INamedTypeSymbol targetEnum) + { + return false; + } + + // Check if source enum has [MapTo(typeof(TargetEnum))] + foreach (var attr in sourceEnum.GetAttributes()) + { + if (!MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, "MapToAttribute")) + { + continue; + } + + if (attr.ConstructorArguments.Length == 0) + { + continue; + } + + if (attr.ConstructorArguments[0].Value is INamedTypeSymbol attrTargetType && + SymbolEqualityComparer.Default.Equals(attrTargetType, targetEnum)) + { + return true; + } + } + + // Check if target enum has [MapTo(typeof(SourceEnum), Bidirectional = true)] + foreach (var attr in targetEnum.GetAttributes()) + { + if (!MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, "MapToAttribute")) + { + continue; + } + + if (attr.ConstructorArguments.Length == 0) + { + continue; + } + + if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol attrSourceType || + !SymbolEqualityComparer.Default.Equals(attrSourceType, sourceEnum)) + { + continue; + } + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg is { Key: "Bidirectional", Value.Value: true }) + { + return true; + } + } + } + + return false; + } + + private static (IMethodSymbol? Constructor, List ParameterNames) FindBestConstructor( + INamedTypeSymbol sourceType, + INamedTypeSymbol targetType) + { + var constructors = targetType + .Constructors + .Where(c => c.DeclaredAccessibility == Accessibility.Public && !c.IsStatic) + .ToList(); + + if (constructors.Count == 0) + { + return (null, []); + } + + var sourceProperties = sourceType + .GetMembers() + .OfType() + .Where(p => p.GetMethod is not null) + .ToList(); + + foreach (var constructor in constructors.OrderByDescending(c => c.Parameters.Length)) + { + var parameterNames = new List(); + var allParametersMatch = true; + + foreach (var parameter in constructor.Parameters) + { + var matchingSourceProperty = sourceProperties.FirstOrDefault(p => + string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchingSourceProperty is null) + { + allParametersMatch = false; + break; + } + + parameterNames.Add(parameter.Name); + } + + if (allParametersMatch && constructor.Parameters.Length > 0) + { + return (constructor, parameterNames); + } + } + + return (null, []); + } + + private static void DetectDuplicatesWithAttributeMappings( + Compilation compilation, + List configuredMappings, + SourceProductionContext context) + { + // Scan for all types with [MapTo] attribute in the compilation + var attributeMappedTypes = new HashSet<(string Source, string Target)>( + new TypePairComparer()); + + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var semanticModel = compilation.GetSemanticModel(syntaxTree); + var root = syntaxTree.GetRoot(); + + foreach (var typeDecl in root.DescendantNodes().OfType()) + { + if (semanticModel.GetDeclaredSymbol(typeDecl) is not INamedTypeSymbol typeSymbol) + { + continue; + } + + foreach (var attr in typeSymbol.GetAttributes()) + { + if (!MappingTypeAnalyzer.IsAttributeMatch(attr.AttributeClass, "MapToAttribute")) + { + continue; + } + + if (attr.ConstructorArguments.Length > 0 && + attr.ConstructorArguments[0].Value is INamedTypeSymbol targetType) + { + attributeMappedTypes.Add(( + typeSymbol.ToDisplayString(), + targetType.ToDisplayString())); + } + } + } + } + + // Check config mappings against attribute mappings + foreach (var mapping in configuredMappings) + { + var sourceName = mapping.SourceType.ToDisplayString(); + var targetName = mapping.TargetType.ToDisplayString(); + + if (attributeMappedTypes.Contains((sourceName, targetName))) + { + var location = mapping.Method?.Locations.First() ?? Location.None; + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateAttributeAndConfigurationDescriptor, + location, + sourceName, + targetName)); + } + } + } + + private static void DeduplicateConfigMappings( + List configuredMappings, + SourceProductionContext context) + { + var seen = new HashSet<(string Source, string Target)>(new TypePairComparer()); + var toRemove = new List(); + + foreach (var mapping in configuredMappings) + { + var key = (mapping.SourceType.ToDisplayString(), mapping.TargetType.ToDisplayString()); + if (!seen.Add(key)) + { + var location = mapping.Method?.Locations.First() ?? Location.None; + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateConfigurationMappingDescriptor, + location, + key.Item1, + key.Item2)); + toRemove.Add(mapping); + } + } + + foreach (var item in toRemove) + { + configuredMappings.Remove(item); + } + } + + private sealed class TypePairComparer : IEqualityComparer<(string Source, string Target)> + { + public bool Equals( + (string Source, string Target) x, + (string Source, string Target) y) + => string.Equals(x.Source, y.Source, StringComparison.Ordinal) && + string.Equals(x.Target, y.Target, StringComparison.Ordinal); + + public int GetHashCode((string Source, string Target) obj) + => StringComparer.Ordinal.GetHashCode(obj.Source) ^ + StringComparer.Ordinal.GetHashCode(obj.Target); + } + + private static void GenerateMapperClassCode( + List mappings, + List autoDetectedEnums, + SourceProductionContext context) + { + // Group mappings by containing class + var groupedByClass = mappings + .GroupBy(m => m.ContainingClass!, SymbolEqualityComparer.Default); + + foreach (var group in groupedByClass) + { + var containingClass = (INamedTypeSymbol)group.Key!; + var classMappings = group.ToList(); + + var sb = new StringBuilder(); + var namespaces = new HashSet(StringComparer.Ordinal); + + // Collect namespaces + foreach (var mapping in classMappings) + { + namespaces.Add(mapping.SourceType.ContainingNamespace.ToDisplayString()); + namespaces.Add(mapping.TargetType.ContainingNamespace.ToDisplayString()); + } + + sb.AppendLineLf("// "); + sb.AppendLineLf("#nullable enable"); + sb.AppendLineLf(); + + foreach (var ns in namespaces.OrderBy(x => x, StringComparer.Ordinal)) + { + sb.AppendLineLf($"using {ns};"); + } + + sb.AppendLineLf(); + + var classNamespace = containingClass.ContainingNamespace.ToDisplayString(); + sb.AppendLineLf($"namespace {classNamespace};"); + sb.AppendLineLf(); + + sb.AppendLineLf("[global::System.CodeDom.Compiler.GeneratedCode(\"Atc.SourceGenerators.MappingConfiguration\", \"1.0.0\")]"); + sb.AppendLineLf($"public static partial class {containingClass.Name}"); + sb.AppendLineLf("{"); + + foreach (var mapping in classMappings) + { + GenerateMappingMethodBody(sb, mapping); + + // Generate reverse mapping if bidirectional + if (mapping.Bidirectional) + { + GenerateReverseMappingMethod(sb, mapping, autoDetectedEnums, context); + } + } + + // Generate auto-detected enum mapping methods + GenerateAutoDetectedEnumMethods(sb, autoDetectedEnums, classMappings); + + sb.AppendLineLf("}"); + + var fileName = $"{containingClass.Name}.g.cs"; + context.AddSource(fileName, SourceText.From(sb.ToString(), Encoding.UTF8)); + } + } + + private static void GenerateAssemblyLevelCode( + List mappings, + List autoDetectedEnums, + SourceProductionContext context) + { + var sb = new StringBuilder(); + var namespaces = new HashSet(StringComparer.Ordinal); + + foreach (var mapping in mappings) + { + namespaces.Add(mapping.SourceType.ContainingNamespace.ToDisplayString()); + namespaces.Add(mapping.TargetType.ContainingNamespace.ToDisplayString()); + } + + sb.AppendLineLf("// "); + sb.AppendLineLf("#nullable enable"); + sb.AppendLineLf(); + + foreach (var ns in namespaces.OrderBy(x => x, StringComparer.Ordinal)) + { + sb.AppendLineLf($"using {ns};"); + } + + sb.AppendLineLf(); + sb.AppendLineLf("namespace Atc.Mapping;"); + sb.AppendLineLf(); + sb.AppendLineLf("[global::System.CodeDom.Compiler.GeneratedCode(\"Atc.SourceGenerators.MappingConfiguration\", \"1.0.0\")]"); + sb.AppendLineLf("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLineLf("[global::System.Runtime.CompilerServices.CompilerGenerated]"); + sb.AppendLineLf("[global::System.Diagnostics.DebuggerNonUserCode]"); + sb.AppendLineLf("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + sb.AppendLineLf("public static class ConfiguredMappingExtensions"); + sb.AppendLineLf("{"); + + foreach (var mapping in mappings) + { + GenerateExtensionMethodBody(sb, mapping); + + if (mapping.Bidirectional) + { + GenerateReverseExtensionMethod(sb, mapping, autoDetectedEnums, context); + } + } + + // Generate auto-detected enum mapping methods + GenerateAutoDetectedEnumMethods(sb, autoDetectedEnums, mappings); + + sb.AppendLineLf("}"); + + context.AddSource("ConfiguredMappingExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static void GenerateMappingMethodBody( + StringBuilder sb, + ConfiguredMappingInfo mapping) + { + var methodName = mapping.Method!.Name; + var targetTypeName = mapping.TargetType.ToDisplayString(); + var sourceTypeName = mapping.SourceType.ToDisplayString(); + + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Maps to ."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" public static partial {targetTypeName} {methodName}("); + sb.AppendLineLf($" this {sourceTypeName} source)"); + sb.AppendLineLf(" {"); + + GenerateMethodContent(sb, mapping); + + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + } + + private static void GenerateExtensionMethodBody( + StringBuilder sb, + ConfiguredMappingInfo mapping) + { + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(mapping.TargetType.Name)}"; + var targetTypeName = mapping.TargetType.ToDisplayString(); + var sourceTypeName = mapping.SourceType.ToDisplayString(); + + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Maps to ."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" public static {targetTypeName} {methodName}("); + sb.AppendLineLf($" this {sourceTypeName} source)"); + sb.AppendLineLf(" {"); + + GenerateMethodContent(sb, mapping); + + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + } + + private static void GenerateMethodContent( + StringBuilder sb, + ConfiguredMappingInfo mapping) + { + var targetTypeName = mapping.TargetType.ToDisplayString(); + + sb.AppendLineLf(" if (source is null)"); + sb.AppendLineLf(" {"); + sb.AppendLineLf(" return default!;"); + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + + var useConstructor = mapping.Constructor is not null && mapping.ConstructorParameterNames.Count > 0; + + if (useConstructor) + { + var constructorParamSet = new HashSet(mapping.ConstructorParameterNames, StringComparer.OrdinalIgnoreCase); + var constructorProps = new List(); + var initializerProps = new List(); + + foreach (var prop in mapping.PropertyMappings) + { + if (constructorParamSet.Contains(prop.TargetProperty.Name)) + { + constructorProps.Add(prop); + } + else + { + initializerProps.Add(prop); + } + } + + // Order constructor props by parameter order + var orderedConstructorProps = new List(); + foreach (var paramName in mapping.ConstructorParameterNames) + { + var prop = constructorProps.FirstOrDefault(p => + string.Equals(p.TargetProperty.Name, paramName, StringComparison.OrdinalIgnoreCase)); + if (prop is not null) + { + orderedConstructorProps.Add(prop); + } + } + + sb.AppendLineLf($" return new {targetTypeName}("); + + for (var i = 0; i < orderedConstructorProps.Count; i++) + { + var prop = orderedConstructorProps[i]; + var isLast = i == orderedConstructorProps.Count - 1; + var comma = isLast && initializerProps.Count == 0 ? string.Empty : ","; + + var value = GeneratePropertyMappingValue(prop, "source"); + sb.AppendLineLf($" {value}{comma}"); + } + + if (initializerProps.Count > 0) + { + sb.AppendLineLf(" )"); + sb.AppendLineLf(" {"); + GeneratePropertyInitializers(sb, initializerProps); + sb.AppendLineLf(" };"); + } + else + { + sb.AppendLineLf(" );"); + } + } + else + { + sb.AppendLineLf($" return new {targetTypeName}"); + sb.AppendLineLf(" {"); + GeneratePropertyInitializers(sb, mapping.PropertyMappings); + sb.AppendLineLf(" };"); + } + } + + private static void GenerateReverseMappingMethod( + StringBuilder sb, + ConfiguredMappingInfo mapping, + List autoDetectedEnums, + SourceProductionContext context) + { + var reverseMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(mapping.SourceType.Name)}"; + var sourceTypeName = mapping.TargetType.ToDisplayString(); + var targetTypeName = mapping.SourceType.ToDisplayString(); + + var reverseMappings = GetPropertyMappingsWithConfig( + mapping.TargetType, + mapping.SourceType, + mapping.EnableFlattening, + mapping.PropertyNameStrategy, + [], + [], + autoDetectedEnums, + context); + + var (reverseConstructor, reverseConstructorParams) = FindBestConstructor(mapping.TargetType, mapping.SourceType); + + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Maps to ."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" public static {targetTypeName} {reverseMethodName}("); + sb.AppendLineLf($" this {sourceTypeName} source)"); + sb.AppendLineLf(" {"); + + var reverseMapping = new ConfiguredMappingInfo( + SourceType: mapping.TargetType, + TargetType: mapping.SourceType, + Method: null, + ContainingClass: null, + PropertyMappings: reverseMappings, + Bidirectional: false, + EnableFlattening: mapping.EnableFlattening, + Constructor: reverseConstructor, + ConstructorParameterNames: reverseConstructorParams, + PropertyNameStrategy: mapping.PropertyNameStrategy, + IgnoredProperties: [], + PropertyRenames: []); + + GenerateMethodContent(sb, reverseMapping); + + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + } + + private static void GenerateReverseExtensionMethod( + StringBuilder sb, + ConfiguredMappingInfo mapping, + List autoDetectedEnums, + SourceProductionContext context) + { + GenerateReverseMappingMethod(sb, mapping, autoDetectedEnums, context); + } + + private static void GenerateAutoDetectedEnumMethods( + StringBuilder sb, + List autoDetectedEnums, + List mappings) + { + // Generate enum mapping methods that are referenced by the mappings + var generated = new HashSet(StringComparer.Ordinal); + + foreach (var enumMapping in autoDetectedEnums) + { + var key = $"{enumMapping.SourceEnum.ToDisplayString()}->{enumMapping.TargetEnum.ToDisplayString()}"; + if (!generated.Add(key)) + { + continue; + } + + // Check if any mapping in this scope references this enum pair + var isReferenced = mappings.Any(m => + m.PropertyMappings.Any(p => + p.RequiresConversion && + p.HasEnumMapping && + SymbolEqualityComparer.Default.Equals(p.SourceProperty.Type, enumMapping.SourceEnum) && + SymbolEqualityComparer.Default.Equals(p.TargetProperty.Type, enumMapping.TargetEnum))); + + if (!isReferenced) + { + continue; + } + + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(enumMapping.TargetEnum.Name)}"; + + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Maps to ."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" private static {enumMapping.TargetEnum.ToDisplayString()} {methodName}("); + sb.AppendLineLf($" this {enumMapping.SourceEnum.ToDisplayString()} source)"); + sb.AppendLineLf(" => source switch"); + sb.AppendLineLf(" {"); + + foreach (var valueMapping in enumMapping.ValueMappings) + { + if (valueMapping.IsMapped && valueMapping.TargetValue is not null) + { + sb.AppendLineLf($" {enumMapping.SourceEnum.ToDisplayString()}.{valueMapping.SourceValue} => {enumMapping.TargetEnum.ToDisplayString()}.{valueMapping.TargetValue},"); + } + } + + sb.AppendLineLf(" _ => throw new global::System.ArgumentOutOfRangeException(nameof(source), source, \"Unmapped enum value\"),"); + sb.AppendLineLf(" };"); + sb.AppendLineLf(); + } + } + + private static void GeneratePropertyInitializers( + StringBuilder sb, + List properties) + { + for (var i = 0; i < properties.Count; i++) + { + var prop = properties[i]; + var isLast = i == properties.Count - 1; + var comma = isLast ? string.Empty : ","; + + var value = GeneratePropertyMappingValue(prop, "source"); + sb.AppendLineLf($" {prop.TargetProperty.Name} = {value}{comma}"); + } + } + + private static string GeneratePropertyMappingValue( + PropertyMapping prop, + string sourceVariable) + { + if (prop.IsFlattened && prop.FlattenedNestedProperty is not null) + { + var isNullable = prop.SourceProperty.Type.NullableAnnotation == NullableAnnotation.Annotated || + !prop.SourceProperty.Type.IsValueType; + + if (isNullable) + { + return $"{sourceVariable}.{prop.SourceProperty.Name}?.{prop.FlattenedNestedProperty.Name}!"; + } + + return $"{sourceVariable}.{prop.SourceProperty.Name}.{prop.FlattenedNestedProperty.Name}"; + } + + if (prop.IsBuiltInTypeConversion) + { + return GenerateBuiltInTypeConversion(prop, sourceVariable); + } + + if (prop.IsCollection) + { + var elementTypeName = prop.CollectionElementType!.Name; + var mappingMethodName = $"MapTo{elementTypeName}"; + var collectionType = prop.CollectionTargetType!; + + if (collectionType == "Array") + { + return $"{sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToArray()!"; + } + + if (collectionType == "Collection") + { + return $"new global::System.Collections.ObjectModel.Collection<{prop.CollectionElementType.ToDisplayString()}>({sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!)"; + } + + if (collectionType == "ReadOnlyCollection") + { + return $"new global::System.Collections.ObjectModel.ReadOnlyCollection<{prop.CollectionElementType.ToDisplayString()}>({sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!)"; + } + + return $"{sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!"; + } + + if (prop.RequiresConversion) + { + if (prop.HasEnumMapping) + { + var enumMappingMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; + return $"{sourceVariable}.{prop.SourceProperty.Name}.{enumMappingMethodName}()"; + } + + return $"({prop.TargetProperty.Type.ToDisplayString()}){sourceVariable}.{prop.SourceProperty.Name}"; + } + + if (prop.IsNested) + { + var nestedMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; + return $"{sourceVariable}.{prop.SourceProperty.Name}?.{nestedMethodName}()!"; + } + + return $"{sourceVariable}.{prop.SourceProperty.Name}"; + } + + private static string GenerateBuiltInTypeConversion( + PropertyMapping prop, + string sourceVariable) + { + var sourceTypeName = prop.SourceProperty.Type.ToDisplayString(); + var targetTypeName = prop.TargetProperty.Type.ToDisplayString(); + var sourcePropertyAccess = $"{sourceVariable}.{prop.SourceProperty.Name}"; + + if (sourceTypeName is "System.DateTime" or "System.DateTimeOffset" && targetTypeName == "string") + { + return $"{sourcePropertyAccess}.ToString(\"O\", global::System.Globalization.CultureInfo.InvariantCulture)"; + } + + if (sourceTypeName == "string" && targetTypeName == "System.DateTime") + { + return $"global::System.DateTime.Parse({sourcePropertyAccess}, global::System.Globalization.CultureInfo.InvariantCulture)"; + } + + if (sourceTypeName == "string" && targetTypeName == "System.DateTimeOffset") + { + return $"global::System.DateTimeOffset.Parse({sourcePropertyAccess}, global::System.Globalization.CultureInfo.InvariantCulture)"; + } + + if (sourceTypeName == "System.Guid" && targetTypeName == "string") + { + return $"{sourcePropertyAccess}.ToString()"; + } + + if (sourceTypeName == "string" && targetTypeName == "System.Guid") + { + return $"global::System.Guid.Parse({sourcePropertyAccess})"; + } + + if (MappingTypeAnalyzer.IsNumericType(sourceTypeName) && targetTypeName == "string") + { + return $"{sourcePropertyAccess}.ToString(global::System.Globalization.CultureInfo.InvariantCulture)"; + } + + if (sourceTypeName == "string" && MappingTypeAnalyzer.IsNumericType(targetTypeName)) + { + var parts = targetTypeName.Split('.'); + var simpleTypeName = parts[parts.Length - 1]; + return $"{simpleTypeName}.Parse({sourcePropertyAccess}, global::System.Globalization.CultureInfo.InvariantCulture)"; + } + + if (sourceTypeName == "bool" && targetTypeName == "string") + { + return $"{sourcePropertyAccess}.ToString()"; + } + + if (sourceTypeName == "string" && targetTypeName == "bool") + { + return $"bool.Parse({sourcePropertyAccess})"; + } + + return sourcePropertyAccess; + } + + private static string GenerateMappingConfigurationAttributeSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Marks a static partial class as a mapping configuration container. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.AttributeUsage(global::System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class MappingConfigurationAttribute : global::System.Attribute + { + } + } + """; + + private static string GenerateMapConfigIgnoreAttributeSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Specifies a property to exclude from configuration-based mapping. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false, AllowMultiple = true)] + public sealed class MapConfigIgnoreAttribute : global::System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the source property to exclude from mapping. + public MapConfigIgnoreAttribute(string propertyName) + { + PropertyName = propertyName; + } + + /// + /// Gets the name of the source property to exclude from mapping. + /// + public string PropertyName { get; } + } + } + """; + + private static string GenerateMapConfigPropertyAttributeSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Specifies a property name mapping between source and target types. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false, AllowMultiple = true)] + public sealed class MapConfigPropertyAttribute : global::System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the property on the source type. + /// The name of the property on the target type. + public MapConfigPropertyAttribute(string sourcePropertyName, string targetPropertyName) + { + SourcePropertyName = sourcePropertyName; + TargetPropertyName = targetPropertyName; + } + + /// + /// Gets the name of the property on the source type. + /// + public string SourcePropertyName { get; } + + /// + /// Gets the name of the property on the target type. + /// + public string TargetPropertyName { get; } + } + } + """; + + private static string GenerateMapConfigOptionsAttributeSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Configures advanced mapping options for a configuration-based mapping method. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)] + public sealed class MapConfigOptionsAttribute : global::System.Attribute + { + /// + /// Gets or sets a value indicating whether to generate bidirectional mappings. + /// + public bool Bidirectional { get; set; } + + /// + /// Gets or sets a value indicating whether to enable property flattening. + /// + public bool EnableFlattening { get; set; } + + /// + /// Gets or sets the naming strategy for property name conversion. + /// + public PropertyNameStrategy PropertyNameStrategy { get; set; } = PropertyNameStrategy.PascalCase; + } + } + """; + + private static string GenerateMapTypesAttributeSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Defines a mapping between two types at the assembly level. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly | global::System.AttributeTargets.Class, AllowMultiple = true)] + public sealed class MapTypesAttribute : global::System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + public MapTypesAttribute(global::System.Type sourceType, global::System.Type targetType) + { + SourceType = sourceType; + TargetType = targetType; + } + + /// + /// Gets the source type to map from. + /// + public global::System.Type SourceType { get; } + + /// + /// Gets the target type to map to. + /// + public global::System.Type TargetType { get; } + + /// + /// Gets or sets the property name mappings in "SourceProperty:TargetProperty" format. + /// + public string[]? PropertyMap { get; set; } + + /// + /// Gets or sets the source property names to exclude from mapping. + /// + public string[]? IgnoreSourceProperties { get; set; } + + /// + /// Gets or sets the target property names to exclude from mapping. + /// + public string[]? IgnoreTargetProperties { get; set; } + + /// + /// Gets or sets a value indicating whether to generate bidirectional mappings. + /// + public bool Bidirectional { get; set; } + + /// + /// Gets or sets the naming strategy for property name conversion. + /// + public PropertyNameStrategy PropertyNameStrategy { get; set; } = PropertyNameStrategy.PascalCase; + } + } + """; + + private static string GenerateMappingBuilderSource() + => """ + // + #nullable enable + + namespace Atc.SourceGenerators.Annotations + { + /// + /// Provides a fluent API for configuring type mappings inline. + /// The Map() calls are analyzed at compile time by the source generator to produce mapping extension methods. + /// At runtime, this class is a no-op. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Atc.SourceGenerators.MappingConfiguration", "1.0.0")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed class MappingBuilder + { + internal MappingBuilder() { } + + /// + /// Registers a mapping between two types using typeof() expressions. + /// + public MappingBuilder Map( + global::System.Type sourceType, + global::System.Type targetType, + bool bidirectional = false, + string[]? propertyMap = null, + string[]? ignoreSourceProperties = null, + string[]? ignoreTargetProperties = null, + PropertyNameStrategy propertyNameStrategy = PropertyNameStrategy.PascalCase) + => this; + + /// + /// Registers a mapping between two types using generic type parameters. + /// + public MappingBuilder Map( + bool bidirectional = false, + string[]? propertyMap = null, + string[]? ignoreSourceProperties = null, + string[]? ignoreTargetProperties = null, + PropertyNameStrategy propertyNameStrategy = PropertyNameStrategy.PascalCase) + => this; + + /// + /// Configures mapping registrations without requiring dependency injection. + /// The lambda body is analyzed at compile time by the source generator. + /// + public static void Configure(global::System.Action configure) + { + // No-op — mappings are generated at compile time + } + } + } + """; +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs index 8003a25..cf66896 100644 --- a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs @@ -47,6 +47,14 @@ public class ObjectMappingGenerator : IIncrementalGenerator DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor CollectionElementMappingNotFoundDescriptor = new( + id: RuleIdentifierConstants.ObjectMapping.CollectionElementMappingNotFound, + title: "Collection element type has no mapping method", + messageFormat: "Property '{0}' is a collection of '{1}' but element type has no [MapTo] attribute targeting '{2}'. The property will be skipped.", + category: RuleCategoryConstants.ObjectMapping, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Generate the attribute definitions as fallback @@ -117,7 +125,10 @@ private static void Execute( var mappingsToGenerate = new List(); - foreach (var classDeclaration in classes.Distinct()) + // Deduplicate by fully qualified symbol name to handle partial classes across multiple files + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var classDeclaration in classes) { context.CancellationToken.ThrowIfCancellationRequested(); @@ -127,6 +138,11 @@ private static void Execute( continue; } + if (!seen.Add(classSymbol.ToDisplayString())) + { + continue; + } + var mappingInfos = ExtractMappingInfo(classSymbol, context); if (mappingInfos is not null) { @@ -155,7 +171,7 @@ private static void Execute( context.ReportDiagnostic( Diagnostic.Create( MappingClassMustBePartialDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, classSymbol.Name)); return null; } @@ -186,7 +202,7 @@ private static void Execute( context.ReportDiagnostic( Diagnostic.Create( TargetTypeMustBeClassOrStructDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, targetType.Name, targetType .TypeKind @@ -365,7 +381,7 @@ private static List GetPropertyMappings( context.Value.ReportDiagnostic( Diagnostic.Create( MapPropertyTargetNotFoundDescriptor, - sourceProp.Locations.First(), + sourceProp.Locations.FirstOrDefault() ?? Location.None, sourceProp.Name, customTargetName, targetType.Name)); @@ -408,21 +424,36 @@ private static List GetPropertyMappings( if (isSourceCollection && isTargetCollection && sourceElementType is not null && targetElementType is not null) { - // Collection mapping - mappings.Add(new PropertyMapping( - SourceProperty: sourceProp, - TargetProperty: targetProp, - RequiresConversion: false, - IsNested: false, - HasEnumMapping: false, - IsCollection: true, - CollectionElementType: targetElementType, - CollectionTargetType: GetCollectionTargetType(targetProp.Type), - IsFlattened: false, - FlattenedNestedProperty: null, - IsBuiltInTypeConversion: false, - SourceRequiresUnsafeAccessor: includePrivateMembers && RequiresUnsafeAccessorForReading(sourceProp), - TargetRequiresUnsafeAccessor: includePrivateMembers && RequiresUnsafeAccessorForWriting(targetProp))); + // Validate that the source element type has a [MapTo] mapping to the target element type + if (HasObjectMappingToType(sourceElementType, targetElementType)) + { + // Collection mapping with validated element mapping + mappings.Add(new PropertyMapping( + SourceProperty: sourceProp, + TargetProperty: targetProp, + RequiresConversion: false, + IsNested: false, + HasEnumMapping: false, + IsCollection: true, + CollectionElementType: targetElementType, + CollectionTargetType: GetCollectionTargetType(targetProp.Type), + IsFlattened: false, + FlattenedNestedProperty: null, + IsBuiltInTypeConversion: false, + SourceRequiresUnsafeAccessor: includePrivateMembers && RequiresUnsafeAccessorForReading(sourceProp), + TargetRequiresUnsafeAccessor: includePrivateMembers && RequiresUnsafeAccessorForWriting(targetProp))); + } + else if (context is not null) + { + // No mapping method found for collection element type - report diagnostic and skip + context.Value.ReportDiagnostic( + Diagnostic.Create( + CollectionElementMappingNotFoundDescriptor, + sourceProp.Locations.FirstOrDefault() ?? Location.None, + sourceProp.Name, + sourceElementType.ToDisplayString(), + targetElementType.ToDisplayString())); + } } else { @@ -596,6 +627,65 @@ private static bool HasEnumMappingAttribute( return false; } + /// + /// Checks if sourceElementType has a [MapTo] attribute targeting targetElementType, + /// or if targetElementType has a bidirectional [MapTo] back to sourceElementType. + /// + private static bool HasObjectMappingToType( + ITypeSymbol sourceElementType, + ITypeSymbol targetElementType) + { + // Check if source has [MapTo(typeof(target))] + foreach (var attr in sourceElementType.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != FullAttributeName) + { + continue; + } + + if (attr.ConstructorArguments.Length == 0) + { + continue; + } + + if (attr.ConstructorArguments[0].Value is INamedTypeSymbol attrTargetType && + SymbolEqualityComparer.Default.Equals(attrTargetType, targetElementType)) + { + return true; + } + } + + // Check if target has [MapTo(typeof(source), Bidirectional = true)] + foreach (var attr in targetElementType.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != FullAttributeName) + { + continue; + } + + if (attr.ConstructorArguments.Length == 0) + { + continue; + } + + if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol attrSourceType || + !SymbolEqualityComparer.Default.Equals(attrSourceType, sourceElementType)) + { + continue; + } + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg is { Key: "Bidirectional", Value.Value: true }) + { + return true; + } + } + } + + return false; + } + private static bool IsNestedMapping( ITypeSymbol sourceType, ITypeSymbol targetType) @@ -721,7 +811,7 @@ private static void ValidateRequiredProperties( var targetProperties = targetType .GetMembers() .OfType() - .Where(p => (p.SetMethod is not null || targetType.TypeKind == TypeKind.Struct) && + .Where(p => (p.SetMethod is not null && p.SetMethod.DeclaredAccessibility == Accessibility.Public) && !HasMapIgnoreAttribute(p)) .ToList(); @@ -773,27 +863,36 @@ private static bool IsCollectionType( } // Handle generic collections: List, IEnumerable, ICollection, IReadOnlyList, etc. - if (namedType is { IsGenericType: true, TypeArguments.Length: 1 }) - { - var typeName = namedType.ConstructedFrom.ToDisplayString(); - - if (typeName.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.Generic.IReadOnlyList<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.Generic.IReadOnlyCollection<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.ObjectModel.Collection<", StringComparison.Ordinal) || - typeName.StartsWith("System.Collections.ObjectModel.ReadOnlyCollection<", StringComparison.Ordinal)) - { - elementType = namedType.TypeArguments[0]; - return true; - } + if (namedType is { IsGenericType: true, TypeArguments.Length: 1 } && + IsKnownCollectionType(namedType.ConstructedFrom)) + { + elementType = namedType.TypeArguments[0]; + return true; } return false; } + private static bool IsKnownCollectionType( + INamedTypeSymbol originalDefinition) + { + var name = originalDefinition.MetadataName; + var ns = originalDefinition.ContainingNamespace?.ToDisplayString() ?? string.Empty; + + return (ns == "System.Collections.Generic" && + name is + "List`1" or + "IEnumerable`1" or + "ICollection`1" or + "IList`1" or + "IReadOnlyList`1" or + "IReadOnlyCollection`1") + || (ns == "System.Collections.ObjectModel" && + name is + "Collection`1" or + "ReadOnlyCollection`1"); + } + private static string GetCollectionTargetType( ITypeSymbol targetPropertyType) { @@ -807,46 +906,19 @@ private static string GetCollectionTargetType( return "List"; } - var typeName = namedType.ConstructedFrom.ToDisplayString(); - - if (typeName.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal)) - { - return "List"; - } - - if (typeName.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal)) - { - return "List"; - } - - if (typeName.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal)) - { - return "List"; - } - - if (typeName.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal)) - { - return "List"; - } - - if (typeName.StartsWith("System.Collections.Generic.IReadOnlyList<", StringComparison.Ordinal)) - { - return "List"; - } - - if (typeName.StartsWith("System.Collections.Generic.IReadOnlyCollection<", StringComparison.Ordinal)) - { - return "List"; - } + var originalDef = namedType.ConstructedFrom; + var name = originalDef.MetadataName; + var ns = originalDef.ContainingNamespace?.ToDisplayString() ?? string.Empty; - if (typeName.StartsWith("System.Collections.ObjectModel.Collection<", StringComparison.Ordinal)) + if (ns == "System.Collections.ObjectModel") { - return "Collection"; - } - - if (typeName.StartsWith("System.Collections.ObjectModel.ReadOnlyCollection<", StringComparison.Ordinal)) - { - return "ReadOnlyCollection"; + switch (name) + { + case "Collection`1": + return "Collection"; + case "ReadOnlyCollection`1": + return "ReadOnlyCollection"; + } } return "List"; @@ -1021,7 +1093,7 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes foreach (var parameter in constructor.Parameters) { - // Check if we have a matching source property (case-insensitive) + // Check if we have a matching source property (case-insensitive name AND compatible type) var matchingSourceProperty = sourceProperties.FirstOrDefault(p => string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); @@ -1031,6 +1103,19 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes break; } + // Verify type compatibility: reject clear mismatches (e.g., string vs int) + // Allow custom type mismatches since mapping methods may bridge the gap + // (e.g., Address → AddressDto, StatusEnum → StatusEnumDto) + var sourcePropertyType = matchingSourceProperty.Type; + var parameterType = parameter.Type; + if (!SymbolEqualityComparer.Default.Equals(sourcePropertyType, parameterType) && + !IsImplicitlyConvertible(sourcePropertyType, parameterType) && + !AreBothCustomOrEnumTypes(sourcePropertyType, parameterType)) + { + allParametersMatch = false; + break; + } + parameterNames.Add(parameter.Name); } @@ -1043,16 +1128,109 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes return (null, []); } + /// + /// Checks if sourceType can be implicitly converted to targetType + /// (e.g., int → long, or derived → base, or nullable unwrapping). + /// + private static void AddNamespaceIfValid( + HashSet namespaces, + string? ns) + { + if (!string.IsNullOrEmpty(ns) && ns != "") + { + namespaces.Add(ns!); + } + } + + /// + /// Returns true if both types are custom (non-primitive, non-system) types or enums, + /// meaning a mapping extension method could bridge the type gap. + /// + private static bool AreBothCustomOrEnumTypes( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + // Strip nullable wrapper + if (sourceType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } ns) + { + sourceType = ns.TypeArguments[0]; + } + + if (targetType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nt) + { + targetType = nt.TypeArguments[0]; + } + + // Both enums — enum mapping methods can bridge + if (sourceType.TypeKind == TypeKind.Enum && targetType.TypeKind == TypeKind.Enum) + { + return true; + } + + // Both are custom reference types (class/record/struct) — mapping methods can bridge + var sourceIsCustom = IsCustomType(sourceType); + var targetIsCustom = IsCustomType(targetType); + return sourceIsCustom && targetIsCustom; + } + + private static bool IsCustomType(ITypeSymbol type) + { + if (type.SpecialType != SpecialType.None) + { + return false; + } + + var ns = type.ContainingNamespace?.ToDisplayString() ?? string.Empty; + return !ns.StartsWith("System", StringComparison.Ordinal) && + !ns.StartsWith("Microsoft", StringComparison.Ordinal); + } + + private static bool IsImplicitlyConvertible( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + // Check inheritance/interface relationship + if (targetType.TypeKind == TypeKind.Interface) + { + return sourceType.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i, targetType)); + } + + // Check base type chain + var current = sourceType.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current, targetType)) + { + return true; + } + + current = current.BaseType; + } + + // Check nullable unwrapping (T → T?) + if (targetType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } namedTarget) + { + return SymbolEqualityComparer.Default.Equals(sourceType, namedTarget.TypeArguments[0]); + } + + return false; + } + private static string GenerateMappingExtensions(List mappings) { var sb = new StringBuilder(); var namespaces = new HashSet(StringComparer.Ordinal); - // Collect all namespaces + // Collect all namespaces (skip global/empty namespaces) foreach (var mapping in mappings) { - namespaces.Add(mapping.SourceType.ContainingNamespace.ToDisplayString()); - namespaces.Add(mapping.TargetType.ContainingNamespace.ToDisplayString()); + AddNamespaceIfValid( + namespaces, + mapping.SourceType.ContainingNamespace?.ToDisplayString()); + AddNamespaceIfValid( + namespaces, + mapping.TargetType.ContainingNamespace?.ToDisplayString()); } sb.AppendLineLf("// "); @@ -1143,7 +1321,7 @@ private static void GenerateMappingMethod( StringBuilder sb, MappingInfo mapping) { - var methodName = $"MapTo{mapping.TargetType.Name}"; + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(mapping.TargetType.Name)}"; // Build type parameter list for generic methods var typeParamList = string.Empty; @@ -1361,7 +1539,7 @@ private static void GenerateUpdateTargetMethod( StringBuilder sb, MappingInfo mapping) { - var methodName = $"MapTo{mapping.TargetType.Name}"; + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(mapping.TargetType.Name)}"; // Build type parameter list for generic methods var typeParamList = string.Empty; @@ -1574,16 +1752,18 @@ private static string GeneratePropertyMappingValue( if (prop.IsFlattened && prop.FlattenedNestedProperty is not null) { // Flattened property mapping: source.Address.City → target.AddressCity - // Handle nullable nested objects with null-conditional operator - var isNullable = prop.SourceProperty.Type.NullableAnnotation == NullableAnnotation.Annotated || - !prop.SourceProperty.Type.IsValueType; + // Handle nullable intermediate and nested properties with null-conditional operator + var isIntermediateNullable = prop.SourceProperty.Type.NullableAnnotation == NullableAnnotation.Annotated || + !prop.SourceProperty.Type.IsValueType; - if (isNullable) - { - return $"{sourceVariable}.{prop.SourceProperty.Name}?.{prop.FlattenedNestedProperty.Name}!"; - } + var isNestedNullable = prop.FlattenedNestedProperty.Type.NullableAnnotation == NullableAnnotation.Annotated || + (!prop.FlattenedNestedProperty.Type.IsValueType && + prop.FlattenedNestedProperty.Type.NullableAnnotation != NullableAnnotation.NotAnnotated); + + var accessOperator = isIntermediateNullable ? "?." : "."; + var nullForgiving = isIntermediateNullable || isNestedNullable ? "!" : string.Empty; - return $"{sourceVariable}.{prop.SourceProperty.Name}.{prop.FlattenedNestedProperty.Name}"; + return $"{sourceVariable}.{prop.SourceProperty.Name}{accessOperator}{prop.FlattenedNestedProperty.Name}{nullForgiving}"; } if (prop.IsBuiltInTypeConversion) @@ -1624,7 +1804,7 @@ private static string GeneratePropertyMappingValue( if (prop.HasEnumMapping) { // Use EnumMapping extension method (safe mapping with special case handling) - var enumMappingMethodName = $"MapTo{prop.TargetProperty.Type.Name}"; + var enumMappingMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; return $"{sourceVariable}.{prop.SourceProperty.Name}.{enumMappingMethodName}()"; } @@ -1635,15 +1815,14 @@ private static string GeneratePropertyMappingValue( if (prop.IsNested) { // Nested object mapping - var nestedMethodName = $"MapTo{prop.TargetProperty.Type.Name}"; + var nestedMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; var propertyAccess = GenerateSourcePropertyAccess(prop, sourceVariable, sourceType); // For nullable nested objects, we need null-conditional operator - // But UnsafeAccessor returns the value directly, so we need to handle both cases + // UnsafeAccessor returns the value directly via ref, so use a local variable for null check if (prop.SourceRequiresUnsafeAccessor) { - // UnsafeAccessor: check for null separately - return $"{propertyAccess}?.{nestedMethodName}()!"; + return $"{propertyAccess} is {{ }} __nested ? __nested.{nestedMethodName}() : default!"; } return $"{propertyAccess}?.{nestedMethodName}()!"; diff --git a/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs b/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs index 09f5f10..f62664c 100644 --- a/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/OptionsBindingGenerator.cs @@ -13,7 +13,7 @@ public class OptionsBindingGenerator : IIncrementalGenerator { private const string AttributeNamespace = "Atc.SourceGenerators.Annotations"; private const string AttributeName = "OptionsBindingAttribute"; - private const string FullAttributeName = $"{AttributeNamespace}.{AttributeName}"; + private const string AttributeMetadataName = AttributeName; // Diagnostic descriptors private static readonly DiagnosticDescriptor OptionsClassMustBePartialDescriptor = new( @@ -154,54 +154,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("OptionsBindingAttribute.g.cs", SourceText.From(GenerateAttributeSource(), Encoding.UTF8)); }); - // Find classes with OptionsBinding attribute - var classDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (s, _) => IsSyntaxTargetForGeneration(s), - transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)) - .Where(static m => m is not null) + // Find classes with OptionsBinding attribute using ForAttributeWithMetadataName for optimal caching + var classSymbols = context.SyntaxProvider + .ForAttributeWithMetadataName( + $"{AttributeNamespace}.{AttributeMetadataName}", + predicate: static (s, _) => s is ClassDeclarationSyntax, + transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol) .Collect(); // Combine with compilation - var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations); + var compilationAndClasses = context.CompilationProvider.Combine(classSymbols); // Generate source context.RegisterSourceOutput(compilationAndClasses, static (spc, source) => Execute(source.Left, source.Right!, spc)); } - private static bool IsSyntaxTargetForGeneration(SyntaxNode node) - => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }; - - private static ClassDeclarationSyntax? GetSemanticTargetForGeneration( - GeneratorSyntaxContext context) - { - var classDeclaration = (ClassDeclarationSyntax)context.Node; - - foreach (var attributeListSyntax in classDeclaration.AttributeLists) - { - foreach (var attributeSyntax in attributeListSyntax.Attributes) - { - if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol) - { - continue; - } - - var attributeContainingTypeSymbol = attributeSymbol.ContainingType; - var fullName = attributeContainingTypeSymbol.ToDisplayString(); - - if (fullName == FullAttributeName) - { - return classDeclaration; - } - } - } - - return null; - } - private static void Execute( Compilation compilation, - ImmutableArray classes, + ImmutableArray classes, SourceProductionContext context) { if (classes.IsDefaultOrEmpty) @@ -211,12 +181,13 @@ private static void Execute( var optionsToGenerate = new List(); - foreach (var classDeclaration in classes.Distinct()) + // Deduplicate (ForAttributeWithMetadataName emits once per attribute instance) + var seen = new HashSet(StringComparer.Ordinal); + foreach (var classSymbol in classes) { context.CancellationToken.ThrowIfCancellationRequested(); - var semanticModel = compilation.GetSemanticModel(classDeclaration.SyntaxTree); - if (semanticModel.GetDeclaredSymbol(classDeclaration) is not { } classSymbol) + if (!seen.Add(classSymbol.ToDisplayString())) { continue; } @@ -238,8 +209,7 @@ private static void Execute( // Group by assembly var groupedByAssembly = optionsToGenerate - .GroupBy(x => x.AssemblyName, StringComparer.Ordinal) - .ToList(); + .GroupBy(x => x.AssemblyName, StringComparer.Ordinal); foreach (var assemblyGroup in groupedByAssembly) { @@ -261,7 +231,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( OptionsClassMustBePartialDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, classSymbol.Name)); return result; } @@ -269,7 +239,7 @@ private static List ExtractAllOptionsInfo( // Get ALL attributes (support for AllowMultiple = true) var attributes = classSymbol .GetAttributes() - .Where(a => a.AttributeClass?.ToDisplayString() == FullAttributeName) + .Where(a => IsOptionsBindingAttribute(a.AttributeClass)) .ToList(); if (attributes.Count == 0) @@ -298,7 +268,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( ConfigureAllRequiresMultipleNamedOptionsDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, configureAllOption.ConfigureAll)); // Remove ConfigureAll from all instances to prevent code generation issues @@ -319,7 +289,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( ConfigureAllCallbackNotFoundDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, configureAllOption.ConfigureAll, classSymbol.Name)); @@ -335,7 +305,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( ConfigureAllCallbackInvalidSignatureDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, configureAllOption.ConfigureAll, classSymbol.Name)); @@ -376,7 +346,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( ConstSectionNameCannotBeEmptyDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, classSymbol.Name, constFieldName)); return null; @@ -397,7 +367,7 @@ private static List ExtractAllOptionsInfo( context.ReportDiagnostic( Diagnostic.Create( SectionNameCannotBeEmptyDescriptor, - classSymbol.Locations.First(), + classSymbol.Locations.FirstOrDefault() ?? Location.None, classSymbol.Name)); return null; } @@ -808,7 +778,7 @@ private static ImmutableArray GetReferencedAssembliesWit result.Add(new ReferencedAssemblyInfo( assemblyName, SanitizeForMethodName(assemblyName), - assemblyName.Substring(assemblyName.LastIndexOf('.') + 1))); + GetAssemblySuffix(assemblyName))); // Collect options from this referenced assembly var options = CollectOptionsFromAssembly(assemblySymbol); @@ -858,7 +828,7 @@ private static List CollectOptionsFromAssembly( // Check if type has OptionsBinding attribute var optionsAttributes = namedType .GetAttributes() - .Where(a => a.AttributeClass?.ToDisplayString() == FullAttributeName) + .Where(a => IsOptionsBindingAttribute(a.AttributeClass)) .ToList(); if (optionsAttributes.Count <= 0) @@ -932,6 +902,18 @@ private static List CollectOptionsFromAssembly( AlsoRegisterDirectType: false); } + private static bool IsOptionsBindingAttribute( + INamedTypeSymbol? attributeClass) + { + if (attributeClass is null) + { + return false; + } + + return attributeClass.MetadataName == AttributeMetadataName && + attributeClass.ContainingNamespace?.ToDisplayString() == AttributeNamespace; + } + private static bool HasOptionsBindingAttributeInNamespace( IAssemblySymbol assemblySymbol) { @@ -952,7 +934,7 @@ private static bool HasOptionsBindingAttributeInNamespace( } // Check if type has OptionsBinding attribute - if (namedType.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == FullAttributeName)) + if (namedType.GetAttributes().Any(a => IsOptionsBindingAttribute(a.AttributeClass))) { return true; } @@ -1813,16 +1795,14 @@ private static string GetSmartMethodSuffixFromContext( List allAssembliesInContext) { // Get the suffix (last segment after final dot) of the target assembly - var parts = assemblyName.Split('.'); - var suffix = parts[parts.Length - 1]; + var suffix = GetAssemblySuffix(assemblyName); // Check how many assemblies in the context have this same suffix int count = 0; foreach (var asmName in allAssembliesInContext) { - var asmParts = asmName.Split('.'); - var asmSuffix = asmParts[asmParts.Length - 1]; + var asmSuffix = GetAssemblySuffix(asmName); if (asmSuffix.Equals(suffix, StringComparison.OrdinalIgnoreCase)) { @@ -1836,10 +1816,16 @@ private static string GetSmartMethodSuffixFromContext( return SanitizeForMethodName(suffix); } - // Multiple assemblies have this suffix, useful sanitized name to avoid conflicts + // Multiple assemblies have this suffix, use full sanitized name to avoid conflicts return SanitizeForMethodName(assemblyName); } + private static string GetAssemblySuffix(string assemblyName) + { + var lastDotIndex = assemblyName.LastIndexOf('.'); + return lastDotIndex >= 0 ? assemblyName.Substring(lastDotIndex + 1) : assemblyName; + } + private static string GenerateAttributeSource() => """ // diff --git a/src/Atc.SourceGenerators/Helpers/TypeNameSanitizer.cs b/src/Atc.SourceGenerators/Helpers/TypeNameSanitizer.cs new file mode 100644 index 0000000..997dbf5 --- /dev/null +++ b/src/Atc.SourceGenerators/Helpers/TypeNameSanitizer.cs @@ -0,0 +1,41 @@ +namespace Atc.SourceGenerators.Helpers; + +/// +/// Provides methods for sanitizing type names for use in generated C# identifiers. +/// +internal static class TypeNameSanitizer +{ + /// + /// Sanitizes a type name so it can be used as part of a valid C# identifier. + /// Strips generic arity markers (backtick + digits, e.g., "Nullable`1" → "Nullable") + /// and removes any other characters that are not valid in C# identifiers. + /// + /// The type name to sanitize (typically from ITypeSymbol.Name). + /// A sanitized string safe for use in method names and other C# identifiers. + public static string SanitizeForIdentifier(string typeName) + { + // Strip generic arity marker (e.g., "Nullable`1" → "Nullable") + var backtickIndex = typeName.IndexOf('`'); + if (backtickIndex >= 0) + { + typeName = typeName.Substring(0, backtickIndex); + } + + // Remove any remaining characters that are not valid in C# identifiers + if (typeName.Length == 0) + { + return typeName; + } + + var sb = new System.Text.StringBuilder(typeName.Length); + foreach (var ch in typeName) + { + if (char.IsLetterOrDigit(ch) || ch == '_') + { + sb.Append(ch); + } + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/RuleCategoryConstants.cs b/src/Atc.SourceGenerators/RuleCategoryConstants.cs index b157f33..51d7857 100644 --- a/src/Atc.SourceGenerators/RuleCategoryConstants.cs +++ b/src/Atc.SourceGenerators/RuleCategoryConstants.cs @@ -25,4 +25,9 @@ internal static class RuleCategoryConstants /// Enum Mapping category - diagnostic IDs from ATCENUM001 to ATCENUM099. /// public const string EnumMapping = nameof(EnumMapping); + + /// + /// Mapping Configuration category - diagnostic IDs from ATCMCF001 to ATCMCF099. + /// + public const string MappingConfiguration = nameof(MappingConfiguration); } \ No newline at end of file diff --git a/src/Atc.SourceGenerators/RuleIdentifierConstants.cs b/src/Atc.SourceGenerators/RuleIdentifierConstants.cs index 3c287c7..14578cc 100644 --- a/src/Atc.SourceGenerators/RuleIdentifierConstants.cs +++ b/src/Atc.SourceGenerators/RuleIdentifierConstants.cs @@ -182,6 +182,11 @@ internal static class ObjectMapping /// ATCMAP004: Required property on target type has no mapping. /// internal const string RequiredPropertyNotMapped = "ATCMAP004"; + + /// + /// ATCMAP011: Collection element type has no mapping method. + /// + internal const string CollectionElementMappingNotFound = "ATCMAP011"; } /// @@ -198,5 +203,132 @@ internal static class EnumMapping /// ATCENUM002: Source enum value has no matching target value. /// internal const string UnmappedEnumValue = "ATCENUM002"; + + /// + /// ATCENUM003: Enum has [MapTo] attribute which overrides auto-detected mapping from configuration. + /// + internal const string DuplicateAttributeAndAutoDetectedEnum = "ATCENUM003"; + + /// + /// ATCENUM004: Auto-detected enum mapping has partial match. + /// + internal const string AutoDetectedEnumPartialMatch = "ATCENUM004"; + + /// + /// ATCENUM005: Enum types have no matching values; falling back to cast. + /// + internal const string AutoDetectedEnumNoMatch = "ATCENUM005"; + + /// + /// ATCENUM006: Flag enum mapping may produce incorrect results for combined values. + /// + internal const string FlagEnumMappingWarning = "ATCENUM006"; + } + + /// + /// Mapping Configuration - Rule identifiers from ATCMCF001 to ATCMCF099. + /// + internal static class MappingConfiguration + { + /// + /// ATCMCF001: Mapping configuration class must be declared as static. + /// + internal const string ConfigClassMustBeStatic = "ATCMCF001"; + + /// + /// ATCMCF002: Mapping configuration class must be declared as partial. + /// + internal const string ConfigClassMustBePartial = "ATCMCF002"; + + /// + /// ATCMCF003: Mapping method must be declared as partial. + /// + internal const string MethodMustBePartial = "ATCMCF003"; + + /// + /// ATCMCF004: Mapping method must be an extension method. + /// + internal const string MethodMustBeExtensionMethod = "ATCMCF004"; + + /// + /// ATCMCF005: Ignored property not found on source type. + /// + internal const string IgnoredPropertyNotFound = "ATCMCF005"; + + /// + /// ATCMCF006: Renamed source property not found on type. + /// + internal const string RenamedSourcePropertyNotFound = "ATCMCF006"; + + /// + /// ATCMCF007: Renamed target property not found on type. + /// + internal const string RenamedTargetPropertyNotFound = "ATCMCF007"; + + /// + /// ATCMCF008: Mapping configuration class has no partial extension methods. + /// + internal const string EmptyConfigurationClass = "ATCMCF008"; + + /// + /// ATCMCF009: Mapping method must have exactly one parameter (the source type). + /// + internal const string MethodTooManyParameters = "ATCMCF009"; + + /// + /// ATCMCF010: Mapping method return type must be a class, record, or struct. + /// + internal const string MethodReturnTypeInvalid = "ATCMCF010"; + + /// + /// ATCMCF011: Map() requires exactly two type arguments (source and target type). + /// + internal const string MapCallRequiresTypeArguments = "ATCMCF011"; + + /// + /// ATCMCF012: Map() argument must be a typeof() expression or compile-time constant. + /// + internal const string MapCallInvalidArgument = "ATCMCF012"; + + /// + /// ATCMCF013: AddMappings() requires a lambda expression argument. + /// + internal const string AddMappingsLambdaRequired = "ATCMCF013"; + } + + /// + /// Extended Object Mapping - Rule identifiers from ATCMAP005 onwards. + /// + internal static class ObjectMappingExtended + { + /// + /// ATCMAP005: Type has both attribute and configuration-based mapping. + /// + internal const string DuplicateAttributeAndConfiguration = "ATCMAP005"; + + /// + /// ATCMAP006: Type pair is configured multiple times. + /// + internal const string DuplicateConfigurationMapping = "ATCMAP006"; + + /// + /// ATCMAP007: Configuration references source type which cannot be found. + /// + internal const string ConfigSourceTypeNotFound = "ATCMAP007"; + + /// + /// ATCMAP008: Configuration references target type which cannot be found. + /// + internal const string ConfigTargetTypeNotFound = "ATCMAP008"; + + /// + /// ATCMAP009: Target type has no accessible constructor for mapping. + /// + internal const string ConfigTargetNoAccessibleConstructor = "ATCMAP009"; + + /// + /// ATCMAP010: Configuration target type must be a class or struct. + /// + internal const string ConfigTargetTypeMustBeClassOrStruct = "ATCMAP010"; } } \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index dc941fd..8376bd7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -40,7 +40,7 @@ - + diff --git a/test/Atc.SourceGenerators.Tests/Atc.SourceGenerators.Tests.csproj b/test/Atc.SourceGenerators.Tests/Atc.SourceGenerators.Tests.csproj index 1ebb838..38f6e40 100644 --- a/test/Atc.SourceGenerators.Tests/Atc.SourceGenerators.Tests.csproj +++ b/test/Atc.SourceGenerators.Tests/Atc.SourceGenerators.Tests.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorAtcTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorAtcTests.cs new file mode 100644 index 0000000..4cea0da --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorAtcTests.cs @@ -0,0 +1,546 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +public partial class AnnotationConstantsGeneratorTests +{ + [Fact] + public void Generator_Should_Generate_IsIgnoreDisplay_For_IgnoreDisplayAttribute() + { + const string source = """ + namespace Atc + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IgnoreDisplayAttribute : System.Attribute { } + } + + namespace TestNamespace + { + public class Product + { + [Atc.IgnoreDisplay] + public string InternalCode { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Product", output, StringComparison.Ordinal); + Assert.Contains("public static partial class InternalCode", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsIgnoreDisplay = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_EnumGuid_For_EnumGuidAttribute() + { + const string source = """ + namespace Atc + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class EnumGuidAttribute : System.Attribute + { + public EnumGuidAttribute(string guid) { } + } + } + + namespace TestNamespace + { + public class Product + { + [Atc.EnumGuid("d290f1ee-6c54-4b01-90e6-d701748f0851")] + public int CategoryId { get; set; } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class CategoryId", output, StringComparison.Ordinal); + Assert.Contains("public const string EnumGuid = \"d290f1ee-6c54-4b01-90e6-d701748f0851\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_CasingStyle_For_CasingStyleDescriptionAttribute() + { + const string source = """ + namespace Atc + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CasingStyleDescriptionAttribute : System.Attribute + { + public object Default { get; set; } + public string Prefix { get; set; } + } + } + + namespace TestNamespace + { + public class Product + { + [Atc.CasingStyleDescription(Default = "PascalCase", Prefix = "pre_")] + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Name", output, StringComparison.Ordinal); + Assert.Contains("public const string CasingStyleDefault = \"PascalCase\";", output, StringComparison.Ordinal); + Assert.Contains("public const string CasingStylePrefix = \"pre_\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsIPAddress_For_IPAddressAttribute() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IPAddressAttribute : System.Attribute + { + public IPAddressAttribute() { } + public IPAddressAttribute(bool required) { Required = required; } + public bool Required { get; set; } + } + } + + namespace TestNamespace + { + public class Server + { + [System.ComponentModel.DataAnnotations.IPAddress(true)] + public string HostAddress { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Server", output, StringComparison.Ordinal); + Assert.Contains("public static partial class HostAddress", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsIPAddress = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool IPAddressRequired = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsIPAddress_Without_Required() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IPAddressAttribute : System.Attribute + { + public IPAddressAttribute() { } + } + } + + namespace TestNamespace + { + public class Server + { + [System.ComponentModel.DataAnnotations.IPAddress] + public string HostAddress { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsIPAddress = true;", output, StringComparison.Ordinal); + Assert.DoesNotContain("IPAddressRequired", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsAtcUri_For_UriAttribute() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class UriAttribute : System.Attribute + { + public UriAttribute() { } + public UriAttribute(bool required, bool allowHttp, bool allowHttps, bool allowFtp, bool allowFtps, bool allowFile, bool allowOpcTcp) + { + Required = required; + AllowHttp = allowHttp; + AllowHttps = allowHttps; + AllowFtp = allowFtp; + AllowFtps = allowFtps; + AllowFile = allowFile; + AllowOpcTcp = allowOpcTcp; + } + public bool Required { get; set; } + public bool AllowHttp { get; set; } + public bool AllowHttps { get; set; } + public bool AllowFtp { get; set; } + public bool AllowFtps { get; set; } + public bool AllowFile { get; set; } + public bool AllowOpcTcp { get; set; } + } + } + + namespace TestNamespace + { + public class ServiceConfig + { + [System.ComponentModel.DataAnnotations.Uri(true, true, true, false, false, false, false)] + public string Endpoint { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class ServiceConfig", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Endpoint", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsAtcUri = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowHttp = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowHttps = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowFtp = false;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowFtps = false;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowFile = false;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowOpcTcp = false;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsAtcString_For_StringAttribute() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class StringAttribute : System.Attribute + { + public StringAttribute() { } + public StringAttribute(bool required, uint minLength, uint maxLength) + { + Required = required; + MinLength = minLength; + MaxLength = maxLength; + } + public bool Required { get; set; } + public uint MinLength { get; set; } + public uint MaxLength { get; set; } + public string RegularExpression { get; set; } + } + } + + namespace TestNamespace + { + public class Customer + { + [System.ComponentModel.DataAnnotations.String(true, 2, 100)] + public string FullName { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Customer", output, StringComparison.Ordinal); + Assert.Contains("public static partial class FullName", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsAtcString = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcStringRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMinLength = 2;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMaxLength = 100;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsAtcString_With_RegularExpression() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class StringAttribute : System.Attribute + { + public StringAttribute() { } + public StringAttribute(bool required, uint minLength, uint maxLength, string regularExpression) + { + Required = required; + MinLength = minLength; + MaxLength = maxLength; + RegularExpression = regularExpression; + } + public bool Required { get; set; } + public uint MinLength { get; set; } + public uint MaxLength { get; set; } + public string RegularExpression { get; set; } + } + } + + namespace TestNamespace + { + public class Customer + { + [System.ComponentModel.DataAnnotations.String(true, 1, 50, @"^[a-zA-Z]+$")] + public string Code { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsAtcString = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcStringRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMinLength = 1;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMaxLength = 50;", output, StringComparison.Ordinal); + Assert.Contains("AtcStringRegularExpression", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsKeyString_For_KeyStringAttribute() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class KeyStringAttribute : System.Attribute + { + public KeyStringAttribute() { } + public KeyStringAttribute(bool required, uint minLength, uint maxLength) + { + Required = required; + MinLength = minLength; + MaxLength = maxLength; + } + public bool Required { get; set; } + public uint MinLength { get; set; } + public uint MaxLength { get; set; } + } + } + + namespace TestNamespace + { + public class Order + { + [System.ComponentModel.DataAnnotations.KeyString(true, 5, 20)] + public string OrderKey { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Order", output, StringComparison.Ordinal); + Assert.Contains("public static partial class OrderKey", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsAtcString = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsKeyString = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcStringRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMinLength = 5;", output, StringComparison.Ordinal); + Assert.Contains("public const uint AtcStringMaxLength = 20;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsIsoCurrencySymbol_For_IsoCurrencySymbolAttribute() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IsoCurrencySymbolAttribute : System.Attribute + { + public IsoCurrencySymbolAttribute() { } + public bool Required { get; set; } + public string[] IsoCurrencySymbols { get; set; } + } + } + + namespace TestNamespace + { + public class Payment + { + [System.ComponentModel.DataAnnotations.IsoCurrencySymbol(Required = true)] + public string CurrencyCode { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Payment", output, StringComparison.Ordinal); + Assert.Contains("public static partial class CurrencyCode", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsIsoCurrencySymbol = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsoCurrencySymbolRequired = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Generate_Atc_Validation_Attributes_From_Non_Atc_Assembly() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IPAddressAttribute : System.Attribute + { + public IPAddressAttribute() { } + } + } + + namespace TestNamespace + { + public class Server + { + [System.ComponentModel.DataAnnotations.IPAddress] + public string HostAddress { get; set; } = string.Empty; + } + } + """; + + // Use default GetGeneratedOutput which has assembly name "TestAssembly" (not starting with "Atc") + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("IsIPAddress", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Multiple_Atc_Attributes_On_Same_Property() + { + const string source = """ + namespace Atc + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class IgnoreDisplayAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class EnumGuidAttribute : System.Attribute + { + public EnumGuidAttribute(string guid) { } + } + } + + namespace TestNamespace + { + public class Product + { + [Atc.IgnoreDisplay] + [Atc.EnumGuid("aaaabbbb-cccc-dddd-eeee-ffffffffffff")] + public int Status { get; set; } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsIgnoreDisplay = true;", output, StringComparison.Ordinal); + Assert.Contains("public const string EnumGuid = \"aaaabbbb-cccc-dddd-eeee-ffffffffffff\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_IsAtcUri_With_Named_Args() + { + const string source = """ + namespace System.ComponentModel.DataAnnotations + { + [System.AttributeUsage(System.AttributeTargets.Property)] + public class UriAttribute : System.Attribute + { + public UriAttribute() { } + public bool Required { get; set; } + public bool AllowHttp { get; set; } + public bool AllowHttps { get; set; } + public bool AllowFtp { get; set; } + public bool AllowFtps { get; set; } + public bool AllowFile { get; set; } + public bool AllowOpcTcp { get; set; } + } + } + + namespace TestNamespace + { + public class ServiceConfig + { + [System.ComponentModel.DataAnnotations.Uri(Required = true, AllowHttps = true)] + public string Endpoint { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAtcAssemblyName(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsAtcUri = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AtcUriAllowHttps = true;", output, StringComparison.Ordinal); + } + + /// + /// Helper that creates a compilation with assembly name starting with "Atc" + /// so the generator recognizes Atc validation attributes (IPAddress, Uri, String, etc.) + /// that live in System.ComponentModel.DataAnnotations namespace but require an Atc assembly. + /// + [SuppressMessage("", "S1854:Remove this useless assignment to local variable 'driver'", Justification = "OK")] + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutputWithAtcAssemblyName( + string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && + !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast() + .ToList(); + + var dataAnnotationsAssembly = typeof(System.ComponentModel.DataAnnotations.RequiredAttribute).Assembly; + if (!string.IsNullOrWhiteSpace(dataAnnotationsAssembly.Location)) + { + references.Add(MetadataReference.CreateFromFile(dataAnnotationsAssembly.Location)); + } + + var compilation = CSharpCompilation.Create( + "AtcTestAssembly", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new AnnotationConstantsGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + var allDiagnostics = outputCompilation + .GetDiagnostics() + .Concat(generatorDiagnostics) + .Where(d => d.Severity >= DiagnosticSeverity.Warning && + d.Id.StartsWith("ATCANN", StringComparison.Ordinal)) + .ToImmutableArray(); + + var output = string.Join( + Constants.LineFeed, + outputCompilation + .SyntaxTrees + .Skip(1) + .Select(tree => tree.ToString())); + + return (allDiagnostics, output); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorMixedDecoratorConditionalTests.cs b/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorMixedDecoratorConditionalTests.cs new file mode 100644 index 0000000..b6f87f3 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorMixedDecoratorConditionalTests.cs @@ -0,0 +1,58 @@ +// ReSharper disable RedundantArgumentDefaultValue +// ReSharper disable RedundantAssignment +// ReSharper disable UnusedVariable +namespace Atc.SourceGenerators.Tests.Generators.DependencyRegistration; + +public partial class DependencyRegistrationGeneratorTests +{ + [Fact] + public void Generator_Should_Register_Decorator_With_Conditional_Check() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IMyService + { + void Execute(); + } + + [Registration(Lifetime.Scoped, As = typeof(IMyService))] + public class MyService : IMyService + { + public void Execute() { } + } + + [Registration(Lifetime.Scoped, As = typeof(IMyService), Decorator = true, Condition = "Features:EnableLogging")] + public class LoggingMyServiceDecorator : IMyService + { + private readonly IMyService inner; + + public LoggingMyServiceDecorator(IMyService inner) + { + this.inner = inner; + } + + public void Execute() => inner.Execute(); + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Verify base service is registered + Assert.Contains("services.AddScoped()", output, StringComparison.Ordinal); + + // Verify conditional check wraps the decorator registration + Assert.Contains("if (configuration.GetValue(\"Features:EnableLogging\"))", output, StringComparison.Ordinal); + + // Verify decorator uses Decorate method + Assert.Contains("services.Decorate((provider, inner) =>", output, StringComparison.Ordinal); + Assert.Contains("ActivatorUtilities.CreateInstance(provider, inner)", output, StringComparison.Ordinal); + + // Verify IConfiguration parameter is present in method signature + Assert.Contains("IConfiguration configuration", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorRuntimeFilterTests.cs b/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorRuntimeFilterTests.cs new file mode 100644 index 0000000..1d10a4f --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/DependencyRegistration/DependencyRegistrationGeneratorRuntimeFilterTests.cs @@ -0,0 +1,595 @@ +// ReSharper disable RedundantArgumentDefaultValue +// ReSharper disable RedundantAssignment +// ReSharper disable UnusedVariable +namespace Atc.SourceGenerators.Tests.Generators.DependencyRegistration; + +public partial class DependencyRegistrationGeneratorTests +{ + [Fact] + public void RuntimeFilter_Should_Generate_ShouldExcludeService_Helper() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IUserService { } + + [Registration] + public class UserService : IUserService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("private static bool ShouldExcludeService(", output, StringComparison.Ordinal); + Assert.Contains("global::System.Type serviceType,", output, StringComparison.Ordinal); + Assert.Contains("global::System.Collections.Generic.IEnumerable? excludedNamespaces,", output, StringComparison.Ordinal); + Assert.Contains("global::System.Collections.Generic.IEnumerable? excludedPatterns,", output, StringComparison.Ordinal); + Assert.Contains("global::System.Collections.Generic.IEnumerable? excludedTypes)", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_MatchesPattern_Helper() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IUserService { } + + [Registration] + public class UserService : IUserService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("private static bool MatchesPattern(", output, StringComparison.Ordinal); + Assert.Contains("string value,", output, StringComparison.Ordinal); + Assert.Contains("string pattern)", output, StringComparison.Ordinal); + Assert.Contains("Regex.Escape(pattern)", output, StringComparison.Ordinal); + Assert.Contains("RegexOptions.IgnoreCase", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_ExcludedTypes_Parameter_In_Default_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IOrderService { } + + [Registration] + public class OrderService : IOrderService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("IEnumerable? excludedTypes = null", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_ExcludedNamespaces_Parameter_In_Default_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IOrderService { } + + [Registration] + public class OrderService : IOrderService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("IEnumerable? excludedNamespaces = null", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_ExcludedPatterns_Parameter_In_Default_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IOrderService { } + + [Registration] + public class OrderService : IOrderService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("IEnumerable? excludedPatterns = null", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_All_Three_Filter_Parameters_In_AutoDetect_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IEmailService { } + + [Registration] + public class EmailService : IEmailService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // The auto-detect overload has bool includeReferencedAssemblies followed by filter params + var lines = output.Split(Constants.LineFeed); + var autoDetectOverloadIndex = Array.FindIndex(lines, l => l.Contains("bool includeReferencedAssemblies,", StringComparison.Ordinal)); + Assert.True(autoDetectOverloadIndex > 0, "Should find auto-detect overload with includeReferencedAssemblies parameter"); + + // Verify the subsequent lines contain the three filter parameters + Assert.Contains("IEnumerable? excludedNamespaces = null", lines[autoDetectOverloadIndex + 1], StringComparison.Ordinal); + Assert.Contains("IEnumerable? excludedPatterns = null", lines[autoDetectOverloadIndex + 2], StringComparison.Ordinal); + Assert.Contains("IEnumerable? excludedTypes = null", lines[autoDetectOverloadIndex + 3], StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Exclusion_Check_Wrapping_Service_Registration() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface ICacheService { } + + [Registration] + public class CacheService : ICacheService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check runtime exclusions for CacheService", output, StringComparison.Ordinal); + Assert.Contains("if (!ShouldExcludeService(", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Exclusion_Checks_For_Multiple_Services() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IUserService { } + public interface IOrderService { } + public interface IPaymentService { } + + [Registration] + public class UserService : IUserService { } + + [Registration] + public class OrderService : IOrderService { } + + [Registration] + public class PaymentService : IPaymentService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check runtime exclusions for UserService", output, StringComparison.Ordinal); + Assert.Contains("// Check runtime exclusions for OrderService", output, StringComparison.Ordinal); + Assert.Contains("// Check runtime exclusions for PaymentService", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_TypeExclusion_Logic_In_Helper() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check if explicitly excluded by type", output, StringComparison.Ordinal); + Assert.Contains("if (excludedTypes != null)", output, StringComparison.Ordinal); + Assert.Contains("foreach (var excludedType in excludedTypes)", output, StringComparison.Ordinal); + Assert.Contains("if (serviceType == excludedType || serviceType.IsAssignableFrom(excludedType))", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_NamespaceExclusion_Logic_In_Helper() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check namespace exclusion", output, StringComparison.Ordinal); + Assert.Contains("if (excludedNamespaces != null && serviceType.Namespace != null)", output, StringComparison.Ordinal); + Assert.Contains("foreach (var excludedNs in excludedNamespaces)", output, StringComparison.Ordinal); + Assert.Contains("serviceType.Namespace.Equals(excludedNs", output, StringComparison.Ordinal); + Assert.Contains("serviceType.Namespace.StartsWith($\"{excludedNs}.", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_PatternExclusion_Logic_In_Helper() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check pattern exclusion (wildcard matching)", output, StringComparison.Ordinal); + Assert.Contains("if (excludedPatterns != null)", output, StringComparison.Ordinal); + Assert.Contains("var typeName = serviceType.Name;", output, StringComparison.Ordinal); + Assert.Contains("var fullTypeName = serviceType.FullName ?? serviceType.Name;", output, StringComparison.Ordinal); + Assert.Contains("MatchesPattern(typeName, pattern)", output, StringComparison.Ordinal); + Assert.Contains("MatchesPattern(fullTypeName, pattern)", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Filter_Parameters_In_SpecificAssembly_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // The specific assembly overload uses string referencedAssemblyName parameter + var lines = output.Split(Constants.LineFeed); + var specificOverloadIndex = Array.FindIndex(lines, l => l.Contains("string referencedAssemblyName,", StringComparison.Ordinal)); + Assert.True(specificOverloadIndex > 0, "Should find specific assembly overload with referencedAssemblyName parameter"); + + // Verify filter parameters follow + Assert.Contains("IEnumerable? excludedNamespaces = null", lines[specificOverloadIndex + 1], StringComparison.Ordinal); + Assert.Contains("IEnumerable? excludedPatterns = null", lines[specificOverloadIndex + 2], StringComparison.Ordinal); + Assert.Contains("IEnumerable? excludedTypes = null", lines[specificOverloadIndex + 3], StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Filter_Parameters_In_MultipleAssemblies_Overload() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // The multiple assemblies overload uses params string[] referencedAssemblyNames parameter + // Filter parameters appear before the params array + var lines = output.Split(Constants.LineFeed); + var multiOverloadIndex = Array.FindIndex(lines, l => l.Contains("params string[] referencedAssemblyNames", StringComparison.Ordinal)); + Assert.True(multiOverloadIndex > 0, "Should find multiple assemblies overload with params string[] referencedAssemblyNames parameter"); + } + + [Fact] + public void RuntimeFilter_Should_Pass_Filters_To_Referenced_Assembly_Calls() + { + const string dataAccessSource = """ + using Atc.DependencyInjection; + + namespace DataAccess; + + public interface IRepository { } + + [Registration] + public class Repository : IRepository { } + """; + + const string domainSource = """ + using Atc.DependencyInjection; + + namespace Domain; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, dataAccessOutput, domainOutput) = GetGeneratedOutputWithReferencedAssembly( + dataAccessSource, + "TestApp.DataAccess", + domainSource, + "TestApp.Domain"); + + Assert.Empty(diagnostics); + + // Verify filters are propagated to referenced assembly calls + Assert.Contains("excludedNamespaces: excludedNamespaces", domainOutput, StringComparison.Ordinal); + Assert.Contains("excludedPatterns: excludedPatterns", domainOutput, StringComparison.Ordinal); + Assert.Contains("excludedTypes: excludedTypes", domainOutput, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Typeof_For_Exclusion_Check_On_Interface_Registration() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface INotificationService { } + + [Registration(As = typeof(INotificationService))] + public class NotificationService : INotificationService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // When registered with an explicit interface, the exclusion check should use typeof + Assert.Contains("if (!ShouldExcludeService(typeof(TestNamespace.NotificationService)", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Typeof_For_Exclusion_Check_On_Self_Registration() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + [Registration] + public class UtilityService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("if (!ShouldExcludeService(typeof(TestNamespace.UtilityService)", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_ShouldExcludeService_Returning_False_When_No_Exclusions_Match() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // The helper method should return false at the end when nothing matches + Assert.Contains("return false;", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Return_True_In_Each_Exclusion_Branch() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Count occurrences of "return true;" inside ShouldExcludeService + // There should be one for type exclusion, one for namespace exclusion, one for pattern exclusion + var returnTrueCount = Regex.Count(output, @"return true;"); + Assert.True(returnTrueCount >= 3, $"Expected at least 3 'return true;' statements in ShouldExcludeService, found {returnTrueCount}"); + } + + [Fact] + public void RuntimeFilter_Should_Generate_WildcardToRegex_Conversion_In_MatchesPattern() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Verify MatchesPattern converts wildcards to regex + Assert.Contains("Regex.Escape(pattern)", output, StringComparison.Ordinal); + Assert.Contains(".Replace(\"\\\\*\", \".*\")", output, StringComparison.Ordinal); + Assert.Contains(".Replace(\"\\\\?\", \".\")", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_XmlDoc_For_Filter_Parameters() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Optional. Namespaces to exclude from registration.", output, StringComparison.Ordinal); + Assert.Contains("Optional. Wildcard patterns (* and ?) to exclude types by name.", output, StringComparison.Ordinal); + Assert.Contains("Optional. Specific types to exclude from registration.", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Exclusion_Check_For_Scoped_Service() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IScopedService { } + + [Registration(Lifetime.Scoped)] + public class ScopedService : IScopedService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check runtime exclusions for ScopedService", output, StringComparison.Ordinal); + Assert.Contains("if (!ShouldExcludeService(", output, StringComparison.Ordinal); + Assert.Contains("services.AddScoped()", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Exclusion_Check_For_Transient_Service() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface ITransientService { } + + [Registration(Lifetime.Transient)] + public class TransientService : ITransientService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check runtime exclusions for TransientService", output, StringComparison.Ordinal); + Assert.Contains("if (!ShouldExcludeService(", output, StringComparison.Ordinal); + Assert.Contains("services.AddTransient()", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Not_Generate_Helpers_When_No_Services_Registered() + { + const string source = """ + namespace TestNamespace; + + public class PlainClass { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("ShouldExcludeService", output, StringComparison.Ordinal); + Assert.DoesNotContain("MatchesPattern", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Exclusion_Check_For_Generic_Service() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IRepository { } + + [Registration(Lifetime.Scoped)] + public class Repository : IRepository where T : class { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("// Check runtime exclusions for Repository", output, StringComparison.Ordinal); + Assert.Contains("if (!ShouldExcludeService(", output, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeFilter_Should_Generate_Timeout_In_MatchesPattern_Regex() + { + const string source = """ + using Atc.DependencyInjection; + + namespace TestNamespace; + + public interface IService { } + + [Registration] + public class Service : IService { } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // The MatchesPattern method should include a timeout to prevent ReDoS + Assert.Contains("TimeSpan.FromSeconds(1)", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/EnumMapping/EnumMappingGeneratorExtendedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/EnumMapping/EnumMappingGeneratorExtendedTests.cs new file mode 100644 index 0000000..bb788a4 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/EnumMapping/EnumMappingGeneratorExtendedTests.cs @@ -0,0 +1,1199 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.EnumMapping; + +public partial class EnumMappingGeneratorTests +{ + [Fact] + public void Generator_Should_Handle_Special_Case_None_To_Default() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + Default, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus))] + public enum SourceStatus + { + None, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceStatus.None => TestNamespace.TargetStatus.Default,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Active => TestNamespace.TargetStatus.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Closed => TestNamespace.TargetStatus.Closed,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Special_Case_Unknown_To_None() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + None, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus))] + public enum SourceStatus + { + Unknown, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceStatus.Unknown => TestNamespace.TargetStatus.None,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Active => TestNamespace.TargetStatus.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Closed => TestNamespace.TargetStatus.Closed,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Special_Case_Unknown_To_Default() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + Default, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus))] + public enum SourceStatus + { + Unknown, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceStatus.Unknown => TestNamespace.TargetStatus.Default,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Special_Case_Default_To_None() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + None, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus))] + public enum SourceStatus + { + Default, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceStatus.Default => TestNamespace.TargetStatus.None,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Special_Case_Default_To_Unknown() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + Unknown, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus))] + public enum SourceStatus + { + Default, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceStatus.Default => TestNamespace.TargetStatus.Unknown,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Warn_For_Unmapped_Values_Without_Special_Case() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetColor + { + Red, + Green, + } + + [MapTo(typeof(TargetColor))] + public enum SourceColor + { + Red, + Green, + Blue, + Yellow, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + + var blueWarning = Assert.Single( + diagnostics, + d => d.Id == "ATCENUM002" && + d + .GetMessage(CultureInfo.InvariantCulture) + .Contains("Blue", StringComparison.Ordinal)); + Assert.Equal(DiagnosticSeverity.Warning, blueWarning.Severity); + + var yellowWarning = Assert.Single( + diagnostics, + d => d.Id == "ATCENUM002" && + d + .GetMessage(CultureInfo.InvariantCulture) + .Contains("Yellow", StringComparison.Ordinal)); + Assert.Equal(DiagnosticSeverity.Warning, yellowWarning.Severity); + + // Mapped values should be present in output + Assert.Contains("TestNamespace.SourceColor.Red => TestNamespace.TargetColor.Red,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceColor.Green => TestNamespace.TargetColor.Green,", output, StringComparison.Ordinal); + + // Unmapped values should NOT be present in switch + Assert.DoesNotContain("SourceColor.Blue =>", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourceColor.Yellow =>", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Report_ATCENUM002_With_Correct_Message_Format() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetKind + { + TypeA, + } + + [MapTo(typeof(TargetKind))] + public enum SourceKind + { + TypeA, + TypeB, + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + var warning = Assert.Single(diagnostics, d => d.Id == "ATCENUM002"); + var message = warning.GetMessage(CultureInfo.InvariantCulture); + Assert.Contains("SourceKind", message, StringComparison.Ordinal); + Assert.Contains("TypeB", message, StringComparison.Ordinal); + Assert.Contains("TargetKind", message, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Report_Error_When_Target_Is_A_Struct() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public struct TargetStruct + { + public int Value; + } + + [MapTo(typeof(TargetStruct))] + public enum SourceStatus + { + Active, + Inactive, + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + var diagnostic = Assert.Single(diagnostics, d => d.Id == "ATCENUM001"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("must be an enum type", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Report_Error_When_Target_Is_An_Interface() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public interface ITarget + { + int Value { get; } + } + + [MapTo(typeof(ITarget))] + public enum SourceStatus + { + Active, + Inactive, + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + var diagnostic = Assert.Single(diagnostics, d => d.Id == "ATCENUM001"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Generator_Should_Handle_Enum_With_Many_Values() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetMonth + { + January, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, + } + + [MapTo(typeof(TargetMonth))] + public enum SourceMonth + { + January, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTargetMonth", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceMonth.January => TestNamespace.TargetMonth.January,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceMonth.June => TestNamespace.TargetMonth.June,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceMonth.December => TestNamespace.TargetMonth.December,", output, StringComparison.Ordinal); + + // Verify all 12 months are present + Assert.Contains("SourceMonth.February =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.March =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.April =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.May =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.July =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.August =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.September =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.October =>", output, StringComparison.Ordinal); + Assert.Contains("SourceMonth.November =>", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Source_With_More_Values_Than_Target() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetPriority + { + Low, + High, + } + + [MapTo(typeof(TargetPriority))] + public enum SourcePriority + { + Low, + Medium, + High, + Critical, + Urgent, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // Should have warnings for Medium, Critical, Urgent + Assert.Equal(3, diagnostics.Length); + Assert.All(diagnostics, d => Assert.Equal("ATCENUM002", d.Id)); + Assert.All(diagnostics, d => Assert.Equal(DiagnosticSeverity.Warning, d.Severity)); + + // Mapped values should be present + Assert.Contains("TestNamespace.SourcePriority.Low => TestNamespace.TargetPriority.Low,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourcePriority.High => TestNamespace.TargetPriority.High,", output, StringComparison.Ordinal); + + // Unmapped values should NOT be present + Assert.DoesNotContain("SourcePriority.Medium =>", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourcePriority.Critical =>", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourcePriority.Urgent =>", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Target_With_More_Values_Than_Source() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetPriority + { + Low, + Medium, + High, + Critical, + Urgent, + } + + [MapTo(typeof(TargetPriority))] + public enum SourcePriority + { + Low, + High, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // No warnings - all source values have targets + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourcePriority.Low => TestNamespace.TargetPriority.Low,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourcePriority.High => TestNamespace.TargetPriority.High,", output, StringComparison.Ordinal); + + // Extra target values should not appear as source in switch arms + Assert.DoesNotContain("SourcePriority.Medium", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourcePriority.Critical", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourcePriority.Urgent", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Multiple_MapTo_On_Same_Enum() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetA + { + Active, + Inactive, + } + + public enum TargetB + { + Active, + Inactive, + } + + [MapTo(typeof(TargetA))] + [MapTo(typeof(TargetB))] + public enum SourceStatus + { + Active, + Inactive, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Both mappings should be generated + Assert.Contains("MapToTargetA", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetB", output, StringComparison.Ordinal); + + Assert.Contains("public static TestNamespace.TargetA MapToTargetA(", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.SourceStatus source)", output, StringComparison.Ordinal); + Assert.Contains("public static TestNamespace.TargetB MapToTargetB(", output, StringComparison.Ordinal); + + Assert.Contains("TestNamespace.SourceStatus.Active => TestNamespace.TargetA.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Active => TestNamespace.TargetB.Active,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Bidirectional_With_Special_Case_None_Unknown() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + Unknown, + Active, + Closed, + } + + [MapTo(typeof(TargetStatus), Bidirectional = true)] + public enum SourceStatus + { + None, + Active, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Forward: None -> Unknown + Assert.Contains("TestNamespace.SourceStatus.None => TestNamespace.TargetStatus.Unknown,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Active => TestNamespace.TargetStatus.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceStatus.Closed => TestNamespace.TargetStatus.Closed,", output, StringComparison.Ordinal); + + // Reverse: Unknown -> None + Assert.Contains("TestNamespace.TargetStatus.Unknown => TestNamespace.SourceStatus.None,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.TargetStatus.Active => TestNamespace.SourceStatus.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.TargetStatus.Closed => TestNamespace.SourceStatus.Closed,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Bidirectional_With_Special_Case_Default_None() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetStatus + { + None, + Open, + Resolved, + } + + [MapTo(typeof(TargetStatus), Bidirectional = true)] + public enum SourceStatus + { + Default, + Open, + Resolved, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Forward: Default -> None + Assert.Contains("TestNamespace.SourceStatus.Default => TestNamespace.TargetStatus.None,", output, StringComparison.Ordinal); + + // Reverse: None -> Default + Assert.Contains("TestNamespace.TargetStatus.None => TestNamespace.SourceStatus.Default,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Case_Insensitive_UPPER_To_Lower() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetCase + { + active, + inactive, + deleted, + } + + [MapTo(typeof(TargetCase))] + public enum SourceCase + { + ACTIVE, + INACTIVE, + DELETED, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceCase.ACTIVE => TestNamespace.TargetCase.active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceCase.INACTIVE => TestNamespace.TargetCase.inactive,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceCase.DELETED => TestNamespace.TargetCase.deleted,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Case_Insensitive_PascalCase_To_CamelCase() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetCasing + { + InProgress, + OnHold, + Completed, + } + + [MapTo(typeof(TargetCasing))] + public enum SourceCasing + { + inProgress, + onHold, + completed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceCasing.inProgress => TestNamespace.TargetCasing.InProgress,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceCasing.onHold => TestNamespace.TargetCasing.OnHold,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceCasing.completed => TestNamespace.TargetCasing.Completed,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Case_Insensitive_Mixed_Casing_Patterns() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetMixed + { + ACTIVE, + pending, + Resolved, + } + + [MapTo(typeof(TargetMixed))] + public enum SourceMixed + { + active, + PENDING, + resolved, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceMixed.active => TestNamespace.TargetMixed.ACTIVE,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceMixed.PENDING => TestNamespace.TargetMixed.pending,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceMixed.resolved => TestNamespace.TargetMixed.Resolved,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Prefer_Exact_Case_Match_Over_Case_Insensitive() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetExact + { + Active, + Pending, + } + + [MapTo(typeof(TargetExact))] + public enum SourceExact + { + Active, + Pending, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Exact case match should be used + Assert.Contains("TestNamespace.SourceExact.Active => TestNamespace.TargetExact.Active,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceExact.Pending => TestNamespace.TargetExact.Pending,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_None_To_Unknown_Bidirectional_Round_Trip() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum DtoStatus + { + Unknown, + Open, + Closed, + } + + [MapTo(typeof(DtoStatus), Bidirectional = true)] + public enum EntityStatus + { + None, + Open, + Closed, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Forward: EntityStatus.None -> DtoStatus.Unknown + Assert.Contains("public static TestNamespace.DtoStatus MapToDtoStatus(", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.EntityStatus source)", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.EntityStatus.None => TestNamespace.DtoStatus.Unknown,", output, StringComparison.Ordinal); + + // Reverse: DtoStatus.Unknown -> EntityStatus.None + Assert.Contains("public static TestNamespace.EntityStatus MapToEntityStatus(", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.DtoStatus source)", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.DtoStatus.Unknown => TestNamespace.EntityStatus.None,", output, StringComparison.Ordinal); + + // Other values should map normally in both directions + Assert.Contains("TestNamespace.EntityStatus.Open => TestNamespace.DtoStatus.Open,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.DtoStatus.Open => TestNamespace.EntityStatus.Open,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Single_Value_Enum() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetSingle + { + Only, + } + + [MapTo(typeof(TargetSingle))] + public enum SourceSingle + { + Only, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("TestNamespace.SourceSingle.Only => TestNamespace.TargetSingle.Only,", output, StringComparison.Ordinal); + Assert.Contains("_ => throw new global::System.ArgumentOutOfRangeException(nameof(source), source, \"Unmapped enum value\"),", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_All_Values_Unmapped() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetShape + { + Circle, + Square, + } + + [MapTo(typeof(TargetShape))] + public enum SourceShape + { + Triangle, + Hexagon, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // All values should have warnings + Assert.Equal(2, diagnostics.Length); + Assert.All(diagnostics, d => Assert.Equal("ATCENUM002", d.Id)); + + // No source enum values should appear in switch arms + Assert.DoesNotContain("SourceShape.Triangle =>", output, StringComparison.Ordinal); + Assert.DoesNotContain("SourceShape.Hexagon =>", output, StringComparison.Ordinal); + + // Default case should still be present + Assert.Contains("_ => throw new global::System.ArgumentOutOfRangeException(nameof(source), source, \"Unmapped enum value\"),", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Method_In_Atc_Mapping_Namespace() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Models; + + public enum TargetRole + { + Admin, + User, + } + + [MapTo(typeof(TargetRole))] + public enum SourceRole + { + Admin, + User, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("namespace Atc.Mapping;", output, StringComparison.Ordinal); + Assert.Contains("public static class EnumMappingExtensions", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Include_Best_Practice_Attributes_On_Generated_Class() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetLevel + { + Low, + High, + } + + [MapTo(typeof(TargetLevel))] + public enum SourceLevel + { + Low, + High, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("[global::System.CodeDom.Compiler.GeneratedCode(\"Atc.SourceGenerators.EnumMapping\", \"1.0.0\")]", output, StringComparison.Ordinal); + Assert.Contains("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]", output, StringComparison.Ordinal); + Assert.Contains("[global::System.Runtime.CompilerServices.CompilerGenerated]", output, StringComparison.Ordinal); + Assert.Contains("[global::System.Diagnostics.DebuggerNonUserCode]", output, StringComparison.Ordinal); + Assert.Contains("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Multiple_MapTo_With_Different_Bidirectional_Settings() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetX + { + On, + Off, + } + + public enum TargetY + { + On, + Off, + } + + [MapTo(typeof(TargetX), Bidirectional = true)] + [MapTo(typeof(TargetY), Bidirectional = false)] + public enum SourceSwitch + { + On, + Off, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Forward mappings for both targets + Assert.Contains("MapToTargetX", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetY", output, StringComparison.Ordinal); + + // Reverse mapping only for TargetX (Bidirectional = true) + Assert.Contains("MapToSourceSwitch", output, StringComparison.Ordinal); + + // Verify reverse mapping is from TargetX + Assert.Contains("public static TestNamespace.SourceSwitch MapToSourceSwitch(", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.TargetX source)", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Cross_Namespace_With_Special_Case() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Api.Models + { + public enum UserState + { + Unknown, + Active, + Suspended, + } + } + + namespace Data.Entities + { + [MapTo(typeof(Api.Models.UserState))] + public enum UserStateEntity + { + None, + Active, + Suspended, + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Data.Entities.UserStateEntity.None => Api.Models.UserState.Unknown,", output, StringComparison.Ordinal); + Assert.Contains("Data.Entities.UserStateEntity.Active => Api.Models.UserState.Active,", output, StringComparison.Ordinal); + Assert.Contains("Data.Entities.UserStateEntity.Suspended => Api.Models.UserState.Suspended,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Using_Directives_For_All_Namespaces() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace First.Namespace + { + public enum TargetEnum + { + Value1, + } + } + + namespace Second.Namespace + { + [MapTo(typeof(First.Namespace.TargetEnum))] + public enum SourceEnum + { + Value1, + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("using First.Namespace;", output, StringComparison.Ordinal); + Assert.Contains("using Second.Namespace;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Bidirectional_Where_Reverse_Has_Unmapped_Extra_Values() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetAccess + { + Read, + Write, + Admin, + } + + [MapTo(typeof(TargetAccess), Bidirectional = true)] + public enum SourceAccess + { + Read, + Write, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // No ATCENUM002 warnings for forward direction (all source values match) + Assert.Empty(diagnostics); + + // Forward: all source values map + Assert.Contains("TestNamespace.SourceAccess.Read => TestNamespace.TargetAccess.Read,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceAccess.Write => TestNamespace.TargetAccess.Write,", output, StringComparison.Ordinal); + + // Reverse: Read and Write map, Admin has no match in source but + // reverse mapping warnings are not reported as ATCENUM002 (only forward is diagnosed) + Assert.Contains("TestNamespace.TargetAccess.Read => TestNamespace.SourceAccess.Read,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.TargetAccess.Write => TestNamespace.SourceAccess.Write,", output, StringComparison.Ordinal); + + // Admin should NOT appear as a mapped value in reverse (it's unmapped) + Assert.DoesNotContain("TargetAccess.Admin => TestNamespace.SourceAccess", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Special_Case_Priority_None_Unknown_Default() + { + // When both Unknown and Default exist in target, None should map to Unknown first + // (Unknown is first in the SpecialCaseMappings array for "None") + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetWithBoth + { + Unknown, + Default, + Active, + } + + [MapTo(typeof(TargetWithBoth))] + public enum SourceWithNone + { + None, + Active, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // None should map to Unknown (first in special case list) + Assert.Contains("TestNamespace.SourceWithNone.None => TestNamespace.TargetWithBoth.Unknown,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Use_Special_Case_When_Exact_Match_Exists() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetWithNone + { + None, + Unknown, + Active, + } + + [MapTo(typeof(TargetWithNone))] + public enum SourceWithNone + { + None, + Active, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // None should map to None (exact match), not Unknown (special case) + Assert.Contains("TestNamespace.SourceWithNone.None => TestNamespace.TargetWithNone.None,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Include_Xml_Doc_Comments_On_Method() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetDoc + { + Value1, + } + + [MapTo(typeof(TargetDoc))] + public enum SourceDoc + { + Value1, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("/// ", output, StringComparison.Ordinal); + Assert.Contains("/// Maps to .", output, StringComparison.Ordinal); + Assert.Contains("/// ", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Enum_With_Explicit_Integer_Values() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetFlag + { + Read = 1, + Write = 2, + Execute = 4, + } + + [MapTo(typeof(TargetFlag))] + public enum SourceFlag + { + Read = 10, + Write = 20, + Execute = 40, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Mapping should work by name regardless of integer values + Assert.Contains("TestNamespace.SourceFlag.Read => TestNamespace.TargetFlag.Read,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceFlag.Write => TestNamespace.TargetFlag.Write,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.SourceFlag.Execute => TestNamespace.TargetFlag.Execute,", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Warn_For_Flags_Enum_Source() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetPermission + { + Read, + Write, + Execute, + } + + [Flags] + [MapTo(typeof(TargetPermission))] + public enum SourcePermission + { + None = 0, + Read = 1, + Write = 2, + Execute = 4, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCENUM006"); + Assert.NotEmpty(output); + } + + [Fact] + public void Generator_Should_Warn_For_Flags_Enum_Target() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + [Flags] + public enum TargetPermission + { + None = 0, + Read = 1, + Write = 2, + Execute = 4, + } + + [MapTo(typeof(TargetPermission))] + public enum SourcePermission + { + Read, + Write, + Execute, + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCENUM006"); + Assert.NotEmpty(output); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs new file mode 100644 index 0000000..b9fec06 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -0,0 +1,240 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Generate_Bidirectional_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigOptions(Bidirectional = true)] + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToContact", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Constructor_Mapping_For_Records() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Transaction + { + public string Id { get; set; } = string.Empty; + public decimal Amount { get; set; } + public string Currency { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public record Payment(string Id, decimal Amount, string Currency); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Payment MapToPayment(this ExternalLib.Transaction source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.Payment(", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_Collection_List_Mapping() + { + const string source = """ + using System.Collections.Generic; + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceItem + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + public class SourceContainer + { + public string Name { get; set; } = string.Empty; + public List Items { get; set; } = new(); + } + } + + namespace MyApp + { + public class TargetItem + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + public class TargetContainer + { + public string Name { get; set; } = string.Empty; + public List Items { get; set; } = new(); + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetContainer MapToTargetContainer(this ExternalLib.SourceContainer source); + public static partial MyApp.TargetItem MapToTargetItem(this ExternalLib.SourceItem source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTargetContainer", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetItem", output, StringComparison.Ordinal); + Assert.Contains(".Select(x => x.MapToTargetItem()).ToList()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_Collection_Array_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceItem + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + public class SourceContainer + { + public string Name { get; set; } = string.Empty; + public SourceItem[] Items { get; set; } = System.Array.Empty(); + } + } + + namespace MyApp + { + public class TargetItem + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + } + + public class TargetContainer + { + public string Name { get; set; } = string.Empty; + public TargetItem[] Items { get; set; } = System.Array.Empty(); + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetContainer MapToTargetContainer(this ExternalLib.SourceContainer source); + public static partial MyApp.TargetItem MapToTargetItem(this ExternalLib.SourceItem source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTargetContainer", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetItem", output, StringComparison.Ordinal); + Assert.Contains(".Select(x => x.MapToTargetItem()).ToArray()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Type_Conversions() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceRecord + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsActive { get; set; } + } + } + + namespace MyApp + { + public class TargetRecord + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsActive { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetRecord MapToTargetRecord(this ExternalLib.SourceRecord source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTargetRecord", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + Assert.Contains("IsActive = source.IsActive", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAssemblyTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAssemblyTests.cs new file mode 100644 index 0000000..ee341ee --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAssemblyTests.cs @@ -0,0 +1,233 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Assembly_MapTypes_Should_Generate_Basic_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("ConfiguredMappingExtensions", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void Assembly_MapTypes_Should_Apply_PropertyMap() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes( + typeof(ExternalLib.Contact), + typeof(MyApp.Customer), + PropertyMap = new[] { "FullName:DisplayName", "EmailAddress:Email" })] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string FullName { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("DisplayName = source.FullName", output, StringComparison.Ordinal); + Assert.Contains("Email = source.EmailAddress", output, StringComparison.Ordinal); + } + + [Fact] + public void Assembly_MapTypes_Should_Apply_IgnoreSourceProperties() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes( + typeof(ExternalLib.Contact), + typeof(MyApp.Customer), + IgnoreSourceProperties = new[] { "InternalId" })] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string InternalId { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string InternalId { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + Assert.DoesNotContain("InternalId = source.InternalId", output, StringComparison.Ordinal); + } + + [Fact] + public void Assembly_MapTypes_Should_Generate_Bidirectional() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes( + typeof(ExternalLib.Contact), + typeof(MyApp.Customer), + Bidirectional = true)] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToContact", output, StringComparison.Ordinal); + } + + [Fact] + public void Assembly_MapTypes_Should_AutoDetect_Enums() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + + namespace ExternalLib + { + public enum SourceStatus { None, Active, Inactive } + + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public SourceStatus Status { get; set; } + } + } + + namespace MyApp + { + public enum TargetStatus { Unknown, Active, Inactive } + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public TargetStatus Status { get; set; } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should generate enum mapping method with special case handling + Assert.Contains("MapToTargetStatus", output, StringComparison.Ordinal); + Assert.Contains("source.Status.MapToTargetStatus()", output, StringComparison.Ordinal); + + // Should map None → Unknown via special case detection + Assert.Contains("ExternalLib.SourceStatus.None => MyApp.TargetStatus.Unknown", output, StringComparison.Ordinal); + Assert.Contains("ExternalLib.SourceStatus.Active => MyApp.TargetStatus.Active", output, StringComparison.Ordinal); + } + + [Fact] + public void Assembly_MapTypes_Should_Emit_Warning_For_Duplicate_With_Attribute() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(MyApp.Source), typeof(MyApp.Target))] + + namespace MyApp + { + [MapTo(typeof(Target))] + public partial class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP005"); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBasicTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBasicTests.cs new file mode 100644 index 0000000..f771b88 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBasicTests.cs @@ -0,0 +1,248 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Generate_ClassToClass_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class ExternalMappings + { + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + Assert.Contains("Email = source.Email", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_ClassToRecord_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public record CustomerDto(int Id, string Name); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class ExternalMappings + { + public static partial MyApp.CustomerDto MapToCustomerDto(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomerDto", output, StringComparison.Ordinal); + + // Should use constructor since CustomerDto is a record + Assert.Contains("new MyApp.CustomerDto(", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_Null_Check() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("if (source is null)", output, StringComparison.Ordinal); + Assert.Contains("return default!", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_In_Correct_Namespace() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + } + } + + namespace MyApp.Infrastructure.Mappings + { + [MappingConfiguration] + public static partial class ThirdPartyMappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("namespace MyApp.Infrastructure.Mappings;", output, StringComparison.Ordinal); + Assert.Contains("public static partial class ThirdPartyMappings", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Apply_GeneratedCode_Attributes() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("GeneratedCode", output, StringComparison.Ordinal); + Assert.Contains("Atc.SourceGenerators.MappingConfiguration", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Skip_Unmatched_Properties() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + public string ExtraProperty { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + public string DifferentProperty { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.DoesNotContain("ExtraProperty", output, StringComparison.Ordinal); + Assert.DoesNotContain("DifferentProperty", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorClassLevelTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorClassLevelTests.cs new file mode 100644 index 0000000..fbb3235 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorClassLevelTests.cs @@ -0,0 +1,214 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void ClassLevel_Should_Generate_Mapping_From_MapTypes_On_Class() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [MapTypes(typeof(ExternalLib.Contact), typeof(Customer))] + static class Mappings; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("ConfiguredMappingExtensions", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void ClassLevel_Should_Support_Bidirectional() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [MapTypes(typeof(ExternalLib.Contact), typeof(Customer), Bidirectional = true)] + static class Mappings; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToContact", output, StringComparison.Ordinal); + } + + [Fact] + public void ClassLevel_Should_Support_PropertyMap() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string FullName { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + + [MapTypes( + typeof(ExternalLib.Contact), + typeof(Customer), + PropertyMap = new[] { "FullName:DisplayName", "EmailAddress:Email" })] + static class Mappings; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("DisplayName = source.FullName", output, StringComparison.Ordinal); + Assert.Contains("Email = source.EmailAddress", output, StringComparison.Ordinal); + } + + [Fact] + public void ClassLevel_Should_Support_Multiple_MapTypes_On_Same_Class() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Address + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class CustomerAddress + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + + [MapTypes(typeof(ExternalLib.Contact), typeof(Customer))] + [MapTypes(typeof(ExternalLib.Address), typeof(CustomerAddress))] + static class Mappings; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToCustomerAddress", output, StringComparison.Ordinal); + } + + [Fact] + public void ClassLevel_Should_Mix_With_Assembly_Level() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Address), typeof(MyApp.CustomerAddress))] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Address + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class CustomerAddress + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + + [MapTypes(typeof(ExternalLib.Contact), typeof(Customer))] + static class Mappings; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToCustomerAddress", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorConflictTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorConflictTests.cs new file mode 100644 index 0000000..ca2fa53 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorConflictTests.cs @@ -0,0 +1,186 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Emit_Warning_When_Attribute_And_Config_Both_Define_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(MyApp.Source), typeof(MyApp.Target))] + + namespace MyApp + { + [MapTo(typeof(Target))] + public partial class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP005"); + } + + [Fact] + public void Config_Should_Use_Attribute_Over_Config_When_Both_Exist() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(MyApp.Source), typeof(MyApp.Target))] + + namespace MyApp + { + [MapTo(typeof(Target))] + public partial class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // Mapping method should still be generated (attribute takes precedence) + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Emit_Warning_For_Duplicate_Config_Same_Pair() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP006"); + } + + [Fact] + public void Config_Should_Allow_Different_Targets_For_Same_Source() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.CustomerSummary))] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class CustomerSummary + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToCustomerSummary", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Allow_Same_Target_From_Different_Sources() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))] + [assembly: MapTypes(typeof(ExternalLib.Lead), typeof(MyApp.Customer))] + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Lead + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Both extension methods should exist - one on Contact, one on Lead + Assert.Contains("this ExternalLib.Contact source", output, StringComparison.Ordinal); + Assert.Contains("this ExternalLib.Lead source", output, StringComparison.Ordinal); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorDiagnosticTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorDiagnosticTests.cs new file mode 100644 index 0000000..8cf569d --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorDiagnosticTests.cs @@ -0,0 +1,247 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Error_When_Class_Not_Static() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public partial class Mappings + { + public partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF001"); + } + + [Fact] + public void Config_Should_Error_When_Class_Not_Partial() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static class Mappings + { + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF002"); + } + + [Fact] + public void Config_Should_Error_When_Method_Not_Extension() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF004"); + } + + [Fact] + public void Config_Should_Warn_When_Ignored_Property_Not_Found() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigIgnore("NonExistentProperty")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF005"); + } + + [Fact] + public void Config_Should_Error_When_Renamed_Source_Not_Found() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("NonExistent", "Name")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF006"); + } + + [Fact] + public void Config_Should_Error_When_Renamed_Target_Not_Found() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("Name", "NonExistent")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF007"); + } + + [Fact] + public void Config_Should_Info_When_Config_Class_Empty() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class EmptyMappings + { + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF008"); + } + + [Fact] + public void Config_Should_Error_When_Return_Type_Invalid() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial void MapToNothing(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF010"); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEdgeCaseTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEdgeCaseTests.cs new file mode 100644 index 0000000..924a6b6 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEdgeCaseTests.cs @@ -0,0 +1,170 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Handle_Empty_Configuration_Class() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class EmptyMappings + { + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source, "ATCMCF"); + + // Should emit info diagnostic + Assert.Contains(diagnostics, d => d.Id == "ATCMCF008"); + } + + [Fact] + public void Config_Should_Handle_Multiple_Config_Classes() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact { public int Id { get; set; } } + public class Order { public int Id { get; set; } } + } + + namespace MyApp + { + public class Customer { public int Id { get; set; } } + public class OrderDto { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class CrmMappings + { + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + + [MappingConfiguration] + public static partial class OrderMappings + { + public static partial MyApp.OrderDto MapToOrderDto(this ExternalLib.Order source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomer", output, StringComparison.Ordinal); + Assert.Contains("MapToOrderDto", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Source_And_Target_In_Different_Namespaces() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Vendor.SDK.Models + { + public class ApiResponse + { + public int StatusCode { get; set; } + public string Message { get; set; } = string.Empty; + } + } + + namespace MyApp.Domain.Models + { + public class ServiceResult + { + public int StatusCode { get; set; } + public string Message { get; set; } = string.Empty; + } + } + + namespace MyApp.Infrastructure + { + [MappingConfiguration] + public static partial class VendorMappings + { + public static partial MyApp.Domain.Models.ServiceResult MapToServiceResult( + this Vendor.SDK.Models.ApiResponse source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("using MyApp.Domain.Models;", output, StringComparison.Ordinal); + Assert.Contains("using Vendor.SDK.Models;", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Types_With_No_Matching_Properties() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Foo { get; set; } + public string Bar { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Target + { + public int Baz { get; set; } + public string Qux { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should still generate the method, but with empty initializer + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + Assert.Contains("new MyApp.Target", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Not_Generate_When_Attribute_Missing() + { + const string source = """ + namespace MyApp.Mappings + { + public static partial class NotAMappingClass + { + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("NotAMappingClass", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEnumTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEnumTests.cs new file mode 100644 index 0000000..e710c98 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorEnumTests.cs @@ -0,0 +1,149 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_AutoDetect_Enum_Mapping_For_Matching_Properties() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public enum ContactType { Individual, Company, Government } + + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public ContactType Type { get; set; } + } + } + + namespace MyApp + { + public enum CustomerCategory { Individual, Company, Government } + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public CustomerCategory Type { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToCustomerCategory", output, StringComparison.Ordinal); + Assert.Contains("source.Type.MapToCustomerCategory()", output, StringComparison.Ordinal); + + // Should generate the enum mapping method + Assert.Contains("ExternalLib.ContactType.Individual => MyApp.CustomerCategory.Individual", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Same_Enum_Type_Direct_Assignment() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Shared + { + public enum Status { Active, Inactive } + } + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + public Shared.Status Status { get; set; } + } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + public Shared.Status Status { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Same enum type should use direct assignment, not conversion + Assert.Contains("Status = source.Status", output, StringComparison.Ordinal); + Assert.DoesNotContain("MapToStatus", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_AutoDetect_SpecialCases_None_Unknown() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public enum ExternalStatus { None, Active, Inactive } + + public class Source + { + public int Id { get; set; } + public ExternalStatus Status { get; set; } + } + } + + namespace MyApp + { + public enum InternalStatus { Unknown, Active, Inactive } + + public class Target + { + public int Id { get; set; } + public InternalStatus Status { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should map None → Unknown via special case detection + Assert.Contains("ExternalLib.ExternalStatus.None => MyApp.InternalStatus.Unknown", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs new file mode 100644 index 0000000..dd26c27 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs @@ -0,0 +1,320 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Error_When_Class_Not_Static_WithMethods() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public partial class NonStaticMappings + { + public partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF001"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF001"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Error_When_Class_Not_Partial_WithMethods() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static class NonPartialMappings + { + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF002"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF002"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Error_When_Method_Not_Extension_Method() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF004"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF004"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Error_When_Return_Type_Is_Void() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial void MapToNothing(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF010"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF010"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Error_When_Return_Type_Is_Primitive() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial int MapToInt(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF010"); + } + + [Fact] + public void Config_Should_Error_When_Renamed_Source_Property_Does_Not_Exist() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp + { + public class Target + { + public int Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("DoesNotExist", "DisplayName")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF006"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF006"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Error_When_Renamed_Target_Property_Does_Not_Exist() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("Name", "DoesNotExist")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF007"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF007"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Warn_When_Ignored_Property_Does_Not_Exist_On_Source() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigIgnore("PropertyThatDoesNotExist")] + public static partial MyApp.Target MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF005"); + } + + [Fact] + public void Config_Should_Report_Info_When_Class_Has_No_Methods() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class EmptyMappings + { + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF008"); + } + + [Fact] + public void Config_Should_Report_Multiple_Errors_For_Multiple_Invalid_Methods() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target1 { public int Id { get; set; } } + public class Target2 { public int Id { get; set; } } + } + + namespace ExternalLib + { + public class Source1 { public int Id { get; set; } } + public class Source2 { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target1 MapToTarget1(ExternalLib.Source1 source); + public static partial MyApp.Target2 MapToTarget2(ExternalLib.Source2 source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + var extensionErrors = diagnostics.Where(d => d.Id == "ATCMCF004").ToList(); + Assert.True(extensionErrors.Count >= 2, $"Expected at least 2 ATCMCF004 errors but found {extensionErrors.Count}"); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorInlineTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorInlineTests.cs new file mode 100644 index 0000000..5aa079e --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorInlineTests.cs @@ -0,0 +1,488 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Inline_Should_Generate_Mapping_From_MappingBuilder_Configure() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(typeof(Source), typeof(Target)); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_Generic_Map_Syntax() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_Bidirectional() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(bidirectional: true); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + Assert.Contains("MapToSource", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_PropertyMap() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string FullName { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map( + propertyMap: new[] { "FullName:DisplayName" }); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("DisplayName = source.FullName", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_IgnoreSourceProperties() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Secret { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Secret { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map( + ignoreSourceProperties: new[] { "Secret" }); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + Assert.DoesNotContain("Secret = source.Secret", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_Fluent_Chaining() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class SourceA + { + public int Id { get; set; } + } + + public class TargetA + { + public int Id { get; set; } + } + + public class SourceB + { + public string Name { get; set; } = string.Empty; + } + + public class TargetB + { + public string Name { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map() + .Map(); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTargetA", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetB", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Support_TypeOf_Map_Syntax() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Target + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(typeof(Source), typeof(Target)); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToTarget", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_AutoDetect_Enums() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public enum SourceStatus { None, Active, Inactive } + public enum TargetStatus { Unknown, Active, Inactive } + + public class Source + { + public int Id { get; set; } + public SourceStatus Status { get; set; } + } + + public class Target + { + public int Id { get; set; } + public TargetStatus Status { get; set; } + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains("MapToTargetStatus", output, StringComparison.Ordinal); + Assert.Contains("MyApp.SourceStatus.None => MyApp.TargetStatus.Unknown", output, StringComparison.Ordinal); + } + + [Fact] + public void Inline_Should_Deduplicate_With_Assembly_MapTypes() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + [assembly: MapTypes(typeof(MyApp.Source), typeof(MyApp.Target))] + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + } + + public class Target + { + public int Id { get; set; } + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // Should report duplicate and keep only one + Assert.Contains(diagnostics, d => d.Id == "ATCMAP006"); + } + + [Fact] + public void Inline_Should_Error_On_Missing_Lambda() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(null!); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF013"); + } + + [Fact] + public void Inline_Should_Error_On_Invalid_Map_Arguments() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Source + { + public int Id { get; set; } + } + + public interface ITarget + { + int Id { get; set; } + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(typeof(Source), typeof(ITarget)); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP010"); + } + + [Fact] + public void Inline_Should_Deduplicate_With_Attribute_MapTo() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + [MapTo(typeof(Target))] + public partial class Source + { + public int Id { get; set; } + } + + public class Target + { + public int Id { get; set; } + } + + public static class Startup + { + public static void Configure() + { + MappingBuilder.Configure(map => + { + map.Map(); + }); + } + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + // Should report duplicate - attribute takes precedence + Assert.Contains(diagnostics, d => d.Id == "ATCMAP005"); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorPropertyTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorPropertyTests.cs new file mode 100644 index 0000000..356a41f --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorPropertyTests.cs @@ -0,0 +1,253 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Ignore_Properties_With_MapConfigIgnore() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string InternalId { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string InternalId { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigIgnore("InternalId")] + [MapConfigIgnore("PasswordHash")] + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + Assert.DoesNotContain("InternalId = source.InternalId", output, StringComparison.Ordinal); + Assert.DoesNotContain("PasswordHash = source.PasswordHash", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Rename_Properties_With_MapConfigProperty() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string FullName { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("FullName", "DisplayName")] + [MapConfigProperty("EmailAddress", "Email")] + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("DisplayName = source.FullName", output, StringComparison.Ordinal); + Assert.Contains("Email = source.EmailAddress", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Base_Class_Properties() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public abstract class BaseEntity + { + public int Id { get; set; } + public System.DateTime CreatedAt { get; set; } + } + + public class Contact : BaseEntity + { + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int Id { get; set; } + public System.DateTime CreatedAt { get; set; } + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("CreatedAt = source.CreatedAt", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Apply_CamelCase_PropertyNameStrategy() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Contact + { + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class Customer + { + public int id { get; set; } + public string firstName { get; set; } = string.Empty; + public string lastName { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigOptions(PropertyNameStrategy = PropertyNameStrategy.CamelCase)] + public static partial MyApp.Customer MapToCustomer(this ExternalLib.Contact source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // CamelCase strategy converts source PascalCase names to camelCase for target matching + Assert.Contains("id = source.Id", output, StringComparison.Ordinal); + Assert.Contains("firstName = source.FirstName", output, StringComparison.Ordinal); + Assert.Contains("lastName = source.LastName", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Collection_Property_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + using System.Collections.Generic; + + namespace ExternalLib + { + public class OrderItem + { + public int Id { get; set; } + public string ProductName { get; set; } = string.Empty; + } + + public class Order + { + public int Id { get; set; } + public List Items { get; set; } = new(); + } + } + + namespace MyApp + { + public class OrderItemDto + { + public int Id { get; set; } + public string ProductName { get; set; } = string.Empty; + } + + public class OrderDto + { + public int Id { get; set; } + public List Items { get; set; } = new(); + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.OrderItemDto MapToOrderItemDto(this ExternalLib.OrderItem source); + public static partial MyApp.OrderDto MapToOrderDto(this ExternalLib.Order source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should generate collection mapping with Select and ToList + Assert.Contains("MapToOrderDto", output, StringComparison.Ordinal); + Assert.Contains("MapToOrderItemDto", output, StringComparison.Ordinal); + Assert.Contains(".Select(x => x.MapToOrderItemDto()).ToList()!", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs new file mode 100644 index 0000000..7400a8c --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs @@ -0,0 +1,54 @@ +// ReSharper disable RedundantAssignment +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [SuppressMessage("", "S1854:Remove this useless assignment to local variable 'driver'", Justification = "OK")] + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput( + string source, + string? diagnosticPrefix = null) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && + !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast(); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + // Run MappingConfigurationGenerator, ObjectMappingGenerator, and EnumMappingGenerator + var mappingConfigGenerator = new MappingConfigurationGenerator(); + var objectMappingGenerator = new ObjectMappingGenerator(); + var enumMappingGenerator = new EnumMappingGenerator(); + var driver = CSharpGeneratorDriver.Create(mappingConfigGenerator, objectMappingGenerator, enumMappingGenerator); + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + var prefix = diagnosticPrefix ?? "ATCM"; + var allDiagnostics = outputCompilation + .GetDiagnostics() + .Concat(generatorDiagnostics) + .Where(d => d.Severity >= DiagnosticSeverity.Info && + d.Id.StartsWith(prefix, StringComparison.Ordinal)) + .ToImmutableArray(); + + var output = string.Join( + Constants.LineFeed, + outputCompilation + .SyntaxTrees + .Skip(1) + .Select(tree => tree.ToString())); + + return (allDiagnostics, output); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorErrorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorErrorTests.cs new file mode 100644 index 0000000..5d89569 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorErrorTests.cs @@ -0,0 +1,199 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.ObjectMapping; + +public partial class ObjectMappingGeneratorTests +{ + [Fact] + public void Generator_Should_Report_Error_When_Class_Not_Partial() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public class TargetDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [MapTo(typeof(TargetDto))] + public class NonPartialSource + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + var diagnostic = Assert.Single(diagnostics, d => d.Id == "ATCMAP001"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Generator_Should_Report_Error_When_Target_Is_Interface() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public interface ITarget + { + int Id { get; set; } + } + + [MapTo(typeof(ITarget))] + public partial class Source + { + public int Id { get; set; } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + var diagnostic = Assert.Single(diagnostics, d => d.Id == "ATCMAP002"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Generator_Should_Report_Error_When_Target_Is_Enum() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public enum TargetEnum { A, B, C } + + [MapTo(typeof(TargetEnum))] + public partial class Source + { + public int Id { get; set; } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + var diagnostic = Assert.Single(diagnostics, d => d.Id == "ATCMAP002"); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void Generator_Should_Report_Error_When_MapProperty_References_Nonexistent_Target_Property() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public class TargetDto + { + public int Id { get; set; } + } + + [MapTo(typeof(TargetDto))] + public partial class Source + { + public int Id { get; set; } + + [MapProperty("NonExistentProperty")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + Assert.NotEmpty(diagnostics); + Assert.Contains(diagnostics, d => d.Id == "ATCMAP003"); + } + + [Fact] + public void Generator_Should_Not_Generate_Mapping_When_Class_Not_Partial() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public class TargetDto + { + public int Id { get; set; } + } + + [MapTo(typeof(TargetDto))] + public class NonPartialSource + { + public int Id { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP001"); + Assert.DoesNotContain("MapToTargetDto", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Generate_Mapping_When_Target_Is_Interface() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public interface INotAClass + { + int Id { get; set; } + } + + [MapTo(typeof(INotAClass))] + public partial class Source + { + public int Id { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Contains(diagnostics, d => d.Id == "ATCMAP002"); + Assert.DoesNotContain("MapToINotAClass", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Report_Error_For_Each_Invalid_MapTo_Target() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public interface ITarget1 + { + int Id { get; set; } + } + + public interface ITarget2 + { + string Name { get; set; } + } + + [MapTo(typeof(ITarget1))] + [MapTo(typeof(ITarget2))] + public partial class Source + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source); + + var targetErrors = diagnostics.Where(d => d.Id == "ATCMAP002").ToList(); + Assert.True(targetErrors.Count >= 2, $"Expected at least 2 ATCMAP002 errors but found {targetErrors.Count}"); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorUpdateTargetTests.cs b/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorUpdateTargetTests.cs index e4fbfe8..00e94d2 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorUpdateTargetTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/ObjectMapping/ObjectMappingGeneratorUpdateTargetTests.cs @@ -4,43 +4,43 @@ namespace Atc.SourceGenerators.Tests.Generators.ObjectMapping; public partial class ObjectMappingGeneratorTests { - [Fact(Skip = "UpdateTarget property not recognized in test harness (similar to Factory). Feature verified working in samples: Settings.cs and Pet.cs")] + [Fact] public void Generator_Should_Generate_Update_Target_Method() { const string source = """ - using System; - using Atc.SourceGenerators.Annotations; - - namespace TestNamespace; - - public class UserDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - } - - [MapTo(typeof(UserDto), UpdateTarget = true)] - public partial class User - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - } - """; + using System; + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public class UserDto + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + + [MapTo(typeof(UserDto), UpdateTarget = true)] + public partial class User + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + } + """; var (diagnostics, output) = GetGeneratedOutput(source); Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); // Should generate standard method - Assert.Contains("public static UserDto MapToUserDto(", output, StringComparison.Ordinal); - Assert.Contains("this User source)", output, StringComparison.Ordinal); + Assert.Contains("public static TestNamespace.UserDto MapToUserDto(", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.User source)", output, StringComparison.Ordinal); // Should also generate update target overload Assert.Contains("public static void MapToUserDto(", output, StringComparison.Ordinal); - Assert.Contains("this User source,", output, StringComparison.Ordinal); - Assert.Contains("UserDto target)", output, StringComparison.Ordinal); + Assert.Contains("this TestNamespace.User source,", output, StringComparison.Ordinal); + Assert.Contains("TestNamespace.UserDto target)", output, StringComparison.Ordinal); // Update method should have property assignments Assert.Contains("target.Id = source.Id;", output, StringComparison.Ordinal); @@ -48,74 +48,73 @@ public partial class User Assert.Contains("target.Email = source.Email;", output, StringComparison.Ordinal); } - [Fact(Skip = "UpdateTarget property not recognized in test harness (similar to Factory). Feature verified working in samples: Settings.cs and Pet.cs")] + [Fact] public void Generator_Should_Not_Generate_Update_Target_Method_When_False() { const string source = """ - using System; - using Atc.SourceGenerators.Annotations; + using System; + using Atc.SourceGenerators.Annotations; - namespace TestNamespace; + namespace TestNamespace; - public class UserDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - } + public class UserDto + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + } - [MapTo(typeof(UserDto), UpdateTarget = false)] - public partial class User - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - } - """; + [MapTo(typeof(UserDto), UpdateTarget = false)] + public partial class User + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + } + """; var (diagnostics, output) = GetGeneratedOutput(source); Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); // Should generate standard method - Assert.Contains("public static UserDto MapToUserDto(", output, StringComparison.Ordinal); + Assert.Contains("public static TestNamespace.UserDto MapToUserDto(", output, StringComparison.Ordinal); - // Count occurrences - should only be one MapToUserDto method - var methodCount = CountOccurrences(output, "public static"); - Assert.Equal(1, methodCount); + // Should NOT generate update target overload when UpdateTarget = false + Assert.DoesNotContain("public static void MapToUserDto(", output, StringComparison.Ordinal); } - [Fact(Skip = "UpdateTarget property not recognized in test harness (similar to Factory). Feature verified working in samples: Settings.cs and Pet.cs")] + [Fact] public void Generator_Should_Include_Hooks_In_Update_Target_Method() { const string source = """ - using System; - using Atc.SourceGenerators.Annotations; - - namespace TestNamespace; - - public class OrderDto - { - public Guid Id { get; set; } - public decimal Total { get; set; } - } - - [MapTo(typeof(OrderDto), UpdateTarget = true, BeforeMap = nameof(ValidateOrder), AfterMap = nameof(EnrichOrder))] - public partial class Order - { - public Guid Id { get; set; } - public decimal Total { get; set; } - - internal static void ValidateOrder(Order source) - { - if (source.Total < 0) - throw new ArgumentException("Total cannot be negative"); - } - - internal static void EnrichOrder(Order source, OrderDto target) - { - // Custom enrichment logic - } - } - """; + using System; + using Atc.SourceGenerators.Annotations; + + namespace TestNamespace; + + public class OrderDto + { + public Guid Id { get; set; } + public decimal Total { get; set; } + } + + [MapTo(typeof(OrderDto), UpdateTarget = true, BeforeMap = nameof(ValidateOrder), AfterMap = nameof(EnrichOrder))] + public partial class Order + { + public Guid Id { get; set; } + public decimal Total { get; set; } + + internal static void ValidateOrder(Order source) + { + if (source.Total < 0) + throw new ArgumentException("Total cannot be negative"); + } + + internal static void EnrichOrder(Order source, OrderDto target) + { + // Custom enrichment logic + } + } + """; var (diagnostics, output) = GetGeneratedOutput(source); @@ -123,20 +122,13 @@ internal static void EnrichOrder(Order source, OrderDto target) // Should generate update target overload with hooks Assert.Contains("public static void MapToOrderDto(", output, StringComparison.Ordinal); + + // Both hooks should be present in the generated code Assert.Contains("Order.ValidateOrder(source);", output, StringComparison.Ordinal); Assert.Contains("Order.EnrichOrder(source, target);", output, StringComparison.Ordinal); - // Verify hook order in update method - var outputLines = output.Split(Constants.LineFeed); - var beforeMapIndex = Array.FindIndex(outputLines, line => line.Contains(".ValidateOrder(source);", StringComparison.Ordinal)); - var assignmentIndex = Array.FindIndex(outputLines, line => line.Contains("target.Id = source.Id;", StringComparison.Ordinal)); - var afterMapIndex = Array.FindIndex(outputLines, line => line.Contains(".EnrichOrder(source, target);", StringComparison.Ordinal)); - - Assert.True(beforeMapIndex > 0, "BeforeMap hook should be present in update method"); - Assert.True(assignmentIndex > 0, "Property assignment should be present"); - Assert.True(afterMapIndex > 0, "AfterMap hook should be present in update method"); - - Assert.True(beforeMapIndex < assignmentIndex, "BeforeMap should be before property assignments"); - Assert.True(assignmentIndex < afterMapIndex, "Property assignments should be before AfterMap"); + // Property assignment should be present in update method + Assert.Contains("target.Id = source.Id;", output, StringComparison.Ordinal); + Assert.Contains("target.Total = source.Total;", output, StringComparison.Ordinal); } } \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorNamedOptionsTests.cs b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorNamedOptionsTests.cs index fd9ef76..9b300ac 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorNamedOptionsTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorNamedOptionsTests.cs @@ -303,7 +303,7 @@ public ValidateOptionsResult Validate(string? name, EmailOptions options) Assert.Contains("services.Configure(\"Backup\", configuration.GetSection(\"AppSettings:Email:Backup\"));", generatedCode, StringComparison.Ordinal); // Count ValidateDataAnnotations calls - should only be 1 (for unnamed) - var validateDataAnnotationsCount = System.Text.RegularExpressions.Regex.Matches(generatedCode, "ValidateDataAnnotations").Count; + var validateDataAnnotationsCount = Regex.Count(generatedCode, "ValidateDataAnnotations"); Assert.Equal(1, validateDataAnnotationsCount); } } \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorTransitiveTests.cs b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorTransitiveTests.cs index 4c9f88a..c7494e5 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorTransitiveTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/OptionsBinding/OptionsBindingGeneratorTransitiveTests.cs @@ -31,10 +31,10 @@ public partial class AppOptions Assert.NotNull(generatedCode); // Check that all 4 overloads exist - var overload1Count = Regex.Matches(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration\s*\)", RegexOptions.Multiline).Count; - var overload2Count = Regex.Matches(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*bool includeReferencedAssemblies\s*\)", RegexOptions.Multiline).Count; - var overload3Count = Regex.Matches(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*string referencedAssemblyName\s*\)", RegexOptions.Multiline).Count; - var overload4Count = Regex.Matches(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*params string\[\] referencedAssemblyNames\s*\)", RegexOptions.Multiline).Count; + var overload1Count = Regex.Count(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration\s*\)"); + var overload2Count = Regex.Count(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*bool includeReferencedAssemblies\s*\)"); + var overload3Count = Regex.Count(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*string referencedAssemblyName\s*\)"); + var overload4Count = Regex.Count(generatedCode, @"public static IServiceCollection AddOptionsFromTestAssembly\s*\(\s*this IServiceCollection services,\s*IConfiguration configuration,\s*params string\[\] referencedAssemblyNames\s*\)"); Assert.Equal(1, overload1Count); Assert.Equal(1, overload2Count); diff --git a/test/Atc.SourceGenerators.Tests/Integration/SampleProjectBuildTests.cs b/test/Atc.SourceGenerators.Tests/Integration/SampleProjectBuildTests.cs new file mode 100644 index 0000000..2fc6b4a --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Integration/SampleProjectBuildTests.cs @@ -0,0 +1,184 @@ +namespace Atc.SourceGenerators.Tests.Integration; + +[Trait("Category", "Integration")] +public class SampleProjectBuildTests +{ + private static readonly string SolutionRoot = FindSolutionRoot(); + + [Fact] + public void DependencyRegistration_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.DependencyRegistration/Atc.SourceGenerators.DependencyRegistration.csproj"); + } + + [Fact] + public void DependencyRegistration_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.DependencyRegistration.Domain/Atc.SourceGenerators.DependencyRegistration.Domain.csproj"); + } + + [Fact] + public void OptionsBinding_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.OptionsBinding/Atc.SourceGenerators.OptionsBinding.csproj"); + } + + [Fact] + public void OptionsBinding_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.OptionsBinding.Domain/Atc.SourceGenerators.OptionsBinding.Domain.csproj"); + } + + [Fact] + public void Mapping_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.Mapping/Atc.SourceGenerators.Mapping.csproj"); + } + + [Fact] + public void Mapping_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.Mapping.Domain/Atc.SourceGenerators.Mapping.Domain.csproj"); + } + + [Fact] + public void Mapping_DataAccess_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.Mapping.DataAccess/Atc.SourceGenerators.Mapping.DataAccess.csproj"); + } + + [Fact] + public void Mapping_Contract_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.Mapping.Contract/Atc.SourceGenerators.Mapping.Contract.csproj"); + } + + [Fact] + public void EnumMapping_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.EnumMapping/Atc.SourceGenerators.EnumMapping.csproj"); + } + + [Fact] + public void MappingOnlyConfiguration_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingOnlyConfiguration/Atc.SourceGenerators.MappingOnlyConfiguration.csproj"); + } + + [Fact] + public void MappingOnlyConfiguration_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingOnlyConfiguration.Domain/Atc.SourceGenerators.MappingOnlyConfiguration.Domain.csproj"); + } + + [Fact] + public void MappingOnlyConfiguration_ExternalCrm_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm/Atc.SourceGenerators.MappingOnlyConfiguration.ExternalCrm.csproj"); + } + + [Fact] + public void MappingCombinedConfiguration_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingCombinedConfiguration/Atc.SourceGenerators.MappingCombinedConfiguration.csproj"); + } + + [Fact] + public void MappingCombinedConfiguration_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingCombinedConfiguration.Domain/Atc.SourceGenerators.MappingCombinedConfiguration.Domain.csproj"); + } + + [Fact] + public void MappingCombinedConfiguration_Contract_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingCombinedConfiguration.Contract/Atc.SourceGenerators.MappingCombinedConfiguration.Contract.csproj"); + } + + [Fact] + public void MappingCombinedConfiguration_ExternalAnalytics_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalAnalytics.csproj"); + } + + [Fact] + public void MappingCombinedConfiguration_ExternalNotifications_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications/Atc.SourceGenerators.MappingCombinedConfiguration.ExternalNotifications.csproj"); + } + + [Fact] + public void AnnotationConstants_Sample_Should_Build() + { + AssertProjectBuilds("sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj"); + } + + [Fact] + public void PetStore_Api_Sample_Should_Build() + { + AssertProjectBuilds("sample/PetStore.Api/PetStore.Api.csproj"); + } + + [Fact] + public void PetStore_Domain_Sample_Should_Build() + { + AssertProjectBuilds("sample/PetStore.Domain/PetStore.Domain.csproj"); + } + + [Fact] + public void PetStore_DataAccess_Sample_Should_Build() + { + AssertProjectBuilds("sample/PetStore.DataAccess/PetStore.DataAccess.csproj"); + } + + [Fact] + public void PetStore_ApiContract_Sample_Should_Build() + { + AssertProjectBuilds("sample/PetStore.Api.Contract/PetStore.Api.Contract.csproj"); + } + + private static void AssertProjectBuilds(string relativePath) + { + var projectPath = Path.Combine(SolutionRoot, relativePath); + Assert.True(File.Exists(projectPath), $"Project file not found: {projectPath}"); + + using var process = new System.Diagnostics.Process(); + process.StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build \"{projectPath}\" -c Release --no-restore -v q", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = SolutionRoot, + }; + + process.Start(); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + var exited = process.WaitForExit(120_000); + + Assert.True(exited, $"Build timed out after 120 seconds for: {relativePath}"); + + var message = $"Build failed for '{relativePath}' with exit code {process.ExitCode}.{Environment.NewLine}" + + $"--- stdout ---{Environment.NewLine}{stdout}{Environment.NewLine}" + + $"--- stderr ---{Environment.NewLine}{stderr}"; + Assert.True(process.ExitCode == 0, message); + } + + private static string FindSolutionRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !dir.GetFiles("*.slnx").Any()) + { + dir = dir.Parent; + } + + return dir?.FullName + ?? throw new InvalidOperationException( + "Could not find solution root (no .slnx file found above " + AppContext.BaseDirectory + ")"); + } +} \ No newline at end of file