diff --git a/docs/docs/breaking-changes/5-0.md b/docs/docs/breaking-changes/5-0.md index e6a26b7dfd..be6df78517 100644 --- a/docs/docs/breaking-changes/5-0.md +++ b/docs/docs/breaking-changes/5-0.md @@ -17,7 +17,8 @@ description: How to upgrade to Mapperly 5.0 and a list of all its breaking chang - `Stack` deep cloning now preserves the order of elements by default. - Inaccessible members from other assemblies are now included in the mapping process if `IncludedMembers` or `IncludedConstructors` options are configured to include them. - Constructor mappings with nullable source types no longer generate null guards when the constructor parameter accepts null, see below. -- `MaybeNull` attributes are now respected for fields, properties and constructor parameters. +- `MaybeNull` attributes are now respected for fields, properties and constructor parameters when reading values. +- `AllowNull` attributes are now respected for fields, properties and constructor parameters when writing values. - Copy constructors are no longer selected over constructors annotated with `[MapperConstructor]` or when `[MapProperty]` configurations are present. ## Inaccessible members from other assemblies included diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersContainerBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersContainerBuilderContext.cs index 04a5b87736..0bde6daea2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersContainerBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersContainerBuilderContext.cs @@ -35,7 +35,7 @@ public void AddNullDelegateMemberAssignmentMapping(IMemberAssignmentMapping memb // set target member to null if null assignments are allowed // and the source is null var setMemberToNull = - BuilderContext.Configuration.Mapper.AllowNullPropertyAssignment && memberMapping.MemberInfo.TargetMember.Member.IsNullable; + BuilderContext.Configuration.Mapper.AllowNullPropertyAssignment && memberMapping.MemberInfo.TargetMember.Member.IsWriteNullable; // if the member is explicitly set to null, // make sure the parent members are initialized/non-null, @@ -50,7 +50,7 @@ public void AddNullDelegateMemberAssignmentMapping(IMemberAssignmentMapping memb var nullConditionSourcePath = new NonEmptyMemberPath( memberMapping.MemberInfo.SourceMember.MemberPath.RootType, - memberMapping.MemberInfo.SourceMember.MemberPath.PathWithoutTrailingNonNullable().ToList() + memberMapping.MemberInfo.SourceMember.MemberPath.ReadPathWithoutTrailingNonNullable().ToList() ); var container = GetOrCreateNullDelegateMappingForPath(nullConditionSourcePath); AddMemberAssignmentMapping(container, memberMapping); @@ -76,7 +76,7 @@ private void AddMemberAssignmentMapping(IMemberAssignmentMappingContainer contai // if the source value is a non-nullable value, // the target should be non-null after this assignment and can be set as initialized. - if (!mapping.MemberInfo.IsSourceNullable && mapping.MemberInfo.TargetMember.MemberType.IsNullable()) + if (!mapping.MemberInfo.IsSourceNullable && mapping.MemberInfo.TargetMember.Member.IsWriteNullable) { _initializedNullableTargetPaths.Add((container, mapping.MemberInfo.TargetMember)); } @@ -84,7 +84,7 @@ private void AddMemberAssignmentMapping(IMemberAssignmentMappingContainer contai private void AddNullMemberInitializers(IMemberAssignmentMappingContainer container, MemberPath path) { - foreach (var nullablePathList in path.ObjectPathNullableSubPaths()) + foreach (var nullablePathList in path.ObjectReadPathNullableSubPaths()) { var nullablePath = new NonEmptyMemberPath(path.RootType, nullablePathList); var type = nullablePath.Member.Type.NonNullable(); @@ -137,7 +137,7 @@ private IMemberAssignmentMappingContainer FindParentNonNullContainer(MemberPath // try to reuse parent path mappings and wrap inside them // if the parentMapping is the first nullable path, no need to access the path in the condition in a null-safe way. needsNullSafeAccess = false; - foreach (var nullablePathList in nullConditionSourcePath.ObjectPathNullableSubPaths().Reverse()) + foreach (var nullablePathList in nullConditionSourcePath.ObjectReadPathNullableSubPaths().Reverse()) { var nullablePath = new NonEmptyMemberPath(nullConditionSourcePath.RootType, nullablePathList); if (_nullDelegateMappings.TryGetValue(nullablePath, out var parentMappingContainer)) diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs index 6fc9f976e1..0ae9dc19a2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingState.cs @@ -28,7 +28,8 @@ internal class MembersMappingState( Dictionary> memberConfigsByRootTargetName, Dictionary> configuredTargetMembersByRootName, HashSet ignoredSourceMemberNames, - ParameterScope parameterScope + ParameterScope parameterScope, + SymbolAccessor symbolAccessor ) { private readonly Dictionary _aliasedSourceMembers = new(StringComparer.OrdinalIgnoreCase); @@ -58,7 +59,7 @@ ParameterScope parameterScope public IReadOnlyDictionary AdditionalSourceMembers => field ??= parameterScope.Parameters.Values.ToDictionary( x => x.NormalizedName, - x => new ParameterSourceMember(x), + x => new ParameterSourceMember(x, symbolAccessor), StringComparer.OrdinalIgnoreCase ); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs index f484aff7b9..393a989596 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/MembersMappingStateBuilder.cs @@ -68,7 +68,8 @@ public static MembersMappingState Build(MappingBuilderContext ctx, IMapping mapp memberConfigsByRootTargetName, configuredTargetMembersByRootName.AsDictionary(), ignoredSourceMemberNames, - parameterScope + parameterScope, + ctx.SymbolAccessor ); } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/NestedMappingsContext.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/NestedMappingsContext.cs index 16c48b20a8..3cfd332dc4 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/NestedMappingsContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/NestedMappingsContext.cs @@ -70,7 +70,7 @@ private bool TryFindNestedSourcePath( { if ( _context.SymbolAccessor.TryFindMemberPath( - nestedMemberPath.MemberType, + nestedMemberPath.MemberReadType, pathCandidates, // Use empty ignore list to support ignoring a property for normal search while flattening its properties Array.Empty(), diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs index bb7ab04242..1f6f950fe2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs @@ -85,7 +85,7 @@ public static bool TryBuild( return false; } - var memberTargetNullable = memberMappingInfo.TargetMember.MemberType.IsNullable(); + var memberTargetAcceptsNull = memberMappingInfo.TargetMember.Member.IsWriteNullable; var delegateTargetNullable = delegateMapping.TargetType.IsNullable(); var memberSourceNullable = memberMappingInfo.IsSourceNullable; var delegateSourceNullable = delegateMapping.SourceType.IsNullable(); @@ -93,7 +93,7 @@ public static bool TryBuild( if ( memberMappingInfo.Configuration?.SuppressNullMismatchDiagnostic != true && memberSourceNullable - && !memberTargetNullable + && !memberTargetAcceptsNull && !(delegateSourceNullable && !delegateTargetNullable) ) { @@ -107,8 +107,8 @@ public static bool TryBuild( } if ( - (memberSourceNullable == delegateSourceNullable && memberTargetNullable == delegateTargetNullable) - || (memberSourceNullable && !memberTargetNullable && delegateSourceNullable && !delegateTargetNullable) + (memberSourceNullable == delegateSourceNullable && memberTargetAcceptsNull == delegateTargetNullable) + || (memberSourceNullable && !memberTargetAcceptsNull && delegateSourceNullable && !delegateTargetNullable) ) { sourceValue = new MappedMemberSourceValue( @@ -132,7 +132,7 @@ public static bool TryBuild( return false; } - sourceValue = BuildInlineNullHandlingMapping(ctx, delegateMapping, sourceMember.MemberPath, targetMember.MemberType); + sourceValue = BuildInlineNullHandlingMapping(ctx, delegateMapping, sourceMember.MemberPath, targetMember.MemberWriteType); return true; } @@ -174,7 +174,7 @@ ITypeSymbol targetMemberType ) { var nullFallback = NullFallbackValue.Default; - if (!delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyNullable()) + if (!delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable()) { nullFallback = ctx.BuilderContext.GetNullFallbackValue(targetMemberType); } @@ -198,7 +198,7 @@ NonEmptyMemberPath targetMember var sourceGetter = sourceMember.BuildGetter(ctx.BuilderContext); // no member of the source path is nullable, no null handling needed - if (!sourceMember.IsAnyNullable()) + if (!sourceMember.IsAnyReadNullable()) { return new MappedMemberSourceValue(delegateMapping, sourceGetter, false, true); } @@ -209,7 +209,7 @@ NonEmptyMemberPath targetMember // access the source in a null save matter (via ?.) but no other special handling required. if ( ctx.BuilderContext.Configuration.Mapper.AllowNullPropertyAssignment - && (delegateMapping.SourceType.IsNullable() || delegateMapping.IsSynthetic && targetMember.Member.IsNullable) + && (delegateMapping.SourceType.IsNullable() || delegateMapping.IsSynthetic && targetMember.Member.IsWriteNullable) ) { return new MappedMemberSourceValue(delegateMapping, sourceGetter, true, false); diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs index a370e16e35..c7cf9d3347 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/SourceValueBuilder.cs @@ -77,8 +77,8 @@ private static bool TryBuildConstantSourceValue( // but the provided value is null or default (for default IsNullable is also true) if ( value.ConstantValue.IsNull - && memberMappingInfo.TargetMember.MemberType.IsReferenceType - && !memberMappingInfo.TargetMember.Member.IsNullable + && memberMappingInfo.TargetMember.MemberWriteType.IsReferenceType + && !memberMappingInfo.TargetMember.Member.IsWriteNullable ) { ctx.BuilderContext.ReportDiagnostic( @@ -92,8 +92,8 @@ private static bool TryBuildConstantSourceValue( // target is value type but value is null if ( value.ConstantValue.IsNull - && memberMappingInfo.TargetMember.MemberType.IsValueType - && !memberMappingInfo.TargetMember.MemberType.IsNullableValueType() + && memberMappingInfo.TargetMember.MemberWriteType.IsValueType + && !memberMappingInfo.TargetMember.MemberWriteType.IsNullableValueType() && value.Expression.IsKind(SyntaxKind.NullLiteralExpression) ) { @@ -116,7 +116,7 @@ private static bool TryBuildConstantSourceValue( // use non-nullable target type to allow non-null value type assignments // to nullable value types - if (!SymbolEqualityComparer.Default.Equals(value.ConstantValue.Type, memberMappingInfo.TargetMember.MemberType.NonNullable())) + if (!SymbolEqualityComparer.Default.Equals(value.ConstantValue.Type, memberMappingInfo.TargetMember.MemberWriteType.NonNullable())) { ctx.BuilderContext.ReportDiagnostic( DiagnosticDescriptors.MapValueTypeMismatch, @@ -137,7 +137,7 @@ private static bool TryBuildConstantSourceValue( // expand enum member access to fully qualified identifier // use simple member name approach instead of slower visitor pattern on the expression var enumMemberName = ((MemberAccessExpressionSyntax)value.Expression).Name.Identifier.Text; - var enumTypeFullName = FullyQualifiedIdentifier(memberMappingInfo.TargetMember.MemberType.NonNullable()); + var enumTypeFullName = FullyQualifiedIdentifier(memberMappingInfo.TargetMember.MemberWriteType.NonNullable()); sourceValue = new ConstantSourceValue(MemberAccess(enumTypeFullName, enumMemberName)); return true; case TypedConstantKind.Type: @@ -186,10 +186,10 @@ private static bool TryBuildMethodProvidedSourceValue( // to nullable value types // nullable is checked with nullable annotation var methodCandidates = namedMethodCandidates.Where(x => - SymbolEqualityComparer.Default.Equals(x.ReturnType.NonNullable(), memberMappingInfo.TargetMember.MemberType.NonNullable()) + SymbolEqualityComparer.Default.Equals(x.ReturnType.NonNullable(), memberMappingInfo.TargetMember.MemberWriteType.NonNullable()) ); - if (!memberMappingInfo.TargetMember.Member.IsNullable) + if (!memberMappingInfo.TargetMember.Member.IsWriteNullable) { // Filter out methods that may return null when the target is non-nullable. methodCandidates = methodCandidates.Where(m => !ctx.BuilderContext.SymbolAccessor.MayReturnNull(m, false)); @@ -201,7 +201,7 @@ private static bool TryBuildMethodProvidedSourceValue( ctx.BuilderContext.ReportDiagnostic( DiagnosticDescriptors.MapValueMethodTypeMismatch, methodReferenceConfiguration.Name, - namedMethodCandidates[0].ReturnType.ToDisplayString(), + ctx.BuilderContext.SymbolAccessor.UpgradeReturnNullable(namedMethodCandidates[0]).ToDisplayString(), memberMappingInfo.TargetMember.ToDisplayString() ); sourceValue = null; diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberMappingInfo.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberMappingInfo.cs index c9465ad397..e0766a0155 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberMappingInfo.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/MemberMappingInfo.cs @@ -18,7 +18,7 @@ public MemberMappingInfo(SourceMemberPath? sourceMember, NonEmptyMemberPath targ public MemberMappingInfo(NonEmptyMemberPath targetMember, MemberValueMappingConfiguration configuration) : this(null, targetMember, configuration) { } - public bool IsSourceNullable => SourceMember?.MemberPath.IsAnyNullable() ?? ValueConfiguration?.Value?.ConstantValue.IsNull ?? true; + public bool IsSourceNullable => SourceMember?.MemberPath.IsAnyReadNullable() ?? ValueConfiguration?.Value?.ConstantValue.IsNull ?? true; private string DebuggerDisplay => $"{SourceMember?.MemberPath.FullName ?? ValueConfiguration?.DescribeValue()} => {TargetMember.FullName}"; @@ -34,7 +34,11 @@ public TypeMappingKey ToTypeMappingKey() if (SourceMember == null) throw new InvalidOperationException($"{SourceMember} and {TargetMember} need to be set to create a {nameof(TypeMappingKey)}"); - return new TypeMappingKey(SourceMember.MemberPath.MemberType, TargetMember.MemberType, Configuration?.ToTypeMappingConfiguration()); + return new TypeMappingKey( + SourceMember.MemberPath.MemberReadType, + TargetMember.MemberWriteType, + Configuration?.ToTypeMappingConfiguration() + ); } public string DescribeSource() diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MappedMemberSourceValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MappedMemberSourceValue.cs index dacde868fe..02439ca6f6 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MappedMemberSourceValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/MappedMemberSourceValue.cs @@ -19,7 +19,7 @@ bool addValuePropertyOnNullable private readonly MemberPathGetter _sourceMember = sourceMember; private readonly INewInstanceMapping _delegateMapping = delegateMapping; - public bool RequiresSourceNullCheck => !nullConditionalAccess && _sourceMember.MemberPath.IsAnyNullable(); + public bool RequiresSourceNullCheck => !nullConditionalAccess && _sourceMember.MemberPath.IsAnyReadNullable(); public ExpressionSyntax Build(TypeMappingBuildContext ctx) { diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs index c5c7cef44f..da4dbe9e8e 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs @@ -27,7 +27,7 @@ bool useNullConditionalAccess public ExpressionSyntax Build(TypeMappingBuildContext ctx) { // if the source is not nullable, return it directly. - if (!_sourceGetter.MemberPath.IsAnyNullable()) + if (!_sourceGetter.MemberPath.IsAnyReadNullable()) { ctx = ctx.WithSource(_sourceGetter.BuildAccess(ctx.Source)); return _delegateMapping.Build(ctx); @@ -49,7 +49,7 @@ public ExpressionSyntax Build(TypeMappingBuildContext ctx) // source.A?.B == null ? : Map(source.A.B.Value) // use simplified coalesce expression for synthetic mappings: // source.A?.B ?? - if (_delegateMapping.IsSynthetic && (useNullConditionalAccess || !_sourceGetter.MemberPath.IsAnyObjectPathNullable())) + if (_delegateMapping.IsSynthetic && (useNullConditionalAccess || !_sourceGetter.MemberPath.IsAnyObjectReadPathNullable())) { var nullConditionalSourceAccess = _sourceGetter.BuildAccess(ctx.Source, nullConditional: true); var nameofSourceAccess = _sourceGetter.BuildAccess(ctx.Source, nullConditional: false); diff --git a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs index 99995c0f1c..8699b0f0f4 100644 --- a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs +++ b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs @@ -91,7 +91,7 @@ private bool IsAccessible(ISymbol symbol, MemberVisibility visibility) }; } - public bool IsNullable(ISymbol symbol) + public bool IsReadNullable(ISymbol symbol) { return symbol switch { @@ -103,6 +103,18 @@ public bool IsNullable(ISymbol symbol) }; } + public bool IsWriteNullable(ISymbol symbol) + { + return symbol switch + { + ITypeSymbol t => t.IsNullable(), + IPropertySymbol p => p.Type.IsNullable() || TryHasAttribute(p), + IFieldSymbol f => f.Type.IsNullable() || TryHasAttribute(f), + IParameterSymbol p => p.Type.IsNullable() || TryHasAttribute(p), + _ => false, + }; + } + public bool MayReturnNull(IMethodSymbol symbol, bool treatUnannotatedAsNullable = true) { // only treat annotated as nullable @@ -134,6 +146,30 @@ public bool CanAssign(ITypeSymbol sourceType, ITypeSymbol targetType) public MethodParameter WrapMethodParameter(IParameterSymbol symbol) => new(symbol, UpgradeNullable(symbol.Type)); + public ITypeSymbol UpgradeReturnNullable(IMethodSymbol methodSymbol) + { + TryUpgradeReturnNullable(methodSymbol, out var upgradedReturnSymbol); + return upgradedReturnSymbol ?? methodSymbol.ReturnType; + } + + private bool TryUpgradeReturnNullable(IMethodSymbol methodSymbol, [NotNullWhen(true)] out ITypeSymbol? upgradedReturnSymbol) + { + upgradedReturnSymbol = default; + + if (methodSymbol.ReturnsVoid || methodSymbol.ReturnType.IsValueType) + { + return false; + } + + if (!TryHasAttribute(methodSymbol.GetReturnTypeAttributes())) + { + return false; + } + + upgradedReturnSymbol = methodSymbol.ReturnType.WithNullableAnnotation(NullableAnnotation.Annotated); + return true; + } + /// /// Upgrade the nullability of a symbol from to . /// Value types are not upgraded. diff --git a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs index c2fce437bb..34d3c24cd4 100644 --- a/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs +++ b/src/Riok.Mapperly/Descriptors/UserMethodMappingExtractor.cs @@ -267,7 +267,9 @@ private static (ITypeSymbol, UserImplementedMethodMapping.TargetNullability) Bui string sourceParameterName ) { - var targetType = ctx.SymbolAccessor.UpgradeNullable(method.ReturnType); + var effectiveReturnType = ctx.SymbolAccessor.UpgradeReturnNullable(method); + var targetType = ctx.SymbolAccessor.UpgradeNullable(effectiveReturnType); + if (!targetType.IsNullable() || ctx.SymbolAccessor.TryHasAttribute(method.GetReturnTypeAttributes())) { return (targetType, UserImplementedMethodMapping.TargetNullability.NeverNull); diff --git a/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs b/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs index 39a1926b27..148004d096 100644 --- a/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/ConstructorParameterMember.cs @@ -20,7 +20,8 @@ public class ConstructorParameterMember(IParameterSymbol symbol, SymbolAccessor { public ITypeSymbol Type { get; } = accessor.UpgradeNullable(symbol.Type); public INamedTypeSymbol ContainingType { get; } = symbol.ContainingType; - public bool IsNullable => accessor.IsNullable(Symbol); + public bool IsReadNullable => accessor.IsReadNullable(Symbol); + public bool IsWriteNullable => accessor.IsWriteNullable(Symbol); public bool CanGet => false; public bool CanGetDirectly => false; public bool CanSet => false; diff --git a/src/Riok.Mapperly/Symbols/Members/EmptyMemberPath.cs b/src/Riok.Mapperly/Symbols/Members/EmptyMemberPath.cs index 34cd5feee3..f568d04c16 100644 --- a/src/Riok.Mapperly/Symbols/Members/EmptyMemberPath.cs +++ b/src/Riok.Mapperly/Symbols/Members/EmptyMemberPath.cs @@ -8,7 +8,13 @@ public class EmptyMemberPath(ITypeSymbol rootType) : MemberPath(rootType, []) { public override IMappableMember? Member => null; - public override ITypeSymbol MemberType => RootType; + public override ITypeSymbol MemberReadType => RootType; + + public override ITypeSymbol MemberWriteType => RootType; + + public override bool IsAnyReadNullable() => false; + + public override bool IsWriteNullable() => false; public override string ToDisplayString(bool includeRootType = true, bool includeMemberType = true) => includeRootType ? RootType.ToDisplayString() : string.Empty; diff --git a/src/Riok.Mapperly/Symbols/Members/FieldMember.cs b/src/Riok.Mapperly/Symbols/Members/FieldMember.cs index 7275a26963..3918c0f2d2 100644 --- a/src/Riok.Mapperly/Symbols/Members/FieldMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/FieldMember.cs @@ -17,7 +17,8 @@ public class FieldMember(IFieldSymbol symbol, SymbolAccessor symbolAccessor) { public ITypeSymbol Type { get; } = symbolAccessor.UpgradeNullable(symbol.Type); public INamedTypeSymbol ContainingType { get; } = symbol.ContainingType; - public bool IsNullable => symbolAccessor.IsNullable(Symbol); + public bool IsReadNullable => symbolAccessor.IsReadNullable(Symbol); + public bool IsWriteNullable => symbolAccessor.IsWriteNullable(Symbol); public bool CanGet => true; public bool CanGetDirectly => symbolAccessor.IsDirectlyAccessible(Symbol); public bool CanSet => !Symbol.IsReadOnly; diff --git a/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs b/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs index 02e1833f2a..05417e4fbd 100644 --- a/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/IMappableMember.cs @@ -16,7 +16,9 @@ public interface IMappableMember INamedTypeSymbol? ContainingType { get; } - bool IsNullable { get; } + bool IsReadNullable { get; } + + bool IsWriteNullable { get; } /// /// Whether the member can be read using direct access or an unsafe accessor method. diff --git a/src/Riok.Mapperly/Symbols/Members/MemberPath.cs b/src/Riok.Mapperly/Symbols/Members/MemberPath.cs index 5fa5cec385..5318be90d1 100644 --- a/src/Riok.Mapperly/Symbols/Members/MemberPath.cs +++ b/src/Riok.Mapperly/Symbols/Members/MemberPath.cs @@ -30,10 +30,16 @@ public abstract class MemberPath(ITypeSymbol rootType, IReadOnlyList - /// Gets the type of the total path, e.g. that of the if it exists, or the otherwise. + /// Gets the type of the total path in the context of read, e.g. that of the if it exists, or the otherwise. /// If any part of the path is nullable, this type will be nullable too. /// - public abstract ITypeSymbol MemberType { get; } + public abstract ITypeSymbol MemberReadType { get; } + + /// + /// Gets the type of the last part of path in the context of write, e.g. that of the if it exists, or the otherwise. + /// If last part of the path is nullable, this type will be nullable too. + /// + public abstract ITypeSymbol MemberWriteType { get; } /// /// Gets the full name of the path (e.g. A.B.C). @@ -44,29 +50,31 @@ public abstract class MemberPath(ITypeSymbol rootType, IReadOnlyList /// The built path. - public IEnumerable PathWithoutTrailingNonNullable() => Path.Reverse().SkipWhile(x => !x.IsNullable).Reverse(); + public IEnumerable ReadPathWithoutTrailingNonNullable() => Path.Reverse().SkipWhile(x => !x.IsReadNullable).Reverse(); /// /// Returns an element for each nullable sub-path of the . /// If the is nullable, the entire is not returned. /// /// All nullable sub-paths of the . - public IEnumerable> ObjectPathNullableSubPaths() + public IEnumerable> ObjectReadPathNullableSubPaths() { var pathParts = new List(Path.Count); foreach (var pathPart in ObjectPath) { pathParts.Add(pathPart); - if (!pathPart.IsNullable) + if (!pathPart.IsReadNullable) continue; yield return pathParts.ToArray(); } } - public bool IsAnyNullable() => Path.Any(p => p.IsNullable); + public abstract bool IsAnyReadNullable(); + + public abstract bool IsWriteNullable(); - public bool IsAnyObjectPathNullable() => ObjectPath.Any(p => p.IsNullable); + public bool IsAnyObjectReadPathNullable() => ObjectPath.Any(p => p.IsReadNullable); public MemberPathGetter BuildGetter(SimpleMappingBuilderContext ctx) => MemberPathGetter.Build(ctx, this); diff --git a/src/Riok.Mapperly/Symbols/Members/MemberPathGetter.cs b/src/Riok.Mapperly/Symbols/Members/MemberPathGetter.cs index 153cc061f2..2a44bc7938 100644 --- a/src/Riok.Mapperly/Symbols/Members/MemberPathGetter.cs +++ b/src/Riok.Mapperly/Symbols/Members/MemberPathGetter.cs @@ -40,7 +40,7 @@ public static MemberPathGetter Build(SimpleMappingBuilderContext ctx, MemberPath bool skipTrailingNonNullable = false ) { - var path = skipTrailingNonNullable ? PathWithoutTrailingNonNullable() : _path; + var path = skipTrailingNonNullable ? ReadPathWithoutTrailingNonNullable() : _path; return BuildAccess(baseAccess, path, addValuePropertyOnNullable, nullConditional); } @@ -56,7 +56,7 @@ public static MemberPathGetter Build(SimpleMappingBuilderContext ctx, MemberPath { return path.AggregateWithPrevious( baseAccess, - (expr, prevProp, prop) => prop.Getter.BuildAccess(expr, prop.Member.ContainingType, prevProp.Member?.IsNullable == true) + (expr, prevProp, prop) => prop.Getter.BuildAccess(expr, prop.Member.ContainingType, prevProp.Member?.IsReadNullable == true) ); } @@ -94,14 +94,14 @@ private BinaryExpressionSyntax BuildNonNullCondition(ExpressionSyntax baseAccess private ExpressionSyntax? BuildNonNullConditionWithoutConditionalAccess(ExpressionSyntax baseAccess) { - var nullablePath = PathWithoutTrailingNonNullable(); + var nullablePath = ReadPathWithoutTrailingNonNullable(); var access = baseAccess; var conditions = new List(); foreach (var pathPart in nullablePath) { access = pathPart.Getter.BuildAccess(access, pathPart.Member.ContainingType); - if (!pathPart.Member.IsNullable) + if (!pathPart.Member.IsReadNullable) continue; conditions.Add(IsNotNull(access)); @@ -110,8 +110,8 @@ private BinaryExpressionSyntax BuildNonNullCondition(ExpressionSyntax baseAccess return conditions.Count == 0 ? null : And(conditions); } - private IEnumerable PathWithoutTrailingNonNullable() => - _path.Reverse().SkipWhile(x => !x.Member.IsNullable).Reverse(); + private IEnumerable ReadPathWithoutTrailingNonNullable() => + _path.Reverse().SkipWhile(x => !x.Member.IsReadNullable).Reverse(); public override bool Equals(object? obj) { diff --git a/src/Riok.Mapperly/Symbols/Members/NonEmptyMemberPath.cs b/src/Riok.Mapperly/Symbols/Members/NonEmptyMemberPath.cs index c88b98419a..0dbedd83ff 100644 --- a/src/Riok.Mapperly/Symbols/Members/NonEmptyMemberPath.cs +++ b/src/Riok.Mapperly/Symbols/Members/NonEmptyMemberPath.cs @@ -23,13 +23,23 @@ public NonEmptyMemberPath(ITypeSymbol rootType, IReadOnlyList p public string RootName => Path[0].Name; /// - /// Gets the type of the . If any part of the path is nullable, this type will be nullable too. + /// Gets the type of the in the context of read. If any part of the path is nullable, this type will be nullable too. /// - public override ITypeSymbol MemberType => - IsAnyNullable() ? Member.Type.WithNullableAnnotation(NullableAnnotation.Annotated) : Member.Type; + public override ITypeSymbol MemberReadType => + IsAnyReadNullable() ? Member.Type.WithNullableAnnotation(NullableAnnotation.Annotated) : Member.Type; + + /// + /// Gets the type of the in the context of write. If last part of the path is nullable, this type will be nullable too. + /// + public override ITypeSymbol MemberWriteType => + IsWriteNullable() ? Member.Type.WithNullableAnnotation(NullableAnnotation.Annotated) : Member.Type; public MemberPathSetter BuildSetter(SimpleMappingBuilderContext ctx) => MemberPathSetter.Build(ctx, this); + public override bool IsAnyReadNullable() => Path.Any(x => x.IsReadNullable); + + public override bool IsWriteNullable() => Path[^1].IsWriteNullable; + public override string ToDisplayString(bool includeRootType = true, bool includeMemberType = true) { var ofType = includeMemberType ? $" of type {Member.Type.ToDisplayString()}" : null; diff --git a/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs b/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs index af8fef90ba..086c0c5552 100644 --- a/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/ParameterSourceMember.cs @@ -14,12 +14,15 @@ namespace Riok.Mapperly.Symbols.Members; /// and is therefore in terms of the mapping the same. /// [DebuggerDisplay("{Name}")] -public class ParameterSourceMember(MethodParameter parameter) : IMappableMember, IMemberGetter +public class ParameterSourceMember(MethodParameter parameter, SymbolAccessor symbolAccessor) : IMappableMember, IMemberGetter { public string Name => parameter.Name; public ITypeSymbol Type => parameter.Type; public INamedTypeSymbol? ContainingType => null; - public bool IsNullable => parameter.Type.IsNullable(); + public bool IsReadNullable => + parameter.Symbol is not null ? symbolAccessor.IsReadNullable(parameter.Symbol) : parameter.Type.IsNullable(); + public bool IsWriteNullable => + parameter.Symbol is not null ? symbolAccessor.IsWriteNullable(parameter.Symbol) : parameter.Type.IsNullable(); public bool CanGet => true; public bool CanGetDirectly => true; public bool CanSet => false; diff --git a/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs b/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs index b747e4c21f..b7a8682bca 100644 --- a/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs +++ b/src/Riok.Mapperly/Symbols/Members/PropertyMember.cs @@ -19,7 +19,9 @@ public class PropertyMember(IPropertySymbol symbol, SymbolAccessor symbolAccesso public INamedTypeSymbol? ContainingType { get; } = symbol.ContainingType; - public bool IsNullable => symbolAccessor.IsNullable(Symbol); + public bool IsReadNullable => symbolAccessor.IsReadNullable(Symbol); + + public bool IsWriteNullable => symbolAccessor.IsWriteNullable(Symbol); public bool CanGet => !Symbol.IsWriteOnly && (Symbol.GetMethod == null || symbolAccessor.IsMemberAccessible(Symbol.GetMethod)); diff --git a/src/Riok.Mapperly/Symbols/MethodParameter.cs b/src/Riok.Mapperly/Symbols/MethodParameter.cs index a669deae3a..1b35414a50 100644 --- a/src/Riok.Mapperly/Symbols/MethodParameter.cs +++ b/src/Riok.Mapperly/Symbols/MethodParameter.cs @@ -3,7 +3,13 @@ namespace Riok.Mapperly.Symbols; -public readonly record struct MethodParameter(int Ordinal, string Name, ITypeSymbol Type, RefKind RefKind = RefKind.None) +public readonly record struct MethodParameter( + int Ordinal, + string Name, + ITypeSymbol Type, + IParameterSymbol? Symbol = null, + RefKind RefKind = RefKind.None +) { private static readonly SymbolDisplayFormat _parameterNameFormat = new( parameterOptions: SymbolDisplayParameterOptions.IncludeName, @@ -11,7 +17,7 @@ public readonly record struct MethodParameter(int Ordinal, string Name, ITypeSym ); public MethodParameter(IParameterSymbol symbol, ITypeSymbol parameterType) - : this(symbol.Ordinal, symbol.ToDisplayString(_parameterNameFormat), parameterType, symbol.RefKind) { } + : this(symbol.Ordinal, symbol.ToDisplayString(_parameterNameFormat), parameterType, symbol, symbol.RefKind) { } /// /// The parameter name with the verbatim identifier prefix (@) removed. diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs index e42ccdb909..93f40a8f11 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFlatteningTest.cs @@ -774,7 +774,7 @@ public Task ManualUnflattenedPropertyTargetPropertyNotFoundShouldDiagnostic() } [Fact] - public void ManualNestedPropertyNullablePath() + public void ManualNestedPropertyNullablePathShouldDiagnoseNullableProperty() { var source = TestSourceBuilder.MapperWithBodyAndTypes( "[MapProperty(\"Value1.Value1.Id1\", \"Value2.Value2.Id2\")]" @@ -790,8 +790,21 @@ public void ManualNestedPropertyNullablePath() ); TestHelper - .GenerateMapper(source) + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value1.Id100 of A to the target property Value2.Id200 of B which is not nullable" + ) + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value1.Value1.Id1 of A to the target property Value2.Value2.Id2 of B which is not nullable" + ) + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value1.Value1.Id10 of A to the target property Value2.Value2.Id20 of B which is not nullable" + ) + .HaveAssertedAllDiagnostics() .HaveSingleMethodBody( """ var target = new global::B(); diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs index 9a94d5ad10..26d12353c4 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs @@ -441,7 +441,7 @@ public Task ShouldReportNonExistentExternalMappingsOnString() } [Fact] - public void ReturnMaybeNullMethodToMaybeNullTargetProperty() + public void ReturnMaybeNullMethodToMaybeNullTargetPropertyShouldDiagnoseNullableSource() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ @@ -461,6 +461,44 @@ class B """ ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceTypeToNonNullableTargetType, + "Mapping the nullable source of type string? to target of type string which is not nullable" + ) + .HaveAssertedAllDiagnostics() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = BuildValue(source) ?? throw new global::System.NullReferenceException("BuildValue returned null"); + return target; + """ + ); + } + + [Fact] + public void ReturnMaybeNullMethodToAllowNullTargetProperty() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapPropertyFromSource(nameof(B.Value), Use = nameof(BuildValue))] + partial B Map(A source); + + [return: System.Diagnostics.CodeAnalysis.MaybeNull] + string BuildValue(A a) => a.Name; + """, + "class A { public string Name { get; set; } }", + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Value { get; set; } = default!; + } + """ + ); + TestHelper .GenerateMapper(source) .Should() diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNestedTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNestedTest.cs index 825f324e4f..c514bf8c71 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNestedTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNestedTest.cs @@ -31,7 +31,7 @@ public void ManualNestedToNestedProperty() } [Fact] - public void ManualNullableNestedToNullableNestedProperty() + public void ManualNullableNestedToNullableNestedPropertyShouldDiagnoseNullableSourceProperty() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ @@ -45,8 +45,13 @@ public void ManualNullableNestedToNullableNestedProperty() ); TestHelper - .GenerateMapper(source) + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value.IntValue of A to the target property Value.StringValue of B which is not nullable" + ) + .HaveAssertedAllDiagnostics() .HaveSingleMethodBody( """ var target = new global::B(); diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNullableTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNullableTest.cs index ce8b5a99d3..2bd1d3cedb 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNullableTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyNullableTest.cs @@ -1049,7 +1049,7 @@ public record A: ABase; } [Fact] - public void MaybeNullSourceToMaybeNullTargetProperty() + public void MaybeNullSourceToMaybeNullTargetPropertyShouldDiagnoseNullableSource() { var source = TestSourceBuilder.Mapping( "A", @@ -1071,12 +1071,20 @@ class B ); TestHelper - .GenerateMapper(source) + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Name of A to the target property Name of B which is not nullable" + ) + .HaveAssertedAllDiagnostics() .HaveSingleMethodBody( """ var target = new global::B(); - target.Name = source.Name; + if (source.Name != null) + { + target.Name = source.Name; + } return target; """ ); @@ -1147,11 +1155,59 @@ class B } [Fact] - public void MaybeNullSourceClassToMaybeNullTargetClassPropertyShouldSetNull() + public void MaybeNullSourceClassToMaybeNullTargetClassPropertyShouldDiagnoseNullableSourceClass() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + """ + class A + { + [System.Diagnostics.CodeAnalysis.MaybeNull] + public C Value { get; set; } = default!; + } + """, + """ + class B + { + [System.Diagnostics.CodeAnalysis.MaybeNull] + public D Value { get; set; } = default!; + } + """, + "class C { public string V { get; set; } }", + "class D { public string V { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value of A to the target property Value of B which is not nullable" + ) + .HaveAssertedAllDiagnostics() + .HaveMapMethodBody( + """ + var target = new global::B(); + if (source.Value != null) + { + target.Value = MapToD(source.Value); + } + return target; + """ + ); + } + + [Fact] + public void MaybeNullSourceClassToMaybeNullTargetClassPropertyWithNoNullAssignmentShouldDiagnoseNullableSourceClass() { var source = TestSourceBuilder.Mapping( "A", "B", + TestSourceBuilderOptions.Default with + { + AllowNullPropertyAssignment = false, + }, """ class A { @@ -1170,6 +1226,112 @@ class B "class D { public string V { get; set; } }" ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue, + "Mapping the nullable source property Value of A to the target property Value of B which is not nullable" + ) + .HaveAssertedAllDiagnostics() + .HaveMapMethodBody( + """ + var target = new global::B(); + if (source.Value != null) + { + target.Value = MapToD(source.Value); + } + return target; + """ + ); + } + + [Fact] + public void MaybeNullSourceToAllowNullTargetProperty() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + """ + class A + { + [System.Diagnostics.CodeAnalysis.MaybeNull] + public string Name { get; set; } = default!; + } + """, + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Name { get; set; } = default!; + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Name = source.Name; + return target; + """ + ); + } + + [Fact] + public void NonNullableSourceToAllowNullTargetProperty() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + "class A { public string Name { get; set; } }", + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Name { get; set; } = default!; + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Name = source.Name; + return target; + """ + ); + } + + [Fact] + public void MaybeNullSourceClassToAllowNullTargetClassPropertyShouldSetNull() + { + var source = TestSourceBuilder.Mapping( + "A", + "B", + """ + class A + { + [System.Diagnostics.CodeAnalysis.MaybeNull] + public C Value { get; set; } = default!; + } + """, + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public D Value { get; set; } = default!; + } + """, + "class C { public string V { get; set; } }", + "class D { public string V { get; set; } }" + ); + TestHelper .GenerateMapper(source) .Should() @@ -1190,7 +1352,7 @@ class B } [Fact] - public void MaybeNullSourceClassToMaybeNullTargetClassPropertyWithNoNullAssignment() + public void MaybeNullSourceClassToAllowNullTargetClassPropertyWithNoNullAssignment() { var source = TestSourceBuilder.Mapping( "A", @@ -1209,7 +1371,7 @@ class A """ class B { - [System.Diagnostics.CodeAnalysis.MaybeNull] + [System.Diagnostics.CodeAnalysis.AllowNull] public D Value { get; set; } = default!; } """, diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyValueMethodTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyValueMethodTest.cs index 7d09fd9fc6..f7b6a4e28e 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyValueMethodTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyValueMethodTest.cs @@ -611,7 +611,7 @@ public Task ShouldReportNonExistentStaticClassExternalMethodWithString() } [Fact] - public void MethodToMaybeNullTargetPropertyShouldAllowNullableReturnType() + public void ReturnNullableMethodToMaybeNullTargetPropertyShouldDiagnoseNullableReturnType() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ @@ -629,15 +629,13 @@ class B ); TestHelper - .GenerateMapper(source) + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) .Should() - .HaveSingleMethodBody( - """ - var target = new global::B(); - target.Value = BuildValue(); - return target; - """ - ); + .HaveDiagnostic( + DiagnosticDescriptors.MapValueMethodTypeMismatch, + "Cannot assign method return type string? of BuildValue() to B.Value of type string" + ) + .HaveAssertedAllDiagnostics(); } [Fact] @@ -688,13 +686,13 @@ public void ReturnMaybeNullMethodToNonNullableTargetShouldDiagnostic() .Should() .HaveDiagnostic( DiagnosticDescriptors.MapValueMethodTypeMismatch, - "Cannot assign method return type string of BuildValue() to B.Value of type string" + "Cannot assign method return type string? of BuildValue() to B.Value of type string" ) .HaveAssertedAllDiagnostics(); } [Fact] - public void ReturnMaybeNullMethodToMaybeNullTargetProperty() + public void ReturnMaybeNullMethodToMaybeNullTargetPropertyShouldDiagnostic() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ @@ -712,6 +710,95 @@ class B """ ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.MapValueMethodTypeMismatch, + "Cannot assign method return type string? of BuildValue() to B.Value of type string" + ) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void MethodToAllowNullTargetPropertyShouldAllowNullableReturnType() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("Value", Use = nameof(BuildValue))] partial B Map(A source); + string? BuildValue() => null; + """, + "class A;", + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Value { get; set; } = default!; + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = BuildValue(); + return target; + """ + ); + } + + [Fact] + public void MethodToAllowTargetPropertyShouldAllowNonNullableReturnType() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("Value", Use = nameof(BuildValue))] partial B Map(A source); + string BuildValue() => "hello"; + """, + "class A;", + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Value { get; set; } = default!; + } + """ + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = BuildValue(); + return target; + """ + ); + } + + [Fact] + public void ReturnMaybeNullMethodToAllowNullTargetProperty() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapValue("Value", Use = nameof(BuildValue))] partial B Map(A source); + [return: System.Diagnostics.CodeAnalysis.MaybeNull] + string BuildValue() => null!; + """, + "class A;", + """ + class B + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public string Value { get; set; } = default!; + } + """ + ); + TestHelper .GenerateMapper(source) .Should()