Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/docs/breaking-changes/5-0.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,25 @@ description: How to upgrade to Mapperly 5.0 and a list of all its breaking chang
- The fullnameof feature does not apply automatically to three-segment paths.
- Default enabled conversions no longer include explicit casts, see below.
- `Stack<T>` deep cloning now preserves the order of elements by default.
- Non-required `init` properties with nullable-to-non-nullable mismatch are now omitted from the object initializer by default (respecting `ThrowOnPropertyMappingNullMismatch`).
- Inaccessible members from other assemblies are now included in the mapping process if `IncludedMembers` or `IncludedConstructors` options are configured to include them.

## Non-required init properties respect `ThrowOnPropertyMappingNullMismatch`

Previously, all `init` properties with a nullable-to-non-nullable mismatch used inline null handling in the object initializer (e.g. `Name = source.Name ?? throw new ArgumentNullException(...)`) regardless of the `ThrowOnPropertyMappingNullMismatch` setting.

Starting with v5.0, non-required `init` properties now respect `ThrowOnPropertyMappingNullMismatch`. Since this setting defaults to `false`, non-required `init` properties with a nullable source and non-nullable target are omitted from the object initializer, preserving the target type's own default value. This is consistent with how `set` properties handle this setting (they skip the assignment when the source is null).

To restore the previous behavior, set `ThrowOnPropertyMappingNullMismatch` to `true`:

```csharp
[Mapper(ThrowOnPropertyMappingNullMismatch = true)]
public partial class MyMapper
{
// ...
}
```

## Inaccessible members from other assemblies included

Previously, private, protected, or internal members from other assemblies were ignored, even if `IncludedMembers` or `IncludedConstructors` were configured to include them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings;
using Riok.Mapperly.Diagnostics;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols.Members;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders;
Expand Down Expand Up @@ -149,9 +150,50 @@ MemberMappingInfo memberInfo
if (!MemberMappingBuilder.TryBuildAssignment(ctx, memberInfo, out var memberAssignmentMapping))
return;

// For non-required init members with ThrowOnPropertyMappingNullMismatch disabled:
// if the built mapping has inline null handling (e.g. ?? throw or ?? default),
// skip it entirely to preserve the target's own default initializer.
// This is consistent with set properties, which use an if-guard
// and skip the assignment when the source is null.
if (ShouldSkipNullableInitMember(ctx, memberAssignmentMapping))
{
ctx.SetMembersMapped(memberInfo);
return;
}

ctx.AddInitMemberMapping(memberAssignmentMapping);
}

/// <summary>
/// Determines whether a non-required init member mapping should be skipped
/// because it contains inline null handling that should be avoided
/// when ThrowOnPropertyMappingNullMismatch is disabled.
/// Skipping omits the property from the object initializer, preserving the target's own default.
/// </summary>
private static bool ShouldSkipNullableInitMember(
INewInstanceBuilderContext<INewInstanceObjectMemberMapping> ctx,
MemberAssignmentMapping mapping
)
{
// Only skip when the mapping has inline null handling for a non-nullable target.
// NullMappedMemberSourceValue with a nullable target uses NullFallbackValue.Default
// which just does null-safe access (no coalescing); that should not be skipped.
if (!mapping.HasNullFallback || mapping.MemberInfo.TargetMember.MemberType.IsNullable())
return false;

// Required init members must always be in the object initializer.
if (mapping.MemberInfo.TargetMember.Member.IsRequired)
return false;

// IQueryable<T> projections ignore ThrowOnPropertyMappingNullMismatch.
if (ctx.BuilderContext.IsExpression)
return false;

// When ThrowOnPropertyMappingNullMismatch is enabled,
// the user expects a throw on null; keep the init mapping.
return !ctx.BuilderContext.Configuration.Mapper.ThrowOnPropertyMappingNullMismatch;
}

private static bool TryBuildConstructorMapping(
INewInstanceBuilderContext<IMapping> ctx,
IMethodSymbol ctor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ public class MemberAssignmentMapping(MemberPathSetter targetPath, ISourceValue s
private readonly ISourceValue _sourceValue = sourceValue;
private readonly MemberPathSetter _targetPath = targetPath;

/// <summary>
/// Whether the source value uses inline null handling with a fallback
/// (e.g. <c>source.Value ?? throw</c> or <c>source.Value ?? default</c>).
/// </summary>
internal bool HasNullFallback => _sourceValue is NullMappedMemberSourceValue;

public bool TryGetMemberAssignmentMappingContainer([NotNullWhen(true)] out IMemberAssignmentMappingContainer? container)
{
container = null;
Expand Down
244 changes: 232 additions & 12 deletions test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ public void InitOnlyPropertyWithNullableSource()
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value ?? throw new global::System.ArgumentNullException(nameof(source.Value)),
};
var target = new global::B();
return target;
"""
);
Expand Down Expand Up @@ -107,10 +104,7 @@ TestSourceBuilderOptions.Default with
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value ?? "",
};
var target = new global::B();
return target;
"""
);
Expand Down Expand Up @@ -184,10 +178,7 @@ public void InitOnlyPropertyWithAutoFlattenedNullablePath()
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
NestedValue = source.Nested?.Value ?? throw new global::System.ArgumentNullException(nameof(source.Nested.Value)),
};
var target = new global::B();
return target;
"""
);
Expand Down Expand Up @@ -521,4 +512,233 @@ public class B { public int Value { get; init; } public required int Value2 { ge
)
.HaveAssertedAllDiagnostics();
}

[Fact]
public void InitOnlyPropertyWithNullableSourceAndThrowOnPropertyNullMismatch()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
TestSourceBuilderOptions.Default with
{
ThrowOnPropertyMappingNullMismatch = true,
},
"class A { public string? Value { get; init; } }",
"class B { public string Value { get; init; } }"
);

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()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value ?? throw new global::System.ArgumentNullException(nameof(source.Value)),
};
return target;
"""
);
}

