diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e3988b..ddb89f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,13 +32,8 @@ jobs: - name: Build run: dotnet build TenJames.CompMap.sln -c Release --no-restore - - name: Test (if present) - run: | - if [ -d "TenJames.CompMap/TenJames.CompMap.Tests" ]; then - dotnet test TenJames.CompMap/TenJames.CompMap.Tests -c Release --no-build --verbosity normal - else - echo "No tests found" - fi + - name: Test + run: dotnet test TenJames.CompMap.sln -c Release --no-build --verbosity normal bump-version: name: Bump package version (patch) on PR diff --git a/Readme.md b/Readme.md index 40004b1..67d9ea0 100644 --- a/Readme.md +++ b/Readme.md @@ -96,17 +96,20 @@ Install the **TenJames.CompMap** package via NuGet: ```shell dotnet add package TenJames.CompMap ``` -Ensure its correclty referenced 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`, as the package is already configured correctly. + ### Component registration If you are using dependency injection, register the mapping services in your DI container: diff --git a/TenJames.CompMap.sln b/TenJames.CompMap.sln index 7f1e5bb..d58c3bc 100644 --- a/TenJames.CompMap.sln +++ b/TenJames.CompMap.sln @@ -6,23 +6,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TenJames.CompMap.Tests", "T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TenJames.CompMap.Example", "TenJames.CompMap\TenJames.CompMap.Example\TenJames.CompMap.Example.csproj", "{B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TenJames.CompMap.IntegrationTests", "TenJames.CompMap\TenJames.CompMap.IntegrationTests\TenJames.CompMap.IntegrationTests.csproj", "{8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|x64.Build.0 = Debug|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Debug|x86.Build.0 = Debug|Any CPU {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|Any CPU.Build.0 = Release|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|x64.ActiveCfg = Release|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|x64.Build.0 = Release|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|x86.ActiveCfg = Release|Any CPU + {9B224A43-D90A-4D47-8A92-1E02A9BA5658}.Release|x86.Build.0 = Release|Any CPU {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|x64.ActiveCfg = Debug|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|x64.Build.0 = Debug|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|x86.ActiveCfg = Debug|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Debug|x86.Build.0 = Debug|Any CPU {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|Any CPU.ActiveCfg = Release|Any CPU {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|Any CPU.Build.0 = Release|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|x64.ActiveCfg = Release|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|x64.Build.0 = Release|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|x86.ActiveCfg = Release|Any CPU + {045DCA29-C2DC-4707-9521-98097B1B2F84}.Release|x86.Build.0 = Release|Any CPU {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|x64.Build.0 = Debug|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Debug|x86.Build.0 = Debug|Any CPU {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|Any CPU.Build.0 = Release|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|x64.ActiveCfg = Release|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|x64.Build.0 = Release|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|x86.ActiveCfg = Release|Any CPU + {B7BB7EF5-EBA5-45F2-903F-9A0DBCAD0D9C}.Release|x86.Build.0 = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|x64.Build.0 = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Debug|x86.Build.0 = Debug|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|Any CPU.Build.0 = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|x64.ActiveCfg = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|x64.Build.0 = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|x86.ActiveCfg = Release|Any CPU + {8CB48D1B-7F2C-40F5-830D-0BC8AE46B07E}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/TenJames.CompMap.sln.DotSettings.user b/TenJames.CompMap.sln.DotSettings.user index b59813f..30bf38d 100644 --- a/TenJames.CompMap.sln.DotSettings.user +++ b/TenJames.CompMap.sln.DotSettings.user @@ -10,4 +10,7 @@ ForceIncluded ForceIncluded ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> \ No newline at end of file diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs new file mode 100644 index 0000000..d38ed57 --- /dev/null +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TenJames.CompMap.Mappper; +using Xunit; + +namespace TenJames.CompMap.IntegrationTests; + +public class MappingIntegrationTests +{ + private readonly IMapper _mapper; + + public MappingIntegrationTests() + { + _mapper = new BaseMapper(); + } + + [Fact] + public void ProductReadDto_MapFrom_ShouldMapAllMatchingProperties() + { + // Arrange + var category = new Category + { + Id = 1, + Name = "Electronics", + Description = "Electronic devices" + }; + + var reviews = new List + { + new() { Id = 1, Comment = "Great!", Rating = 5, CreatedAt = DateTime.Now }, + new() { Id = 2, Comment = "Good", Rating = 4, CreatedAt = DateTime.Now } + }; + + var product = new Product + { + Id = 100, + Name = "Laptop", + Description = "High performance laptop", + Price = 999.99m, + StockQuantity = 10, + Sku = "LAP-001", + CreatedAt = new DateTime(2024, 1, 1), + UpdatedAt = new DateTime(2024, 1, 15), + IsActive = true, + InternalNotes = "Premium product", + ProductGuid = Guid.NewGuid(), + Category = category, + Reviews = reviews + }; + + // Act + var dto = ProductReadDto.MapFrom(_mapper, product); + + // Assert - Matching properties + Assert.Equal(product.Id, dto.Id); + Assert.Equal(product.Name, dto.Name); + Assert.Equal(product.Description, dto.Description); + Assert.Equal(product.Price, dto.Price); + Assert.Equal(product.StockQuantity, dto.StockQuantity); + Assert.Equal(product.Sku, dto.Sku); + Assert.Equal(product.CreatedAt, dto.CreatedAt); + Assert.Equal(product.IsActive, dto.IsActive); + Assert.Equal(product.ProductGuid, dto.ProductGuid); + + // Assert - Unmapped properties (computed) + Assert.Equal("Laptop (LAP-001)", dto.DisplayName); + Assert.True(dto.IsAvailable); // IsActive && StockQuantity > 0 + Assert.Equal("$999.99", dto.FormattedPrice); + Assert.Equal(2, dto.ReviewCount); + Assert.Equal(4.5, dto.AverageRating); + } + + [Fact] + public void ProductReadDto_MapFrom_WithNoStock_ShouldSetIsAvailableToFalse() + { + // Arrange + var product = new Product + { + Id = 101, + Name = "Out of Stock Item", + Description = "Test", + Price = 50m, + StockQuantity = 0, + Sku = "OOS-001", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + IsActive = true, + InternalNotes = "", + ProductGuid = Guid.NewGuid(), + Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Reviews = new List() + }; + + // Act + var dto = ProductReadDto.MapFrom(_mapper, product); + + // Assert + Assert.False(dto.IsAvailable); + } + + [Fact] + 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 } + }; + + var user = new User + { + Id = 1, + Username = "johndoe", + Email = "john.doe@example.com", + FirstName = "John", + LastName = "Doe", + DateOfBirth = new DateTime(1990, 5, 15), + PhoneNumber = "+1234567890", + Address = "123 Main St", + CreatedAt = new DateTime(2020, 1, 1), + LastLoginAt = DateTime.Now, + IsEmailVerified = true, + PasswordHash = "hashed_password_should_not_be_mapped", + Orders = orders + }; + + // Act + var dto = UserReadDto.MapFrom(_mapper, user); + + // Assert - Matching properties + Assert.Equal(user.Id, dto.Id); + Assert.Equal(user.Username, dto.Username); + Assert.Equal(user.Email, dto.Email); + Assert.Equal(user.FirstName, dto.FirstName); + Assert.Equal(user.LastName, dto.LastName); + Assert.Equal(user.DateOfBirth, dto.DateOfBirth); + Assert.Equal(user.PhoneNumber, dto.PhoneNumber); + Assert.Equal(user.CreatedAt, dto.CreatedAt); + Assert.Equal(user.IsEmailVerified, dto.IsEmailVerified); + + // 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--; + Assert.Equal(expectedAge, dto.Age); + Assert.True(dto.IsAdult); + Assert.True(dto.MembershipDuration > 0); + Assert.Equal(2, dto.TotalOrders); + Assert.Equal("j***@example.com", dto.MaskedEmail); + } + + [Fact] + public void CategoryDto_MapFrom_ShouldMapAllProperties() + { + // Arrange + var category = new Category + { + Id = 5, + Name = "Books", + Description = "All kinds of books" + }; + + // Act + var dto = CategoryDto.MapFrom(_mapper, category); + + // Assert + Assert.Equal(category.Id, dto.Id); + Assert.Equal(category.Name, dto.Name); + Assert.Equal(category.Description, dto.Description); + } + + [Fact] + public void ReviewDto_MapFrom_ShouldMapPropertiesAndComputeFormattedRating() + { + // Arrange + var review = new Review + { + Id = 10, + Comment = "Excellent product!", + Rating = 5, + CreatedAt = new DateTime(2024, 11, 1) + }; + + // Act + var dto = ReviewDto.MapFrom(_mapper, review); + + // Assert + Assert.Equal(review.Id, dto.Id); + Assert.Equal(review.Comment, dto.Comment); + Assert.Equal(review.Rating, dto.Rating); + Assert.Equal(review.CreatedAt, dto.CreatedAt); + Assert.Equal("5/5 stars", dto.FormattedRating); + } + + [Fact] + public void OrderDto_MapFrom_ShouldMapAllProperties() + { + // Arrange + var order = new Order + { + Id = 42, + OrderNumber = "ORD-12345", + TotalAmount = 599.99m, + CreatedAt = new DateTime(2024, 10, 15) + }; + + // Act + var dto = OrderDto.MapFrom(_mapper, order); + + // Assert + Assert.Equal(order.Id, dto.Id); + Assert.Equal(order.OrderNumber, dto.OrderNumber); + Assert.Equal(order.TotalAmount, dto.TotalAmount); + Assert.Equal(order.CreatedAt, dto.CreatedAt); + } + + [Fact] + public void ProductCreateDto_MapTo_ShouldCreateProductWithUnmappedProperties() + { + // Arrange + var category = new Category + { + Id = 3, + Name = "Clothing", + Description = "Fashion items" + }; + + var createDto = new ProductCreateDto + { + Name = "T-Shirt", + Description = "Cotton T-Shirt", + Price = 29.99m, + StockQuantity = 100, + Sku = "TSH-001", + IsActive = true, + Category = category + }; + + // Act + var product = createDto.MapTo(_mapper); + + // Assert - Matching properties + Assert.Equal(createDto.Name, product.Name); + Assert.Equal(createDto.Description, product.Description); + Assert.Equal(createDto.Price, product.Price); + Assert.Equal(createDto.StockQuantity, product.StockQuantity); + Assert.Equal(createDto.Sku, product.Sku); + Assert.Equal(createDto.IsActive, product.IsActive); + Assert.Equal(createDto.Category, product.Category); + + // 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.Equal(string.Empty, product.InternalNotes); + Assert.NotEqual(Guid.Empty, product.ProductGuid); + Assert.NotNull(product.Reviews); + Assert.Empty(product.Reviews); + } + + [Fact] + 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 } + }; + + var product = new Product + { + Id = 200, + Name = "Test Product", + Description = "Test", + Price = 100m, + StockQuantity = 5, + Sku = "TEST-001", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + IsActive = true, + InternalNotes = "", + ProductGuid = Guid.NewGuid(), + Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Reviews = reviews + }; + + // Act + var dto = ProductReadDto.MapFrom(_mapper, product); + + // Assert + Assert.NotNull(dto.Reviews); + Assert.Equal(2, dto.Reviews.Count); + + var reviewDtos = dto.Reviews.ToList(); + Assert.Equal(reviews[0].Id, reviewDtos[0].Id); + Assert.Equal(reviews[0].Comment, reviewDtos[0].Comment); + Assert.Equal(reviews[1].Id, reviewDtos[1].Id); + Assert.Equal(reviews[1].Comment, reviewDtos[1].Comment); + } + + [Fact] + public void BaseMapper_ShouldMapNestedObject() + { + // Arrange + var category = new Category + { + Id = 7, + Name = "Sports", + Description = "Sports equipment" + }; + + var product = new Product + { + Id = 300, + Name = "Basketball", + Description = "Professional basketball", + Price = 49.99m, + StockQuantity = 20, + Sku = "BALL-001", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + IsActive = true, + InternalNotes = "", + ProductGuid = Guid.NewGuid(), + Category = category, + Reviews = new List() + }; + + // Act + var dto = ProductReadDto.MapFrom(_mapper, product); + + // Assert + Assert.NotNull(dto.Category); + Assert.Equal(category.Id, dto.Category.Id); + Assert.Equal(category.Name, dto.Category.Name); + Assert.Equal(category.Description, dto.Category.Description); + } + + [Fact] + public void UserReadDto_WithNoOrders_ShouldHandleEmptyCollection() + { + // Arrange + var user = new User + { + Id = 2, + Username = "janedoe", + Email = "jane@example.com", + FirstName = "Jane", + LastName = "Doe", + DateOfBirth = new DateTime(1995, 8, 20), + PhoneNumber = "+9876543210", + Address = "456 Oak St", + CreatedAt = DateTime.Now, + LastLoginAt = DateTime.Now, + IsEmailVerified = false, + PasswordHash = "hashed", + Orders = new List() + }; + + // Act + var dto = UserReadDto.MapFrom(_mapper, user); + + // Assert + Assert.NotNull(dto.Orders); + Assert.Empty(dto.Orders); + Assert.Equal(0, dto.TotalOrders); + } + + [Fact] + public void ProductReadDto_WithNoReviews_ShouldSetAverageRatingToZero() + { + // Arrange + var product = new Product + { + Id = 400, + Name = "New Product", + Description = "Brand new", + Price = 199.99m, + StockQuantity = 50, + Sku = "NEW-001", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + IsActive = true, + InternalNotes = "", + ProductGuid = Guid.NewGuid(), + Category = new Category { Id = 1, Name = "Test", Description = "Test" }, + Reviews = new List() + }; + + // Act + var dto = ProductReadDto.MapFrom(_mapper, product); + + // Assert + Assert.Equal(0, dto.ReviewCount); + Assert.Equal(0.0, dto.AverageRating); + } +} diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj new file mode 100644 index 0000000..b232675 --- /dev/null +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TenJames.CompMap.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + + false + + TenJames.CompMap.IntegrationTests + + + + + + + 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 new file mode 100644 index 0000000..9830b6b --- /dev/null +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using TenJames.CompMap.Attributes; +using TenJames.CompMap.Mappper; + +namespace TenJames.CompMap.IntegrationTests; + +using System.Linq; + +/// +/// DTO for reading product data +/// Has 5 unmapped properties: DisplayName, IsAvailable, FormattedPrice, ReviewCount, AverageRating +/// +[MapFrom(typeof(Product))] +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 + { + DisplayName = $"{source.Name} ({source.Sku})", + IsAvailable = source.IsActive && source.StockQuantity > 0, + FormattedPrice = $"${source.Price.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}", + ReviewCount = source.Reviews?.Count ?? 0, + AverageRating = source.Reviews?.Count > 0 + ? source.Reviews.Average(r => r.Rating) + : 0.0 + }; + } +} + +/// +/// DTO for category data +/// +[MapFrom(typeof(Category))] +public partial class CategoryDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} + +/// +/// DTO for review data +/// Has 1 unmapped property: FormattedRating +/// +[MapFrom(typeof(Review))] +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) + { + return new ReviewUnmappedProperties + { + FormattedRating = $"{source.Rating}/5 stars" + }; + } +} + +/// +/// DTO for reading user data +/// Has 6 unmapped properties: FullName, Age, IsAdult, MembershipDuration, TotalOrders, MaskedEmail +/// Excludes sensitive fields like PasswordHash +/// +[MapFrom(typeof(User))] +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--; + + var membershipDays = (DateTime.Now - source.CreatedAt).Days; + + return new UserUnmappedProperties + { + FullName = $"{source.FirstName} {source.LastName}", + Age = age, + IsAdult = age >= 18, + MembershipDuration = membershipDays, + TotalOrders = source.Orders?.Count ?? 0, + MaskedEmail = MaskEmail(source.Email) + }; + } + + private static string MaskEmail(string email) + { + if (string.IsNullOrEmpty(email)) return string.Empty; + var atIndex = email.IndexOf('@'); + if (atIndex <= 1) return email; + return $"{email[0]}***{email.Substring(atIndex)}"; + } +} + +/// +/// DTO for order data +/// +[MapFrom(typeof(Order))] +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; } +} + +/// +/// DTO for creating/updating products - using MapTo +/// Has 3 unmapped properties that need to be set from somewhere else +/// +[MapTo(typeof(Product))] +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) + { + 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() + }; + } +} diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs new file mode 100644 index 0000000..904a10d --- /dev/null +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs @@ -0,0 +1,76 @@ +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(); +} + +/// +/// Entity class representing a category +/// +public class Category +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} + +/// +/// Entity class representing a review +/// +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; } +} + +/// +/// Entity class representing a user in the database +/// +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(); +} + +/// +/// Entity class representing an order +/// +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; } +} diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs b/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs new file mode 100644 index 0000000..f120f17 --- /dev/null +++ b/TenJames.CompMap/TenJames.CompMap.Tests/MapperGeneratorTests.cs @@ -0,0 +1,139 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace TenJames.CompMap.Tests; + +public class MapperGeneratorTests +{ + [Fact] + public void AttributeGenerator_ShouldGenerateMapFromAttribute() + { + // Arrange + var attributeGenerator = new AttributeGenerator(); + var driver = CSharpGeneratorDriver.Create(attributeGenerator); + var compilation = CSharpCompilation.Create( + nameof(AttributeGenerator_ShouldGenerateMapFromAttribute), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + ); + + // Act + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + // Assert + var generatedAttribute = runResult.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("MapFromAttribute.g.cs", System.StringComparison.Ordinal)); + + Assert.NotNull(generatedAttribute); + var generatedCode = generatedAttribute.GetText().ToString(); + Assert.Contains("public class MapFromAttribute", generatedCode); + Assert.Contains("Type sourceType", generatedCode); + } + + [Fact] + public void AttributeGenerator_ShouldGenerateMapToAttribute() + { + // Arrange + var attributeGenerator = new AttributeGenerator(); + var driver = CSharpGeneratorDriver.Create(attributeGenerator); + var compilation = CSharpCompilation.Create( + nameof(AttributeGenerator_ShouldGenerateMapToAttribute), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + ); + + // Act + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + // Assert + var generatedAttribute = runResult.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("MapToAttribute.g.cs", System.StringComparison.Ordinal)); + + Assert.NotNull(generatedAttribute); + var generatedCode = generatedAttribute.GetText().ToString(); + Assert.Contains("public class MapToAttribute", generatedCode); + Assert.Contains("Type destinationType", generatedCode); + } + + [Fact] + public void AttributeGenerator_ShouldGenerateMapperInterface() + { + // Arrange + var attributeGenerator = new AttributeGenerator(); + var driver = CSharpGeneratorDriver.Create(attributeGenerator); + var compilation = CSharpCompilation.Create( + nameof(AttributeGenerator_ShouldGenerateMapperInterface), + references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) } + ); + + // Act + var runResult = driver.RunGenerators(compilation).GetRunResult(); + + // Assert + var generatedMapper = runResult.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("Mapper.g.cs", System.StringComparison.Ordinal)); + + Assert.NotNull(generatedMapper); + var generatedCode = generatedMapper.GetText().ToString(); + Assert.Contains("public interface IMapper", generatedCode); + Assert.Contains("public class BaseMapper : IMapper", generatedCode); + Assert.Contains("TDestination Map(object source)", generatedCode); + } + + [Fact] + public void MapperGenerator_ShouldRunWithoutErrors() + { + // Arrange + var sourceCode = @" +using TenJames.CompMap.Attributes; + +namespace TestNamespace +{ + public class Source + { + public int Id { get; set; } + public string Name { get; set; } + } + + [MapFrom(typeof(Source))] + public partial class Target + { + public int Id { get; set; } + public string Name { get; set; } + } +}"; + + var compilation = CreateCompilation(sourceCode); + var generators = new IIncrementalGenerator[] { new AttributeGenerator(), new MapperGenerator() }; + var driver = CSharpGeneratorDriver.Create(generators); + + // Act + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + // Assert + // Check no errors occurred during generation + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.Empty(errors); + + // Check that some code was generated + Assert.True(outputCompilation.SyntaxTrees.Count() > 1, "Generator should produce additional syntax trees"); + } + + private static CSharpCompilation CreateCompilation(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.ICollection<>).Assembly.Location), + }; + + return CSharpCompilation.Create( + "TestCompilation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } +} diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAdditionalFilesTests.cs b/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAdditionalFilesTests.cs deleted file mode 100644 index 915dc7b..0000000 --- a/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAdditionalFilesTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using Microsoft.CodeAnalysis; -using TenJames.CompMap.Tests.Utils; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace TenJames.CompMap.Tests; -/* -public class SourceGeneratorWithAdditionalFilesTests { - private const string DddRegistryText = @"User -Document -Customer"; - - //[Fact] - public void GenerateClassesBasedOnDDDRegistry() - { - // Create an instance of the source generator. - var generator = new SourceGeneratorWithAdditionalFiles(); - - // Source generators should be tested using 'GeneratorDriver'. - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - - // Add the additional file separately from the compilation. - driver = driver.AddAdditionalTexts( - ImmutableArray.Create( - new TestAdditionalFile("./DDD.UbiquitousLanguageRegistry.txt", DddRegistryText)) - ); - - // To run generators, we can use an empty compilation. - var compilation = CSharpCompilation.Create(nameof(SourceGeneratorWithAdditionalFilesTests)); - - // Run generators. Don't forget to use the new compilation rather than the previous one. - driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out _); - - // Retrieve all files in the compilation. - var generatedFiles = newCompilation.SyntaxTrees - .Select(t => Path.GetFileName(t.FilePath)) - .ToArray(); - - // In this case, it is enough to check the file name. - Assert.Equivalent(new[] - { - "User.g.cs", - "Document.g.cs", - "Customer.g.cs" - }, - generatedFiles); - } -}*/ \ No newline at end of file diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAttributesTests.cs b/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAttributesTests.cs deleted file mode 100644 index 0b8b643..0000000 --- a/TenJames.CompMap/TenJames.CompMap.Tests/SourceGeneratorWithAttributesTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace TenJames.CompMap.Tests; -/* -public class SourceGeneratorWithAttributesTests { - private const string VectorClassText = @" -namespace TestNamespace; - -[Generators.Report] -public partial class Vector3 -{ - public float X { get; set; } - public float Y { get; set; } - public float Z { get; set; } -}"; - - private const string ExpectedGeneratedClassText = @"// - -using System; -using System.Collections.Generic; - -namespace TestNamespace; - -partial class Vector3 -{ - public IEnumerable Report() - { - yield return $""X:{this.X}""; - yield return $""Y:{this.Y}""; - yield return $""Z:{this.Z}""; - } -} -"; - - //[Fact] - public void GenerateReportMethod() - { - // Create an instance of the source generator. - var generator = new SourceGeneratorWithAttributes(); - - // Source generators should be tested using 'GeneratorDriver'. - var driver = CSharpGeneratorDriver.Create(generator); - - // We need to create a compilation with the required source code. - var compilation = CSharpCompilation.Create(nameof(SourceGeneratorWithAdditionalFilesTests), - new[] { CSharpSyntaxTree.ParseText(VectorClassText) }, - new[] - { - // To support 'System.Attribute' inheritance, add reference to 'System.Private.CoreLib'. - MetadataReference.CreateFromFile(typeof(object).Assembly.Location) - }); - - // Run generators and retrieve all results. - var runResult = driver.RunGenerators(compilation).GetRunResult(); - - // All generated files can be found in 'RunResults.GeneratedTrees'. - var generatedFileSyntax = runResult.GeneratedTrees.Single(t => t.FilePath.EndsWith("Vector3.g.cs")); - - // Complex generators should be tested using text comparison. - Assert.Equal(ExpectedGeneratedClassText, - generatedFileSyntax.GetText().ToString(), - ignoreLineEndingDifferences: true); - } -} -*/ \ No newline at end of file diff --git a/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj b/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj index 186f4cd..7312bd0 100644 --- a/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj +++ b/TenJames.CompMap/TenJames.CompMap.Tests/TenJames.CompMap.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable false @@ -11,6 +11,7 @@ + diff --git a/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj b/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj index 5061468..06ca1eb 100644 --- a/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj +++ b/TenJames.CompMap/TenJames.CompMap/TenJames.CompMap.csproj @@ -14,7 +14,7 @@ - 0.1.6 + 0.1.7 Compiletime Mapper Map your object on compile time https://github.com/Ten-James/CompMap @@ -50,9 +50,13 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + +