diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs index d38ed57..ad8b3bd 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/MappingIntegrationTests.cs @@ -397,4 +397,72 @@ public void ProductReadDto_WithNoReviews_ShouldSetAverageRatingToZero() Assert.Equal(0, dto.ReviewCount); Assert.Equal(0.0, dto.AverageRating); } + + [Fact] + public void VehicleReadDto_MapFrom_ShouldMapPropertiesFromBaseClass() + { + // Arrange + var vehicle = new Vehicle + { + // Properties from BaseEntity + Id = 1, + CreatedAt = new DateTime(2024, 1, 1), + UpdatedAt = new DateTime(2024, 1, 15), + IsDeleted = false, + // Properties from Vehicle + Make = "Toyota", + Model = "Camry", + Year = 2024, + Color = "Blue" + }; + + // Act + var dto = VehicleReadDto.MapFrom(_mapper, vehicle); + + // Assert - Properties from base class BaseDto + Assert.Equal(vehicle.Id, dto.Id); + Assert.Equal(vehicle.CreatedAt, dto.CreatedAt); + Assert.Equal(vehicle.UpdatedAt, dto.UpdatedAt); + + // Assert - Properties from VehicleReadDto + Assert.Equal(vehicle.Make, dto.Make); + Assert.Equal(vehicle.Model, dto.Model); + Assert.Equal(vehicle.Year, dto.Year); + Assert.Equal(vehicle.Color, dto.Color); + } + + [Fact] + public void VehicleCreateDto_MapTo_ShouldMapPropertiesFromBaseClass() + { + // Arrange + var dto = new VehicleCreateDto + { + // Properties from BaseDto + Id = 2, + CreatedAt = new DateTime(2024, 2, 1), + UpdatedAt = new DateTime(2024, 2, 10), + // Properties from VehicleCreateDto + Make = "Honda", + Model = "Accord", + Year = 2023, + Color = "Red" + }; + + // Act + var vehicle = _mapper.Map(dto); + + // Assert - Properties from base class BaseEntity + Assert.Equal(dto.Id, vehicle.Id); + Assert.Equal(dto.CreatedAt, vehicle.CreatedAt); + Assert.Equal(dto.UpdatedAt, vehicle.UpdatedAt); + + // Assert - Properties from Vehicle + Assert.Equal(dto.Make, vehicle.Make); + Assert.Equal(dto.Model, vehicle.Model); + Assert.Equal(dto.Year, vehicle.Year); + Assert.Equal(dto.Color, vehicle.Color); + + // Assert - Unmapped property + Assert.False(vehicle.IsDeleted); + } } diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs index 9830b6b..4b03a25 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestDtos.cs @@ -129,6 +129,7 @@ private static partial UserUnmappedProperties GetUserUnmappedProperties(IMapper }; } + private static string MaskEmail(string email) { if (string.IsNullOrEmpty(email)) return string.Empty; @@ -179,3 +180,38 @@ private static partial ProductUnmappedProperties GetProductUnmappedProperties(IM }; } } + +/// +/// DTO for Vehicle that inherits from BaseDto +/// Tests that properties from base classes are properly mapped +/// +[MapFrom(typeof(Vehicle))] +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; +} + +/// +/// DTO for creating Vehicle that inherits from BaseDto +/// Tests MapTo with inheritance +/// +[MapTo(typeof(Vehicle))] +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 + }; + } + +} diff --git a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs index 904a10d..9144f0f 100644 --- a/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs +++ b/TenJames.CompMap/TenJames.CompMap.IntegrationTests/TestEntities.cs @@ -74,3 +74,35 @@ public class Order public decimal TotalAmount { get; set; } public DateTime CreatedAt { get; set; } } + +/// +/// Base entity class with common properties +/// +public abstract class BaseEntity +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsDeleted { get; set; } +} + +/// +/// Vehicle entity that inherits from 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; +} + +/// +/// Base DTO class with common properties +/// +public abstract class BaseDto +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs b/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs index a53f586..81c266f 100644 --- a/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs +++ b/TenJames.CompMap/TenJames.CompMap/MapperGenerator.cs @@ -57,33 +57,43 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati var sourceText = new SourceBuilder(); sourceText.AppendLine($"using {Consts.MapperNamespace};"); + + // Add using for target namespace if different from current namespace + if (ma.TargetNamespace != ma.Namespace && ma.TargetNamespace != "GlobalNamespace") + { + sourceText.AppendLine($"using {ma.TargetNamespace};"); + } + sourceText.AppendLine(); sourceText.AppendLine($"namespace {ma.Namespace};"); sourceText.AppendLine(); sourceText.AppendLine($"partial class {className}"); sourceText.AppendLine("{"); sourceText.IncreaseIndent(); - var matchingFields = ma.ClassDeclarationSyntax.Members - .OfType() - .Where(prop => ma.Target != null - && ma.Target.Members - .OfType() - .Any(targetProp => targetProp.Identifier.Text == prop.Identifier.Text)) + + // Get all properties including inherited ones + var allSourceProperties = MappingOptions.GetAllProperties(ma.SemanticModel, ma.ClassDeclarationSyntax); + + // Get semantic model for target (which might be in a different file) + var targetSemanticModel = compilation.GetSemanticModel(ma.Target.SyntaxTree); + var allTargetProperties = MappingOptions.GetAllProperties(targetSemanticModel, ma.Target); + + var matchingFields = allSourceProperties + .Where(prop => allTargetProperties + .Any(targetProp => targetProp.Name == prop.Name)) .ToList(); - + if (ma.AttributeName.Contains("MapFrom")) { - var missingFields = ma.ClassDeclarationSyntax.Members - .OfType() - .Where(prop => ma.Target != null - && ma.Target.Members.OfType().All(targetProp => targetProp.Identifier.Text != prop.Identifier.Text)) + var missingFields = allSourceProperties + .Where(prop => allTargetProperties.All(targetProp => targetProp.Name != prop.Name)) .ToList(); var isMissing = missingFields.Any(); if (isMissing) { - // create a subclass inside + // 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"); @@ -92,17 +102,15 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati using var block = sourceText.BeginBlock($"internal class {ma.TargetName}UnmappedProperties"); foreach (var prop in missingFields) { - var location = prop.GetLocation().GetMappedLineSpan(); + // Add property documentation sourceText.AppendLine($"/// "); - sourceText.AppendLine($"/// Found at {location.Path.Substring(location.Path.LastIndexOf('/'))} at {location.StartLinePosition.Line + 1}"); + sourceText.AppendLine($"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); sourceText.AppendLine($"/// "); - sourceText.AppendLine($"public { - string.Join("",prop.Modifiers.Where(x => !x.ToFullString().Contains("public")).Select(x => x.ToFullString())) - }{prop.Type.ToFullString().Trim()} {prop.Identifier.Text} {{ 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.Target.Identifier.Text} source);"); + sourceText.AppendLine($"private static partial {ma.TargetName}UnmappedProperties Get{ma.TargetName}UnmappedProperties(IMapper mapper, {ma.TargetFullName} source);"); } sourceText.AppendLine(); @@ -111,7 +119,7 @@ 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.Target?.Identifier.Text} source)"); + using var mapFromBlock = sourceText.BeginBlock($"public static {className} MapFrom(IMapper mapper, {ma.TargetFullName} source)"); if (isMissing) { @@ -124,23 +132,23 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati sourceText.IncreaseIndent(); foreach (var prop in matchingFields) { - if (prop.Type.ToFullString() != ma.Target?.Members - .OfType() - .FirstOrDefault(p => p.Identifier.Text == prop.Identifier.Text)? - .Type.ToFullString()) + var targetProp = allTargetProperties + .FirstOrDefault(p => p.Name == prop.Name); + + if (targetProp != null && prop.TypeFullName != targetProp.TypeFullName) { // Type mismatch, use mapper - sourceText.AppendLine($"{prop.Identifier.Text} = mapper.Map<{prop.Type.ToFullString()}>(source.{prop.Identifier.Text}),"); + sourceText.AppendLine($"{prop.Name} = mapper.Map<{prop.TypeFullName.Replace("global::", "")}>(source.{prop.Name}),"); } else { - sourceText.AppendLine($"{prop.Identifier.Text} = source.{prop.Identifier.Text},"); + sourceText.AppendLine($"{prop.Name} = source.{prop.Name},"); } } - + foreach (var prop in missingFields) { - sourceText.AppendLine($"{prop.Identifier.Text} = unmapped.{prop.Identifier.Text},"); + sourceText.AppendLine($"{prop.Name} = unmapped.{prop.Name},"); } sourceText.DecreaseIndent(); sourceText.AppendLine("};"); @@ -149,15 +157,14 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati } else if (ma.AttributeName.Contains("MapTo")) { - var missingFields = ma.Target.Members - .OfType() - .Where(prop => ma.ClassDeclarationSyntax.Members.OfType().All(targetProp => targetProp.Identifier.Text != prop.Identifier.Text)) + var missingFields = allTargetProperties + .Where(prop => allSourceProperties.All(targetProp => targetProp.Name != prop.Name)) .ToList(); - + var isMissing = missingFields.Any(); if (isMissing) { - // create a subclass inside + // 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"); @@ -166,13 +173,11 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati using var block = sourceText.BeginBlock($"internal class {ma.TargetName}UnmappedProperties"); foreach (var prop in missingFields) { - var location = prop.GetLocation().GetMappedLineSpan(); + // Add property documentation sourceText.AppendLine($"/// "); - sourceText.AppendLine($"/// Found at {location.Path.Substring(location.Path.LastIndexOf('/'))} at {location.StartLinePosition.Line + 1}"); + sourceText.AppendLine($"/// Property: {prop.Name} of type {prop.TypeFullName.Replace("global::", "")}"); sourceText.AppendLine($"/// "); - sourceText.AppendLine($"public { - string.Join("",prop.Modifiers.Where(x => !x.ToFullString().Contains("public")).Select(x => x.ToFullString())) - }{prop.Type.ToFullString().Trim()} {prop.Identifier.Text} {{ get; set; }}"); + sourceText.AppendLine($"public {prop.TypeFullName.Replace("global::", "")} {prop.Name} {{ get; set; }}"); } } sourceText.AppendLine(); @@ -185,23 +190,35 @@ private void GenerateCode(SourceProductionContext context, Compilation compilati sourceText.AppendLine("/// Mapping method generated by TenJames.CompMap"); sourceText.AppendLine("/// "); using var mapToBlock = sourceText.BeginBlock( - $"public {ma.TargetName} MapTo(IMapper mapper)" + $"public {ma.TargetFullName} MapTo(IMapper mapper)" ); if (isMissing) { sourceText.AppendLine("var unmapped = Get" + ma.TargetName + "UnmappedProperties(mapper, this);"); } - sourceText.AppendLine($"var target = new {ma.TargetName}() {{"); + sourceText.AppendLine($"var target = new {ma.TargetFullName}() {{"); sourceText.IncreaseIndent(); foreach (var prop in matchingFields) { - sourceText.AppendLine($" {prop.Identifier.Text} = this.{prop.Identifier.Text},"); + // Get the corresponding target property to check type + var targetProp = allTargetProperties + .FirstOrDefault(p => p.Name == prop.Name); + + if (targetProp != null && prop.TypeFullName != targetProp.TypeFullName) + { + // Type mismatch, use mapper + sourceText.AppendLine($" {prop.Name} = mapper.Map<{targetProp.TypeFullName.Replace("global::", "")}>(this.{prop.Name}),"); + } + else + { + sourceText.AppendLine($" {prop.Name} = this.{prop.Name},"); + } } if (isMissing) { foreach (var prop in missingFields) { - sourceText.AppendLine($" {prop.Identifier.Text} = unmapped.{prop.Identifier.Text},"); + sourceText.AppendLine($" {prop.Name} = unmapped.{prop.Name},"); } } sourceText.DecreaseIndent(); diff --git a/TenJames.CompMap/TenJames.CompMap/Properties/MappingOptions.cs b/TenJames.CompMap/TenJames.CompMap/Properties/MappingOptions.cs index d501436..a01ea30 100644 --- a/TenJames.CompMap/TenJames.CompMap/Properties/MappingOptions.cs +++ b/TenJames.CompMap/TenJames.CompMap/Properties/MappingOptions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,8 +13,48 @@ public class MappingOptions { public string Namespace { get; set; } public ClassDeclarationSyntax Target { get; set; } public string TargetName => Target.Identifier.Text; - - + public string TargetNamespace { get; set; } + public string TargetFullName => string.IsNullOrEmpty(TargetNamespace) ? TargetName : $"{TargetNamespace}.{TargetName}"; + public SemanticModel SemanticModel { get; set; } + + /// + /// Gets all properties including inherited ones from a class declaration + /// + public static List GetAllProperties(SemanticModel semanticModel, ClassDeclarationSyntax classDecl) + { + var properties = new List(); + var symbol = semanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol; + + if (symbol == null) return properties; + + // Walk up the inheritance chain to get all properties + var currentType = symbol; + while (currentType != null && currentType.SpecialType != SpecialType.System_Object) + { + // Get properties declared on this type + var typeProperties = currentType.GetMembers().OfType(); + + foreach (var prop in typeProperties) + { + // Avoid duplicates (overridden properties) + if (!properties.Any(p => p.Name == prop.Name)) + { + properties.Add(new PropertyInfo + { + Name = prop.Name, + TypeFullName = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + PropertySymbol = prop + }); + } + } + + // Move to base type + currentType = currentType.BaseType; + } + + return properties; + } + public static MappingOptions? Create( GeneratorSyntaxContext context, ClassDeclarationSyntax classDeclarationSyntax) @@ -33,19 +74,40 @@ public class MappingOptions { var attributeName = attributeSyntax.Name.ToString(); if (AttributeDefinitions.GetAllAttributes().Select(x => x.Name).Any(x => attributeName.Contains(x))) { + var targetClass = attributeSyntax.ArgumentList?.Arguments.First().Expression switch { + TypeOfExpressionSyntax typeOfExpression => context.SemanticModel.GetSymbolInfo(typeOfExpression.Type).Symbol + ?.DeclaringSyntaxReferences.First().GetSyntax() as ClassDeclarationSyntax, + _ => null + } ?? throw new InvalidOperationException("Target type could not be determined."); + + // Get target namespace + var targetNs = targetClass.FirstAncestorOrSelf(); + var targetFileScoped = targetClass.FirstAncestorOrSelf(); + + var targetNamespace = targetNs != null + ? targetNs.Name.ToString() + : targetFileScoped != null + ? targetFileScoped.Name.ToString() + : "GlobalNamespace"; + return new MappingOptions { ClassDeclarationSyntax = classDeclarationSyntax, AttributeName = attributeName, Namespace = namespaceName, - Target = attributeSyntax.ArgumentList?.Arguments.First().Expression switch { - TypeOfExpressionSyntax typeOfExpression => context.SemanticModel.GetSymbolInfo(typeOfExpression.Type).Symbol - ?.DeclaringSyntaxReferences.First().GetSyntax() as ClassDeclarationSyntax, - _ => null - } ?? throw new InvalidOperationException("Target type could not be determined.") + Target = targetClass, + TargetNamespace = targetNamespace, + SemanticModel = context.SemanticModel }; } } return null; } +} + +public class PropertyInfo +{ + public string Name { get; set; } = string.Empty; + public string TypeFullName { get; set; } = string.Empty; + public IPropertySymbol PropertySymbol { get; set; } = null!; } \ No newline at end of file