[Fact]
public void InitOnlyPropertyWithNullableSourceAndThrowOnPropertyNullMismatchNoThrowOnMapping()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
TestSourceBuilderOptions.Default with
{
ThrowOnPropertyMappingNullMismatch = true,
ThrowOnMappingNullMismatch = false,
},
"class A { public string? Value { get; init; } }",
"class B { public string Value { get; init; } }"
);

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()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value ?? "",
};
return target;
"""
);
Comment thread
rcdailey marked this conversation as resolved.
}

[Fact]
public void RequiredInitPropertyWithNullableSourceShouldAlwaysThrow()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
"class A { public string? Value { get; init; } }",
"class B { public required string Value { get; init; } }"
);

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()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value ?? throw new global::System.ArgumentNullException(nameof(source.Value)),
};
return target;
"""
);
}

[Fact]
public void InitOnlyPropertyMixedNullability()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
"class A { public string? NullableValue { get; init; } public string NonNullableValue { get; init; } }",
"class B { public string NullableValue { get; init; } public string NonNullableValue { get; init; } }"
);

TestHelper
.GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
.Should()
.HaveDiagnostic(
DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue,
"Mapping the nullable source property NullableValue of A to the target property NullableValue of B which is not nullable"
)
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
NonNullableValue = source.NonNullableValue,
};
return target;
"""
);
}

[Fact]
public void InitOnlyPropertyWithNullableSourceToNullableTarget()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
"class A { public string? Value { get; init; } }",
"class B { public string? Value { get; init; } }"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Value = source.Value,
};
return target;
"""
);
}

[Fact]
public void InitOnlyPropertyWithNullableSourceMultipleProperties()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
"""
class A
{
public string? Name { get; init; }
public int? Count { get; init; }
public string Description { get; init; }
}
""",
"""
class B
{
public string Name { get; init; }
public int Count { get; init; }
public string Description { get; init; }
}
"""
);

TestHelper
.GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
.Should()
.HaveDiagnostics(
DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue,
[
"Mapping the nullable source property Name of A to the target property Name of B which is not nullable",
"Mapping the nullable source property Count of A to the target property Count of B which is not nullable",
]
)
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
Description = source.Description,
};
return target;
"""
);
}

[Fact]
public void InitOnlyPropertyWithAutoFlattenedNullablePathAndThrowOnPropertyNullMismatch()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
TestSourceBuilderOptions.Default with
{
ThrowOnPropertyMappingNullMismatch = true,
},
"class A { public C? Nested { get; init; } }",
"class B { public string NestedValue { get; init; } }",
"class C { public string Value { get; } }"
);

TestHelper
.GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
.Should()
.HaveDiagnostic(
DiagnosticDescriptors.NullableSourceValueToNonNullableTargetValue,
"Mapping the nullable source property Nested.Value of A to the target property NestedValue of B which is not nullable"
)
.HaveAssertedAllDiagnostics()
.HaveSingleMethodBody(
"""
var target = new global::B()
{
NestedValue = source.Nested?.Value ?? throw new global::System.ArgumentNullException(nameof(source.Nested.Value)),
};
return target;
"""
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ public void AdditionalInitNullableIntParameter()
.HaveAssertedAllDiagnostics()
.HaveMapMethodBody(
"""
var target = new global::B()
{
Value = value != null ? value.Value.ToString() : throw new global::System.ArgumentNullException(nameof(value.Value)),
};
var target = new global::B();
target.StringValue = src.StringValue;
return target;
"""
Comment thread
rcdailey marked this conversation as resolved.
Expand Down Expand Up @@ -166,10 +163,7 @@ public void NullableClassFlattening()
.HaveAssertedAllDiagnostics()
.HaveMapMethodBody(
"""
var target = new global::B()
{
NestedValue = nested != null ? nested.Value.ToString() : throw new global::System.ArgumentNullException(nameof(nested.Value)),
};
var target = new global::B();
target.StringValue = src.StringValue;
return target;
"""
Comment thread
rcdailey marked this conversation as resolved.
Expand Down