fix: non-required init properties respect ThrowOnPropertyMappingNullMismatch#2179
fix: non-required init properties respect ThrowOnPropertyMappingNullMismatch#2179
Conversation
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
left a comment
There was a problem hiding this comment.
Thank you for this contribution! 🥳
This is a breaking change, could you therefore add an entry to docs/docs/breaking-changes/5-0.md
There was a problem hiding this comment.
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()whenThrowOnPropertyMappingNullMismatchis 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
ThrowOnPropertyMappingNullMismatchscenarios.
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.
| // 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; | ||
| } |
There was a problem hiding this comment.
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.
| // 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| .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; | ||
| """ |
There was a problem hiding this comment.
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.
| "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; | ||
| """ | ||
| ); |
There was a problem hiding this comment.
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.
| [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; | ||
| """ | ||
| ); |
There was a problem hiding this comment.
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.
| .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; | ||
| """ |
There was a problem hiding this comment.
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.
| 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; | ||
| """ | ||
| ); |
There was a problem hiding this comment.
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.
| [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; | ||
| """ | ||
| ); |
There was a problem hiding this comment.
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.
Summary
Fixes #2178.
Non-required
initproperties now respectThrowOnPropertyMappingNullMismatch. Previously, all init properties ignored this setting and always used inline null handling (?? throwor?? default) in the object initializer. With this fix, non-required init properties with a nullable-to-non-nullable mismatch are omitted from the initializer whenThrowOnPropertyMappingNullMismatchisfalse(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
setproperties 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:requiredIQueryable<T>projection (which ignores property-level null settings per the docs)ThrowOnPropertyMappingNullMismatchisfalseWhen skipped, the
RMG020diagnostic is still reported for visibility.Before (default settings)
After (default settings)
Setting
ThrowOnPropertyMappingNullMismatch = truepreserves the previous behavior.