diff --git a/.editorconfig b/.editorconfig index 9591495..9eb4d9b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -90,8 +90,8 @@ dotnet_style_predefined_type_for_locals_parameters_members = true:warning dotnet_style_predefined_type_for_member_access = true:warning # Modifier preferences dotnet_style_require_accessibility_modifiers = always:warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning -visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:warning +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:warning +visual_basic_preferred_modifier_order = Partial, Default, Private, Protected, Public, Friend, NotOverridable, Overridable, MustOverride, Overloads, Overrides, MustInherit, NotInheritable, Static, Shared, Shadows, ReadOnly, WriteOnly, Dim, Const, WithEvents, Widening, Narrowing, Custom, Async:warning dotnet_style_readonly_field = true:warning # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning @@ -272,11 +272,11 @@ dotnet_naming_style.prefix_interface_with_i_style.required_prefix = I dotnet_naming_style.prefix_type_parameters_with_t_style.capitalization = pascal_case dotnet_naming_style.prefix_type_parameters_with_t_style.required_prefix = T # disallowed_style - Anything that has this style applied is marked as disallowed -dotnet_naming_style.disallowed_style.capitalization = pascal_case +dotnet_naming_style.disallowed_style.capitalization = pascal_case dotnet_naming_style.disallowed_style.required_prefix = ____RULE_VIOLATION____ dotnet_naming_style.disallowed_style.required_suffix = ____RULE_VIOLATION____ # internal_error_style - This style should never occur... if it does, it indicates a bug in file or in the parser using the file -dotnet_naming_style.internal_error_style.capitalization = pascal_case +dotnet_naming_style.internal_error_style.capitalization = pascal_case dotnet_naming_style.internal_error_style.required_prefix = ____INTERNAL_ERROR____ dotnet_naming_style.internal_error_style.required_suffix = ____INTERNAL_ERROR____ @@ -289,28 +289,28 @@ dotnet_naming_style.internal_error_style.required_suffix = ____INTERNAL_ERROR___ # All public/protected/protected_internal constant fields must be PascalCase # https://docs.microsoft.com/dotnet/standard/design-guidelines/field dotnet_naming_symbols.public_protected_constant_fields_group.applicable_accessibilities = public, protected, protected_internal -dotnet_naming_symbols.public_protected_constant_fields_group.required_modifiers = const -dotnet_naming_symbols.public_protected_constant_fields_group.applicable_kinds = field -dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.symbols = public_protected_constant_fields_group -dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_symbols.public_protected_constant_fields_group.required_modifiers = const +dotnet_naming_symbols.public_protected_constant_fields_group.applicable_kinds = field +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.symbols = public_protected_constant_fields_group +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.style = pascal_case_style +dotnet_naming_rule.public_protected_constant_fields_must_be_pascal_case_rule.severity = warning # All public/protected/protected_internal static readonly fields must be PascalCase # https://docs.microsoft.com/dotnet/standard/design-guidelines/field dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_accessibilities = public, protected, protected_internal -dotnet_naming_symbols.public_protected_static_readonly_fields_group.required_modifiers = static, readonly -dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_kinds = field -dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.symbols = public_protected_static_readonly_fields_group -dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_symbols.public_protected_static_readonly_fields_group.required_modifiers = static, readonly +dotnet_naming_symbols.public_protected_static_readonly_fields_group.applicable_kinds = field +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.symbols = public_protected_static_readonly_fields_group +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style +dotnet_naming_rule.public_protected_static_readonly_fields_must_be_pascal_case_rule.severity = warning # No other public/protected/protected_internal fields are allowed # https://docs.microsoft.com/dotnet/standard/design-guidelines/field dotnet_naming_symbols.other_public_protected_fields_group.applicable_accessibilities = public, protected, protected_internal -dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = field -dotnet_naming_rule.other_public_protected_fields_disallowed_rule.symbols = other_public_protected_fields_group -dotnet_naming_rule.other_public_protected_fields_disallowed_rule.style = disallowed_style -dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity = error +dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = field +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.symbols = other_public_protected_fields_group +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.style = disallowed_style +dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity = error ########################################## # StyleCop Field Naming Rules @@ -322,52 +322,52 @@ dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity # All constant fields must be PascalCase # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1303.md dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private -dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = pascal_case_style +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning # All static readonly fields must be PascalCase # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1311.md dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private -dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning # No non-private instance fields are allowed # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1401.md dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected -dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.severity = error +dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.severity = error # Private fields must be camelCase # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1306.md dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private -dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning +dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field +dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group +dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style +dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning # Local variables must be camelCase # https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1312.md dotnet_naming_symbols.stylecop_local_fields_group.applicable_accessibilities = local -dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent +dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent # This rule should never fire. However, it's included for at least two purposes: # First, it helps to understand, reason about, and root-case certain types of issues, such as bugs in .editorconfig parsers. # Second, it helps to raise immediate awareness if a new field type is added (as occurred recently in C#). dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_accessibilities = * -dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_kinds = field -dotnet_naming_rule.sanity_check_uncovered_field_case_rule.symbols = sanity_check_uncovered_field_case_group -dotnet_naming_rule.sanity_check_uncovered_field_case_rule.style = internal_error_style +dotnet_naming_symbols.sanity_check_uncovered_field_case_group.applicable_kinds = field +dotnet_naming_rule.sanity_check_uncovered_field_case_rule.symbols = sanity_check_uncovered_field_case_group +dotnet_naming_rule.sanity_check_uncovered_field_case_rule.style = internal_error_style dotnet_naming_rule.sanity_check_uncovered_field_case_rule.severity = error @@ -387,27 +387,27 @@ dotnet_naming_rule.sanity_check_uncovered_field_case_rule.severity = error # - Constructors, Properties, Events, Methods # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-type-members dotnet_naming_symbols.element_group.applicable_kinds = namespace, class, enum, struct, delegate, event, method, property -dotnet_naming_rule.element_rule.symbols = element_group -dotnet_naming_rule.element_rule.style = pascal_case_style -dotnet_naming_rule.element_rule.severity = warning +dotnet_naming_rule.element_rule.symbols = element_group +dotnet_naming_rule.element_rule.style = pascal_case_style +dotnet_naming_rule.element_rule.severity = warning # Interfaces use PascalCase and are prefixed with uppercase 'I' # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces dotnet_naming_symbols.interface_group.applicable_kinds = interface -dotnet_naming_rule.interface_rule.symbols = interface_group -dotnet_naming_rule.interface_rule.style = prefix_interface_with_i_style -dotnet_naming_rule.interface_rule.severity = warning +dotnet_naming_rule.interface_rule.symbols = interface_group +dotnet_naming_rule.interface_rule.style = prefix_interface_with_i_style +dotnet_naming_rule.interface_rule.severity = warning # Generics Type Parameters use PascalCase and are prefixed with uppercase 'T' # https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces dotnet_naming_symbols.type_parameter_group.applicable_kinds = type_parameter -dotnet_naming_rule.type_parameter_rule.symbols = type_parameter_group -dotnet_naming_rule.type_parameter_rule.style = prefix_type_parameters_with_t_style -dotnet_naming_rule.type_parameter_rule.severity = warning +dotnet_naming_rule.type_parameter_rule.symbols = type_parameter_group +dotnet_naming_rule.type_parameter_rule.style = prefix_type_parameters_with_t_style +dotnet_naming_rule.type_parameter_rule.severity = warning # Function parameters use camelCase # https://docs.microsoft.com/dotnet/standard/design-guidelines/naming-parameters dotnet_naming_symbols.parameters_group.applicable_kinds = parameter -dotnet_naming_rule.parameters_rule.symbols = parameters_group -dotnet_naming_rule.parameters_rule.style = camel_case_style -dotnet_naming_rule.parameters_rule.severity = warning \ No newline at end of file +dotnet_naming_rule.parameters_rule.symbols = parameters_group +dotnet_naming_rule.parameters_rule.style = camel_case_style +dotnet_naming_rule.parameters_rule.severity = warning \ No newline at end of file diff --git a/Readme.md b/Readme.md index e780821..cda2b25 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,7 @@ # CompileMapper (TenJames.CompMap) -> CompMap is a C# Roslyn-based source generator that automatically creates mapping methods between classes or records. It simplifies the process of converting one class type to another by generating the necessary code at compile time. +> CompMap is a C# Roslyn-based source generator that automatically creates mapping methods between classes or records. +> It simplifies the process of converting one class type to another by generating the necessary code at compile time. Ensure your DTO's are correctly mapped with compile time safety, to ensure valid changes are tracked in your project. @@ -53,7 +54,6 @@ partial class UserReadDto Which can be generated simply from: - ```csharp using TenJames.CompMap.Attributes; @@ -97,7 +97,8 @@ Install the **TenJames.CompMap** package via NuGet: dotnet add package TenJames.CompMap ``` -The package will automatically be configured as a source generator. If you need to reference it manually in your project file: +The package will automatically be configured as a source generator. If you need to reference it manually in your project +file: ```xml ``` -Note: The `OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"` attributes are typically not required when using `dotnet add package`. +Note: The `OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"` attributes are typically not required when +using `dotnet add package`. ### Component registration @@ -132,10 +134,8 @@ Add Attributes to your DTO classes and then enjoy the generated mapping methods ## Example - See the [Example](./example/Example.Console) project for a complete working example. - ## Contributing Contributions are welcome! Please fork the repository and submit a pull request with your changes. diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs index 8e1b42a..fa1eb56 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs @@ -1,19 +1,16 @@ +namespace TenJames.CompMap.IntegrationTests; + using System; using System.Collections.Generic; using System.Linq; -using TenJames.CompMap.Mappper; +using Mappper; using Xunit; -namespace TenJames.CompMap.IntegrationTests; - public class MappingIntegrationTests { private readonly IMapper _mapper; - public MappingIntegrationTests() - { - _mapper = new BaseMapper(); - } + public MappingIntegrationTests() => _mapper = new BaseMapper(); [Fact] public void ProductReadDto_MapFrom_ShouldMapAllMatchingProperties() @@ -28,8 +25,20 @@ public void ProductReadDto_MapFrom_ShouldMapAllMatchingProperties() var reviews = new List { - new() { Id = 1, Comment = "Great!", Rating = 5, CreatedAt = DateTime.Now }, - new() { Id = 2, Comment = "Good", Rating = 4, CreatedAt = DateTime.Now } + new() + { + Id = 1, + Comment = "Great!", + Rating = 5, + CreatedAt = DateTime.Now + }, + new() + { + Id = 2, + Comment = "Good", + Rating = 4, + CreatedAt = DateTime.Now + } }; var product = new Product @@ -88,7 +97,12 @@ public void ProductReadDto_MapFrom_WithNoStock_ShouldSetIsAvailableToFalse() IsActive = true, InternalNotes = "", ProductGuid = Guid.NewGuid(), - Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Category = new Category + { + Id = 1, + Name = "Test", + Description = "Test" + }, Reviews = new List() }; @@ -105,8 +119,20 @@ public void UserReadDto_MapFrom_ShouldMapAllMatchingProperties() // Arrange var orders = new List { - new() { Id = 1, OrderNumber = "ORD-001", TotalAmount = 100m, CreatedAt = DateTime.Now }, - new() { Id = 2, OrderNumber = "ORD-002", TotalAmount = 200m, CreatedAt = DateTime.Now } + new() + { + Id = 1, + OrderNumber = "ORD-001", + TotalAmount = 100m, + CreatedAt = DateTime.Now + }, + new() + { + Id = 2, + OrderNumber = "ORD-002", + TotalAmount = 200m, + CreatedAt = DateTime.Now + } }; var user = new User @@ -143,7 +169,10 @@ public void UserReadDto_MapFrom_ShouldMapAllMatchingProperties() // Assert - Unmapped properties (computed) Assert.Equal("John Doe", dto.FullName); var expectedAge = DateTime.Now.Year - 1990; - if (DateTime.Now < new DateTime(DateTime.Now.Year, 5, 15)) expectedAge--; + if (DateTime.Now < new DateTime(DateTime.Now.Year, 5, 15)) + { + expectedAge--; + } Assert.Equal(expectedAge, dto.Age); Assert.True(dto.IsAdult); Assert.True(dto.MembershipDuration > 0); @@ -252,8 +281,8 @@ public void ProductCreateDto_MapTo_ShouldCreateProductWithUnmappedProperties() // Assert - Unmapped properties (auto-generated) Assert.Equal(0, product.Id); // Default for new entity - Assert.NotEqual(default(DateTime), product.CreatedAt); - Assert.NotEqual(default(DateTime), product.UpdatedAt); + Assert.NotEqual(default, product.CreatedAt); + Assert.NotEqual(default, product.UpdatedAt); Assert.Equal(string.Empty, product.InternalNotes); Assert.NotEqual(Guid.Empty, product.ProductGuid); Assert.NotNull(product.Reviews); @@ -266,8 +295,20 @@ public void BaseMapper_ShouldMapNestedCollections() // Arrange var reviews = new List { - new() { Id = 1, Comment = "Great!", Rating = 5, CreatedAt = DateTime.Now }, - new() { Id = 2, Comment = "Good", Rating = 4, CreatedAt = DateTime.Now } + new() + { + Id = 1, + Comment = "Great!", + Rating = 5, + CreatedAt = DateTime.Now + }, + new() + { + Id = 2, + Comment = "Good", + Rating = 4, + CreatedAt = DateTime.Now + } }; var product = new Product @@ -283,7 +324,12 @@ public void BaseMapper_ShouldMapNestedCollections() IsActive = true, InternalNotes = "", ProductGuid = Guid.NewGuid(), - Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Category = new Category + { + Id = 1, + Name = "Test", + Description = "Test" + }, Reviews = reviews }; @@ -386,7 +432,12 @@ public void ProductReadDto_WithNoReviews_ShouldSetAverageRatingToZero() IsActive = true, InternalNotes = "", ProductGuid = Guid.NewGuid(), - Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Category = new Category + { + Id = 1, + Name = "Test", + Description = "Test" + }, Reviews = new List() }; @@ -536,7 +587,7 @@ public void NoteCreateDto_MapTo_ShouldCreateNoteRecord() // Assert - Unmapped properties (auto-generated) Assert.Equal(0, note.Id); - Assert.NotEqual(default(DateTime), note.CreatedAt); + Assert.NotEqual(default, note.CreatedAt); } [Fact] @@ -557,4 +608,79 @@ public void Record_MapFrom_ClassEntity_ShouldWork() Assert.Equal(contact.Id, dto.Id); Assert.Equal("Bob Jones", dto.FullName); } + + [Fact] + public void AutoPropertyChain_MapFrom_ShouldFlattenNestedProperties() + { + // Arrange + var company = new Company + { + Id = 1, + Name = "Acme Corp", + Address = new CompanyAddress + { + Street = "123 Main St", + City = "New York", + PostalCode = "10001", + Country = new AddressCountry + { + Name = "United States", + Code = "US" + } + }, + Contact = new ContactPerson + { + FirstName = "John", + LastName = "Doe", + Email = "john.doe@acme.com" + } + }; + + // Act + var dto = CompanyFlatDto.MapFrom(_mapper, company); + + // Assert - Direct properties + Assert.Equal(company.Id, dto.Id); + Assert.Equal(company.Name, dto.Name); + + // Assert - Auto-mapped property chains (1 level deep) + Assert.Equal(company.Address.City, dto.AddressCity); + Assert.Equal(company.Address.Street, dto.AddressStreet); + Assert.Equal(company.Address.PostalCode, dto.AddressPostalCode); + Assert.Equal(company.Contact.FirstName, dto.ContactFirstName); + Assert.Equal(company.Contact.Email, dto.ContactEmail); + + // Assert - Deeply nested property chains (2 levels deep) + Assert.Equal(company.Address.Country.Name, dto.AddressCountryName); + Assert.Equal(company.Address.Country.Code, dto.AddressCountryCode); + } + + [Fact] + public void AutoPropertyChain_MapTo_ShouldUnflattenNestedProperties() + { + // Arrange + var dto = new CompanyWithNestedDto + { + Id = 2, + Name = "Beta Inc", + Address = new NestedAddress + { + City = "Los Angeles", + Street = "456 Oak Ave" + }, + Contact = new NestedContact { Email = "info@beta.com" } + }; + + // Act + var flatCompany = dto.MapTo(_mapper); + + // Assert - Direct properties + Assert.Equal(dto.Id, flatCompany.Id); + Assert.Equal(dto.Name, flatCompany.Name); + + // Assert - Auto-mapped property chains (flattened from nested) + Assert.Equal(dto.Address.City, flatCompany.AddressCity); + Assert.Equal(dto.Address.Street, flatCompany.AddressStreet); + Assert.Equal(dto.Contact.Email, flatCompany.ContactEmail); + } } diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj index b232675..5fe7fe3 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj @@ -1,27 +1,27 @@ - - net10.0 - enable + + net10.0 + enable - false + false - TenJames.CompMap.IntegrationTests - + TenJames.CompMap.IntegrationTests + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs index 1c4ef6e..056c01c 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs @@ -1,11 +1,11 @@ -using System; -using System.Collections.Generic; -using TenJames.CompMap.Attributes; -using TenJames.CompMap.Mappper; - namespace TenJames.CompMap.IntegrationTests; +using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using Attributes; +using Mappper; /// /// DTO for reading product data @@ -15,38 +15,50 @@ namespace TenJames.CompMap.IntegrationTests; public partial class ProductReadDto { public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public int StockQuantity { get; set; } + public string Sku { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public bool IsActive { get; set; } + public Guid ProductGuid { get; set; } + public CategoryDto Category { get; set; } = null!; + public ICollection Reviews { get; set; } = new List(); // Unmapped properties (not in Product entity) public required string DisplayName { get; set; } + public required bool IsAvailable { get; set; } + public required string FormattedPrice { get; set; } + public required int ReviewCount { get; set; } + public required double AverageRating { get; set; } // Implementation of unmapped properties mapping - private static partial ProductUnmappedProperties GetProductUnmappedProperties(IMapper mapper, Product source) - { - return new ProductUnmappedProperties + private static partial ProductUnmappedProperties GetProductUnmappedProperties(IMapper mapper, Product source) => + new() { DisplayName = $"{source.Name} ({source.Sku})", IsAvailable = source.IsActive && source.StockQuantity > 0, - FormattedPrice = $"${source.Price.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}", + FormattedPrice = $"${source.Price.ToString("F2", CultureInfo.InvariantCulture)}", ReviewCount = source.Reviews?.Count ?? 0, AverageRating = source.Reviews?.Count > 0 ? source.Reviews.Average(r => r.Rating) : 0.0 }; - } } /// @@ -56,7 +68,9 @@ private static partial ProductUnmappedProperties GetProductUnmappedProperties(IM public partial class CategoryDto { public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; } @@ -68,20 +82,20 @@ public partial class CategoryDto public partial class ReviewDto { public int Id { get; set; } + public string Comment { get; set; } = string.Empty; + public int Rating { get; set; } + public DateTime CreatedAt { get; set; } // Unmapped property public required string FormattedRating { get; set; } - private static partial ReviewUnmappedProperties GetReviewUnmappedProperties(IMapper mapper, Review source) + private static partial ReviewUnmappedProperties GetReviewUnmappedProperties(IMapper mapper, Review source) => new() { - return new ReviewUnmappedProperties - { - FormattedRating = $"{source.Rating}/5 stars" - }; - } + FormattedRating = $"{source.Rating}/5 stars" + }; } /// @@ -93,28 +107,45 @@ private static partial ReviewUnmappedProperties GetReviewUnmappedProperties(IMap public partial class UserReadDto { public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + public string PhoneNumber { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public bool IsEmailVerified { get; set; } + public ICollection Orders { get; set; } = new List(); // Unmapped properties (computed/derived fields) public required string FullName { get; set; } + public required int Age { get; set; } + public required bool IsAdult { get; set; } + public required int MembershipDuration { get; set; } + public required int TotalOrders { get; set; } + public required string MaskedEmail { get; set; } private static partial UserUnmappedProperties GetUserUnmappedProperties(IMapper mapper, User source) { var age = DateTime.Now.Year - source.DateOfBirth.Year; - if (DateTime.Now < source.DateOfBirth.AddYears(age)) age--; + if (DateTime.Now < source.DateOfBirth.AddYears(age)) + { + age--; + } var membershipDays = (DateTime.Now - source.CreatedAt).Days; @@ -132,9 +163,15 @@ private static partial UserUnmappedProperties GetUserUnmappedProperties(IMapper private static string MaskEmail(string email) { - if (string.IsNullOrEmpty(email)) return string.Empty; + if (string.IsNullOrEmpty(email)) + { + return string.Empty; + } var atIndex = email.IndexOf('@'); - if (atIndex <= 1) return email; + if (atIndex <= 1) + { + return email; + } return $"{email[0]}***{email.Substring(atIndex)}"; } } @@ -146,8 +183,11 @@ private static string MaskEmail(string email) public partial class OrderDto { public int Id { get; set; } + public string OrderNumber { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public DateTime CreatedAt { get; set; } } @@ -159,26 +199,30 @@ public partial class OrderDto public partial class ProductCreateDto { public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public int StockQuantity { get; set; } + public string Sku { get; set; } = string.Empty; + public bool IsActive { get; set; } + public Category Category { get; set; } = null!; // Product has these additional fields that need to be populated - private static partial ProductUnmappedProperties GetProductUnmappedProperties(IMapper mapper, ProductCreateDto source) + private static partial ProductUnmappedProperties GetProductUnmappedProperties(IMapper mapper, + ProductCreateDto source) => new() { - return new ProductUnmappedProperties - { - Id = 0, // Will be set by database - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - InternalNotes = string.Empty, - ProductGuid = Guid.NewGuid(), - Reviews = new List() - }; - } + Id = 0, // Will be set by database + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + InternalNotes = string.Empty, + ProductGuid = Guid.NewGuid(), + Reviews = new List() + }; } /// @@ -189,8 +233,11 @@ private static partial ProductUnmappedProperties GetProductUnmappedProperties(IM public partial class VehicleReadDto : BaseDto { public string Make { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Year { get; set; } + public string Color { get; set; } = string.Empty; } @@ -202,17 +249,15 @@ public partial class VehicleReadDto : BaseDto public partial class VehicleCreateDto : BaseDto { public string Make { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Year { get; set; } + public string Color { get; set; } = string.Empty; - private static partial VehicleUnmappedProperties GetVehicleUnmappedProperties(IMapper mapper, VehicleCreateDto source) - { - return new VehicleUnmappedProperties - { - IsDeleted = false - }; - } + private static partial VehicleUnmappedProperties GetVehicleUnmappedProperties(IMapper mapper, + VehicleCreateDto source) => new() { IsDeleted = false }; } @@ -224,21 +269,20 @@ private static partial VehicleUnmappedProperties GetVehicleUnmappedProperties(IM public partial record ContactReadDto { public int Id { get; init; } + public string FirstName { get; init; } = string.Empty; + public string LastName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string Phone { get; init; } = string.Empty; // Unmapped property (computed) public required string FullName { get; init; } - private static partial ContactUnmappedProperties GetContactUnmappedProperties(IMapper mapper, Contact source) - { - return new ContactUnmappedProperties - { - FullName = $"{source.FirstName} {source.LastName}" - }; - } + private static partial ContactUnmappedProperties GetContactUnmappedProperties(IMapper mapper, Contact source) => + new() { FullName = $"{source.FirstName} {source.LastName}" }; } /// @@ -248,9 +292,13 @@ private static partial ContactUnmappedProperties GetContactUnmappedProperties(IM public partial record AddressReadDto { public int Id { get; init; } + public string Street { get; init; } = string.Empty; + public string City { get; init; } = string.Empty; + public string PostalCode { get; init; } = string.Empty; + public string Country { get; init; } = string.Empty; } @@ -261,14 +309,83 @@ public partial record AddressReadDto public partial record NoteCreateDto { public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; - private static partial NoteUnmappedProperties GetNoteUnmappedProperties(IMapper mapper, NoteCreateDto source) - { - return new NoteUnmappedProperties + private static partial NoteUnmappedProperties GetNoteUnmappedProperties(IMapper mapper, NoteCreateDto source) => + new() { Id = 0, CreatedAt = DateTime.UtcNow }; - } +} + +/// +/// DTO for testing AutoPropertyChain with MapFrom +/// Flattens Company.Address.City to AddressCity, Company.Contact.Email to ContactEmail, etc. +/// +[MapFrom(typeof(Company))] +[AutoPropertyChain] +public partial class CompanyFlatDto +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + // Auto-mapped via property chain: Address.City + public string AddressCity { get; set; } = string.Empty; + + // Auto-mapped via property chain: Address.Street + public string AddressStreet { get; set; } = string.Empty; + + // Auto-mapped via property chain: Address.PostalCode + public string AddressPostalCode { get; set; } = string.Empty; + + // Auto-mapped via property chain: Contact.FirstName + public string ContactFirstName { get; set; } = string.Empty; + + // Auto-mapped via property chain: Contact.Email + public string ContactEmail { get; set; } = string.Empty; + + // Deeply nested: Address.Country.Name (3 levels deep) + public string AddressCountryName { get; set; } = string.Empty; + + // Deeply nested: Address.Country.Code (3 levels deep) + public string AddressCountryCode { get; set; } = string.Empty; +} + +/// +/// DTO for testing AutoPropertyChain with MapTo +/// Maps flat properties back to nested objects +/// +[MapTo(typeof(FlatCompany))] +[AutoPropertyChain] +public partial class CompanyWithNestedDto +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + // These nested properties will be flattened to AddressCity, AddressStreet, ContactEmail + public NestedAddress Address { get; set; } = null!; + + public NestedContact Contact { get; set; } = null!; +} + +/// +/// Nested address for CompanyWithNestedDto +/// +public class NestedAddress +{ + public string City { get; set; } = string.Empty; + + public string Street { get; set; } = string.Empty; +} + +/// +/// Nested contact for CompanyWithNestedDto +/// +public class NestedContact +{ + public string Email { get; set; } = string.Empty; } diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs index 3984614..2b82f05 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs @@ -1,25 +1,37 @@ +namespace TenJames.CompMap.IntegrationTests; + using System; using System.Collections.Generic; -namespace TenJames.CompMap.IntegrationTests; - /// /// Entity class representing a product in the database /// public class Product { public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public int StockQuantity { get; set; } + public string Sku { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsActive { get; set; } + public string InternalNotes { get; set; } = string.Empty; + public Guid ProductGuid { get; set; } + public Category Category { get; set; } = null!; + public ICollection Reviews { get; set; } = new List(); } @@ -29,7 +41,9 @@ public class Product public class Category { public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; } @@ -39,8 +53,11 @@ public class Category public class Review { public int Id { get; set; } + public string Comment { get; set; } = string.Empty; + public int Rating { get; set; } + public DateTime CreatedAt { get; set; } } @@ -50,17 +67,29 @@ public class Review public class User { public int Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateTime DateOfBirth { get; set; } + public string PhoneNumber { get; set; } = string.Empty; + public string Address { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime LastLoginAt { get; set; } + public bool IsEmailVerified { get; set; } + public string PasswordHash { get; set; } = string.Empty; + public ICollection Orders { get; set; } = new List(); } @@ -70,8 +99,11 @@ public class User public class Order { public int Id { get; set; } + public string OrderNumber { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public DateTime CreatedAt { get; set; } } @@ -81,8 +113,11 @@ public class Order public abstract class BaseEntity { public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsDeleted { get; set; } } @@ -92,8 +127,11 @@ public abstract class BaseEntity public class Vehicle : BaseEntity { public string Make { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Year { get; set; } + public string Color { get; set; } = string.Empty; } @@ -103,7 +141,9 @@ public class Vehicle : BaseEntity public abstract class BaseDto { public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } } @@ -113,9 +153,13 @@ public abstract class BaseDto public record Contact { public int Id { get; init; } + public string FirstName { get; init; } = string.Empty; + public string LastName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string Phone { get; init; } = string.Empty; } @@ -125,9 +169,13 @@ public record Contact public record Address { public int Id { get; init; } + public string Street { get; init; } = string.Empty; + public string City { get; init; } = string.Empty; + public string PostalCode { get; init; } = string.Empty; + public string Country { get; init; } = string.Empty; } @@ -137,7 +185,76 @@ public record Address public record Note { public int Id { get; init; } + public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } } + +/// +/// Entity for testing AutoPropertyChain - has nested objects +/// +public class Company +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public CompanyAddress Address { get; set; } = null!; + + public ContactPerson Contact { get; set; } = null!; +} + +/// +/// Nested address for Company +/// +public class CompanyAddress +{ + public string Street { get; set; } = string.Empty; + + public string City { get; set; } = string.Empty; + + public string PostalCode { get; set; } = string.Empty; + + public AddressCountry Country { get; set; } = null!; +} + +/// +/// Deeply nested country for testing recursive property chain +/// +public class AddressCountry +{ + public string Name { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; +} + +/// +/// Nested contact person for Company +/// +public class ContactPerson +{ + public string FirstName { get; set; } = string.Empty; + + public string LastName { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; +} + +/// +/// Flat entity for testing MapTo with AutoPropertyChain +/// +public class FlatCompany +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string AddressCity { get; set; } = string.Empty; + + public string AddressStreet { get; set; } = string.Empty; + + public string ContactEmail { get; set; } = string.Empty; +} diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs b/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs index f120f17..cbd049a 100644 --- a/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs +++ b/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs @@ -1,10 +1,12 @@ +namespace TenJames.CompMap.Tests; + +using System; +using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; -namespace TenJames.CompMap.Tests; - public class MapperGeneratorTests { [Fact] @@ -14,8 +16,8 @@ public void AttributeGenerator_ShouldGenerateMapFromAttribute() var attributeGenerator = new AttributeGenerator(); var driver = CSharpGeneratorDriver.Create(attributeGenerator); var compilation = CSharpCompilation.Create( - nameof(AttributeGenerator_ShouldGenerateMapFromAttribute), - references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + nameof(AttributeGenerator_ShouldGenerateMapFromAttribute), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } ); // Act @@ -23,7 +25,7 @@ public void AttributeGenerator_ShouldGenerateMapFromAttribute() // Assert var generatedAttribute = runResult.GeneratedTrees - .FirstOrDefault(t => t.FilePath.EndsWith("MapFromAttribute.g.cs", System.StringComparison.Ordinal)); + .FirstOrDefault(t => t.FilePath.EndsWith("MapFromAttribute.g.cs", StringComparison.Ordinal)); Assert.NotNull(generatedAttribute); var generatedCode = generatedAttribute.GetText().ToString(); @@ -38,8 +40,8 @@ public void AttributeGenerator_ShouldGenerateMapToAttribute() var attributeGenerator = new AttributeGenerator(); var driver = CSharpGeneratorDriver.Create(attributeGenerator); var compilation = CSharpCompilation.Create( - nameof(AttributeGenerator_ShouldGenerateMapToAttribute), - references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + nameof(AttributeGenerator_ShouldGenerateMapToAttribute), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } ); // Act @@ -47,7 +49,7 @@ public void AttributeGenerator_ShouldGenerateMapToAttribute() // Assert var generatedAttribute = runResult.GeneratedTrees - .FirstOrDefault(t => t.FilePath.EndsWith("MapToAttribute.g.cs", System.StringComparison.Ordinal)); + .FirstOrDefault(t => t.FilePath.EndsWith("MapToAttribute.g.cs", StringComparison.Ordinal)); Assert.NotNull(generatedAttribute); var generatedCode = generatedAttribute.GetText().ToString(); @@ -62,8 +64,8 @@ public void AttributeGenerator_ShouldGenerateMapperInterface() var attributeGenerator = new AttributeGenerator(); var driver = CSharpGeneratorDriver.Create(attributeGenerator); var compilation = CSharpCompilation.Create( - nameof(AttributeGenerator_ShouldGenerateMapperInterface), - references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + nameof(AttributeGenerator_ShouldGenerateMapperInterface), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } ); // Act @@ -71,7 +73,7 @@ public void AttributeGenerator_ShouldGenerateMapperInterface() // Assert var generatedMapper = runResult.GeneratedTrees - .FirstOrDefault(t => t.FilePath.EndsWith("Mapper.g.cs", System.StringComparison.Ordinal)); + .FirstOrDefault(t => t.FilePath.EndsWith("Mapper.g.cs", StringComparison.Ordinal)); Assert.NotNull(generatedMapper); var generatedCode = generatedMapper.GetText().ToString(); @@ -108,7 +110,9 @@ public partial class Target var driver = CSharpGeneratorDriver.Create(generators); // Act - driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(compilation, + out var outputCompilation, + out var diagnostics); // Assert // Check no errors occurred during generation @@ -126,14 +130,14 @@ private static CSharpCompilation CreateCompilation(string source) var references = new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Collections.Generic.ICollection<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ICollection<>).Assembly.Location) }; return CSharpCompilation.Create( - "TestCompilation", - new[] { syntaxTree }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + "TestCompilation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); } } diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj b/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj index 7312bd0..e276da5 100644 --- a/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj +++ b/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj @@ -1,28 +1,28 @@ - - net10.0 - enable + + net10.0 + enable - false + false - TenJames.CompMap.Tests - + TenJames.CompMap.Tests + - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/Utils/TestAdditionalFile.cs b/TenJames.CompMap/TenJames.CompMap.Tests/Utils/TestAdditionalFile.cs index e139ecb..9cd737e 100644 --- a/TenJames.CompMap/TenJames.CompMap.Tests/Utils/TestAdditionalFile.cs +++ b/TenJames.CompMap/TenJames.CompMap.Tests/Utils/TestAdditionalFile.cs @@ -1,10 +1,11 @@ +namespace TenJames.CompMap.Tests.Utils; + using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace TenJames.CompMap.Tests.Utils; - -public class TestAdditionalFile : AdditionalText { +public class TestAdditionalFile : AdditionalText +{ private readonly SourceText _text; public TestAdditionalFile(string path, string text) @@ -16,4 +17,4 @@ public TestAdditionalFile(string path, string text) public override SourceText GetText(CancellationToken cancellationToken = new()) => _text; public override string Path { get; } -} \ No newline at end of file +} diff --git a/TenJames.CompMap/TenJames.CompMap/AttributeDefinition.cs b/TenJames.CompMap/TenJames.CompMap/AttributeDefinition.cs index 9df9b23..2f3a92f 100644 --- a/TenJames.CompMap/TenJames.CompMap/AttributeDefinition.cs +++ b/TenJames.CompMap/TenJames.CompMap/AttributeDefinition.cs @@ -1,25 +1,62 @@ -using System; -using System.Collections.Generic; - namespace TenJames.CompMap; -public class AttributeDefinition { +using System.Collections.Generic; + +/// +/// Information about a mapping attribute. +/// +public class AttributeDefinition +{ + /// + /// Name of the attribute. + /// public string Name { get; set; } + + /// + /// Description of the attribute. + /// public string Description { get; set; } + + /// + /// Arguments for the attribute. + /// public IList Arguments { get; set; } } -public class ArgumentDefinition { + +/// +/// Information about an argument for a mapping attribute. +/// +public class ArgumentDefinition +{ + /// + /// Name of the argument. + /// public string Name { get; set; } + + /// + /// Type of the argument. + /// public string Type { get; set; } + + /// + /// Default value or description of the argument. + /// public string Value { get; set; } } -public static class AttributeDefinitions { - public readonly static AttributeDefinition MapFrom = new AttributeDefinition { +/// +/// Static class containing predefined attribute definitions. +/// +public static class AttributeDefinitions +{ + private static readonly AttributeDefinition MapFrom = new() + { Name = "MapFrom", Description = "Indicates that the decorated class can be mapped from the specified source type.", - Arguments = new List { - new ArgumentDefinition { + Arguments = new List + { + new() + { Name = "sourceType", Type = "Type", Value = "The source type to map from." @@ -27,11 +64,15 @@ public static class AttributeDefinitions { } }; - public readonly static AttributeDefinition MapTo = new AttributeDefinition { + + private static readonly AttributeDefinition MapTo = new() + { Name = "MapTo", Description = "Indicates that the decorated class can be mapped to the specified destination type.", - Arguments = new List { - new ArgumentDefinition { + Arguments = new List + { + new() + { Name = "destinationType", Type = "Type", Value = "The destination type to map to." @@ -39,9 +80,30 @@ public static class AttributeDefinitions { } }; + private static readonly AttributeDefinition AutoPropertyChain = new() + { + Name = "AutoPropertyChain", + Description = + "Enables automatic property chain mapping. Maps flattened properties like CategoryName to nested properties like Category.Name.", + Arguments = new List() + }; + + /// + /// Retrieves all mapping attributes. + /// + /// public static IEnumerable GetAllAttributes() { yield return MapFrom; yield return MapTo; } -} \ No newline at end of file + + /// + /// Retrieves all modifier attributes. + /// + /// + public static IEnumerable GetAllModifierAttributes() + { + yield return AutoPropertyChain; + } +} diff --git a/TenJames.CompMap/TenJames.CompMap/AttributeGenerator.cs b/TenJames.CompMap/TenJames.CompMap/AttributeGenerator.cs index 160a89c..7300266 100644 --- a/TenJames.CompMap/TenJames.CompMap/AttributeGenerator.cs +++ b/TenJames.CompMap/TenJames.CompMap/AttributeGenerator.cs @@ -1,22 +1,19 @@ +namespace TenJames.CompMap; + using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace TenJames.CompMap; - /// [Generator] -public class AttributeGenerator: IIncrementalGenerator { +public class AttributeGenerator : IIncrementalGenerator +{ /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - RegisterAttributes(context); - } + public void Initialize(IncrementalGeneratorInitializationContext context) => RegisterAttributes(context); - private void RegisterAttributes(IncrementalGeneratorInitializationContext context) - { + private static void RegisterAttributes(IncrementalGeneratorInitializationContext context) => context.RegisterPostInitializationOutput(ctx => { var attributes = AttributeDefinitions.GetAllAttributes(); @@ -25,9 +22,17 @@ private void RegisterAttributes(IncrementalGeneratorInitializationContext contex var sourceText = GenerateAttributeSourceText(attribute); ctx.AddSource($"{attribute.Name}Attribute.g.cs", sourceText); } + + // Generate modifier attributes (like AutoPropertyChain) + var modifierAttributes = AttributeDefinitions.GetAllModifierAttributes(); + foreach (var attribute in modifierAttributes) + { + var sourceText = GenerateModifierAttributeSourceText(attribute); + ctx.AddSource($"{attribute.Name}Attribute.g.cs", sourceText); + } + ctx.AddSource("Mapper.g.cs", GenerateMapperClass()); }); - } private static SourceText GenerateMapperClass() { @@ -177,7 +182,8 @@ public virtual TDestination Map(object source) } } - """, Encoding.UTF8); + """, + Encoding.UTF8); return sourceText; } @@ -199,7 +205,7 @@ private static SourceText GenerateAttributeSourceText(AttributeDefinition attrib var comma = arg != attribute.Arguments.Last() ? "," : ""; src.AppendLine($" {arg.Type} {arg.Name}{comma} // {arg.Value}"); } - for (int i = 0; i < attribute.Arguments.Count; i++) + for (var i = 0; i < attribute.Arguments.Count; i++) { var arg = attribute.Arguments[i]; var comma = i < attribute.Arguments.Count - 1 ? "," : ""; @@ -210,4 +216,23 @@ private static SourceText GenerateAttributeSourceText(AttributeDefinition attrib src.AppendLine("}"); return SourceText.From(src.ToString(), Encoding.UTF8); } + + private static SourceText GenerateModifierAttributeSourceText(AttributeDefinition attribute) + { + var src = new StringBuilder(); + src.AppendLine("// "); + src.AppendLine("using System;"); + src.AppendLine(); + src.AppendLine($"namespace {Consts.AttributesNamespace}"); + src.AppendLine("{"); + src.AppendLine($" /// "); + src.AppendLine($" /// {attribute.Description}"); + src.AppendLine($" /// "); + src.AppendLine($" [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]"); + src.AppendLine($" public class {attribute.Name}Attribute : Attribute"); + src.AppendLine(" {"); + src.AppendLine(" }"); + src.AppendLine("}"); + return SourceText.From(src.ToString(), Encoding.UTF8); + } } diff --git a/TenJames.CompMap/TenJames.CompMap/Consts.cs b/TenJames.CompMap/TenJames.CompMap/Consts.cs index 989b590..b98065d 100644 --- a/TenJames.CompMap/TenJames.CompMap/Consts.cs +++ b/TenJames.CompMap/TenJames.CompMap/Consts.cs @@ -1,7 +1,18 @@ namespace TenJames.CompMap; -public static class Consts { +/// +/// Internal constants used throughout the CompMap library +/// +public static class Consts +{ + /// + /// Attributes namespace + /// public const string AttributesNamespace = "TenJames.CompMap.Attributes"; + + /// + /// Mapper namespace + /// public const string MapperNamespace = "TenJames.CompMap.Mappper"; - -} \ No newline at end of file + +} diff --git a/TenJames.CompMap/TenJames.CompMap/Mapper.cs b/TenJames.CompMap/TenJames.CompMap/Mapper.cs index 248e753..58e648e 100644 --- a/TenJames.CompMap/TenJames.CompMap/Mapper.cs +++ b/TenJames.CompMap/TenJames.CompMap/Mapper.cs @@ -1,12 +1,12 @@ - // #nullable enable +namespace TenJames.CompMap.Mappper; + using System; +using System.Collections; using System.Collections.Generic; using System.Reflection; -namespace TenJames.CompMap.Mappper; - /// /// Interface for mapping between types. /// @@ -19,24 +19,21 @@ public interface IMapper /// Source for the mapping /// Destination type for the map /// - TDestination Map(object source); + public TDestination Map(object source); } - /// /// Base implementation of IMapper /// -public class BaseMapper : IMapper { +public class BaseMapper : IMapper +{ /// /// Default implementation for null source /// /// Destination type /// - protected virtual TDestination OnNull() - { - return default(TDestination); - } + protected virtual TDestination OnNull() => default; /// /// Default implementation for mapping @@ -46,7 +43,8 @@ protected virtual TDestination OnNull() /// Method on destination /// Destination type /// - protected virtual TDestination OnMap(object source, MethodInfo? mapToMethod, MethodInfo? mapFromMethod) + protected virtual TDestination OnMap(object source, MethodInfo? mapToMethod, + MethodInfo? mapFromMethod) { if (mapToMethod != null && mapToMethod.ReturnType == typeof(TDestination)) { @@ -59,7 +57,9 @@ protected virtual TDestination OnMap(object source, MethodInfo? ma return (TDestination)mapFromMethod.Invoke(null, new object[] { this, source }); } - return OnError(source, new NotImplementedException($"No mapping defined from {source.GetType().FullName} to {typeof(TDestination).FullName}")); + return OnError(source, + new NotImplementedException( + $"No mapping defined from {source.GetType().FullName} to {typeof(TDestination).FullName}")); } /// @@ -70,10 +70,7 @@ protected virtual TDestination OnMap(object source, MethodInfo? ma /// Destination type /// By default, it always throw /// By default, it throws an error - protected virtual TDestination OnError(object source, Exception ex) - { - throw ex; - } + protected virtual TDestination OnError(object source, Exception ex) => throw ex; /// /// What should happen when source is enumerable @@ -83,40 +80,45 @@ protected virtual TDestination OnError(object source, Exception ex /// How should collection be mapped protected virtual TDestination OnEnumerable(object source) { - var enumerable = (System.Collections.IEnumerable)source; + var enumerable = (IEnumerable)source; if (typeof(TDestination).IsArray) { var elementType = typeof(TDestination).GetElementType(); var listType = typeof(List<>).MakeGenericType(elementType); - var list = (System.Collections.IList)Activator.CreateInstance(listType); + var list = (IList)Activator.CreateInstance(listType); foreach (var item in enumerable) { - var mappedItem = this.GetType().GetMethod("Map").MakeGenericMethod(elementType).Invoke(this, new object[] { item }); + var mappedItem = GetType().GetMethod("Map").MakeGenericMethod(elementType) + .Invoke(this, new object[] { item }); list.Add(mappedItem); } var array = Array.CreateInstance(elementType, list.Count); list.CopyTo(array, 0); return (TDestination)(object)array; } - if (typeof(TDestination).IsGenericType && ( typeof(TDestination).GetGenericTypeDefinition() == typeof(List<>) || - typeof(TDestination).GetGenericTypeDefinition() == typeof(IEnumerable<>) || - typeof(TDestination).GetGenericTypeDefinition() == typeof(ICollection<>) || - false + if (typeof(TDestination).IsGenericType + && (typeof(TDestination).GetGenericTypeDefinition() == typeof(List<>) + || typeof(TDestination).GetGenericTypeDefinition() == typeof(IEnumerable<>) + || typeof(TDestination).GetGenericTypeDefinition() == typeof(ICollection<>) + || false )) { var elementType = typeof(TDestination).GetGenericArguments()[0]; - var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType)); + var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType)); foreach (var item in enumerable) { - var mappedItem = this.GetType().GetMethod("Map").MakeGenericMethod(elementType).Invoke(this, new object[] { item }); + var mappedItem = GetType().GetMethod("Map").MakeGenericMethod(elementType) + .Invoke(this, new object[] { item }); list.Add(mappedItem); } return (TDestination)list; } else { - return OnError(source, new NotImplementedException($"Mapping to collection type {typeof(TDestination).FullName} is not supported.")); + return OnError(source, + new NotImplementedException( + $"Mapping to collection type {typeof(TDestination).FullName} is not supported.")); } } @@ -130,7 +132,7 @@ public virtual TDestination Map(object source) return OnNull(); // if source is iEnumerable - case System.Collections.IEnumerable enumerable: + case IEnumerable enumerable: return OnEnumerable(source); } @@ -142,4 +144,3 @@ public virtual TDestination Map(object source) return OnMap(source, mapMethod, mapFromMethod); } } - diff --git a/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs b/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs index 3512fd1..375ec41 100644 --- a/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs +++ b/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs @@ -1,4 +1,7 @@ +namespace TenJames.CompMap; + using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; @@ -6,23 +9,25 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; - -namespace TenJames.CompMap; - +/// +/// Mapper generator that creates mapping methods based on attributes +/// [Generator] -public class MapperGenerator : IIncrementalGenerator { +public class MapperGenerator : IIncrementalGenerator +{ + /// public void Initialize(IncrementalGeneratorInitializationContext context) { var provider = context.SyntaxProvider .CreateSyntaxProvider( - (s, _) => s is ClassDeclarationSyntax or RecordDeclarationSyntax, - (ctx, _) => GetTypeDeclarationForSourceGen(ctx)) + predicate: (s, _) => s is ClassDeclarationSyntax or RecordDeclarationSyntax, + transform: (ctx, _) => GetTypeDeclarationForSourceGen(ctx)) .Where(t => t is not null); // Generate the source code. context.RegisterSourceOutput(context.CompilationProvider.Combine(provider.Collect()), - ((ctx, t) => GenerateCode(ctx, t.Left, t.Right!))); + (ctx, t) => GenerateCode(ctx, t.Left, t.Right!)); } private static MappingOptions? GetTypeDeclarationForSourceGen( @@ -31,21 +36,87 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var typeDeclarationSyntax = (TypeDeclarationSyntax)context.Node; // Go through all attributes of the type. - foreach (var attributeSyntax in typeDeclarationSyntax.AttributeLists.SelectMany(attributeListSyntax => attributeListSyntax.Attributes)) + foreach (var attributeSyntax in typeDeclarationSyntax.AttributeLists.SelectMany(attributeListSyntax => + attributeListSyntax.Attributes)) { if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol) + { continue; // if we can't get the symbol, ignore it + } var attributeName = attributeSymbol.ContainingType.ToDisplayString(); if (AttributeDefinitions.GetAllAttributes().Select(x => x.Name).Any(x => attributeName.Contains(x))) + { return MappingOptions.Create(context, typeDeclarationSyntax); + } + } + + return null; + } + + /// + /// Checks if the type has AutoPropertyChain attribute + /// + private static bool HasAutoPropertyChain(TypeDeclarationSyntax typeDecl) => typeDecl.AttributeLists + .SelectMany(al => al.Attributes) + .Any(a => a.Name.ToString().Contains("AutoPropertyChain")); + + /// + /// Recursively finds a property chain in the target that matches the source property name. + /// For example: CategoryParentName -> Category.Parent.Name (any depth) + /// + private static (string PropertyChain, string TypeFullName)? FindPropertyChainRecursive( + string flattenedName, + INamedTypeSymbol typeSymbol, + int maxDepth = 10) + { + if (maxDepth <= 0 || string.IsNullOrEmpty(flattenedName)) + { + return null; + } + + var properties = MappingOptions.GetAllPropertiesFromSymbol(typeSymbol); + + // First, check for exact match at this level + var exactMatch = properties.FirstOrDefault(p => + string.Equals(p.Name, flattenedName, StringComparison.Ordinal)); + if (exactMatch != null) + { + return (exactMatch.Name, exactMatch.TypeFullName); + } + + // Try to find a property that is a prefix of the flattened name + // Sort by length descending to prefer longer matches first (more specific) + var candidateProps = properties + .Where(p => flattenedName.StartsWith(p.Name, StringComparison.Ordinal) + && flattenedName.Length > p.Name.Length) + .OrderByDescending(p => p.Name.Length) + .ToList(); + + foreach (var prop in candidateProps) + { + var remainingName = flattenedName.Substring(prop.Name.Length); + + // Get the type of this property to search nested properties + var propType = prop.PropertySymbol.Type as INamedTypeSymbol; + if (propType == null) + { + continue; + } + + // Recursively search in the nested type + var nestedResult = FindPropertyChainRecursive(remainingName, propType, maxDepth - 1); + if (nestedResult != null) + { + return ($"{prop.Name}.{nestedResult.Value.PropertyChain}", nestedResult.Value.TypeFullName); + } } return null; } - private void GenerateCode(SourceProductionContext context, Compilation compilation, + private static void GenerateCode(SourceProductionContext context, Compilation compilation, ImmutableArray mappingOptions) { // generate partial classes/records with mapping methods @@ -53,7 +124,7 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati { var className = ma.TypeDeclarationSyntax.Identifier.Text; var typeKeyword = ma.IsRecord ? "record" : "class"; - + var hasAutoPropertyChain = HasAutoPropertyChain(ma.TypeDeclarationSyntax); var sourceText = new SourceBuilder(); sourceText.AppendLine($"using {Consts.MapperNamespace};"); @@ -89,13 +160,36 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati .Where(prop => allTargetProperties.All(targetProp => targetProp.Name != prop.Name)) .ToList(); + // Track property chain mappings for auto property chain feature + var propertyChainMappings = new Dictionary(); + + // If AutoPropertyChain is enabled, try to find property chains for missing fields + if (hasAutoPropertyChain) + { + var stillMissingFields = new List(); + foreach (var prop in missingFields) + { + var chain = FindPropertyChainRecursive(prop.Name, ma.TargetSymbol); + if (chain != null) + { + propertyChainMappings[prop.Name] = chain.Value; + } + else + { + stillMissingFields.Add(prop); + } + } + missingFields = stillMissingFields; + } + var isMissing = missingFields.Any(); if (isMissing) { // create a subclass inside sourceText.AppendLine(); sourceText.AppendLine("///"); - sourceText.AppendLine("/// The following properties were not mapped because they do not exist in the target class"); + sourceText.AppendLine( + "/// The following properties were not mapped because they do not exist in the target class"); sourceText.AppendLine("///"); { using var block = sourceText.BeginBlock($"internal class {ma.TargetName}UnmappedProperties"); @@ -103,13 +197,16 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati { // Add property documentation sourceText.AppendLine($"/// "); - sourceText.AppendLine($"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); + sourceText.AppendLine( + $"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); sourceText.AppendLine($"/// "); - sourceText.AppendLine($"public {prop.TypeFullName.Replace("global::", "")} {prop.Name} {{ get; set; }}"); + sourceText.AppendLine( + $"public {prop.TypeFullName.Replace("global::", "")} {prop.Name} {{ get; set; }}"); } } sourceText.AppendLine(); - sourceText.AppendLine($"private static partial {ma.TargetName}UnmappedProperties Get{ma.TargetName}UnmappedProperties(IMapper mapper, {ma.TargetFullName} source);"); + sourceText.AppendLine( + $"private static partial {ma.TargetName}UnmappedProperties Get{ma.TargetName}UnmappedProperties(IMapper mapper, {ma.TargetFullName} source);"); } sourceText.AppendLine(); @@ -118,11 +215,14 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati sourceText.AppendLine("/// "); sourceText.AppendLine("/// Mapping method generated by TenJames.CompMap"); sourceText.AppendLine("/// "); - using var mapFromBlock = sourceText.BeginBlock($"public static {className} MapFrom(IMapper mapper, {ma.TargetFullName} source)"); + using var mapFromBlock = + sourceText.BeginBlock( + $"public static {className} MapFrom(IMapper mapper, {ma.TargetFullName} source)"); if (isMissing) { - sourceText.AppendLine("// Note: Some properties were not mapped due to missing counterparts in the target class."); + sourceText.AppendLine( + "// Note: Some properties were not mapped due to missing counterparts in the target class."); sourceText.AppendLine($"var unmapped = Get{ma.TargetName}UnmappedProperties(mapper, source);"); } @@ -137,7 +237,8 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati if (targetProp != null && prop.TypeFullName != targetProp.TypeFullName) { // Type mismatch, use mapper - sourceText.AppendLine($"{prop.Name} = mapper.Map<{prop.TypeFullName.Replace("global::", "")}>(source.{prop.Name}),"); + sourceText.AppendLine( + $"{prop.Name} = mapper.Map<{prop.TypeFullName.Replace("global::", "")}>(source.{prop.Name}),"); } else { @@ -145,6 +246,25 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati } } + // Add property chain mappings (auto-resolved from nested properties) + foreach (var chainMapping in propertyChainMappings) + { + var sourcePropName = chainMapping.Key; + var targetChain = chainMapping.Value.Chain; + var sourceProp = allSourceProperties.First(p => p.Name == sourcePropName); + + if (sourceProp.TypeFullName != chainMapping.Value.TypeFullName) + { + // Type mismatch, use mapper + sourceText.AppendLine( + $"{sourcePropName} = mapper.Map<{sourceProp.TypeFullName.Replace("global::", "")}>(source.{targetChain}),"); + } + else + { + sourceText.AppendLine($"{sourcePropName} = source.{targetChain},"); + } + } + foreach (var prop in missingFields) { sourceText.AppendLine($"{prop.Name} = unmapped.{prop.Name},"); @@ -160,13 +280,42 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati .Where(prop => allSourceProperties.All(targetProp => targetProp.Name != prop.Name)) .ToList(); + // Track property chain mappings for auto property chain feature + var propertyChainMappings = new Dictionary(); + + // If AutoPropertyChain is enabled, try to find property chains for missing fields + // For MapTo, we look for source property chains that map to flattened target properties + if (hasAutoPropertyChain) + { + var sourceSymbol = ma.SemanticModel.GetDeclaredSymbol(ma.TypeDeclarationSyntax) as INamedTypeSymbol; + if (sourceSymbol != null) + { + var stillMissingFields = new List(); + foreach (var prop in missingFields) + { + // For MapTo: target has "CategoryName", source might have "Category.Name" + var chain = FindPropertyChainRecursive(prop.Name, sourceSymbol); + if (chain != null) + { + propertyChainMappings[prop.Name] = chain.Value; + } + else + { + stillMissingFields.Add(prop); + } + } + missingFields = stillMissingFields; + } + } + var isMissing = missingFields.Any(); if (isMissing) { // create a subclass inside sourceText.AppendLine(); sourceText.AppendLine("///"); - sourceText.AppendLine("/// The following properties were not mapped because they do not exist in the target class"); + sourceText.AppendLine( + "/// The following properties were not mapped because they do not exist in the target class"); sourceText.AppendLine("///"); { using var block = sourceText.BeginBlock($"internal class {ma.TargetName}UnmappedProperties"); @@ -174,13 +323,16 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati { // Add property documentation sourceText.AppendLine($"/// "); - sourceText.AppendLine($"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); + sourceText.AppendLine( + $"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); sourceText.AppendLine($"/// "); - sourceText.AppendLine($"public {prop.TypeFullName.Replace("global::", "")} {prop.Name} {{ get; set; }}"); + sourceText.AppendLine( + $"public {prop.TypeFullName.Replace("global::", "")} {prop.Name} {{ get; set; }}"); } } sourceText.AppendLine(); - sourceText.AppendLine($"private static partial {ma.TargetName}UnmappedProperties Get{ma.TargetName}UnmappedProperties(IMapper mapper, {ma.ClassName} source);"); + sourceText.AppendLine( + $"private static partial {ma.TargetName}UnmappedProperties Get{ma.TargetName}UnmappedProperties(IMapper mapper, {ma.ClassName} source);"); } sourceText.AppendEmptyLine(); @@ -206,13 +358,33 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati if (targetProp != null && prop.TypeFullName != targetProp.TypeFullName) { // Type mismatch, use mapper - sourceText.AppendLine($" {prop.Name} = mapper.Map<{targetProp.TypeFullName.Replace("global::", "")}>(this.{prop.Name}),"); + sourceText.AppendLine( + $" {prop.Name} = mapper.Map<{targetProp.TypeFullName.Replace("global::", "")}>(this.{prop.Name}),"); } else { sourceText.AppendLine($" {prop.Name} = this.{prop.Name},"); } } + + // Add property chain mappings (auto-resolved from nested properties) + foreach (var chainMapping in propertyChainMappings) + { + var targetPropName = chainMapping.Key; + var sourceChain = chainMapping.Value.Chain; + var targetProp = allTargetProperties.First(p => p.Name == targetPropName); + + if (targetProp.TypeFullName != chainMapping.Value.TypeFullName) + { + sourceText.AppendLine( + $" {targetPropName} = mapper.Map<{targetProp.TypeFullName.Replace("global::", "")}>(this.{sourceChain}),"); + } + else + { + sourceText.AppendLine($" {targetPropName} = this.{sourceChain},"); + } + } + if (isMissing) { foreach (var prop in missingFields) diff --git a/TenJames.CompMap/TenJames.CompMap/MappingOptions.cs b/TenJames.CompMap/TenJames.CompMap/MappingOptions.cs index 7942392..535ab94 100644 --- a/TenJames.CompMap/TenJames.CompMap/MappingOptions.cs +++ b/TenJames.CompMap/TenJames.CompMap/MappingOptions.cs @@ -6,22 +6,31 @@ namespace TenJames.CompMap; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -public class MappingOptions { +public class MappingOptions +{ public TypeDeclarationSyntax TypeDeclarationSyntax { get; set; } + public string ClassName => TypeDeclarationSyntax.Identifier.Text; + public bool IsRecord => TypeDeclarationSyntax is RecordDeclarationSyntax; + public string AttributeName { get; set; } + public string Namespace { get; set; } // Target can be either from source (same compilation) or from metadata (external assembly) public TypeDeclarationSyntax? TargetSyntax { get; set; } + public INamedTypeSymbol TargetSymbol { get; set; } public string TargetName => TargetSymbol.Name; + public string TargetNamespace { get; set; } + public string TargetFullName => string.IsNullOrEmpty(TargetNamespace) || TargetNamespace == "GlobalNamespace" ? TargetName : $"{TargetNamespace}.{TargetName}"; + public SemanticModel SemanticModel { get; set; } /// @@ -51,7 +60,9 @@ public static List GetAllPropertiesFromSymbol(INamedTypeSymbol sym { // Skip EqualityContract property which is compiler-generated for records if (prop.Name == "EqualityContract") + { continue; + } // Avoid duplicates (overridden properties) if (!properties.Any(p => p.Name == prop.Name)) @@ -86,7 +97,8 @@ public static List GetAllPropertiesFromSymbol(INamedTypeSymbol sym : "GlobalNamespace"; - foreach (var attributeSyntax in typeDeclarationSyntax.AttributeLists.SelectMany(attributeListSyntax => attributeListSyntax.Attributes)) + foreach (var attributeSyntax in typeDeclarationSyntax.AttributeLists.SelectMany(attributeListSyntax => + attributeListSyntax.Attributes)) { var attributeName = attributeSyntax.Name.ToString(); if (AttributeDefinitions.GetAllAttributes().Select(x => x.Name).Any(x => attributeName.Contains(x))) @@ -95,7 +107,8 @@ public static List GetAllPropertiesFromSymbol(INamedTypeSymbol sym INamedTypeSymbol? targetSymbol = null; TypeDeclarationSyntax? targetSyntax = null; - if (attributeSyntax.ArgumentList?.Arguments.First().Expression is TypeOfExpressionSyntax typeOfExpression) + if (attributeSyntax.ArgumentList?.Arguments.First().Expression is TypeOfExpressionSyntax + typeOfExpression) { var symbolInfo = context.SemanticModel.GetSymbolInfo(typeOfExpression.Type); targetSymbol = symbolInfo.Symbol as INamedTypeSymbol; @@ -118,7 +131,8 @@ public static List GetAllPropertiesFromSymbol(INamedTypeSymbol sym ? "GlobalNamespace" : targetSymbol.ContainingNamespace.ToDisplayString(); - return new MappingOptions { + return new MappingOptions + { TypeDeclarationSyntax = typeDeclarationSyntax, AttributeName = attributeName, Namespace = namespaceName, @@ -137,6 +151,8 @@ public static List GetAllPropertiesFromSymbol(INamedTypeSymbol sym public class PropertyInfo { public string Name { get; set; } = string.Empty; + public string TypeFullName { get; set; } = string.Empty; + public IPropertySymbol PropertySymbol { get; set; } = null!; } diff --git a/TenJames.CompMap/TenJames.CompMap/SourceBuilder.cs b/TenJames.CompMap/TenJames.CompMap/SourceBuilder.cs index 27adfdb..33db54a 100644 --- a/TenJames.CompMap/TenJames.CompMap/SourceBuilder.cs +++ b/TenJames.CompMap/TenJames.CompMap/SourceBuilder.cs @@ -1,12 +1,13 @@ +namespace TenJames.CompMap; + using System; using System.Text; -namespace TenJames.CompMap; - /// /// SourceBuilder /// -public class SourceBuilder { +public class SourceBuilder +{ private readonly StringBuilder sourceText; /// @@ -20,9 +21,10 @@ public SourceBuilder() sourceText.AppendLine("using System.Collections.Generic;"); sourceText.AppendLine(); } + private int indentLevel = 0; - private string Indent => new string(' ', indentLevel * 4); + private string Indent => new(' ', indentLevel * 4); /// /// Appends the empty line @@ -36,7 +38,7 @@ public SourceBuilder() public void AppendLine(string line) { sourceText.Append(Indent); - sourceText.AppendLine( line); + sourceText.AppendLine(line); } /// diff --git a/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj b/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj index 93b0a82..1e9a2e5 100644 --- a/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj +++ b/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj @@ -1,62 +1,62 @@ - - netstandard2.0 - true - enable - latest - - true - true - - TenJames.CompMap - TenJames.CompMap - - - - 0.2.1 - Compiletime Mapper - Map your object on compile time - https://github.com/Ten-James/CompMap - https://github.com/Ten-James/CompMap - true - Jakub Indrák - readme.md - true - https://github.com/Ten-James/CompMap/blob/main/Readme.md - MIT - Generator Rosylyn DTO Mapper - - false - - - - - \ - true - - - - - - - - - bin\Release\DtoGenerator.xml - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + + netstandard2.0 + true + enable + latest + + true + true + + TenJames.CompMap + TenJames.CompMap + + + + 0.2.1 + Compiletime Mapper + Map your object on compile time + https://github.com/Ten-James/CompMap + https://github.com/Ten-James/CompMap + true + Jakub Indrák + readme.md + true + https://github.com/Ten-James/CompMap/blob/main/LICESNE.md + MIT + Generator Rosylyn DTO Mapper + + false + + + + + \ + true + + + + + + + + + bin\Release\DtoGenerator.xml + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/example/Example.Console/Example.Console.csproj b/example/Example.Console/Example.Console.csproj index 6c44356..3fd3ff8 100644 --- a/example/Example.Console/Example.Console.csproj +++ b/example/Example.Console/Example.Console.csproj @@ -1,15 +1,15 @@ - - Exe - net10.0 - enable - enable - + + Exe + net10.0 + enable + enable + - - - - + + + + diff --git a/example/Example.DTOS/DocumentDto.cs b/example/Example.DTOS/DocumentDto.cs index 7c644bd..ec593d7 100644 --- a/example/Example.DTOS/DocumentDto.cs +++ b/example/Example.DTOS/DocumentDto.cs @@ -1,8 +1,8 @@ -using Example.Entities; -using TenJames.CompMap.Attributes; - namespace Example.DTOS; +using Entities; +using TenJames.CompMap.Attributes; + /// /// DTO for reading document data - maps FROM the Document entity /// @@ -10,6 +10,8 @@ namespace Example.DTOS; public partial class DocumentReadDto { public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; } diff --git a/example/Example.DTOS/Example.DTOS.csproj b/example/Example.DTOS/Example.DTOS.csproj index d6e62e4..73c8c91 100644 --- a/example/Example.DTOS/Example.DTOS.csproj +++ b/example/Example.DTOS/Example.DTOS.csproj @@ -1,16 +1,16 @@ - - net10.0 - enable - enable - - true - + + net10.0 + enable + enable + + true + - - - - + + + + diff --git a/example/Example.DTOS/UserDto.cs b/example/Example.DTOS/UserDto.cs index bd4e91e..8aea4e4 100644 --- a/example/Example.DTOS/UserDto.cs +++ b/example/Example.DTOS/UserDto.cs @@ -1,9 +1,9 @@ -using Example.Entities; +namespace Example.DTOS; + +using Entities; using TenJames.CompMap.Attributes; using TenJames.CompMap.Mappper; -namespace Example.DTOS; - /// /// DTO for reading user data - maps FROM the User entity /// @@ -11,19 +11,21 @@ namespace Example.DTOS; public partial class UserReadDto { public int Id { get; set; } + public string Login { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public ICollection Documents { get; set; } = new List(); // Implementation required for unmapped properties - private static partial UserUnmappedProperties GetUserUnmappedProperties(IMapper mapper, User source) + private static partial UserUnmappedProperties GetUserUnmappedProperties(IMapper mapper, User source) => new() { - return new UserUnmappedProperties - { - // Login doesn't exist on User, so we derive it from Email - Login = source.Email.Split('@')[0] - }; - } + // Login doesn't exist on User, so we derive it from Email + Login = source.Email.Split('@')[0] + }; } diff --git a/example/Example.Entities/Document.cs b/example/Example.Entities/Document.cs index 952c335..a1195c7 100644 --- a/example/Example.Entities/Document.cs +++ b/example/Example.Entities/Document.cs @@ -3,7 +3,10 @@ namespace Example.Entities; public class Document { public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } } diff --git a/example/Example.Entities/Example.Entities.csproj b/example/Example.Entities/Example.Entities.csproj index 8a91831..30402ac 100644 --- a/example/Example.Entities/Example.Entities.csproj +++ b/example/Example.Entities/Example.Entities.csproj @@ -1,9 +1,9 @@ - - net8.0 - enable - enable - + + net8.0 + enable + enable + diff --git a/example/Example.Entities/User.cs b/example/Example.Entities/User.cs index 7fc14f8..dcae1c7 100644 --- a/example/Example.Entities/User.cs +++ b/example/Example.Entities/User.cs @@ -3,8 +3,12 @@ namespace Example.Entities; public class User { public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public ICollection Documents { get; set; } = new List(); }