fix: skip null guard for ctor mappings when parameter accepts nullable source#2180
fix: skip null guard for ctor mappings when parameter accepts nullable source#2180baracchande wants to merge 3 commits intoriok:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes null-guard generation for nullable-source → non-nullable-target constructor mappings when the target constructor parameter can accept the nullable source, aligning generated code with expected behavior from issue #1611.
Changes:
- Adds regression tests covering nullable source types mapped via single-argument ctors that accept nullable inputs.
- Extends nullable-mapping logic to optionally bypass null-guard wrapping for compatible ctor mappings.
- Refactors ctor selection logic in
CtorMappingBuilderand exposes the selected constructor onCtorMappingfor reuse.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/Riok.Mapperly.Tests/Mapping/CtorTest.cs | Adds tests to validate null-guard omission / diagnostics behavior for nullable ctor scenarios. |
| src/Riok.Mapperly/Descriptors/Mappings/CtorMapping.cs | Exposes the IInstanceConstructor via a property for reuse in other builders. |
| src/Riok.Mapperly/Descriptors/MappingBuilders/NullableMappingBuilder.cs | Adds special-case path to return a direct ctor mapping when nullable source is accepted by the ctor param. |
| src/Riok.Mapperly/Descriptors/MappingBuilders/CtorMappingBuilder.cs | Extracts ctor lookup and exposes a helper to query whether a single-arg ctor accepts a given source type. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private static IMethodSymbol? FindSingleArgCtor(INamedTypeSymbol target, ITypeSymbol sourceType, SymbolAccessor symbolAccessor) => | ||
| target | ||
| .InstanceConstructors.Where(symbolAccessor.IsConstructorAccessible) | ||
| .FirstOrDefault(m => | ||
| m.Parameters.Length == 1 | ||
| && SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type.NonNullable(), sourceType.NonNullable()) | ||
| && sourceType.HasSameOrStricterNullability(m.Parameters[0].Type) | ||
| ); |
There was a problem hiding this comment.
FindSingleArgCtor uses sourceType.HasSameOrStricterNullability(m.Parameters[0].Type) which only considers NullableAnnotation (reference nullability) and ignores nullable value types. When sourceType is a nullable value type (e.g. int?), this predicate can incorrectly treat a ctor parameter int as compatible, which then makes CtorAcceptsSourceType return true and can lead to generating new A(source) where source is int? (won't compile). Consider replacing the nullability check with symbolAccessor.CanAssign(sourceType, m.Parameters[0].Type) (while keeping the existing NonNullable() equality check if exact underlying type matching is intended).
| if ( | ||
| delegateMapping is CtorMapping ctorMapping | ||
| && ctx.Target is INamedTypeSymbol namedTarget | ||
| && CtorMappingBuilder.CtorAcceptsSourceType(namedTarget, ctx.Source, ctx.SymbolAccessor) | ||
| ) | ||
| { | ||
| return new CtorMapping(ctx.Source, ctx.Target, ctorMapping.Constructor); | ||
| } |
There was a problem hiding this comment.
TryBuildMappingWithNullableSource checks whether any single-arg ctor accepts ctx.Source, but then reuses ctorMapping.Constructor from the delegate mapping built for the non-nullable mapping key. If the target has multiple single-arg ctors (eg A(int) and A(int?)) and the non-nullable delegate mapping picked a different ctor than the one required for the nullable source (especially when BuildForConstructor creates an unsafe-accessor tied to a specific ctor), the returned CtorMapping can invoke the wrong ctor and produce non-compilable generated code. Consider changing the ctor check to return the matching IMethodSymbol and building a fresh IInstanceConstructor for that ctor (or storing/exposing the ctor symbol on CtorMapping so you can verify compatibility before reusing it).
| && CtorMappingBuilder.CtorAcceptsSourceType(namedTarget, ctx.Source, ctx.SymbolAccessor) | ||
| ) | ||
| { | ||
| return new CtorMapping(ctx.Source, ctx.Target, ctorMapping.Constructor); |
There was a problem hiding this comment.
This doesn't really fit Mapperly's architecture. Usually only the specific mapping builder builds the mappings it is designated to.
The problem here is that the CtorMappingBuilder comes after the nullable mapping builder in MappingBuilder._builders. Another approach would be to add a new CtorMappingBuilder.TryBuildNullable and add it to the chain before the nullable builder. However we would need to check the consequences of this carefully. I'm not sure whether we should move this into a bigger refactoring on how we handle nullability (https://github.com/riok/mapperly/issues/1614)…
|
This is a breaking change, therefore the it needs to be documented in |
Skip null guard for ctor mappings when parameter accepts nullable source
Description
Fixes incorrect null guard generation when mapping a nullable source type to a non-nullable target whose single-argument constructor accepts the nullable type.
Fixes one of the bugs reported in issue 1611:
e.g.
int[]? → record Dto(int[]? Array)Previously generated
model == null ? throw new ArgumentNullException(...) : new Dto(model)Now generates
return new Dto(model)directly.Checklist