Skip to content

fix: non-required init properties respect ThrowOnPropertyMappingNullMismatch#2179

Open
rcdailey wants to merge 1 commit intoriok:mainfrom
rcdailey:fix/non-required-init-skip-nullable-mismatch
Open

fix: non-required init properties respect ThrowOnPropertyMappingNullMismatch#2179
rcdailey wants to merge 1 commit intoriok:mainfrom
rcdailey:fix/non-required-init-skip-nullable-mismatch

Conversation

@rcdailey
Copy link

@rcdailey rcdailey commented Mar 7, 2026

Summary

Fixes #2178.

Non-required init properties now respect ThrowOnPropertyMappingNullMismatch. Previously, all init properties ignored this setting and always used inline null handling (?? throw or ?? default) in the object initializer. With this fix, non-required init properties with a nullable-to-non-nullable mismatch are omitted from the initializer when ThrowOnPropertyMappingNullMismatch is false (the default), preserving the target type's own field initializer default.

This aligns with the documented behavior ("If set to false the property assignment is ignored. This is ignored for required init properties") and is consistent with how set properties handle this setting (conditional if-guard that skips the assignment when the source is null).

What changed

NewInstanceObjectMemberMappingBodyBuilder.BuildInitMemberMapping() now checks whether a non-required init member should be skipped before building the assignment. A member is skipped when all of:

  • The target member is not required
  • The context is not an IQueryable<T> projection (which ignores property-level null settings per the docs)
  • ThrowOnPropertyMappingNullMismatch is false
  • The source is nullable and the target is non-nullable
  • No delegate mapping already handles the null-to-non-null conversion

When skipped, the RMG020 diagnostic is still reported for visibility.

Before (default settings)

var target = new Target()
{
    Name = source.Name ?? throw new ArgumentNullException(nameof(source.Name)),
};

After (default settings)

var target = new Target();
// Name uses Target's own field initializer default

Setting ThrowOnPropertyMappingNullMismatch = true preserves the previous behavior.

Non-required init properties previously ignored
ThrowOnPropertyMappingNullMismatch and always emitted
inline null handling (e.g. ?? throw) in the object
initializer. This conflicted with documented behavior
and was inconsistent with how set properties handle
the same setting.

Non-required init members with a nullable source and
non-nullable target are now omitted from the object
initializer when ThrowOnPropertyMappingNullMismatch is
false, preserving the target property own default.
Required init members are unaffected.

Closes riok#2178
@latonz latonz requested review from Copilot and latonz and removed request for Copilot March 10, 2026 13:21
Copy link
Contributor

@latonz latonz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this contribution! 🥳
This is a breaking change, could you therefore add an entry to docs/docs/breaking-changes/5-0.md

@latonz latonz added bug Something isn't working breaking-change This issue or pull request will break existing consumers labels Mar 10, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates Mapperly’s handling of nullable-to-non-nullable mappings for non-required init target members to (attempt to) respect ThrowOnPropertyMappingNullMismatch, and adjusts/extends tests to cover the new behavior.

Changes:

  • Add “skip init-member mapping” logic in NewInstanceObjectMemberMappingBodyBuilder.BuildInitMemberMapping() when ThrowOnPropertyMappingNullMismatch is disabled and a nullable→non-nullable mismatch is detected.
  • Add helper logic to still emit the null-mismatch diagnostic when skipping.
  • Update existing mapping output expectations and add new test cases for ThrowOnPropertyMappingNullMismatch scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
