diff --git a/README.md b/README.md index 1278ac1..987e492 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ So the benefits of Mappit are: ### Properties * **Implicit property mappings** (properties with matching names and compatible types) * **Custom property mappings** - mapping from one property to another property with a different name, including support for custom value transformations. +* **Implicit nullable mappings** - automatic mapping between a type with it's `Nullable` counterpart. The reverse is supported too, but will be configured to throw + `ArgumentNullException` at runtime is the source value is null. ### Enums * **Implicit enum mappings** where all the enum names match @@ -30,6 +32,7 @@ So the benefits of Mappit are: ### Type construction * **Constructor initialization**, including constructors that only cover some of the properties. Any remaining properties will be initialized via their setters. * **Missing properties on the target type** - by default you'll get compile-time errors, but can opt in to ignore them. +* Support for both structs and classes, including records. ## Getting started diff --git a/src/Mappit.Generator/MappingTypeInfo.cs b/src/Mappit.Generator/MappingTypeInfo.cs index 9c1a410..3dfd842 100644 --- a/src/Mappit.Generator/MappingTypeInfo.cs +++ b/src/Mappit.Generator/MappingTypeInfo.cs @@ -1,6 +1,6 @@ -using System; using System.Collections.Generic; using System.Linq; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -20,20 +20,20 @@ public MappingTypeInfo(IMethodSymbol methodSymbol, ITypeSymbol sourceType, IType TargetType = targetType; MethodDeclaration = methodDeclaration; RequiresPartialMethod = true; - IsEnum = sourceType.TypeKind == TypeKind.Enum || targetType.TypeKind == TypeKind.Enum; + IsEnum = sourceType.IsEnum() || targetType.IsEnum(); if (IsEnum) { - if (sourceType.TypeKind != TypeKind.Enum || targetType.TypeKind != TypeKind.Enum) + if (!sourceType.IsEnum() || !targetType.IsEnum()) { - ValidationErrors.Add((MappitErrorCode.EnumTypeMismatch, $"{(sourceType.TypeKind == TypeKind.Enum ? "source type" : "target type")} is an enum, but the other is not.")); + ValidationErrors.Add((MappitErrorCode.EnumTypeMismatch, $"{(sourceType.IsEnum() ? "source type" : "target type")} is an enum, but the other is not.")); } } } public List<(MappitErrorCode, string)> ValidationErrors { get; set; } = []; public bool RequiresGeneration => _methodSymbol.IsPartialDefinition; - public bool IsEnum { get; init; } + public bool IsEnum { get; } public string MethodName => _methodSymbol.Name; public ITypeSymbol SourceType { get; init; } diff --git a/src/Mappit.Generator/Mappit.Generator.csproj b/src/Mappit.Generator/Mappit.Generator.csproj index ff1a229..d689c80 100644 --- a/src/Mappit.Generator/Mappit.Generator.csproj +++ b/src/Mappit.Generator/Mappit.Generator.csproj @@ -16,7 +16,7 @@ README.md LICENSE true - 0.0.5 + 0.0.6 NU5128 diff --git a/src/Mappit.Generator/MappitGenerator.MappingValidation.cs b/src/Mappit.Generator/MappitGenerator.MappingValidation.cs index 47eba49..8b238d9 100644 --- a/src/Mappit.Generator/MappitGenerator.MappingValidation.cs +++ b/src/Mappit.Generator/MappitGenerator.MappingValidation.cs @@ -24,12 +24,13 @@ private static ValidatedMapperClassInfo ValidateMappings(SourceProductionContext { context.ReportDiagnostic(code, message, mapping.MethodDeclaration); } + continue; } if (mapping.IsEnum) { - ValidateEnumMapping(context, mapping, validatedMapperClass); + ValidateEnumMapping(context, mapperClass, mapping, validatedMapperClass); } else if (TypeHelpers.IsDictionaryType(mapping.SourceType, out var sourceKeyType, out var sourceElementType)) { @@ -152,14 +153,22 @@ private static bool ValidateExplicitlyMappedCollectionGenericType( return true; } - private static void ValidateEnumMapping(SourceProductionContext context, MappingTypeInfo mapping, ValidatedMapperClassInfo validatedMapperClassInfo) + private static void ValidateEnumMapping(SourceProductionContext context, MapperClassInfo mapperClass, MappingTypeInfo mapping, ValidatedMapperClassInfo validatedMapperClass) { - var validatedMapping = new ValidatedMappingEnumInfo(mapping); - - var sourceMembers = mapping.SourceType.GetMembers().OfType() + // Handle nullable enums + var sourceType = mapping.SourceType; + var targetType = mapping.TargetType; + var sourceIsNullable = sourceType.IsNullableType(); + var targetIsNullable = targetType.IsNullableType(); + + // Get the actual enum types (unwrap nullable if needed) + var sourceEnumType = sourceType.GetNullableUnderlyingType(); + var targetEnumType = targetType.GetNullableUnderlyingType(); + + var sourceMembers = sourceEnumType.GetMembers().OfType() .Where(f => f.IsStatic && f.IsConst || f.IsReadOnly).ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - var targetMembers = mapping.TargetType.GetMembers().OfType() + var targetMembers = targetEnumType.GetMembers().OfType() .Where(f => f.IsStatic && f.IsConst || f.IsReadOnly).ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); if (mapping.PropertyMappings.Count > 0) @@ -170,6 +179,8 @@ private static void ValidateEnumMapping(SourceProductionContext context, Mapping mapping.MethodDeclaration); } + var memberMappings = new List(); + // First validate any custom mappings that have been provided foreach (var enumMapping in mapping.EnumValueMappings.Values) { @@ -177,7 +188,7 @@ private static void ValidateEnumMapping(SourceProductionContext context, Mapping { context.ReportDiagnostic( MappitErrorCode.UserMappedSourceEnumValueNotFound, - $"Source enum value '{enumMapping.SourceName}' not found in any enum property of type '{FormatTypeForErrorMessage(mapping.SourceType)}'", + $"Source enum value '{enumMapping.SourceName}' not found in any enum property of type '{FormatTypeForErrorMessage(sourceEnumType)}'", enumMapping.SourceArgument); } @@ -185,25 +196,40 @@ private static void ValidateEnumMapping(SourceProductionContext context, Mapping { context.ReportDiagnostic( MappitErrorCode.UserMappedTargetEnumValueNotFound, - $"Target enum value '{enumMapping.TargetName}' not found in any enum property of type '{FormatTypeForErrorMessage(mapping.TargetType)}'", + $"Target enum value '{enumMapping.TargetName}' not found in any enum property of type '{FormatTypeForErrorMessage(targetEnumType)}'", enumMapping.TargetArgument); } if (sourceMember is not null && targetMember is not null) { - validatedMapping.MemberMappings.Add(new ValidatedMappingEnumMemberInfo(sourceMember, targetMember)); + memberMappings.Add(new ValidatedMappingEnumMemberInfo(sourceMember, targetMember)); } } - ValidateRemainingEnumMembers(context, mapping, validatedMapping, sourceMembers, targetMembers); + ValidateRemainingEnumMembers(context, mapping, memberMappings, sourceMembers, targetMembers); - validatedMapperClassInfo.EnumMappings.Add(validatedMapping); + if (sourceIsNullable || targetIsNullable) + { + // Is there already a mapping for the underlying enum, either user defined, or currently calculated? + // TODO consider carrying around the original user mapper class with the validated mapper class so we can unify these checks and reduce passed parameters + if (!(mapperClass.HasHapping(sourceEnumType, targetEnumType) || validatedMapperClass.HasHapping(sourceEnumType, targetEnumType))) + { + // Create an additional mapping for the nullable versions of the type + validatedMapperClass.EnumMappings.Add(ValidatedMappingEnumInfo.Implicit(mapping, sourceEnumType, targetEnumType, memberMappings)); + } + + // Also register the explicit nullable mapping for the type + validatedMapperClass.NullableMappings.Add(ValidatedNullableMappingTypeInfo.Explicit(mapping)); + } + else + { + // Register the validated enum mapping + validatedMapperClass.EnumMappings.Add(ValidatedMappingEnumInfo.Explicit(mapping, memberMappings)); + } } private static bool ValidateTypeMapping(SourceProductionContext context, MapperClassInfo mapperClass, MappingTypeInfo mapping, ValidatedMapperClassInfo validatedMapperClass) { - var validatedMapping = new ValidatedMappingTypeInfo(mapping); - if (mapping.EnumValueMappings.Count > 0) { context.ReportDiagnostic( @@ -212,19 +238,26 @@ private static bool ValidateTypeMapping(SourceProductionContext context, MapperC mapping.MethodDeclaration); } + var sourceIsNullable = mapping.SourceType.IsNullableType(); + var targetIsNullable = mapping.TargetType.IsNullableType(); + var sourceType = mapping.SourceType.GetNullableUnderlyingType(); + var targetType = mapping.TargetType.GetNullableUnderlyingType(); + // We only consider source properties that are: // * Publicly accessible // * Not static // * Not write-only (i.e. they have a getter) - var sourceProperties = GetMappableProperties(mapping.SourceType) + var sourceProperties = GetMappableProperties(sourceType) .Where(f => !f.IsWriteOnly) .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - var targetProperties = GetMappableProperties(mapping.TargetType) + var targetProperties = GetMappableProperties(targetType) .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); // First validate any custom mappings that have been provided var successfullyValidated = true; + var memberMappings = new ValidatedMappingMemberInfoSet(); + foreach (var propertyMapping in mapping.PropertyMappings.Values) { // Report diagnostics if properties don't exist @@ -232,7 +265,7 @@ private static bool ValidateTypeMapping(SourceProductionContext context, MapperC { context.ReportDiagnostic( MappitErrorCode.UserMappedSourcePropertyNotFound, - $"Source property '{propertyMapping.SourceName}' not found in type '{FormatTypeForErrorMessage(mapping.SourceType)}'", + $"Source property '{propertyMapping.SourceName}' not found in type '{FormatTypeForErrorMessage(sourceType)}'", propertyMapping.SourceArgument); successfullyValidated = false; @@ -242,7 +275,7 @@ private static bool ValidateTypeMapping(SourceProductionContext context, MapperC { context.ReportDiagnostic( MappitErrorCode.UserMappedTargetPropertyNotFound, - $"Target property '{propertyMapping.TargetName}' not found in type '{FormatTypeForErrorMessage(mapping.TargetType)}'", + $"Target property '{propertyMapping.TargetName}' not found in type '{FormatTypeForErrorMessage(targetType)}'", propertyMapping.TargetArgument); successfullyValidated = false; @@ -254,7 +287,7 @@ private static bool ValidateTypeMapping(SourceProductionContext context, MapperC bool isCompatible = propertyMapping.ValueConversionMethod is not null || AreCompatibleTypes(mapperClass, sourceProperty.Type, targetProperty.Type); if (!isCompatible) { - validatedMapping.AddInvalidMapping(sourceProperty, targetProperty); + memberMappings.Add(ValidatedMappingMemberInfo.Invalid(sourceProperty, targetProperty)); ReportIncompatibleSourceAndTargetPropertyTypesDiagnostic(context, sourceProperty, targetProperty, propertyMapping.SyntaxNode); successfullyValidated = false; } @@ -264,26 +297,44 @@ private static bool ValidateTypeMapping(SourceProductionContext context, MapperC { if (ValidateValueConversionMethod(context, propertyMapping.SyntaxNode, sourceProperty.Type, targetProperty.Type, conversionMethod)) { - validatedMapping.AddValidMapping(sourceProperty, targetProperty, conversionMethod); + memberMappings.Add(ValidatedMappingMemberInfo.Valid(sourceProperty, targetProperty, conversionMethod)); } else { - validatedMapping.AddInvalidMapping(sourceProperty, targetProperty); + memberMappings.Add(ValidatedMappingMemberInfo.Invalid(sourceProperty, targetProperty)); } } else { - validatedMapping.AddValidMapping(sourceProperty, targetProperty); + memberMappings.Add(ValidatedMappingMemberInfo.Valid(sourceProperty, targetProperty)); } } } } - ValidateRemainingPropertyMappings(context, mapperClass, validatedMapperClass, mapping, validatedMapping, sourceProperties, targetProperties); + ValidateRemainingPropertyMappings(context, mapperClass, validatedMapperClass, mapping, memberMappings, sourceProperties, targetProperties); - if (ValidateConstructionRequirements(context, mapperClass, validatedMapping)) + if (ValidateConstructionRequirements(context, mapperClass, mapping, targetType, memberMappings, out var constructor)) { - validatedMapperClass.TypeMappings.Add(validatedMapping); + if (sourceIsNullable || targetIsNullable) + { + // Is there already a mapping for the underlying enum, either user defined, or currently calculated? + // TODO consider carrying around the original user mapper class with the validated mapper class so we can unify these checks and reduce passed parameters + if (!(mapperClass.HasHapping(sourceType, targetType) || validatedMapperClass.HasHapping(sourceType, targetType))) + { + // Create an additional mapping for the nullable versions of the type + validatedMapperClass.TypeMappings.Add(ValidatedMappingTypeInfo.Implicit(mapping, sourceType, targetType, memberMappings, constructor!)); + } + + // Also register the explicit nullable mapping for the type + validatedMapperClass.NullableMappings.Add(ValidatedNullableMappingTypeInfo.Explicit(mapping)); + } + else + { + // Register the validated enum mapping + validatedMapperClass.TypeMappings.Add(ValidatedMappingTypeInfo.Explicit(mapping, memberMappings, constructor!)); + } + return successfullyValidated; } @@ -338,15 +389,22 @@ private static void ReportIncompatibleSourceAndTargetPropertyTypesDiagnostic( syntaxNode); } - private static bool ValidateConstructionRequirements(SourceProductionContext context, MapperClassInfo mapperClass, ValidatedMappingTypeInfo mapping) + private static bool ValidateConstructionRequirements( + SourceProductionContext context, + MapperClassInfo mapperClass, + MappingTypeInfo mapping, + ITypeSymbol targetType, + ValidatedMappingMemberInfoSet memberMappings, + out IMethodSymbol? constructor) { - var bestCtor = FindBestConstructor(mapping); + constructor = default; + var bestCtor = FindBestConstructor(targetType, memberMappings); if (bestCtor is null) { context.ReportDiagnostic( MappitErrorCode.NoSuitableConstructorFound, - $"No suitable constructor found for type '{FormatTypeForErrorMessage(mapping.TargetType)}'. Parameter names must match the target type's property names.", + $"No suitable constructor found for type '{FormatTypeForErrorMessage(targetType)}'. Parameter names must match the target type's property names.", mapping.MethodDeclaration); return false; @@ -356,7 +414,7 @@ private static bool ValidateConstructionRequirements(SourceProductionContext con // Then we can validate that the target properties aren't read only. var constructorParams = bestCtor.Parameters.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - foreach (var propertyMapping in mapping.MemberMappings.Values) + foreach (var propertyMapping in memberMappings) { if (constructorParams.TryGetValue(propertyMapping.TargetProperty.Name, out var constructorParam)) { @@ -391,16 +449,15 @@ private static bool ValidateConstructionRequirements(SourceProductionContext con } } - mapping.Constructor = bestCtor; - + constructor = bestCtor; return true; } - private static IMethodSymbol? FindBestConstructor(ValidatedMappingTypeInfo mapping) + private static IMethodSymbol? FindBestConstructor(ITypeSymbol targetType, ValidatedMappingMemberInfoSet memberMappings) { (IMethodSymbol? ctor, int bestMatchCount) bestCtor = (null, 0); - var ctors = mapping.TargetType.GetMembers() + var ctors = targetType.GetMembers() .Where(m => m.Kind == SymbolKind.Method && m.Name == ".ctor") .Cast() .OrderByDescending(m => m.Parameters.Length) @@ -412,10 +469,9 @@ private static bool ValidateConstructionRequirements(SourceProductionContext con foreach (var param in ctor.Parameters) { - // Try to find a mapping that matches parameter name and type. The lookup is keyed by - // the target property name, so we're expecting the target constructor parameter name to - // match the target property name. - if (mapping.MemberMappings.ContainsKey(param.Name)) + // Try to find a mapping that matches parameter name and type. We're expecting the + // target constructor parameter name to match the target property name. + if (memberMappings.ContainsTargetName(param.Name)) { matchingParams++; } @@ -451,7 +507,7 @@ private static void ValidateRemainingPropertyMappings( MapperClassInfo mapperClass, ValidatedMapperClassInfo validatedMapperClass, MappingTypeInfo mappingInfo, - ValidatedMappingTypeInfo validatedMapping, + ValidatedMappingMemberInfoSet memberMappings, Dictionary sourceProperties, Dictionary targetProperties) { @@ -479,23 +535,66 @@ private static void ValidateRemainingPropertyMappings( // Check if property types are compatible if (!AreCompatibleTypes(mapperClass, sourceMember.Type, targetMember.Type)) { - validatedMapping.AddInvalidMapping(sourceMember, targetMember); + memberMappings.Add(ValidatedMappingMemberInfo.Invalid(sourceMember, targetMember)); ReportIncompatibleSourceAndTargetPropertyTypesDiagnostic(context, sourceMember, targetMember, mappingInfo.MethodDeclaration); } else { // The mapping is valid, so we can add it to the validated mapping - validatedMapping.AddValidMapping(sourceMember, targetMember); + memberMappings.Add(ValidatedMappingMemberInfo.Valid(sourceMember, targetMember)); // If the mapped property is a collection of some sort, we also need to generate some additional // type mappings that implement the collection mapping logic. ConfigureImplicitCollectionMappings(validatedMapperClass, mappingInfo.MethodDeclaration, sourceMember.Type, targetMember.Type); + + // If the mapped source or target property is a nullable type, we also need to add maps for them + ConfigureImplicitNullableTypeMappings(mapperClass, validatedMapperClass, mappingInfo.MethodDeclaration, sourceMember.Type, targetMember.Type); } } } } } + private static void ConfigureImplicitNullableTypeMappings( + MapperClassInfo mapperClass, + ValidatedMapperClassInfo validatedMapperClass, + SyntaxNode methodDeclaration, + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + var sourceIsNullable = sourceType.IsNullableType(); + var targetIsNullable = targetType.IsNullableType(); + + if (sourceIsNullable || targetIsNullable) + { + // Is there already a direct mapping configured between the two? + if (validatedMapperClass.HasHapping(sourceType, targetType)) + { + // Nothing to do here, we already have a mapping for this type + return; + } + + // We don't need to emit an implicit conversion if *both* types are nullable and no + // conversion is required between the two, e.g. DateTime? to DateTime? + // By the time we get here, we will have already checked for compatibility between + // the underlying types, so if there is no explicit map, then we can assume that the + // types are compatible. + if (sourceIsNullable + && targetIsNullable + && !mapperClass.HasHapping(sourceType.GetNullableUnderlyingType(), targetType.GetNullableUnderlyingType())) + { + return; + } + + // TODO If the target is not nullable, but the source is, raise a warning + // that there may be conversion errors at runtime. + + // Create an additional mapping for the nullable versions of the type + validatedMapperClass.NullableMappings.Add( + ValidatedNullableMappingTypeInfo.Implicit(sourceType, targetType, methodDeclaration)); + } + } + /// /// Configures implicit mappings required for collections/dictionaries. /// @@ -556,7 +655,7 @@ private static void ConfigureImplicitCollectionMappings( private static void ValidateRemainingEnumMembers( SourceProductionContext context, MappingTypeInfo mappingInfo, - ValidatedMappingEnumInfo validatedMapping, + List memberMappings, Dictionary sourceMembers, Dictionary targetMembers) { @@ -575,7 +674,7 @@ private static void ValidateRemainingEnumMembers( } else { - validatedMapping.MemberMappings.Add(new ValidatedMappingEnumMemberInfo(sourceMember, targetMember)); + memberMappings.Add(new ValidatedMappingEnumMemberInfo(sourceMember, targetMember)); } } } @@ -583,44 +682,7 @@ private static void ValidateRemainingEnumMembers( private static bool AreCompatibleTypes(MapperClassInfoBase mapperClass, ITypeSymbol sourceType, ITypeSymbol targetType) { - // Simple case: if the types are the same, they're compatible - if (sourceType.Equals(targetType, SymbolEqualityComparer.Default)) - { - return true; - } - - // Check if the types are compatible because they've already been mapped. - // For example, sourceType may be TypeA and targetType may be TypeB, which are not the same type, but they - // may be compatible because the user has mapped them. - if (mapperClass.HasHapping(sourceType, targetType)) - { - return true; - } - - // Dictionary types - these also have collection/enumerable interfaces, so we need to check them first - if (TypeHelpers.IsDictionaryType(sourceType, out var sourceKeyType, out var sourceValueType) && - TypeHelpers.IsDictionaryType(targetType, out var targetKeyType, out var targetValueType)) - { - if (sourceKeyType != null && targetKeyType != null && sourceValueType != null && targetValueType != null) - { - // Dictionaries are compatible if their key and value types are compatible - return AreCompatibleTypes(mapperClass, sourceKeyType, targetKeyType) && - AreCompatibleTypes(mapperClass, sourceValueType, targetValueType); - } - } - - // Check for collection types - if (TypeHelpers.IsCollectionType(sourceType, out var sourceElementType) && - TypeHelpers.IsCollectionType(targetType, out var targetElementType)) - { - if (sourceElementType != null && targetElementType != null) - { - // Collections are compatible if their element types are compatible - return AreCompatibleTypes(mapperClass, sourceElementType, targetElementType); - } - } - - return false; + return TypeCompatibilityChecker.AreCompatibleTypes(mapperClass, sourceType, targetType); } /// diff --git a/src/Mappit.Generator/MappitGenerator.cs b/src/Mappit.Generator/MappitGenerator.cs index 058f730..c3c01a5 100644 --- a/src/Mappit.Generator/MappitGenerator.cs +++ b/src/Mappit.Generator/MappitGenerator.cs @@ -48,7 +48,7 @@ private static void GenerateMapper(SourceProductionContext context, MapperClassI source.AppendLine("{"); var classModifiers = string.Join( - " ", + " ", mapperClass.ClassDeclarationSyntax.Modifiers .Where(m => !m.IsKind(SyntaxKind.PartialKeyword) && !m.IsKind(SyntaxKind.SealedKeyword)) .Select(m => m.Text)); @@ -83,6 +83,11 @@ private static void GenerateMapper(SourceProductionContext context, MapperClassI } } + foreach (var mapping in validatedMap.NullableMappings) + { + EmitNullableMappingMethod(source, validatedMap, mapping); + } + source.AppendLine(" }"); source.AppendLine("}"); @@ -101,7 +106,9 @@ private static void GenerateMapperInterface(StringBuilder source, string classMo // Generate interface methods for each mapping type foreach (var mapping in validatedMap.EnumMappings .Concat(validatedMap.TypeMappings) - .Concat(validatedMap.CollectionMappings.Where(cm => !cm.IsImplicitMapping))) + .Concat(validatedMap.CollectionMappings) + .Concat(validatedMap.NullableMappings) + .Where(cm => !cm.IsImplicitMapping)) { source.AppendLine($" /// "); source.AppendLine($" /// Maps {mapping.SourceType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)} to {mapping.TargetType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}"); @@ -142,16 +149,50 @@ private static void EmitSourcePropertyReference(StringBuilder source, ValidatedM } } + private static void EmitNullableMappingMethod(StringBuilder source, ValidatedMapperClassInfo classInfo, ValidatedNullableMappingTypeInfo mapping) + { + EmitMappingMethodDeclaration(source, mapping); + + bool needsElementMapping = classInfo.TryGetMappedType( + mapping.SourceNullableUnderlyingType ?? mapping.SourceType, + mapping.TargetNullableUnderlyingType ?? mapping.TargetType, + out var underlyingTypeMapping); + + source.AppendLine(" return source is {} notNullSource"); + source.Append(" ? "); + if (needsElementMapping) + { + // If we need to map the underlying type, we need to call the mapping method + source.AppendLine($"{underlyingTypeMapping!.MethodName}(notNullSource)"); + } + else + { + // No mapping needed; just return the value + source.AppendLine($"notNullSource"); + } + + source.Append(" : "); + if (mapping.TargetNullableUnderlyingType is null) + { + // If the target type is not nullable, there's a runtime conversion error + source.AppendLine($"throw new global::System.ArgumentNullException(nameof(source), \"Cannot map null to non-nullable type {FormatTypeForErrorMessage(mapping.TargetType)}\");"); + } + else + { + // If the target type is nullable, we can just return null + source.AppendLine($"default({mapping.TargetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)});"); + } + + source.AppendLine(" }"); + } + private static void EmitCollectionMappingMethod(StringBuilder source, ValidatedMapperClassInfo classInfo, ValidatedCollectionMappingTypeInfo mapping) { var (sourceElementType, targetElementType) = mapping.ElementTypeMap; EmitMappingMethodDeclaration(source, mapping); - source.AppendLine(" if (source is null)"); - source.AppendLine(" {"); - source.AppendLine(" return default;"); - source.AppendLine(" }"); - source.AppendLine(); + EmitSourceNullCheck(source, mapping); + source.Append(" "); bool needsElementMapping = classInfo.TryGetMappedType(sourceElementType, targetElementType, out var elementMapping); @@ -201,13 +242,9 @@ private static void EmitDictionaryMappingMethod(StringBuilder source, ValidatedM bool needsValueMapping = classInfo.TryGetMappedType(sourceElementType, targetElementType, out var valueMapping); EmitMappingMethodDeclaration(source, mapping); - source.AppendLine(" if (source is null)"); - source.AppendLine(" {"); - source.AppendLine(" return default;"); - source.AppendLine(" }"); - source.AppendLine(); + EmitSourceNullCheck(source, mapping); + source.Append(" "); - var concreteReturnType = TypeHelpers.InferConcreteDictionaryType(mapping.TargetType, targetKeyType, targetElementType); if (needsKeyMapping || needsValueMapping) { @@ -248,11 +285,7 @@ private static void GenerateTypeMappingMethod(StringBuilder source, ValidatedMap } EmitMappingMethodDeclaration(source, mapping); - source.AppendLine(" if (source is null)"); - source.AppendLine(" {"); - source.AppendLine(" return default;"); - source.AppendLine(" }"); - source.AppendLine(); + EmitSourceNullCheck(source, mapping); // Start object initialization source.Append($" return new {mapping.TargetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}"); @@ -286,14 +319,14 @@ private static void GenerateTypeMappingMethod(StringBuilder source, ValidatedMap source.Append("()"); } - if (mapping.MemberMappings.Values.Any(m => m.TargetMapping == TargetMapping.Initialization)) + if (mapping.MemberMappings.Any(m => m.TargetMapping == TargetMapping.Initialization)) { // Start object initializer source.AppendLine(); source.AppendLine(" {"); // Handle custom property mappings first (skip those already set by constructor) - foreach (var propertyMapping in mapping.MemberMappings.Values.Where(x => x.TargetMapping == TargetMapping.Initialization)) + foreach (var propertyMapping in mapping.MemberMappings.Where(x => x.TargetMapping == TargetMapping.Initialization)) { source.Append($" {propertyMapping.TargetProperty.Name} = "); EmitSourcePropertyReference(source, classInfo, propertyMapping); @@ -314,6 +347,19 @@ private static void GenerateTypeMappingMethod(StringBuilder source, ValidatedMap source.AppendLine(" }"); } + private static void EmitSourceNullCheck(StringBuilder source, ValidatedMappingInfo mapping) + { + var targetType = mapping.TargetType; + if (!targetType.IsValueType) + { + source.AppendLine(" if (source is null)"); + source.AppendLine(" {"); + source.AppendLine($" return default({mapping.TargetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)});"); + source.AppendLine(" }"); + source.AppendLine(); + } + } + private static void GenerateEnumMappingMethod(StringBuilder source, ValidatedMappingEnumInfo mapping) { if (!mapping.RequiresGeneration) diff --git a/src/Mappit.Generator/TypeCompatibilityChecker.cs b/src/Mappit.Generator/TypeCompatibilityChecker.cs new file mode 100644 index 0000000..530bace --- /dev/null +++ b/src/Mappit.Generator/TypeCompatibilityChecker.cs @@ -0,0 +1,64 @@ +using Microsoft.CodeAnalysis; + +namespace Mappit.Generator +{ + internal static class TypeCompatibilityChecker + { + /// + /// Determines if two types are compatible for mapping purposes + /// + public static bool AreCompatibleTypes(MapperClassInfoBase mapperClass, ITypeSymbol sourceType, ITypeSymbol targetType) + { + // Simple case: if the types are the same, they're compatible + if (sourceType.Equals(targetType, SymbolEqualityComparer.Default)) + { + return true; + } + + // Check if the types are compatible because they've already been mapped + if (mapperClass.HasHapping(sourceType, targetType)) + { + return true; + } + + // Check for nullable types + var sourceIsNullable = sourceType.IsNullableType(); + var targetIsNullable = targetType.IsNullableType(); + + // If one or the other is nullable, check if their underlying types are compatible + if (sourceIsNullable || targetIsNullable) + { + var sourceUnderlyingType = sourceIsNullable ? sourceType.GetNullableUnderlyingType() : sourceType; + var targetUnderlyingType = targetIsNullable ? targetType.GetNullableUnderlyingType() : targetType; + + // If the underlying types are compatible, then the nullable and non-nullable types are compatible + return AreCompatibleTypes(mapperClass, sourceUnderlyingType, targetUnderlyingType); + } + + // Dictionary types - these also have collection/enumerable interfaces, so we need to check them first + if (TypeHelpers.IsDictionaryType(sourceType, out var sourceKeyType, out var sourceValueType) && + TypeHelpers.IsDictionaryType(targetType, out var targetKeyType, out var targetValueType)) + { + if (sourceKeyType != null && targetKeyType != null && sourceValueType != null && targetValueType != null) + { + // Dictionaries are compatible if their key and value types are compatible + return AreCompatibleTypes(mapperClass, sourceKeyType, targetKeyType) && + AreCompatibleTypes(mapperClass, sourceValueType, targetValueType); + } + } + + // Check for collection types + if (TypeHelpers.IsCollectionType(sourceType, out var sourceElementType) && + TypeHelpers.IsCollectionType(targetType, out var targetElementType)) + { + if (sourceElementType != null && targetElementType != null) + { + // Collections are compatible if their element types are compatible + return AreCompatibleTypes(mapperClass, sourceElementType, targetElementType); + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/Mappit.Generator/TypeSymbolExtensions.cs b/src/Mappit.Generator/TypeSymbolExtensions.cs new file mode 100644 index 0000000..975506d --- /dev/null +++ b/src/Mappit.Generator/TypeSymbolExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis; + +namespace Mappit.Generator +{ + internal static class TypeSymbolExtensions + { + /// + /// Determines if this type is a nullable type (Nullable) + /// + public static bool IsNullableType(this ITypeSymbol type) + { + return type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + } + + /// + /// If this is a nullable type, returns the underlying type. + /// Otherwise, returns the type itself. + /// + public static ITypeSymbol GetNullableUnderlyingType(this ITypeSymbol type) + { + if (type is INamedTypeSymbol namedType && namedType.IsNullableType()) + { + return namedType.TypeArguments[0]; + } + + return type; + } + + /// + /// Determines if this type is a struct + /// + public static bool IsStruct(this ITypeSymbol type) + { + return type.IsValueType && type.TypeKind == TypeKind.Struct; + } + + /// + /// Determines if this type is an enum + /// + public static bool IsEnum(this ITypeSymbol type) + { + return type.TypeKind == TypeKind.Enum + || (type.IsNullableType() && type.GetNullableUnderlyingType().TypeKind == TypeKind.Enum); + } + } +} \ No newline at end of file diff --git a/src/Mappit.Generator/ValidatedCollectionMappingTypeInfo.cs b/src/Mappit.Generator/ValidatedCollectionMappingTypeInfo.cs index b1073b1..6227538 100644 --- a/src/Mappit.Generator/ValidatedCollectionMappingTypeInfo.cs +++ b/src/Mappit.Generator/ValidatedCollectionMappingTypeInfo.cs @@ -7,8 +7,6 @@ namespace Mappit.Generator { internal sealed record ValidatedCollectionMappingTypeInfo : ValidatedMappingInfo { - private static readonly Regex invalidMethodCharacterReplacement = new(@"[^a-zA-Z0-9_匚コᐸᐳ]"); - private ValidatedCollectionMappingTypeInfo( ITypeSymbol sourceType, ITypeSymbol targetType, @@ -18,51 +16,11 @@ private ValidatedCollectionMappingTypeInfo( bool isImplicitMapping, (ITypeSymbol sourceElementType, ITypeSymbol targetElementType) elementTypeMap, (ITypeSymbol sourceKeyType, ITypeSymbol targetKeyType)? keyTypeMap = null) - : base(methodName, sourceType, targetType, associatedSyntaxNode) + : base(methodName, sourceType, targetType, associatedSyntaxNode, isImplicitMapping) { CollectionKind = collectionKind; ElementTypeMap = elementTypeMap; KeyTypeMap = keyTypeMap; - RequiresPartialMethod = !isImplicitMapping; - IsImplicitMapping = isImplicitMapping; - } - - //public ValidatedCollectionMappingTypeInfo( - // ITypeSymbol sourceType, - // ITypeSymbol targetType, - // SyntaxNode associatedSyntaxNode, - // CollectionKind collectionKind, - // (ITypeSymbol sourceElementType, ITypeSymbol targetElementType) elementTypeMap, - // (ITypeSymbol sourceKeyType, ITypeSymbol targetKeyType)? keyTypeMap = null) - // : base( - // BuildImplicitMappingMethodName(sourceType, targetType), - // sourceType, - // targetType, - // associatedSyntaxNode) - //{ - // CollectionKind = collectionKind; - // ElementTypeMap = elementTypeMap; - // KeyTypeMap = keyTypeMap; - // RequiresPartialMethod = false; - // IsImplicitMapping = true; - //} - - private static string BuildImplicitMappingMethodName(ITypeSymbol sourceType, ITypeSymbol targetType) - { - return $"__Implicit_{FormatForMethodName(sourceType)}_to_{FormatForMethodName(targetType)}"; - } - - private static string FormatForMethodName(ITypeSymbol typeSymbol) - { - var name = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); - - name = name.Replace("[", "匚"); - name = name.Replace("]", "コ"); - name = name.Replace("<", "ᐸ"); - name = name.Replace(">", "ᐳ"); - - // We'll likely have non-valid C# characters in the name, so we need to strip them out - return invalidMethodCharacterReplacement.Replace(name, "_"); } internal static ValidatedCollectionMappingTypeInfo Explicit( @@ -101,12 +59,6 @@ internal static ValidatedCollectionMappingTypeInfo Implicit( keyTypeMap); } - /// - /// Gets whether this collection mapping is implicit (i.e. inferred as part of a mapping to a type's property) - /// or an explicit mapping defined by the user's mapping type. Implicit mappings are not added to the mapper interface. - /// - public bool IsImplicitMapping { get; } - /// /// The kind of collection mapping to generate. /// diff --git a/src/Mappit.Generator/ValidatedMapperClassInfo.cs b/src/Mappit.Generator/ValidatedMapperClassInfo.cs index eb73fd8..247581f 100644 --- a/src/Mappit.Generator/ValidatedMapperClassInfo.cs +++ b/src/Mappit.Generator/ValidatedMapperClassInfo.cs @@ -16,12 +16,14 @@ public ValidatedMapperClassInfo(MapperClassInfo classInfo) public List TypeMappings { get; } = new(); public List EnumMappings { get; } = new(); public List CollectionMappings { get; } = new(); + public List NullableMappings { get; } = new(); internal bool TryGetMappedType(ITypeSymbol sourceType, ITypeSymbol targetType, out ValidatedMappingInfo? typeMapping) { foreach (var mapping in TypeMappings .Concat(EnumMappings) - .Concat(CollectionMappings)) + .Concat(CollectionMappings) + .Concat(NullableMappings)) { if (mapping.SourceType.Equals(sourceType, SymbolEqualityComparer.Default) && mapping.TargetType.Equals(targetType, SymbolEqualityComparer.Default)) diff --git a/src/Mappit.Generator/ValidatedMappingEnumInfo.cs b/src/Mappit.Generator/ValidatedMappingEnumInfo.cs index 2d62bdf..b9b6531 100644 --- a/src/Mappit.Generator/ValidatedMappingEnumInfo.cs +++ b/src/Mappit.Generator/ValidatedMappingEnumInfo.cs @@ -1,14 +1,61 @@ +using Microsoft.CodeAnalysis; + using System.Collections.Generic; namespace Mappit.Generator { internal sealed record ValidatedMappingEnumInfo : ValidatedMappingInfo { - public ValidatedMappingEnumInfo(MappingTypeInfo mappingTypeInfo) - : base(mappingTypeInfo) + private ValidatedMappingEnumInfo( + string methodName, + List memberMappings, + ITypeSymbol sourceEnumType, + ITypeSymbol targetEnumType, + SyntaxNode associatedSyntaxNode, + bool isImplicitMapping, + bool requiresPartialMethod) + : base( + methodName, + sourceEnumType, + targetEnumType, + associatedSyntaxNode, + isImplicitMapping) { + MemberMappings = memberMappings; + RequiresGeneration = true; + RequiresPartialMethod &= requiresPartialMethod; } - public List MemberMappings { get; } = new(); + public List MemberMappings { get; } + + public static ValidatedMappingEnumInfo Implicit( + MappingTypeInfo mapping, + ITypeSymbol sourceEnumType, + ITypeSymbol targetEnumType, + List memberMappings) + { + return new ValidatedMappingEnumInfo( + BuildImplicitMappingMethodName(sourceEnumType, targetEnumType), + memberMappings, + sourceEnumType, + targetEnumType, + mapping.MethodDeclaration, + true, + mapping.RequiresPartialMethod); + } + + public static ValidatedMappingEnumInfo Explicit( + MappingTypeInfo mapping, + List memberMappings) + { + return new ValidatedMappingEnumInfo( + mapping.MethodName, + memberMappings, + mapping.SourceType, + mapping.TargetType, + mapping.MethodDeclaration, + false, + mapping.RequiresPartialMethod); + } } } \ No newline at end of file diff --git a/src/Mappit.Generator/ValidatedMappingInfo.cs b/src/Mappit.Generator/ValidatedMappingInfo.cs index 925ab17..0875126 100644 --- a/src/Mappit.Generator/ValidatedMappingInfo.cs +++ b/src/Mappit.Generator/ValidatedMappingInfo.cs @@ -1,29 +1,26 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; + +using System.Text.RegularExpressions; namespace Mappit.Generator { internal abstract record ValidatedMappingInfo { + private static readonly Regex invalidMethodCharacterReplacement = new(@"[^a-zA-Z0-9_匚コᐸᐳˀ]"); + protected ValidatedMappingInfo( string methodName, ITypeSymbol sourceType, ITypeSymbol targetType, - SyntaxNode associatedSyntaxNode) + SyntaxNode associatedSyntaxNode, + bool isImplicitMapping) { MethodName = methodName; SourceType = sourceType; TargetType = targetType; MethodDeclaration = associatedSyntaxNode; - } - - protected ValidatedMappingInfo(MappingTypeInfo mappingTypeInfo) - { - MethodName = mappingTypeInfo.MethodName; - SourceType = mappingTypeInfo.SourceType; - TargetType = mappingTypeInfo.TargetType; - MethodDeclaration = mappingTypeInfo.MethodDeclaration; - RequiresGeneration = mappingTypeInfo.RequiresGeneration; - RequiresPartialMethod = mappingTypeInfo.RequiresPartialMethod; + IsImplicitMapping = isImplicitMapping; + RequiresPartialMethod = !isImplicitMapping; } public string MethodName { get; init; } @@ -32,5 +29,30 @@ protected ValidatedMappingInfo(MappingTypeInfo mappingTypeInfo) public SyntaxNode MethodDeclaration { get; init; } public bool RequiresGeneration { get; init; } public bool RequiresPartialMethod { get; init; } + + /// + /// Gets whether this is an implicit (e.g. inferred as part of a mapping to a type's property) + /// or an explicit mapping defined by the user's mapping type. Implicit mappings are not added to the mapper interface. + /// + public bool IsImplicitMapping { get; protected init; } + + protected static string BuildImplicitMappingMethodName(ITypeSymbol sourceType, ITypeSymbol targetType) + { + return $"__Implicit_{FormatForMethodName(sourceType)}_to_{FormatForMethodName(targetType)}"; + } + + private static string FormatForMethodName(ITypeSymbol typeSymbol) + { + var name = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + + name = name.Replace("[", "匚"); + name = name.Replace("]", "コ"); + name = name.Replace("<", "ᐸ"); + name = name.Replace(">", "ᐳ"); + name = name.Replace("?", "ˀ"); + + // We'll likely have non-valid C# characters in the name, so we need to strip them out + return invalidMethodCharacterReplacement.Replace(name, "_"); + } } } \ No newline at end of file diff --git a/src/Mappit.Generator/ValidatedMappingMemberInfoSet.cs b/src/Mappit.Generator/ValidatedMappingMemberInfoSet.cs new file mode 100644 index 0000000..e68198d --- /dev/null +++ b/src/Mappit.Generator/ValidatedMappingMemberInfoSet.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Mappit.Generator +{ + internal sealed class ValidatedMappingMemberInfoSet : IEnumerable + { + private readonly Dictionary mappings = new(StringComparer.OrdinalIgnoreCase); + + public ValidatedMappingMemberInfo this[string name] => mappings[name]; + + public void Add(ValidatedMappingMemberInfo mapping) + { + this.mappings.Add(mapping.TargetProperty.Name, mapping); + } + + public IEnumerator GetEnumerator() + { + return mappings.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public bool ContainsTargetName(string name) + { + return mappings.ContainsKey(name); + } + } +} diff --git a/src/Mappit.Generator/ValidatedMappingTypeInfo.cs b/src/Mappit.Generator/ValidatedMappingTypeInfo.cs index 8c53c1d..9f97930 100644 --- a/src/Mappit.Generator/ValidatedMappingTypeInfo.cs +++ b/src/Mappit.Generator/ValidatedMappingTypeInfo.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.CodeAnalysis; @@ -7,9 +8,25 @@ namespace Mappit.Generator { internal sealed record ValidatedMappingTypeInfo : ValidatedMappingInfo { - public ValidatedMappingTypeInfo(MappingTypeInfo mappingTypeInfo) - : base(mappingTypeInfo) + private ValidatedMappingTypeInfo( + string methodName, + ValidatedMappingMemberInfoSet memberMappings, + ITypeSymbol sourceType, + ITypeSymbol targetType, + IMethodSymbol constructor, + bool isImplicitMapping, + MappingTypeInfo mapping) + : base( + methodName, + sourceType, + targetType, + mapping.MethodDeclaration, + isImplicitMapping) { + Constructor = constructor; + MemberMappings = memberMappings; + RequiresGeneration = mapping.RequiresGeneration; + RequiresPartialMethod &= mapping.RequiresPartialMethod; } /// @@ -20,18 +37,40 @@ public ValidatedMappingTypeInfo(MappingTypeInfo mappingTypeInfo) /// However, this will mean that if a property is named "Name" and another property is named "name" we will /// not be able to distinguish between them. /// - public Dictionary MemberMappings { get; } = new(StringComparer.OrdinalIgnoreCase); + public ValidatedMappingMemberInfoSet MemberMappings { get; } - public IMethodSymbol? Constructor { get; internal set; } + public IMethodSymbol Constructor { get; } - public void AddValidMapping(IPropertySymbol sourceProperty, IPropertySymbol targetProperty, IMethodSymbol? valueConversionMethod = null) + public static ValidatedMappingTypeInfo Implicit( + MappingTypeInfo mapping, + ITypeSymbol sourceEnumType, + ITypeSymbol targetEnumType, + ValidatedMappingMemberInfoSet memberMappings, + IMethodSymbol constructor) { - MemberMappings.Add(targetProperty.Name, ValidatedMappingMemberInfo.Valid(sourceProperty, targetProperty, valueConversionMethod)); + return new ValidatedMappingTypeInfo( + BuildImplicitMappingMethodName(sourceEnumType, targetEnumType), + memberMappings, + sourceEnumType, + targetEnumType, + constructor, + true, + mapping); } - public void AddInvalidMapping(IPropertySymbol sourceProperty, IPropertySymbol targetProperty) + public static ValidatedMappingTypeInfo Explicit( + MappingTypeInfo mapping, + ValidatedMappingMemberInfoSet memberMappings, + IMethodSymbol constructor) { - MemberMappings.Add(targetProperty.Name, ValidatedMappingMemberInfo.Invalid(sourceProperty, targetProperty)); + return new ValidatedMappingTypeInfo( + mapping.MethodName, + memberMappings, + mapping.SourceType, + mapping.TargetType, + constructor, + false, + mapping); } } } \ No newline at end of file diff --git a/src/Mappit.Generator/ValidatedNullableMappingTypeInfo.cs b/src/Mappit.Generator/ValidatedNullableMappingTypeInfo.cs new file mode 100644 index 0000000..6df8d1c --- /dev/null +++ b/src/Mappit.Generator/ValidatedNullableMappingTypeInfo.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis; + +namespace Mappit.Generator +{ + internal sealed record ValidatedNullableMappingTypeInfo : ValidatedMappingInfo + { + private ValidatedNullableMappingTypeInfo( + string name, + ITypeSymbol sourceType, + ITypeSymbol targetType, + SyntaxNode associatedSyntaxNode, + bool isImplicitMapping) + : base(name, sourceType, targetType, associatedSyntaxNode, isImplicitMapping) + { + SourceNullableUnderlyingType = sourceType.IsNullableType() ? sourceType.GetNullableUnderlyingType() : null; + TargetNullableUnderlyingType = targetType.IsNullableType() ? targetType.GetNullableUnderlyingType() : null; + } + + public ITypeSymbol? SourceNullableUnderlyingType { get; } + public ITypeSymbol? TargetNullableUnderlyingType { get; } + + internal static ValidatedNullableMappingTypeInfo Explicit(MappingTypeInfo mapping) + { + return new( + mapping.MethodName, + mapping.SourceType, + mapping.TargetType, + mapping.MethodDeclaration, + isImplicitMapping: false); + } + + internal static ValidatedNullableMappingTypeInfo Implicit( + ITypeSymbol sourceType, + ITypeSymbol targetType, + SyntaxNode originatingSyntaxNode) + { + return new( + BuildImplicitMappingMethodName(sourceType, targetType), + sourceType, + targetType, + originatingSyntaxNode, + isImplicitMapping: true); + } + } +} \ No newline at end of file diff --git a/test/Mappit.Tests/MappingGenerationVerification/BasicMappingTests.cs b/test/Mappit.Tests/MappingGenerationVerification/BasicMappingTests.cs index 7a6351e..6ab3dc8 100644 --- a/test/Mappit.Tests/MappingGenerationVerification/BasicMappingTests.cs +++ b/test/Mappit.Tests/MappingGenerationVerification/BasicMappingTests.cs @@ -35,8 +35,9 @@ public void Map_PersonToDto_ShouldMapAllSharedProperties() Assert.Equal(person.LastName, dto.LastName); Assert.Equal(person.BirthDate, dto.BirthDate); Assert.Equal(person.Email, dto.Email); + Assert.Null(person.Height); } - + [Fact] public void Map_DtoToPerson_ShouldMapSharedProperties() { @@ -60,6 +61,7 @@ public void Map_DtoToPerson_ShouldMapSharedProperties() Assert.Equal(dto.BirthDate, person.BirthDate); Assert.Equal(dto.Email, person.Email); Assert.Equal(0, person.Age); // Non-mapped property should have default value + Assert.Null(person.Height); } [Fact] @@ -78,6 +80,7 @@ internal sealed class Person public DateTime BirthDate { get; set; } public int Age { get; set; } public required string Email { get; set; } + public int? Height { get; set; } } internal sealed class PersonDto @@ -88,6 +91,7 @@ internal sealed class PersonDto public DateTime BirthDate { get; set; } public int Age { get; set; } public required string Email { get; set; } + public int? Height { get; set; } } [Mappit] diff --git a/test/Mappit.Tests/MappingGenerationVerification/EnumMappingTests.cs b/test/Mappit.Tests/MappingGenerationVerification/EnumMappingTests.cs index e0d185b..06b5aff 100644 --- a/test/Mappit.Tests/MappingGenerationVerification/EnumMappingTests.cs +++ b/test/Mappit.Tests/MappingGenerationVerification/EnumMappingTests.cs @@ -55,6 +55,16 @@ public void Map_DtoWithEnumToModel_ShouldMapEnumProperties() Assert.Equal(Color.Red, model.PrimaryColor); Assert.Equal(Color.Blue, model.SecondaryColor); } + + [Fact] + public void Map_BetweenNullableAndNonNullable() + { + var mapper = new TestMapperWithExplicitNullableEnums(); + + // Mapping between nullables should work + Assert.Equal((DisplayColor?)DisplayColor.Blue, mapper.Map((Color?)Color.Blue)); + + } } public enum Color @@ -76,7 +86,7 @@ public class ModelWithEnum public int Id { get; set; } public required string Name { get; init; } public Color PrimaryColor { get; set; } - public Color SecondaryColor { get; set; } + public Color? SecondaryColor { get; set; } } public class DtoWithEnum @@ -84,7 +94,7 @@ public class DtoWithEnum public int Id { get; set; } public required string Name { get; init; } public DisplayColor PrimaryColor { get; set; } - public DisplayColor SecondaryColor { get; set; } + public DisplayColor? SecondaryColor { get; set; } } [Mappit] @@ -93,9 +103,18 @@ public partial class TestMapperWithEnums public partial DtoWithEnum Map(ModelWithEnum source); public partial ModelWithEnum Map(DtoWithEnum source); - + + [ReverseMap] public partial Color Map(DisplayColor source); - - public partial DisplayColor Map(Color source); + } + + [Mappit] + public partial class TestMapperWithExplicitNullableEnums + { + public partial DisplayColor? Map(Color? source); + + public partial DisplayColor MapToNonNullable(Color? source); + + public partial DisplayColor? MapToNullable(Color? source); } } \ No newline at end of file diff --git a/test/Mappit.Tests/MappingGenerationVerification/StructMappingTests.cs b/test/Mappit.Tests/MappingGenerationVerification/StructMappingTests.cs new file mode 100644 index 0000000..f3de05e --- /dev/null +++ b/test/Mappit.Tests/MappingGenerationVerification/StructMappingTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Xunit; + +namespace Mappit.Tests.MappingGenerationVerification +{ + public class StructMappingTests + { + [Fact] + public void MappingStructToStruct_ShouldMapProperties() + { + var person1Dto = new PersonStructDto(1, "Bob", null); + var person2Dto = new PersonStructDto(2, "Jenz", new DateTime(2000, 1, 2)); + + ITestMapperWithStructs mapper = new TestMapperWithStructs(); + + var person1 = mapper.Map(person1Dto); + var person2 = mapper.Map(person2Dto); + + Assert.Equivalent(person1, new PersonStruct(1, "Bob", null)); + Assert.Equivalent(person2, new PersonStruct(2, "Jenz", new DateTime(2000, 1, 2))); + } + + [Fact] + public void MappingNullableStructToStruct_ShouldMapProperties() + { + var personDto = new PersonStructDto(1, "Bob", null); + + ITestMapperWithStructs mapper = new TestMapperWithStructs(); + + var person = mapper.MapNullable(new Nullable(personDto)); + + Assert.Equivalent(person, new PersonStruct(1, "Bob", null)); + } + + [Fact] + public void MappingNullNullableStructToStruct_ShouldThrowException() + { + ITestMapperWithStructs mapper = new TestMapperWithStructs(); + Assert.Throws("source", () => mapper.MapNullable(null)); + } + + [Fact] + public void MappingStructToNullableStruct_ShouldMapProperties() + { + var personDto = new PersonStructDto(1, "Bob", null); + + ITestMapperWithStructs mapper = new TestMapperWithStructs(); + + var person = mapper.MapNullableReturn(personDto); + + Assert.Equivalent(person, new PersonStruct(1, "Bob", null)); + } + } + + [Mappit] + public partial class TestMapperWithStructs + { + [ReverseMap] + public partial PersonStruct Map(PersonStructDto source); + + public partial PersonStruct? MapNullableReturn(PersonStructDto source); + + public partial PersonStruct MapNullable(PersonStructDto? source); + } + + public record struct PersonStruct(int Id, string Name, DateTime? BirthDate); + + public record struct PersonStructDto(int Id, string Name, DateTime? BirthDate); +} \ No newline at end of file