diff --git a/Atc.SourceGenerators.slnx b/Atc.SourceGenerators.slnx index fd68529..9b1b8d4 100644 --- a/Atc.SourceGenerators.slnx +++ b/Atc.SourceGenerators.slnx @@ -1,21 +1,24 @@ + + + + + + + + + + + + + + + - - - - - - - - - - - - - + diff --git a/CLAUDE.md b/CLAUDE.md index 05d153d..4b3f1d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,12 @@ 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 three main source generators: +This is a **Roslyn C# Source Generators** project that provides compile-time code generation for .NET applications. The solution contains four 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 All generators eliminate boilerplate code and improve developer productivity while maintaining Native AOT compatibility. @@ -20,6 +21,7 @@ src/ DependencyRegistrationGenerator.cs OptionsBindingGenerator.cs ObjectMappingGenerator.cs + AnnotationConstantsGenerator.cs RuleIdentifierConstants.cs # Diagnostic ID constants RuleCategoryConstants.cs # Diagnostic category constants Atc.SourceGenerators.Annotations/ # Shared attribute definitions (published as separate package) @@ -34,6 +36,7 @@ test/ DependencyRegistrationGeneratorTests.cs OptionsBindingGeneratorTests.cs ObjectMappingGeneratorTests.cs + AnnotationConstantsGeneratorTests.cs sample/ Atc.SourceGenerators.DependencyRegistration/ # DI registration sample @@ -43,6 +46,7 @@ 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.AnnotationConstants/ # DataAnnotation constants sample PetStore.Api/ # Complete 3-layer ASP.NET Core API with OpenAPI/Scalar PetStore.Api.Contract/ # API contracts (DTOs) PetStore.Domain/ # Domain layer using all generators @@ -972,6 +976,98 @@ PetStatus (API) - `ATCENUM001` - Target type must be an enum (Error) - `ATCENUM002` - Source enum value has no matching target value (Warning) +### AnnotationConstantsGenerator + +**Key Features:** +- Automatic scanning of classes/records with DataAnnotation attributes +- No opt-in attribute required - scans all types automatically +- Full support for 17 Microsoft DataAnnotation attributes +- **Atc attributes support** - IPAddress, Uri, String, KeyString, IsoCurrencySymbol, IgnoreDisplay, EnumGuid, CasingStyleDescription (when Atc package is referenced) +- Zero reflection at runtime - all metadata accessible at compile time +- Native AOT compatible +- Configurable via .editorconfig (`atc_annotation_constants.include_unannotated_properties`) + +**Supported Microsoft DataAnnotation Attributes:** +- Display: DisplayName, Description, ShortName, GroupName, Prompt, Order +- Validation: Required (with AllowEmptyStrings, ErrorMessage), StringLength, MinLength, MaxLength, Range, RegularExpression +- Data Types: EmailAddress, Phone, Url, CreditCard, DataType +- Metadata: Key, Compare, Editable, ScaffoldColumn, Timestamp + +**Supported Atc Attributes (from Atc package):** +- `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 + +**Generated Code Pattern:** +```csharp +// Input: +using System.ComponentModel.DataAnnotations; + +public class Product +{ + [Display(Name = "Product Name", Description = "The display name of the product")] + [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; } +} + +// Output (static partial class in same namespace): +namespace MyNamespace; + +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 bool IsRequired = true; + public const int MaximumLength = 100; + } + + public static partial class Price + { + public const string DisplayName = "Price"; + public const bool IsRequired = true; + public const string Minimum = "0.01"; + public const string Maximum = "999999.99"; + public static readonly System.Type OperandType = typeof(decimal); + } + } +} + +// Usage: +string displayName = AnnotationConstants.Product.Name.DisplayName; // "Product Name" +bool isRequired = AnnotationConstants.Product.Name.IsRequired; // true +int maxLength = AnnotationConstants.Product.Name.MaximumLength; // 100 +string priceMin = AnnotationConstants.Product.Price.Minimum; // "0.01" +Type priceType = AnnotationConstants.Product.Price.OperandType; // typeof(decimal) +``` + +**Configuration via .editorconfig:** +```ini +# Include properties without annotations (default: false) +atc_annotation_constants.include_unannotated_properties = true +``` + +**Use Cases:** +- Blazor dynamic forms without reflection +- Client-side validation metadata +- API documentation generation +- TypeScript type generation +- UI label/placeholder generation + ## PetStore Sample - Complete Example The `PetStore.Api` sample demonstrates all four generators working together in a realistic 3-layer ASP.NET Core application with OpenAPI/Scalar documentation. diff --git a/README.md b/README.md index d80bff1..ff7fa3b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A collection of Roslyn C# source generators for .NET that eliminate boilerplate - **[⚙️ 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 ## ✨ See It In Action @@ -808,6 +809,108 @@ Get errors and warnings at compile time, not runtime: --- +### 📋 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 +``` + +--- + ## 🔨 Building ```bash @@ -860,6 +963,15 @@ 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. diff --git a/docs/AnnotationConstantsGenerator-FeatureRoadmap.md b/docs/AnnotationConstantsGenerator-FeatureRoadmap.md new file mode 100644 index 0000000..9a4de8c --- /dev/null +++ b/docs/AnnotationConstantsGenerator-FeatureRoadmap.md @@ -0,0 +1,720 @@ +# 📝 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 new file mode 100644 index 0000000..f012d32 --- /dev/null +++ b/docs/AnnotationConstantsGenerator-Samples.md @@ -0,0 +1,569 @@ +# 🎯 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 new file mode 100644 index 0000000..5d0741d --- /dev/null +++ b/docs/AnnotationConstantsGenerator.md @@ -0,0 +1,439 @@ +# 📝 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 index 9a98632..00c481d 100644 --- a/docs/DependencyRegistrationGenerators-FeatureRoadmap.md +++ b/docs/DependencyRegistrationGenerators-FeatureRoadmap.md @@ -96,6 +96,7 @@ This roadmap is based on comprehensive analysis of: | 🚫 | [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 @@ -534,6 +535,7 @@ services.AddDependencyRegistrationsFromDomain(configuration); - ✅ 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 @@ -671,8 +673,8 @@ Based on priority, user demand, and implementation complexity: **Goal**: Conditional registration and filtering -4. ✅ **TryAdd* Registration** 🟡 Medium - Conditional registration for library scenarios -5. ✅ **Assembly Scanning Filters** 🟡 Medium - Exclude namespaces/patterns from transitive registration +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 @@ -683,7 +685,7 @@ Based on priority, user demand, and implementation complexity: **Goal**: Decorator pattern for cross-cutting concerns -6. ✅ **Decorator Pattern Support** 🟢 Low-Medium ⭐ - Wrap services with logging, caching, validation +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 @@ -694,7 +696,7 @@ Based on priority, user demand, and implementation complexity: **Goal**: Instance registration for pre-created singletons -7. ✅ **Implementation Instance Registration** 🟢 Low - Pre-created singleton instances +1. ✅ **Implementation Instance Registration** 🟢 Low - Pre-created singleton instances **Status**: ✅ COMPLETED (January 2025) **Impact**: Support immutable configuration objects and pre-initialized singletons @@ -705,9 +707,9 @@ Based on priority, user demand, and implementation complexity: **Goal**: Validation and diagnostics -8. **Multi-Interface Registration** 🟢 Low - Selective interface registration -9. **Registration Validation Diagnostics** 🟡 Medium - Compile-time warnings for missing dependencies -10. **Conditional Registration** 🟢 Low-Medium - Feature flag-based registration +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 @@ -718,7 +720,7 @@ Based on priority, user demand, and implementation complexity: **Goal**: Convention-based patterns -11. **Auto-Discovery by Convention** 🟢 Low-Medium - Optional convention-based registration +1. **Auto-Discovery by Convention** 🟢 Low-Medium - Optional convention-based registration **Estimated effort**: 2-3 weeks **Impact**: Reduce boilerplate further with conventions diff --git a/docs/OptionsBindingGenerators.md b/docs/OptionsBindingGenerators.md index 20c3ea1..49e8e7a 100644 --- a/docs/OptionsBindingGenerators.md +++ b/docs/OptionsBindingGenerators.md @@ -97,6 +97,8 @@ services.AddOptions() - [📍 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) @@ -2087,16 +2089,19 @@ if (dbOptions.EnableFeatureX) #### ⚖️ 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) diff --git a/docs/PetStoreApi-Samples.md b/docs/PetStoreApi-Samples.md index de26fd1..ada3be3 100644 --- a/docs/PetStoreApi-Samples.md +++ b/docs/PetStoreApi-Samples.md @@ -652,19 +652,24 @@ public static Pet MapToPet(this PetEntity source) ## ✨ 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); @@ -679,6 +684,7 @@ 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) @@ -687,6 +693,7 @@ For advanced enum mapping with special case detection, see the [EnumMapping samp **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 @@ -695,12 +702,14 @@ 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 @@ -711,9 +720,11 @@ builder.Services.AddDependencyRegistrationsFromPetStoreDomain( **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(); @@ -733,25 +744,31 @@ public static PetResponse MapToPetResponse(Pet pet) { ... } ``` **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) @@ -786,12 +803,14 @@ dotnet run ``` Then open your browser to: -- **Scalar UI**: https://localhost:42616/scalar/v1 -- **OpenAPI JSON**: https://localhost:42616/openapi/v1.json + +- **Scalar UI**: +- **OpenAPI JSON**: ### Try It Out **Create a pet:** + ```bash curl -X POST https://localhost:42616/pets \ -H "Content-Type: application/json" \ @@ -799,16 +818,19 @@ curl -X POST https://localhost:42616/pets \ ``` **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 diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj b/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj new file mode 100644 index 0000000..a1ef7fa --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Atc.SourceGenerators.AnnotationConstants.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);CS0436;SA0001;CS1591;IDE0005 + false + + + + + + + + + + + diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/GlobalUsings.cs b/sample/Atc.SourceGenerators.AnnotationConstants/GlobalUsings.cs new file mode 100644 index 0000000..6af3476 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using System.ComponentModel.DataAnnotations; +global using Atc.SourceGenerators.AnnotationConstants.Models; \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Models/Customer.cs b/sample/Atc.SourceGenerators.AnnotationConstants/Models/Customer.cs new file mode 100644 index 0000000..bc256f3 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Models/Customer.cs @@ -0,0 +1,41 @@ +namespace Atc.SourceGenerators.AnnotationConstants.Models; + +/// +/// Example customer model demonstrating validation and data type annotations. +/// +public class Customer +{ + [Key] + [Editable(false)] + public Guid Id { get; set; } + + [Display(Name = "Full Name", Description = "Customer's full name", Order = 1)] + [Required(ErrorMessage = "Full name is required")] + [StringLength(200, MinimumLength = 2)] + public string FullName { get; set; } = string.Empty; + + [Display(Name = "Email Address", Order = 2)] + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Display(Name = "Phone Number", Order = 3)] + [Phone] + public string? PhoneNumber { get; set; } + + [Display(Name = "Website", Order = 4)] + [Url] + public string? Website { get; set; } + + [Display(Name = "Credit Card", Order = 5)] + [CreditCard] + public string? CreditCardNumber { get; set; } + + [Display(Name = "Notes", Order = 6, Prompt = "Enter additional notes")] + [DataType(DataType.MultilineText)] + public string? Notes { get; set; } + + [Timestamp] + [ScaffoldColumn(false)] + public byte[] RowVersion { get; set; } = []; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Models/NetworkConfig.cs b/sample/Atc.SourceGenerators.AnnotationConstants/Models/NetworkConfig.cs new file mode 100644 index 0000000..0c7c381 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Models/NetworkConfig.cs @@ -0,0 +1,52 @@ +namespace Atc.SourceGenerators.AnnotationConstants.Models; + +/// +/// Example model demonstrating Atc validation attributes. +/// These attributes are from the Atc package and extend System.ComponentModel.DataAnnotations. +/// +public class NetworkConfig +{ + [Display(Name = "Server IP Address")] + [IPAddress(Required = true)] + public string ServerAddress { get; set; } = string.Empty; + + [Display(Name = "Backup Server IP")] + [IPAddress] + public string? BackupServerAddress { get; set; } + + [Display(Name = "API Endpoint")] + [Uri(Required = true, AllowHttp = false, AllowHttps = true, AllowFtp = false, AllowFtps = false, AllowFile = false, AllowOpcTcp = false)] + public string ApiEndpoint { get; set; } = string.Empty; + + [Display(Name = "FTP Server")] + [Uri(AllowHttp = false, AllowHttps = false, AllowFtp = true, AllowFtps = true, AllowFile = false, AllowOpcTcp = false)] + public string? FtpServer { get; set; } + + [Display(Name = "Config File Path")] + [Uri(AllowHttp = false, AllowHttps = false, AllowFtp = false, AllowFtps = false, AllowFile = true, AllowOpcTcp = false)] + public string? ConfigFilePath { get; set; } + + [Display(Name = "Currency Code")] + [IsoCurrencySymbol(Required = true)] + public string CurrencyCode { get; set; } = "USD"; + + [Display(Name = "Allowed Currencies")] + [IsoCurrencySymbol(IsoCurrencySymbols = new[] { "USD", "EUR", "GBP", "JPY" })] + public string? AllowedCurrency { get; set; } + + [Display(Name = "Service Key")] + [KeyString] + public string ServiceKey { get; set; } = string.Empty; + + [Display(Name = "Custom Key")] + [KeyString(true, 5, 50)] + public string CustomKey { get; set; } = string.Empty; + + [Display(Name = "Description")] + [String(false, 0, 500)] + public string? Description { get; set; } + + [Display(Name = "Identifier")] + [String(true, 3, 20, new[] { ' ', '@', '#' }, new[] { "_", "-" })] + public string Identifier { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Models/PasswordReset.cs b/sample/Atc.SourceGenerators.AnnotationConstants/Models/PasswordReset.cs new file mode 100644 index 0000000..deaa6b8 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Models/PasswordReset.cs @@ -0,0 +1,19 @@ +namespace Atc.SourceGenerators.AnnotationConstants.Models; + +/// +/// Example model demonstrating the Compare attribute. +/// +public class PasswordReset +{ + [Display(Name = "New Password")] + [Required] + [DataType(DataType.Password)] + [StringLength(100, MinimumLength = 8, ErrorMessage = "Password must be between 8 and 100 characters")] + public string NewPassword { get; set; } = string.Empty; + + [Display(Name = "Confirm Password")] + [Required] + [DataType(DataType.Password)] + [Compare("NewPassword", ErrorMessage = "Passwords do not match")] + public string ConfirmPassword { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Models/Product.cs b/sample/Atc.SourceGenerators.AnnotationConstants/Models/Product.cs new file mode 100644 index 0000000..b054533 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Models/Product.cs @@ -0,0 +1,37 @@ +namespace Atc.SourceGenerators.AnnotationConstants.Models; + +/// +/// Example product model demonstrating DataAnnotation attributes. +/// The AnnotationConstantsGenerator will create compile-time accessible constants +/// for all the annotation metadata. +/// +public class Product +{ + [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 between 3 and 100 characters")] + public string Name { get; set; } = string.Empty; + + [Display(Name = "Description", Description = "Detailed product description")] + [StringLength(2000)] + public string? Description { get; set; } + + [Display(Name = "Price", Description = "Product price in USD")] + [Required] + [Range(typeof(decimal), "0.01", "999999.99", ErrorMessage = "Price must be between $0.01 and $999,999.99")] + public decimal Price { get; set; } + + [Display(Name = "SKU", ShortName = "SKU")] + [Required] + [RegularExpression(@"^[A-Z]{3}-\d{6}$", ErrorMessage = "SKU must be in format XXX-123456")] + public string Sku { get; set; } = string.Empty; + + [Display(Name = "Stock Quantity", GroupName = "Inventory")] + [Required] + [Range(0, 100000)] + public int StockQuantity { get; set; } + + [Display(Name = "Category", GroupName = "Classification")] + [Required] + public string Category { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/sample/Atc.SourceGenerators.AnnotationConstants/Program.cs b/sample/Atc.SourceGenerators.AnnotationConstants/Program.cs new file mode 100644 index 0000000..f065d42 --- /dev/null +++ b/sample/Atc.SourceGenerators.AnnotationConstants/Program.cs @@ -0,0 +1,135 @@ +Console.WriteLine("=== AnnotationConstantsGenerator Demo ==="); +Console.WriteLine(); + +// Access Product annotation constants +Console.WriteLine("--- Product Annotations ---"); +Console.WriteLine(); + +Console.WriteLine("Product.Name:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Name.DisplayName}"); +Console.WriteLine($" Description: {AnnotationConstants.Product.Name.Description}"); +Console.WriteLine($" IsRequired: {AnnotationConstants.Product.Name.IsRequired}"); +Console.WriteLine($" MinimumLength: {AnnotationConstants.Product.Name.MinimumLength}"); +Console.WriteLine($" MaximumLength: {AnnotationConstants.Product.Name.MaximumLength}"); +Console.WriteLine($" RequiredErrorMessage: {AnnotationConstants.Product.Name.RequiredErrorMessage}"); +Console.WriteLine($" StringLengthErrorMessage: {AnnotationConstants.Product.Name.StringLengthErrorMessage}"); +Console.WriteLine(); + +Console.WriteLine("Product.Price:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Price.DisplayName}"); +Console.WriteLine($" Description: {AnnotationConstants.Product.Price.Description}"); +Console.WriteLine($" IsRequired: {AnnotationConstants.Product.Price.IsRequired}"); +Console.WriteLine($" Minimum: {AnnotationConstants.Product.Price.Minimum}"); +Console.WriteLine($" Maximum: {AnnotationConstants.Product.Price.Maximum}"); +Console.WriteLine($" OperandType: {AnnotationConstants.Product.Price.OperandType}"); +Console.WriteLine($" RangeErrorMessage: {AnnotationConstants.Product.Price.RangeErrorMessage}"); +Console.WriteLine(); + +Console.WriteLine("Product.Sku:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Product.Sku.DisplayName}"); +Console.WriteLine($" ShortName: {AnnotationConstants.Product.Sku.ShortName}"); +Console.WriteLine($" IsRequired: {AnnotationConstants.Product.Sku.IsRequired}"); +Console.WriteLine($" Pattern: {AnnotationConstants.Product.Sku.Pattern}"); +Console.WriteLine($" RegularExpressionErrorMessage: {AnnotationConstants.Product.Sku.RegularExpressionErrorMessage}"); +Console.WriteLine(); + +// Access Customer annotation constants +Console.WriteLine("--- Customer Annotations ---"); +Console.WriteLine(); + +Console.WriteLine("Customer.Id:"); +Console.WriteLine($" IsKey: {AnnotationConstants.Customer.Id.IsKey}"); +Console.WriteLine($" IsEditable: {AnnotationConstants.Customer.Id.IsEditable}"); +Console.WriteLine(); + +Console.WriteLine("Customer.Email:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.Email.DisplayName}"); +Console.WriteLine($" Order: {AnnotationConstants.Customer.Email.Order}"); +Console.WriteLine($" IsRequired: {AnnotationConstants.Customer.Email.IsRequired}"); +Console.WriteLine($" IsEmailAddress: {AnnotationConstants.Customer.Email.IsEmailAddress}"); +Console.WriteLine(); + +Console.WriteLine("Customer.PhoneNumber:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.PhoneNumber.DisplayName}"); +Console.WriteLine($" IsPhone: {AnnotationConstants.Customer.PhoneNumber.IsPhone}"); +Console.WriteLine(); + +Console.WriteLine("Customer.Website:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.Website.DisplayName}"); +Console.WriteLine($" IsUrl: {AnnotationConstants.Customer.Website.IsUrl}"); +Console.WriteLine(); + +Console.WriteLine("Customer.CreditCardNumber:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.Customer.CreditCardNumber.DisplayName}"); +Console.WriteLine($" IsCreditCard: {AnnotationConstants.Customer.CreditCardNumber.IsCreditCard}"); +Console.WriteLine(); + +Console.WriteLine("Customer.RowVersion:"); +Console.WriteLine($" IsTimestamp: {AnnotationConstants.Customer.RowVersion.IsTimestamp}"); +Console.WriteLine($" IsScaffoldColumn: {AnnotationConstants.Customer.RowVersion.IsScaffoldColumn}"); +Console.WriteLine(); + +// Access PasswordReset annotation constants +Console.WriteLine("--- PasswordReset Annotations ---"); +Console.WriteLine(); + +Console.WriteLine("PasswordReset.ConfirmPassword:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.PasswordReset.ConfirmPassword.DisplayName}"); +Console.WriteLine($" IsRequired: {AnnotationConstants.PasswordReset.ConfirmPassword.IsRequired}"); +Console.WriteLine($" CompareProperty: {AnnotationConstants.PasswordReset.ConfirmPassword.CompareProperty}"); +Console.WriteLine(); + +// Access NetworkConfig annotation constants (Atc attributes) +Console.WriteLine("--- NetworkConfig Annotations (Atc Package) ---"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.ServerAddress:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.ServerAddress.DisplayName}"); +Console.WriteLine($" IsIPAddress: {AnnotationConstants.NetworkConfig.ServerAddress.IsIPAddress}"); +Console.WriteLine($" IPAddressRequired: {AnnotationConstants.NetworkConfig.ServerAddress.IPAddressRequired}"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.ApiEndpoint:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.ApiEndpoint.DisplayName}"); +Console.WriteLine($" IsAtcUri: {AnnotationConstants.NetworkConfig.ApiEndpoint.IsAtcUri}"); +Console.WriteLine($" AtcUriRequired: {AnnotationConstants.NetworkConfig.ApiEndpoint.AtcUriRequired}"); +Console.WriteLine($" AtcUriAllowHttp: {AnnotationConstants.NetworkConfig.ApiEndpoint.AtcUriAllowHttp}"); +Console.WriteLine($" AtcUriAllowHttps: {AnnotationConstants.NetworkConfig.ApiEndpoint.AtcUriAllowHttps}"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.CurrencyCode:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.CurrencyCode.DisplayName}"); +Console.WriteLine($" IsIsoCurrencySymbol: {AnnotationConstants.NetworkConfig.CurrencyCode.IsIsoCurrencySymbol}"); +Console.WriteLine($" IsoCurrencySymbolRequired: {AnnotationConstants.NetworkConfig.CurrencyCode.IsoCurrencySymbolRequired}"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.AllowedCurrency:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.AllowedCurrency.DisplayName}"); +Console.WriteLine($" IsIsoCurrencySymbol: {AnnotationConstants.NetworkConfig.AllowedCurrency.IsIsoCurrencySymbol}"); +Console.WriteLine($" AllowedIsoCurrencySymbols: [{string.Join(", ", AnnotationConstants.NetworkConfig.AllowedCurrency.AllowedIsoCurrencySymbols)}]"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.ServiceKey:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.ServiceKey.DisplayName}"); +Console.WriteLine($" IsAtcString: {AnnotationConstants.NetworkConfig.ServiceKey.IsAtcString}"); +Console.WriteLine($" IsKeyString: {AnnotationConstants.NetworkConfig.ServiceKey.IsKeyString}"); +Console.WriteLine(); + +Console.WriteLine("NetworkConfig.Identifier:"); +Console.WriteLine($" DisplayName: {AnnotationConstants.NetworkConfig.Identifier.DisplayName}"); +Console.WriteLine($" IsAtcString: {AnnotationConstants.NetworkConfig.Identifier.IsAtcString}"); +Console.WriteLine($" AtcStringRequired: {AnnotationConstants.NetworkConfig.Identifier.AtcStringRequired}"); +Console.WriteLine($" AtcStringMinLength: {AnnotationConstants.NetworkConfig.Identifier.AtcStringMinLength}"); +Console.WriteLine($" AtcStringMaxLength: {AnnotationConstants.NetworkConfig.Identifier.AtcStringMaxLength}"); +Console.WriteLine($" AtcStringInvalidCharacters: [{string.Join(", ", AnnotationConstants.NetworkConfig.Identifier.AtcStringInvalidCharacters.Select(c => $"'{c}'"))}]"); +Console.WriteLine($" AtcStringInvalidPrefixStrings: [{string.Join(", ", AnnotationConstants.NetworkConfig.Identifier.AtcStringInvalidPrefixStrings.Select(s => $"\"{s}\""))}]"); +Console.WriteLine(); + +Console.WriteLine("=== Demo Complete ==="); +Console.WriteLine(); +Console.WriteLine("Benefits of AnnotationConstantsGenerator:"); +Console.WriteLine(" - Zero reflection at runtime"); +Console.WriteLine(" - Compile-time access to annotation metadata"); +Console.WriteLine(" - IntelliSense support for all constants"); +Console.WriteLine(" - Native AOT compatible"); +Console.WriteLine(" - Supports both Microsoft DataAnnotations and Atc attributes"); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs b/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs new file mode 100644 index 0000000..d944dc0 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/AnnotationConstantsGenerator.cs @@ -0,0 +1,1161 @@ +// ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator +// ReSharper disable InvertIf +// ReSharper disable UnusedParameter.Local +// ReSharper disable ConvertIfStatementToSwitchStatement +// ReSharper disable MergeIntoPattern +namespace Atc.SourceGenerators.Generators; + +/// +/// Source generator that extracts DataAnnotation attributes from class/record properties +/// and generates compile-time accessible constants. Supports both Microsoft DataAnnotations +/// and Atc-specific attributes when the Atc assembly is referenced. +/// +[SuppressMessage("Major Code Smell", "S1172:Unused method parameters should be removed", Justification = "OK.")] +[Generator] +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 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", + }; + + // Supported Atc attribute full names (from Atc namespace) + private static readonly HashSet SupportedAtcAnnotations = new(StringComparer.Ordinal) + { + "Atc.IgnoreDisplayAttribute", + "Atc.EnumGuidAttribute", + "Atc.CasingStyleDescriptionAttribute", + }; + + // Supported Atc validation attribute full 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", + }; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Step 1: Create configuration pipeline from analyzer options + var configPipeline = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => + { + var includeUnannotated = options.GlobalOptions.TryGetValue( + IncludeUnannotatedKey, out var value) && + value.Equals("true", StringComparison.OrdinalIgnoreCase); + + return new AnnotationGeneratorConfig(includeUnannotated); + }); + + // Step 2: Find types with properties that have DataAnnotation attributes + var typesWithAnnotations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => IsTypeWithPotentialAnnotations(node), + transform: static (ctx, _) => GetTypeWithAnnotatedProperties(ctx)) + .Where(static info => info is not null) + .Collect(); + + // Step 3: Combine with compilation and configuration + var combined = context.CompilationProvider + .Combine(typesWithAnnotations) + .Combine(configPipeline); + + // Step 4: Generate source output + context.RegisterSourceOutput(combined, static (spc, source) => + { + var ((compilation, types), config) = source; + Execute(compilation, types, config, spc); + }); + } + + private static bool IsTypeWithPotentialAnnotations(SyntaxNode node) + { + // Fast check: is this a class or record? + if (node is not TypeDeclarationSyntax typeDecl) + { + return false; + } + + // Only process classes and records + if (typeDecl is not (ClassDeclarationSyntax or RecordDeclarationSyntax)) + { + return false; + } + + // Check if any member is a property with attributes + foreach (var member in typeDecl.Members) + { + if (member is PropertyDeclarationSyntax { AttributeLists.Count: > 0 }) + { + return true; + } + } + + return false; + } + + private static TypeAnnotationInfo? GetTypeWithAnnotatedProperties( + GeneratorSyntaxContext context) + { + var typeDecl = (TypeDeclarationSyntax)context.Node; + var typeSymbol = context.SemanticModel.GetDeclaredSymbol(typeDecl); + + if (typeSymbol is null) + { + return null; + } + + var annotatedProperties = new List(); + + // Process all properties + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IPropertySymbol propertySymbol) + { + continue; + } + + var propertyInfo = ExtractAllPropertyAnnotations(propertySymbol); + if (propertyInfo is not null) + { + annotatedProperties.Add(propertyInfo); + } + } + + if (annotatedProperties.Count == 0) + { + return null; + } + + return new TypeAnnotationInfo( + typeSymbol.Name, + typeSymbol.ContainingNamespace.ToDisplayString(), + typeSymbol.ContainingAssembly.Name, + [.. annotatedProperties]); + } + + private static PropertyAnnotationInfo? ExtractAllPropertyAnnotations( + IPropertySymbol propertySymbol) + { + var (display, msValidation, hasMsAnnotation) = ExtractPropertyMicrosoftAnnotations(propertySymbol); + var (atcAnnotation, hasAtcAnnotation) = ExtractPropertyAtcAnnotations(propertySymbol); + + if (!hasMsAnnotation && !hasAtcAnnotation) + { + return null; + } + + return new PropertyAnnotationInfo( + propertySymbol.Name, + display, + msValidation, + atcAnnotation); + } + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - attribute extraction logic")] + private static (DisplayAnnotationInfo? Display, ValidationAnnotationInfo? Validation, bool HasAny) ExtractPropertyMicrosoftAnnotations( + IPropertySymbol propertySymbol) + { + DisplayAnnotationInfo? display = null; + var validation = new ValidationAnnotationBuilder(); + var hasAnyAnnotation = false; + + foreach (var attribute in propertySymbol.GetAttributes()) + { + var attrFullName = attribute.AttributeClass?.ToDisplayString(); + if (attrFullName is null || !SupportedMicrosoftAnnotations.Contains(attrFullName)) + { + continue; + } + + hasAnyAnnotation = true; + + // Extract based on attribute type + switch (attrFullName) + { + case "System.ComponentModel.DataAnnotations.DisplayAttribute": + display = ExtractDisplayAttribute(attribute); + break; + + case "System.ComponentModel.DataAnnotations.RequiredAttribute": + validation.IsRequired = true; + validation.AllowEmptyStrings = GetNamedArg(attribute, "AllowEmptyStrings"); + validation.RequiredErrorMessage = GetNamedArg(attribute, "ErrorMessage"); + break; + + case "System.ComponentModel.DataAnnotations.StringLengthAttribute": + // First constructor argument is MaximumLength + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is int maxLen) + { + validation.MaxLength = maxLen; + } + + validation.MinLength = GetNamedArg(attribute, "MinimumLength"); + validation.StringLengthErrorMessage = GetNamedArg(attribute, "ErrorMessage"); + break; + + case "System.ComponentModel.DataAnnotations.RangeAttribute": + ExtractRangeAttribute(attribute, validation); + break; + + case "System.ComponentModel.DataAnnotations.MinLengthAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is int minLenVal) + { + validation.MinLength = minLenVal; + } + + break; + + case "System.ComponentModel.DataAnnotations.MaxLengthAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is int maxLenVal) + { + validation.MaxLength = maxLenVal; + } + + break; + + case "System.ComponentModel.DataAnnotations.RegularExpressionAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is string pattern) + { + validation.RegularExpressionPattern = pattern; + } + + validation.RegularExpressionErrorMessage = GetNamedArg(attribute, "ErrorMessage"); + break; + + case "System.ComponentModel.DataAnnotations.EmailAddressAttribute": + validation.IsEmailAddress = true; + break; + + case "System.ComponentModel.DataAnnotations.PhoneAttribute": + validation.IsPhone = true; + break; + + case "System.ComponentModel.DataAnnotations.UrlAttribute": + validation.IsUrl = true; + break; + + case "System.ComponentModel.DataAnnotations.CreditCardAttribute": + validation.IsCreditCard = true; + break; + + case "System.ComponentModel.DataAnnotations.DataTypeAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is int dataType) + { + validation.DataType = dataType; + } + + break; + + case "System.ComponentModel.DataAnnotations.CompareAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is string otherProp) + { + validation.CompareOtherProperty = otherProp; + } + + break; + + case "System.ComponentModel.DataAnnotations.KeyAttribute": + validation.IsKey = true; + break; + + case "System.ComponentModel.DataAnnotations.EditableAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is bool editable) + { + validation.IsEditable = editable; + } + + break; + + case "System.ComponentModel.DataAnnotations.ScaffoldColumnAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is bool scaffold) + { + validation.IsScaffoldColumn = scaffold; + } + + break; + + case "System.ComponentModel.DataAnnotations.TimestampAttribute": + validation.IsTimestamp = true; + break; + } + } + + return (display, validation.HasAnyValue ? validation.Build() : null, hasAnyAnnotation); + } + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - attribute extraction logic")] + private static (AtcAnnotationInfo? Annotation, bool HasAny) ExtractPropertyAtcAnnotations( + IPropertySymbol propertySymbol) + { + var atc = new AtcAnnotationBuilder(); + var hasAnyAnnotation = false; + + foreach (var attribute in propertySymbol.GetAttributes()) + { + var attrFullName = attribute.AttributeClass?.ToDisplayString(); + if (attrFullName is null) + { + continue; + } + + // Check if it's an Atc namespace attribute + if (SupportedAtcAnnotations.Contains(attrFullName)) + { + hasAnyAnnotation = true; + ExtractAtcNamespaceAttribute(attribute, attrFullName, atc); + continue; + } + + // Check if it's an Atc validation attribute (in System.ComponentModel.DataAnnotations namespace but from Atc assembly) + if (SupportedAtcValidationAnnotations.Contains(attrFullName)) + { + // 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); + } + } + } + + return (atc.HasAnyValue ? atc.Build() : null, hasAnyAnnotation); + } + + private static void ExtractAtcNamespaceAttribute( + AttributeData attribute, + string attrFullName, + AtcAnnotationBuilder atc) + { + switch (attrFullName) + { + case "Atc.IgnoreDisplayAttribute": + atc.IsIgnoreDisplay = true; + break; + + case "Atc.EnumGuidAttribute": + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is string guidStr) + { + atc.EnumGuid = guidStr; + } + + break; + + case "Atc.CasingStyleDescriptionAttribute": + atc.CasingStyleDefault = GetNamedArg(attribute, "Default")?.ToString(); + atc.CasingStylePrefix = GetNamedArg(attribute, "Prefix"); + break; + } + } + + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - attribute extraction logic")] + private static void ExtractAtcValidationAttribute( + AttributeData attribute, + string attrFullName, + AtcAnnotationBuilder atc) + { + switch (attrFullName) + { + case "System.ComponentModel.DataAnnotations.IPAddressAttribute": + atc.IsIPAddress = true; + atc.IPAddressRequired = GetBoolConstructorArgOrNamedArg(attribute, "Required"); + break; + + case "System.ComponentModel.DataAnnotations.IsoCurrencySymbolAttribute": + atc.IsIsoCurrencySymbol = true; + atc.IsoCurrencySymbolRequired = GetNamedArg(attribute, "Required"); + atc.IsoCurrencySymbols = GetNamedArgStringArray(attribute, "IsoCurrencySymbols"); + break; + + case "System.ComponentModel.DataAnnotations.StringAttribute": + atc.IsAtcString = true; + ExtractAtcStringAttributeProperties(attribute, atc); + break; + + case "System.ComponentModel.DataAnnotations.KeyStringAttribute": + atc.IsKeyString = true; + atc.IsAtcString = true; + ExtractAtcStringAttributeProperties(attribute, atc); + break; + + case "System.ComponentModel.DataAnnotations.UriAttribute": + atc.IsAtcUri = true; + ExtractAtcUriAttributeProperties(attribute, atc); + break; + } + } + + private static void ExtractAtcStringAttributeProperties( + AttributeData attribute, + AtcAnnotationBuilder atc) + { + var args = attribute.ConstructorArguments; + + // Handle constructor overloads + if (args.Length >= 3) + { + // StringAttribute(bool required, uint minLength, uint maxLength, ...) + if (args[0].Value is bool req) + { + atc.StringRequired = req; + } + + if (args[1].Value is uint minLen) + { + atc.StringMinLength = minLen; + } + + if (args[2].Value is uint maxLen) + { + atc.StringMaxLength = maxLen; + } + + // Check for additional constructor parameters + if (args.Length >= 5 && args[3].Kind == TypedConstantKind.Array) + { + // StringAttribute(bool, uint, uint, char[], string[]) + atc.StringInvalidCharacters = GetCharArrayFromTypedConstant(args[3]); + atc.StringInvalidPrefixStrings = GetStringArrayFromTypedConstant(args[4]); + } + else if (args.Length >= 4 && args[3].Value is string regEx) + { + // StringAttribute(bool, uint, uint, string regularExpression) + atc.StringRegularExpression = regEx; + } + } + + // Override with named arguments if present + atc.StringRequired ??= GetNamedArg(attribute, "Required"); + atc.StringMinLength ??= GetNamedArgUInt(attribute, "MinLength"); + atc.StringMaxLength ??= GetNamedArgUInt(attribute, "MaxLength"); + atc.StringRegularExpression ??= GetNamedArg(attribute, "RegularExpression"); + atc.StringInvalidCharacters ??= GetNamedArgCharArray(attribute, "InvalidCharacters"); + atc.StringInvalidPrefixStrings ??= GetNamedArgStringArray(attribute, "InvalidPrefixStrings"); + } + + private static void ExtractAtcUriAttributeProperties( + AttributeData attribute, + AtcAnnotationBuilder atc) + { + var args = attribute.ConstructorArguments; + + // Handle constructor: UriAttribute(bool required, bool allowHttp, bool allowHttps, bool allowFtp, bool allowFtps, bool allowFile, bool allowOpcTcp) + if (args.Length >= 7) + { + if (args[0].Value is bool req) + { + atc.UriRequired = req; + } + + if (args[1].Value is bool http) + { + atc.UriAllowHttp = http; + } + + if (args[2].Value is bool https) + { + atc.UriAllowHttps = https; + } + + if (args[3].Value is bool ftp) + { + atc.UriAllowFtp = ftp; + } + + if (args[4].Value is bool ftps) + { + atc.UriAllowFtps = ftps; + } + + if (args[5].Value is bool file) + { + atc.UriAllowFile = file; + } + + if (args[6].Value is bool opcTcp) + { + atc.UriAllowOpcTcp = opcTcp; + } + } + + // Override with named arguments if present + atc.UriRequired ??= GetNamedArg(attribute, "Required"); + atc.UriAllowHttp ??= GetNamedArg(attribute, "AllowHttp"); + atc.UriAllowHttps ??= GetNamedArg(attribute, "AllowHttps"); + atc.UriAllowFtp ??= GetNamedArg(attribute, "AllowFtp"); + atc.UriAllowFtps ??= GetNamedArg(attribute, "AllowFtps"); + atc.UriAllowFile ??= GetNamedArg(attribute, "AllowFile"); + atc.UriAllowOpcTcp ??= GetNamedArg(attribute, "AllowOpcTcp"); + } + + private static bool? GetBoolConstructorArgOrNamedArg( + AttributeData attribute, + string namedArgName) + { + // Check constructor arguments first + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is bool boolVal) + { + return boolVal; + } + + return GetNamedArg(attribute, namedArgName); + } + + private static uint? GetNamedArgUInt( + AttributeData attribute, + string name) + { + foreach (var arg in attribute.NamedArguments) + { + if (arg.Key == name && arg.Value.Value is uint value) + { + return value; + } + } + + return null; + } + + private static char[]? GetCharArrayFromTypedConstant(TypedConstant constant) + { + if (constant.Kind != TypedConstantKind.Array || constant.Values.IsDefault) + { + return null; + } + + var result = new List(); + foreach (var item in constant.Values) + { + if (item.Value is char c) + { + result.Add(c); + } + } + + return result.Count > 0 ? [.. result] : null; + } + + private static string[]? GetStringArrayFromTypedConstant( + TypedConstant constant) + { + if (constant.Kind != TypedConstantKind.Array || constant.Values.IsDefault) + { + return null; + } + + var result = new List(); + foreach (var item in constant.Values) + { + if (item.Value is string s) + { + result.Add(s); + } + } + + return result.Count > 0 ? [.. result] : null; + } + + private static char[]? GetNamedArgCharArray( + AttributeData attribute, + string name) + => (from arg + in attribute.NamedArguments + where arg.Key == name + select GetCharArrayFromTypedConstant(arg.Value)).FirstOrDefault(); + + private static string[]? GetNamedArgStringArray( + AttributeData attribute, + string name) + { + foreach (var arg in attribute.NamedArguments) + { + if (arg.Key == name) + { + return GetStringArrayFromTypedConstant(arg.Value); + } + } + + return null; + } + + private static DisplayAnnotationInfo ExtractDisplayAttribute( + AttributeData attribute) + => new( + Name: GetNamedArg(attribute, "Name"), + Description: GetNamedArg(attribute, "Description"), + ShortName: GetNamedArg(attribute, "ShortName"), + GroupName: GetNamedArg(attribute, "GroupName"), + Prompt: GetNamedArg(attribute, "Prompt"), + Order: GetNamedArg(attribute, "Order")); + + private static void ExtractRangeAttribute( + AttributeData attribute, + ValidationAnnotationBuilder validation) + { + var args = attribute.ConstructorArguments; + + if (args.Length >= 2) + { + // Check for Range(Type, string, string) overload + if (args[0].Value is INamedTypeSymbol typeSymbol) + { + validation.RangeOperandType = typeSymbol.ToDisplayString(); + validation.RangeMinimum = args[1].Value?.ToString(); + if (args.Length > 2) + { + validation.RangeMaximum = args[2].Value?.ToString(); + } + } + else + { + // Range(int, int) or Range(double, double) overloads + validation.RangeMinimum = args[0].Value?.ToString(); + validation.RangeMaximum = args[1].Value?.ToString(); + + // Infer operand type from the argument type + if (args[0].Type is { } argType) + { + validation.RangeOperandType = argType.ToDisplayString(); + } + } + } + + validation.RangeErrorMessage = GetNamedArg(attribute, "ErrorMessage"); + } + + private static T? GetNamedArg( + AttributeData attribute, + string name) + { + foreach (var arg in attribute.NamedArguments) + { + if (arg.Key != name) + { + continue; + } + + if (arg.Value.Value is T value) + { + return value; + } + + // Handle nullable value types + if (typeof(T).IsGenericType && + typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>) && + arg.Value.Value is not null) + { + var underlyingType = Nullable.GetUnderlyingType(typeof(T)); + if (underlyingType is not null && + underlyingType.IsInstanceOfType(arg.Value.Value)) + { + return (T)arg.Value.Value; + } + } + + break; + } + + return default; + } + + private static void Execute( + Compilation compilation, + ImmutableArray types, + AnnotationGeneratorConfig config, + SourceProductionContext context) + { + if (types.IsDefaultOrEmpty) + { + return; + } + + // Group types by namespace for generation + var distinctTypes = types + .Where(t => t is not null) + .Cast() + .Distinct() + .ToList(); + + foreach (var typeInfo in distinctTypes) + { + context.CancellationToken.ThrowIfCancellationRequested(); + GenerateConstantsForType(typeInfo, config, context); + } + } + + private static void GenerateConstantsForType( + TypeAnnotationInfo typeInfo, + AnnotationGeneratorConfig config, + SourceProductionContext context) + { + var sb = new StringBuilder(); + + sb.AppendLineLf("// "); + sb.AppendLineLf("#nullable enable"); + sb.AppendLineLf(); + sb.AppendLineLf($"namespace {typeInfo.Namespace};"); + sb.AppendLineLf(); + sb.AppendLineLf("/// "); + sb.AppendLineLf($"/// Annotation constants for {typeInfo.TypeName}."); + sb.AppendLineLf("/// "); + sb.AppendLineLf("public static partial class AnnotationConstants"); + sb.AppendLineLf("{"); + + GenerateTypeConstants(sb, typeInfo); + + sb.AppendLineLf("}"); + + var fileName = $"AnnotationConstants.{typeInfo.Namespace}.{typeInfo.TypeName}.g.cs"; + context.AddSource(fileName, SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static void GenerateTypeConstants( + StringBuilder sb, + TypeAnnotationInfo typeInfo) + { + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Annotation constants for {typeInfo.TypeName} properties."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf(" [global::System.CodeDom.Compiler.GeneratedCode(\"Atc.SourceGenerators.AnnotationConstants\", \"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 partial class {typeInfo.TypeName}"); + sb.AppendLineLf(" {"); + + for (var i = 0; i < typeInfo.Properties.Length; i++) + { + var property = typeInfo.Properties[i]; + GeneratePropertyConstants(sb, property); + + if (i < typeInfo.Properties.Length - 1) + { + sb.AppendLineLf(); + } + } + + sb.AppendLineLf(" }"); + } + + private static void GeneratePropertyConstants( + StringBuilder sb, + PropertyAnnotationInfo property) + { + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Annotation constants for the {property.PropertyName} property."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" public static partial class {property.PropertyName}"); + sb.AppendLineLf(" {"); + + var hasContent = false; + + // Display constants (Microsoft) + if (property.Display is not null) + { + hasContent = GenerateDisplayConstants(sb, property.Display, property.PropertyName) || hasContent; + } + + // Microsoft validation constants + if (property.MicrosoftValidation is not null) + { + if (hasContent) + { + sb.AppendLineLf(); + } + + GenerateMicrosoftValidationConstants(sb, property.MicrosoftValidation); + hasContent = true; + } + + // Atc annotation constants + if (property.AtcAnnotation is not null) + { + if (hasContent) + { + sb.AppendLineLf(); + } + + GenerateAtcConstants(sb, property.AtcAnnotation); + } + + sb.AppendLineLf(" }"); + } + + private static bool GenerateDisplayConstants( + StringBuilder sb, + DisplayAnnotationInfo display, + string propertyName) + { + var hasContent = false; + + if (display.Name is not null) + { + sb.AppendLineLf($" public const string DisplayName = \"{EscapeString(display.Name)}\";"); + hasContent = true; + } + + if (display.Description is not null) + { + // Use DisplayDescription to avoid conflict when property is named "Description" + var constantName = propertyName == "Description" ? "DisplayDescription" : "Description"; + sb.AppendLineLf($" public const string {constantName} = \"{EscapeString(display.Description)}\";"); + hasContent = true; + } + + if (display.ShortName is not null) + { + // Use DisplayShortName to avoid conflict when property is named "ShortName" + var constantName = propertyName == "ShortName" ? "DisplayShortName" : "ShortName"; + sb.AppendLineLf($" public const string {constantName} = \"{EscapeString(display.ShortName)}\";"); + hasContent = true; + } + + if (display.GroupName is not null) + { + // Use DisplayGroupName to avoid conflict when property is named "GroupName" + var constantName = propertyName == "GroupName" ? "DisplayGroupName" : "GroupName"; + sb.AppendLineLf($" public const string {constantName} = \"{EscapeString(display.GroupName)}\";"); + hasContent = true; + } + + if (display.Prompt is not null) + { + // Use DisplayPrompt to avoid conflict when property is named "Prompt" + var constantName = propertyName == "Prompt" ? "DisplayPrompt" : "Prompt"; + sb.AppendLineLf($" public const string {constantName} = \"{EscapeString(display.Prompt)}\";"); + hasContent = true; + } + + if (display.Order.HasValue) + { + // Use DisplayOrder to avoid conflict when property is named "Order" + var constantName = propertyName == "Order" ? "DisplayOrder" : "Order"; + sb.AppendLineLf($" public const int {constantName} = {display.Order.Value};"); + hasContent = true; + } + + return hasContent; + } + + [SuppressMessage("Style", "ATC203:Method chains with 2 or more calls should be placed on separate lines", Justification = "OK.")] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - validation constant generation")] + private static void GenerateMicrosoftValidationConstants( + StringBuilder sb, + ValidationAnnotationInfo validation) + { + // Required + if (validation.IsRequired) + { + sb.AppendLineLf(" public const bool IsRequired = true;"); + } + + if (validation.AllowEmptyStrings.HasValue) + { + sb.AppendLineLf($" public const bool AllowEmptyStrings = {validation.AllowEmptyStrings.Value.ToString().ToLowerInvariant()};"); + } + + if (validation.RequiredErrorMessage is not null) + { + sb.AppendLineLf($" public const string RequiredErrorMessage = \"{EscapeString(validation.RequiredErrorMessage)}\";"); + } + + // Length constraints + if (validation.MinLength.HasValue) + { + sb.AppendLineLf($" public const int MinimumLength = {validation.MinLength.Value};"); + } + + if (validation.MaxLength.HasValue) + { + sb.AppendLineLf($" public const int MaximumLength = {validation.MaxLength.Value};"); + } + + if (validation.StringLengthErrorMessage is not null) + { + sb.AppendLineLf($" public const string StringLengthErrorMessage = \"{EscapeString(validation.StringLengthErrorMessage)}\";"); + } + + // Range + if (validation.RangeMinimum is not null) + { + sb.AppendLineLf($" public const string Minimum = \"{EscapeString(validation.RangeMinimum)}\";"); + } + + if (validation.RangeMaximum is not null) + { + sb.AppendLineLf($" public const string Maximum = \"{EscapeString(validation.RangeMaximum)}\";"); + } + + if (validation.RangeOperandType is not null) + { + sb.AppendLineLf($" public static readonly global::System.Type OperandType = typeof({validation.RangeOperandType});"); + } + + if (validation.RangeErrorMessage is not null) + { + sb.AppendLineLf($" public const string RangeErrorMessage = \"{EscapeString(validation.RangeErrorMessage)}\";"); + } + + // RegularExpression + if (validation.RegularExpressionPattern is not null) + { + sb.AppendLineLf($" public const string Pattern = @\"{EscapeVerbatimString(validation.RegularExpressionPattern)}\";"); + } + + if (validation.RegularExpressionErrorMessage is not null) + { + sb.AppendLineLf($" public const string RegularExpressionErrorMessage = \"{EscapeString(validation.RegularExpressionErrorMessage)}\";"); + } + + // Data type flags + if (validation.IsEmailAddress) + { + sb.AppendLineLf(" public const bool IsEmailAddress = true;"); + } + + if (validation.IsPhone) + { + sb.AppendLineLf(" public const bool IsPhone = true;"); + } + + if (validation.IsUrl) + { + sb.AppendLineLf(" public const bool IsUrl = true;"); + } + + if (validation.IsCreditCard) + { + sb.AppendLineLf(" public const bool IsCreditCard = true;"); + } + + if (validation.DataType.HasValue) + { + sb.AppendLineLf($" public const int DataType = {validation.DataType.Value};"); + } + + // Compare + if (validation.CompareOtherProperty is not null) + { + sb.AppendLineLf($" public const string CompareProperty = \"{EscapeString(validation.CompareOtherProperty)}\";"); + } + + // Metadata + if (validation.IsKey) + { + sb.AppendLineLf(" public const bool IsKey = true;"); + } + + if (validation.IsEditable.HasValue) + { + sb.AppendLineLf($" public const bool IsEditable = {validation.IsEditable.Value.ToString().ToLowerInvariant()};"); + } + + if (validation.IsScaffoldColumn.HasValue) + { + sb.AppendLineLf($" public const bool IsScaffoldColumn = {validation.IsScaffoldColumn.Value.ToString().ToLowerInvariant()};"); + } + + if (validation.IsTimestamp) + { + sb.AppendLineLf(" public const bool IsTimestamp = true;"); + } + } + + [SuppressMessage("Style", "ATC203:Method chains with 2 or more calls should be placed on separate lines", Justification = "OK.")] + [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", Justification = "OK - Atc constant generation")] + private static void GenerateAtcConstants( + StringBuilder sb, + AtcAnnotationInfo atc) + { + // IgnoreDisplayAttribute + if (atc.IsIgnoreDisplay) + { + sb.AppendLineLf(" public const bool IsIgnoreDisplay = true;"); + } + + // EnumGuidAttribute + if (atc.EnumGuid is not null) + { + sb.AppendLineLf($" public const string EnumGuid = \"{EscapeString(atc.EnumGuid)}\";"); + } + + // CasingStyleDescriptionAttribute + if (atc.CasingStyleDefault is not null) + { + sb.AppendLineLf($" public const string CasingStyleDefault = \"{EscapeString(atc.CasingStyleDefault)}\";"); + } + + if (atc.CasingStylePrefix is not null) + { + sb.AppendLineLf($" public const string CasingStylePrefix = \"{EscapeString(atc.CasingStylePrefix)}\";"); + } + + // IPAddressAttribute + if (atc.IsIPAddress) + { + sb.AppendLineLf(" public const bool IsIPAddress = true;"); + if (atc.IPAddressRequired.HasValue) + { + sb.AppendLineLf($" public const bool IPAddressRequired = {atc.IPAddressRequired.Value.ToString().ToLowerInvariant()};"); + } + } + + // IsoCurrencySymbolAttribute + if (atc.IsIsoCurrencySymbol) + { + sb.AppendLineLf(" public const bool IsIsoCurrencySymbol = true;"); + if (atc.IsoCurrencySymbolRequired.HasValue) + { + sb.AppendLineLf($" public const bool IsoCurrencySymbolRequired = {atc.IsoCurrencySymbolRequired.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.IsoCurrencySymbols is { Length: > 0 }) + { + var symbols = string.Join("\", \"", atc.IsoCurrencySymbols.Select(EscapeString)); + sb.AppendLineLf($" public static readonly string[] AllowedIsoCurrencySymbols = new[] {{ \"{symbols}\" }};"); + } + } + + // StringAttribute / KeyStringAttribute + if (atc.IsAtcString) + { + sb.AppendLineLf(" public const bool IsAtcString = true;"); + + if (atc.IsKeyString) + { + sb.AppendLineLf(" public const bool IsKeyString = true;"); + } + + if (atc.StringRequired.HasValue) + { + sb.AppendLineLf($" public const bool AtcStringRequired = {atc.StringRequired.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.StringMinLength.HasValue) + { + sb.AppendLineLf($" public const uint AtcStringMinLength = {atc.StringMinLength.Value};"); + } + + if (atc.StringMaxLength.HasValue) + { + sb.AppendLineLf($" public const uint AtcStringMaxLength = {atc.StringMaxLength.Value};"); + } + + if (atc.StringRegularExpression is not null) + { + sb.AppendLineLf($" public const string AtcStringRegularExpression = @\"{EscapeVerbatimString(atc.StringRegularExpression)}\";"); + } + + if (atc.StringInvalidCharacters is { Length: > 0 }) + { + var chars = string.Join("', '", atc.StringInvalidCharacters.Select(c => EscapeChar(c))); + sb.AppendLineLf($" public static readonly char[] AtcStringInvalidCharacters = new[] {{ '{chars}' }};"); + } + + if (atc.StringInvalidPrefixStrings is { Length: > 0 }) + { + var prefixes = string.Join("\", \"", atc.StringInvalidPrefixStrings.Select(EscapeString)); + sb.AppendLineLf($" public static readonly string[] AtcStringInvalidPrefixStrings = new[] {{ \"{prefixes}\" }};"); + } + } + + // UriAttribute + if (atc.IsAtcUri) + { + sb.AppendLineLf(" public const bool IsAtcUri = true;"); + + if (atc.UriRequired.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriRequired = {atc.UriRequired.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowHttp.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowHttp = {atc.UriAllowHttp.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowHttps.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowHttps = {atc.UriAllowHttps.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowFtp.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowFtp = {atc.UriAllowFtp.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowFtps.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowFtps = {atc.UriAllowFtps.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowFile.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowFile = {atc.UriAllowFile.Value.ToString().ToLowerInvariant()};"); + } + + if (atc.UriAllowOpcTcp.HasValue) + { + sb.AppendLineLf($" public const bool AtcUriAllowOpcTcp = {atc.UriAllowOpcTcp.Value.ToString().ToLowerInvariant()};"); + } + } + } + + private static string EscapeChar(char c) + => c switch + { + '\\' => @"\\", + '\'' => "\\'", + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + _ => c.ToString(), + }; + + private static string EscapeString(string value) + => value + .Replace("\\", @"\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + + // In verbatim strings, only double quotes need escaping (by doubling) + private static string EscapeVerbatimString(string value) + => value.Replace("\"", "\"\""); +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/AnnotationGeneratorConfig.cs b/src/Atc.SourceGenerators/Generators/Internal/AnnotationGeneratorConfig.cs new file mode 100644 index 0000000..b062b49 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/AnnotationGeneratorConfig.cs @@ -0,0 +1,8 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Configuration options for the AnnotationConstantsGenerator. +/// Must implement proper equality for incremental generation caching. +/// +internal readonly record struct AnnotationGeneratorConfig( + bool IncludeUnannotatedProperties); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationBuilder.cs b/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationBuilder.cs new file mode 100644 index 0000000..bf609be --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationBuilder.cs @@ -0,0 +1,109 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Builder class for creating AtcAnnotationInfo incrementally. +/// +internal sealed class AtcAnnotationBuilder +{ + // IgnoreDisplayAttribute + public bool IsIgnoreDisplay { get; set; } + + // EnumGuidAttribute + public string? EnumGuid { get; set; } + + // CasingStyleDescriptionAttribute + public string? CasingStyleDefault { get; set; } + + public string? CasingStylePrefix { get; set; } + + // IPAddressAttribute + public bool IsIPAddress { get; set; } + + public bool? IPAddressRequired { get; set; } + + // IsoCurrencySymbolAttribute + public bool IsIsoCurrencySymbol { get; set; } + + public bool? IsoCurrencySymbolRequired { get; set; } + + public string[]? IsoCurrencySymbols { get; set; } + + // StringAttribute (and KeyStringAttribute) + public bool IsAtcString { get; set; } + + public bool IsKeyString { get; set; } + + public bool? StringRequired { get; set; } + + public uint? StringMinLength { get; set; } + + public uint? StringMaxLength { get; set; } + + public char[]? StringInvalidCharacters { get; set; } + + public string[]? StringInvalidPrefixStrings { get; set; } + + public string? StringRegularExpression { get; set; } + + // UriAttribute + public bool IsAtcUri { get; set; } + + public bool? UriRequired { get; set; } + + public bool? UriAllowHttp { get; set; } + + public bool? UriAllowHttps { get; set; } + + public bool? UriAllowFtp { get; set; } + + public bool? UriAllowFtps { get; set; } + + public bool? UriAllowFile { get; set; } + + public bool? UriAllowOpcTcp { get; set; } + + /// + /// Gets a value indicating whether any Atc annotation has been set. + /// + public bool HasAnyValue => + IsIgnoreDisplay || + EnumGuid is not null || + CasingStyleDefault is not null || + CasingStylePrefix is not null || + IsIPAddress || + IsIsoCurrencySymbol || + IsAtcString || + IsKeyString || + IsAtcUri; + + /// + /// Builds the immutable AtcAnnotationInfo from accumulated values. + /// + public AtcAnnotationInfo Build() => + new( + IsIgnoreDisplay: IsIgnoreDisplay, + EnumGuid: EnumGuid, + CasingStyleDefault: CasingStyleDefault, + CasingStylePrefix: CasingStylePrefix, + IsIPAddress: IsIPAddress, + IPAddressRequired: IPAddressRequired, + IsIsoCurrencySymbol: IsIsoCurrencySymbol, + IsoCurrencySymbolRequired: IsoCurrencySymbolRequired, + IsoCurrencySymbols: IsoCurrencySymbols, + IsAtcString: IsAtcString, + IsKeyString: IsKeyString, + StringRequired: StringRequired, + StringMinLength: StringMinLength, + StringMaxLength: StringMaxLength, + StringInvalidCharacters: StringInvalidCharacters, + StringInvalidPrefixStrings: StringInvalidPrefixStrings, + StringRegularExpression: StringRegularExpression, + IsAtcUri: IsAtcUri, + UriRequired: UriRequired, + UriAllowHttp: UriAllowHttp, + UriAllowHttps: UriAllowHttps, + UriAllowFtp: UriAllowFtp, + UriAllowFtps: UriAllowFtps, + UriAllowFile: UriAllowFile, + UriAllowOpcTcp: UriAllowOpcTcp); +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationInfo.cs new file mode 100644 index 0000000..9ecaa98 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/AtcAnnotationInfo.cs @@ -0,0 +1,46 @@ +// ReSharper disable InconsistentNaming +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Atc-specific annotation information extracted from Atc attributes. +/// +internal sealed record AtcAnnotationInfo( + + // IgnoreDisplayAttribute + bool IsIgnoreDisplay, + + // EnumGuidAttribute + string? EnumGuid, + + // CasingStyleDescriptionAttribute + string? CasingStyleDefault, + string? CasingStylePrefix, + + // IPAddressAttribute + bool IsIPAddress, + bool? IPAddressRequired, + + // IsoCurrencySymbolAttribute + bool IsIsoCurrencySymbol, + bool? IsoCurrencySymbolRequired, + string[]? IsoCurrencySymbols, + + // StringAttribute (and KeyStringAttribute) + bool IsAtcString, + bool IsKeyString, + bool? StringRequired, + uint? StringMinLength, + uint? StringMaxLength, + char[]? StringInvalidCharacters, + string[]? StringInvalidPrefixStrings, + string? StringRegularExpression, + + // UriAttribute + bool IsAtcUri, + bool? UriRequired, + bool? UriAllowHttp, + bool? UriAllowHttps, + bool? UriAllowFtp, + bool? UriAllowFtps, + bool? UriAllowFile, + bool? UriAllowOpcTcp); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/DisplayAnnotationInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/DisplayAnnotationInfo.cs new file mode 100644 index 0000000..48bc080 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/DisplayAnnotationInfo.cs @@ -0,0 +1,12 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Display attribute information extracted from DisplayAttribute. +/// +internal sealed record DisplayAnnotationInfo( + string? Name, + string? Description, + string? ShortName, + string? GroupName, + string? Prompt, + int? Order); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/PropertyAnnotationInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/PropertyAnnotationInfo.cs new file mode 100644 index 0000000..91ddd4c --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/PropertyAnnotationInfo.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Represents extracted annotation data for a single property. +/// +internal sealed record PropertyAnnotationInfo( + string PropertyName, + DisplayAnnotationInfo? Display, + ValidationAnnotationInfo? MicrosoftValidation, + AtcAnnotationInfo? AtcAnnotation); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/TypeAnnotationInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/TypeAnnotationInfo.cs new file mode 100644 index 0000000..c89b29f --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/TypeAnnotationInfo.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Represents a class/record with annotated properties. +/// +internal sealed record TypeAnnotationInfo( + string TypeName, + string Namespace, + string AssemblyName, + ImmutableArray Properties); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationBuilder.cs b/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationBuilder.cs new file mode 100644 index 0000000..e0ec829 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationBuilder.cs @@ -0,0 +1,106 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Builder class for creating ValidationAnnotationInfo incrementally. +/// +internal sealed class ValidationAnnotationBuilder +{ + public bool IsRequired { get; set; } + + public bool? AllowEmptyStrings { get; set; } + + public string? RequiredErrorMessage { get; set; } + + public int? MinLength { get; set; } + + public int? MaxLength { get; set; } + + public string? StringLengthErrorMessage { get; set; } + + public string? RangeMinimum { get; set; } + + public string? RangeMaximum { get; set; } + + public string? RangeOperandType { get; set; } + + public string? RangeErrorMessage { get; set; } + + public string? RegularExpressionPattern { get; set; } + + public string? RegularExpressionErrorMessage { get; set; } + + public bool IsEmailAddress { get; set; } + + public bool IsPhone { get; set; } + + public bool IsUrl { get; set; } + + public bool IsCreditCard { get; set; } + + public int? DataType { get; set; } + + public string? CompareOtherProperty { get; set; } + + public bool IsKey { get; set; } + + public bool? IsEditable { get; set; } + + public bool? IsScaffoldColumn { get; set; } + + public bool IsTimestamp { get; set; } + + /// + /// Gets a value indicating whether any validation annotation has been set. + /// + public bool HasAnyValue => + IsRequired || + AllowEmptyStrings.HasValue || + RequiredErrorMessage is not null || + MinLength.HasValue || + MaxLength.HasValue || + StringLengthErrorMessage is not null || + RangeMinimum is not null || + RangeMaximum is not null || + RangeOperandType is not null || + RangeErrorMessage is not null || + RegularExpressionPattern is not null || + RegularExpressionErrorMessage is not null || + IsEmailAddress || + IsPhone || + IsUrl || + IsCreditCard || + DataType.HasValue || + CompareOtherProperty is not null || + IsKey || + IsEditable.HasValue || + IsScaffoldColumn.HasValue || + IsTimestamp; + + /// + /// Builds the immutable ValidationAnnotationInfo from accumulated values. + /// + public ValidationAnnotationInfo Build() => + new( + IsRequired: IsRequired, + AllowEmptyStrings: AllowEmptyStrings, + RequiredErrorMessage: RequiredErrorMessage, + MinLength: MinLength, + MaxLength: MaxLength, + StringLengthErrorMessage: StringLengthErrorMessage, + RangeMinimum: RangeMinimum, + RangeMaximum: RangeMaximum, + RangeOperandType: RangeOperandType, + RangeErrorMessage: RangeErrorMessage, + RegularExpressionPattern: RegularExpressionPattern, + RegularExpressionErrorMessage: RegularExpressionErrorMessage, + IsEmailAddress: IsEmailAddress, + IsPhone: IsPhone, + IsUrl: IsUrl, + IsCreditCard: IsCreditCard, + DataType: DataType, + CompareOtherProperty: CompareOtherProperty, + IsKey: IsKey, + IsEditable: IsEditable, + IsScaffoldColumn: IsScaffoldColumn, + IsTimestamp: IsTimestamp); +} \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationInfo.cs new file mode 100644 index 0000000..4764da1 --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/ValidationAnnotationInfo.cs @@ -0,0 +1,28 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Combined validation attribute information from various DataAnnotation attributes. +/// +internal sealed record ValidationAnnotationInfo( + bool IsRequired, + bool? AllowEmptyStrings, + string? RequiredErrorMessage, + int? MinLength, + int? MaxLength, + string? StringLengthErrorMessage, + string? RangeMinimum, + string? RangeMaximum, + string? RangeOperandType, + string? RangeErrorMessage, + string? RegularExpressionPattern, + string? RegularExpressionErrorMessage, + bool IsEmailAddress, + bool IsPhone, + bool IsUrl, + bool IsCreditCard, + int? DataType, + string? CompareOtherProperty, + bool IsKey, + bool? IsEditable, + bool? IsScaffoldColumn, + bool IsTimestamp); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs b/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs index d3c454a..73bbb98 100644 --- a/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs +++ b/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs @@ -1,6 +1,7 @@ -// ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator // ReSharper disable InvertIf // ReSharper disable LoopCanBeConvertedToQuery +// ReSharper disable ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator +// ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator namespace Atc.SourceGenerators.Helpers; /// diff --git a/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorBasicTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorBasicTests.cs new file mode 100644 index 0000000..ff49d0f --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorBasicTests.cs @@ -0,0 +1,228 @@ +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +public partial class AnnotationConstantsGeneratorTests +{ + [Fact] + public void Generator_Should_Generate_Constants_For_Class_With_Required_Attribute() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + 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 Product", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Name", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Constants_For_Record_With_Required_Attribute() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public record Product + { + [Required] + public string Name { get; init; } = 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 Name", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Generate_For_Class_Without_Annotations() + { + const string source = """ + namespace TestNamespace; + + public class Product + { + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("AnnotationConstants", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Generate_For_Properties_Without_Supported_Attributes() + { + const string source = """ + using System; + + namespace TestNamespace; + + [AttributeUsage(AttributeTargets.Property)] + public class CustomAttribute : Attribute { } + + public class Product + { + [Custom] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("AnnotationConstants", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_For_Multiple_Properties() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + [Required] + [Range(0.01, 999999.99)] + public decimal Price { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public static partial class Name", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Price", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Correct_Namespace() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace MyCompany.MyProduct.Models; + + public class Customer + { + [Required] + public string Email { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("namespace MyCompany.MyProduct.Models;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Multiple_Types_In_Same_Namespace() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + } + + public class Customer + { + [Required] + public string Email { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public static partial class Product", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Customer", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Include_Generated_Code_Attributes() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("[global::System.CodeDom.Compiler.GeneratedCode", output, StringComparison.Ordinal); + Assert.Contains("[global::System.ComponentModel.EditorBrowsable", 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_Cross_Namespace_Types() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace Domain.Models + { + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + } + } + + namespace Api.Contracts + { + public class ProductDto + { + [Required] + public string Name { get; set; } = string.Empty; + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("namespace Domain.Models;", output, StringComparison.Ordinal); + Assert.Contains("namespace Api.Contracts;", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorConfigTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorConfigTests.cs new file mode 100644 index 0000000..e7941af --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorConfigTests.cs @@ -0,0 +1,178 @@ +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +/// +/// Tests for configuration behavior of AnnotationConstantsGenerator. +/// Note: The generator supports configuration via .editorconfig: +/// atc_annotation_constants.include_unannotated_properties = true|false +/// Default behavior (false): Only properties with DataAnnotation attributes are included. +/// +public partial class AnnotationConstantsGeneratorTests +{ + [Fact] + public void Generator_Should_Only_Include_Annotated_Properties_By_Default() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + [Range(0.01, 999999.99)] + public decimal Price { get; set; } + + public string Sku { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Annotated properties should be included + Assert.Contains("public static partial class Name", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Price", output, StringComparison.Ordinal); + + // Unannotated properties should NOT be included by default + Assert.DoesNotContain("public static partial class Description", output, StringComparison.Ordinal); + Assert.DoesNotContain("public static partial class Sku", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Not_Generate_When_No_Properties_Have_Supported_Annotations() + { + const string source = """ + using System; + + namespace TestNamespace; + + [AttributeUsage(AttributeTargets.Property)] + public class MyCustomAttribute : Attribute { } + + public class Product + { + [MyCustom] + public string Name { get; set; } = string.Empty; + + public decimal Price { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.DoesNotContain("AnnotationConstants", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Ignore_Properties_With_Only_Non_DataAnnotation_Attributes() + { + const string source = """ + using System; + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + [AttributeUsage(AttributeTargets.Property)] + public class CustomAttribute : Attribute { } + + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + + [Custom] + public string Description { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Name with Required should be included + Assert.Contains("public static partial class Name", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + + // Description with only custom attribute should NOT be included + Assert.DoesNotContain("public static partial class Description", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Separate_Files_Per_Type() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required] + public string Name { get; set; } = string.Empty; + } + + public class Customer + { + [EmailAddress] + public string Email { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Both types should have their own AnnotationConstants class + Assert.Contains("public static partial class Product", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Customer", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Partial_Class_Declaration() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public partial class Product + { + [Required] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public static partial class AnnotationConstants", output, StringComparison.Ordinal); + Assert.Contains("public static partial class Product", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Nested_Types() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Outer + { + [Required] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public static partial class Outer", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorDisplayTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorDisplayTests.cs new file mode 100644 index 0000000..d098177 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorDisplayTests.cs @@ -0,0 +1,184 @@ +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +public partial class AnnotationConstantsGeneratorTests +{ + [Fact] + public void Generator_Should_Generate_Display_Name_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Name = "Product Name")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string DisplayName = \"Product Name\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Display_Description_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Description = "The name of the product")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string Description = \"The name of the product\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Display_ShortName_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(ShortName = "Name")] + public string ProductName { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string ShortName = \"Name\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Display_GroupName_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(GroupName = "Basic Info")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string GroupName = \"Basic Info\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Display_Prompt_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Prompt = "Enter product name")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string Prompt = \"Enter product name\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Display_Order_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Order = 1)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int Order = 1;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_All_Display_Properties() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display( + Name = "Product Name", + Description = "The product name", + ShortName = "Name", + GroupName = "Basic", + Prompt = "Enter name", + Order = 1)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string DisplayName = \"Product Name\";", output, StringComparison.Ordinal); + Assert.Contains("public const string Description = \"The product name\";", output, StringComparison.Ordinal); + Assert.Contains("public const string ShortName = \"Name\";", output, StringComparison.Ordinal); + Assert.Contains("public const string GroupName = \"Basic\";", output, StringComparison.Ordinal); + Assert.Contains("public const string Prompt = \"Enter name\";", output, StringComparison.Ordinal); + Assert.Contains("public const int Order = 1;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Escape_Special_Characters_In_Display_Values() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Name = "Product \"Name\"", Description = "Line1\nLine2")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string DisplayName = \"Product \\\"Name\\\"\";", output, StringComparison.Ordinal); + Assert.Contains("public const string Description = \"Line1\\nLine2\";", output, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorTests.cs new file mode 100644 index 0000000..01802ff --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorTests.cs @@ -0,0 +1,58 @@ +// ReSharper disable RedundantAssignment +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +public partial class AnnotationConstantsGeneratorTests +{ + [SuppressMessage("", "S1854:Remove this useless assignment to local variable 'driver'", Justification = "OK")] + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutput( + string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + // Get references from loaded assemblies + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && + !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast() + .ToList(); + + // Explicitly add System.ComponentModel.DataAnnotations reference + var dataAnnotationsAssembly = typeof(System.ComponentModel.DataAnnotations.RequiredAttribute).Assembly; + if (!string.IsNullOrWhiteSpace(dataAnnotationsAssembly.Location)) + { + references.Add(MetadataReference.CreateFromFile(dataAnnotationsAssembly.Location)); + } + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [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/AnnotationConstants/AnnotationConstantsGeneratorValidationTests.cs b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorValidationTests.cs new file mode 100644 index 0000000..976bc3a --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/AnnotationConstants/AnnotationConstantsGeneratorValidationTests.cs @@ -0,0 +1,507 @@ +namespace Atc.SourceGenerators.Tests.Generators.AnnotationConstants; + +public partial class AnnotationConstantsGeneratorTests +{ + [Fact] + public void Generator_Should_Generate_Required_With_AllowEmptyStrings() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required(AllowEmptyStrings = true)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const bool AllowEmptyStrings = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Required_With_ErrorMessage() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Required(ErrorMessage = "Name is required")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const string RequiredErrorMessage = \"Name is required\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_StringLength_Constants() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [StringLength(100, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int MaximumLength = 100;", output, StringComparison.Ordinal); + Assert.Contains("public const int MinimumLength = 1;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_StringLength_With_ErrorMessage() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [StringLength(100, ErrorMessage = "Name must be under 100 characters")] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int MaximumLength = 100;", output, StringComparison.Ordinal); + Assert.Contains("public const string StringLengthErrorMessage = \"Name must be under 100 characters\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_MinLength_Constants() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [MinLength(5)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int MinimumLength = 5;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_MaxLength_Constants() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [MaxLength(200)] + public string Description { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int MaximumLength = 200;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Range_Constants_With_Int() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Range(1, 100)] + public int Quantity { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string Minimum = \"1\";", output, StringComparison.Ordinal); + Assert.Contains("public const string Maximum = \"100\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Range_Constants_With_Double() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Range(0.01, 999999.99)] + public double Price { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Double values may have floating-point representation differences + Assert.Contains("public const string Minimum = ", output, StringComparison.Ordinal); + Assert.Contains("public const string Maximum = ", output, StringComparison.Ordinal); + + // Verify the output contains the property class + Assert.Contains("public static partial class Price", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Range_Constants_With_Typed_Overload() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Range(typeof(decimal), "0.01", "999999.99")] + public decimal Price { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string Minimum = \"0.01\";", output, StringComparison.Ordinal); + Assert.Contains("public const string Maximum = \"999999.99\";", output, StringComparison.Ordinal); + Assert.Contains("public static readonly global::System.Type OperandType = typeof(decimal);", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Range_With_ErrorMessage() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Range(1, 100, ErrorMessage = "Quantity must be between 1 and 100")] + public int Quantity { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string RangeErrorMessage = \"Quantity must be between 1 and 100\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_RegularExpression_Constants() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [RegularExpression(@"^[A-Z]{3}\d{4}$")] + public string Sku { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string Pattern = @\"^[A-Z]{3}\\d{4}$\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_RegularExpression_With_ErrorMessage() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [RegularExpression(@"^[A-Z]{3}\d{4}$", ErrorMessage = "Invalid SKU format")] + public string Sku { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string RegularExpressionErrorMessage = \"Invalid SKU format\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_EmailAddress_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Customer + { + [EmailAddress] + public string Email { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsEmailAddress = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Phone_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Customer + { + [Phone] + public string PhoneNumber { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsPhone = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Url_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Website + { + [Url] + public string Homepage { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsUrl = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_CreditCard_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Payment + { + [CreditCard] + public string CardNumber { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsCreditCard = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Compare_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class ChangePassword + { + public string NewPassword { get; set; } = string.Empty; + + [Compare("NewPassword")] + public string ConfirmPassword { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string CompareProperty = \"NewPassword\";", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Key_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Entity + { + [Key] + public int Id { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsKey = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Editable_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Entity + { + [Editable(false)] + public int Id { get; set; } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsEditable = false;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_ScaffoldColumn_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Entity + { + [ScaffoldColumn(false)] + public string InternalId { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsScaffoldColumn = false;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_Timestamp_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Entity + { + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const bool IsTimestamp = true;", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Generate_DataType_Constant() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class User + { + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const int DataType = ", output, StringComparison.Ordinal); + } + + [Fact] + public void Generator_Should_Handle_Multiple_Validation_Attributes() + { + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace TestNamespace; + + public class Product + { + [Display(Name = "Product Name")] + [Required] + [StringLength(100, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("public const string DisplayName = \"Product Name\";", output, StringComparison.Ordinal); + Assert.Contains("public const bool IsRequired = true;", output, StringComparison.Ordinal); + Assert.Contains("public const int MaximumLength = 100;", output, StringComparison.Ordinal); + Assert.Contains("public const int MinimumLength = 1;", output, StringComparison.Ordinal); + } +} \ No newline at end of file