src/Riok.Mapperly/Descriptors/MappingBodyBuilders/NewInstanceObjectMemberMappingBodyBuilder.cs Introduces logic to skip certain init-only assignments and emit diagnostics when skipped.
test/Riok.Mapperly.Tests/Mapping/UserMethodAdditionalParametersTest.cs Updates expected generated code for nullable additional parameters mapped into init-only targets.
test/Riok.Mapperly.Tests/Mapping/ObjectPropertyInitPropertyTest.cs Updates multiple expected bodies and adds new tests for init-only null-mismatch behaviors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +150 to +160
// For non-required init members with ThrowOnPropertyMappingNullMismatch disabled
// and a nullable source mapped to a non-nullable target:
// skip the init mapping 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, memberInfo))
{
ReportNullMismatchDiagnostic(ctx, memberInfo);
ctx.SetMembersMapped(memberInfo);
return;
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShouldSkipNullableInitMember causes the init-only target member to be omitted from the object initializer entirely. For method mappings this means the target property is never assigned even when the source value is non-null, which is not equivalent to the documented/implemented behavior for set properties (they assign when non-null and only skip on null). Consider generating a conditional target creation (e.g., branch on the source null check) or another strategy that still maps the value when it is present while preserving the target initializer when it is absent.

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +208
// If a delegate mapping already handles the null-to-non-null conversion,
// no inline null handling would be generated and the init member should be kept.
if (memberInfo.SourceMember != null)
{
var mappingKey = memberInfo.ToTypeMappingKey();
var delegateMapping = ctx.BuilderContext.FindOrBuildLooseNullableMapping(
mappingKey,
diagnosticLocation: memberInfo.Configuration?.Location
);
if (delegateMapping != null && delegateMapping.SourceType.IsNullable() && !delegateMapping.TargetType.IsNullable())
{
return false;
}
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShouldSkipNullableInitMember calls FindOrBuildLooseNullableMapping only to decide whether to skip. This can eagerly build and cache mappings (and potentially emit diagnostics) even though the member mapping is going to be ignored. Consider avoiding mapping construction here (or using a cheaper predicate) and letting the normal assignment builder be the only place that triggers mapping builds/diagnostics.

Copilot uses AI. Check for mistakes.
Comment on lines 71 to 77
.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;
"""
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expected body now drops the init-only assignment for Value entirely. That means callers passing a non-null value parameter would still get B.Value left at its type default, which looks like a functional regression. The generated code should still map value when it is non-null (likely via conditional target creation), and the test should assert that behavior rather than expecting the assignment to disappear unconditionally.

Copilot uses AI. Check for mistakes.
Comment on lines 63 to 80
"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)),
};
var target = new global::B();
return target;
"""
);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests now expect nullable-source -> non-nullable init-only mappings to be omitted completely (returning new B() with no initializer). This removes mapping behavior even when the source value is non-null. If the intent is to only skip the assignment when the source is null (matching ThrowOnPropertyMappingNullMismatch = false semantics for settable members), the generated code/tests should reflect conditional object creation (or another approach) rather than unconditional omission.

Copilot uses AI. Check for mistakes.
Comment on lines +549 to +580
[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;
"""
);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test InitOnlyPropertyWithNullableSourceAndThrowOnPropertyNullMismatchNoThrowOnMapping asserts that ThrowOnPropertyMappingNullMismatch = true does not throw when ThrowOnMappingNullMismatch = false (it expects ?? ""). This seems inconsistent with the MapperAttribute.ThrowOnPropertyMappingNullMismatch XML docs (“If set to true an ArgumentNullException is thrown”) and with how non-init (set) members handle this flag (they throw based on ThrowOnPropertyMappingNullMismatch, independent of ThrowOnMappingNullMismatch). Consider aligning init-only behavior with the documented semantics and updating this test accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines 163 to 169
.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;
"""
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expected body omits the NestedValue init-only assignment entirely for a nullable nested parameter. That means NestedValue will never be mapped even when nested is non-null. If the intent is to skip only when nested is null (per ThrowOnPropertyMappingNullMismatch = false), the generated code should still include mapping in the non-null case (likely via conditional target creation), and the test should assert that behavior.

Copilot uses AI. Check for mistakes.
Comment on lines 97 to 110
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 ?? "",
};
var target = new global::B();
return target;
"""
);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expectation now returns new B() without assigning Value at all for a nullable->non-nullable init-only mapping. That drops mapping behavior even when source.Value is non-null; if the intended semantics are to ignore only the null case, the generated code/tests should reflect conditional target creation rather than unconditional omission.

Copilot uses AI. Check for mistakes.
Comment on lines 160 to 184
[Fact]
public void InitOnlyPropertyWithAutoFlattenedNullablePath()
{
var source = TestSourceBuilder.Mapping(
"A",
"B",
"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)),
};
var target = new global::B();
return target;
"""
);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expectation for an auto-flattened nullable path now omits the NestedValue init-only assignment entirely, which would prevent mapping even when source.Nested is non-null. If the goal is to preserve the target initializer only when the source path is null, the generated code should still handle the non-null case (e.g., via conditional object creation), and the test should assert that.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change This issue or pull request will break existing consumers bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-required init properties with initializers should be omittable from object initializer when source is null

3 participants