Skip to content

fix: skip null guard for ctor mappings when parameter accepts nullable source#2180

Open
baracchande wants to merge 3 commits intoriok:mainfrom
baracchande:fix/nullable-array-ctor-mapping-null-guard
Open

fix: skip null guard for ctor mappings when parameter accepts nullable source#2180
baracchande wants to merge 3 commits intoriok:mainfrom
baracchande:fix/nullable-array-ctor-mapping-null-guard

Conversation

@baracchande
Copy link

@baracchande baracchande commented Mar 8, 2026

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

  • I did not use AI tools to generate this PR, or I have manually verified that the code is correct, optimal, and follows the project guidelines and architecture
  • I understand that low-quality, AI-generated PRs will be closed immediately without further explanation
  • The existing code style is followed
  • The commit message follows our guidelines
  • Performed a self-review of my code
  • Hard-to-understand areas of my code are commented
  • The documentation is updated (as applicable)
  • Unit tests are added/updated
  • Integration tests are added/updated (as applicable, especially if feature/bug depends on roslyn or framework version in use)

@baracchande baracchande changed the title skip null guard for ctor mappings when parameter accepts nullable source fix: skip null guard for ctor mappings when parameter accepts nullable source Mar 8, 2026
@baracchande baracchande marked this pull request as ready for review March 8, 2026 16:17
@latonz latonz requested review from Copilot and latonz and removed request for Copilot March 10, 2026 13:25
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

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 CtorMappingBuilder and exposes the selected constructor on CtorMapping for 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.

Comment on lines +29 to +36
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)
);
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.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +73
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);
}
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.

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).

Copilot uses AI. Check for mistakes.
&& CtorMappingBuilder.CtorAcceptsSourceType(namedTarget, ctx.Source, ctx.SymbolAccessor)
)
{
return new CtorMapping(ctx.Source, ctx.Target, ctorMapping.Constructor);
Copy link
Contributor

Choose a reason for hiding this comment

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

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)…

@latonz
Copy link
Contributor

latonz commented Mar 10, 2026

This is a breaking change, therefore the it needs to be documented in 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
@baracchande baracchande requested a review from latonz March 10, 2026 22:35
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.

3 participants