From 114657d87f123bd1ab6fcb0ba4c763f3ff2472b6 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 22:20:57 +0200 Subject: [PATCH 1/7] feat: add QueryableProjectionNullHandling option surface --- .../MapperAttribute.cs | 6 ++++++ .../QueryableProjectionNullHandling.cs | 19 +++++++++++++++++++ .../Configuration/MapperConfiguration.cs | 3 +++ .../MapperConfigurationMerger.cs | 6 ++++++ ...ApiTest.PublicApiHasNotChanged.verified.cs | 6 ++++++ 5 files changed, 40 insertions(+) create mode 100644 src/Riok.Mapperly.Abstractions/QueryableProjectionNullHandling.cs diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index f0e1190379..87342a2596 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -142,4 +142,10 @@ public class MapperAttribute : Attribute /// partial methods are discovered. /// public bool AutoUserMappings { get; set; } = true; + + /// + /// Controls how null values are handled in projection mappings. + /// Defaults to . + /// + public QueryableProjectionNullHandling QueryableProjectionNullHandling { get; set; } = QueryableProjectionNullHandling.NullSafe; } diff --git a/src/Riok.Mapperly.Abstractions/QueryableProjectionNullHandling.cs b/src/Riok.Mapperly.Abstractions/QueryableProjectionNullHandling.cs new file mode 100644 index 0000000000..c4913cd659 --- /dev/null +++ b/src/Riok.Mapperly.Abstractions/QueryableProjectionNullHandling.cs @@ -0,0 +1,19 @@ +namespace Riok.Mapperly.Abstractions; + +/// +/// Controls how null values are handled in projection mappings. +/// +public enum QueryableProjectionNullHandling +{ + /// + /// Emits null-safe projections based on the nullable reference type annotations (default). + /// + NullSafe = 0, + + /// + /// Skips null handling in queryable projections, even when the source may be null. + /// Intended for relational providers (e.g. EF Core) where the projection is translated to SQL + /// and the lambda is never executed. Using this with in-memory queryables may cause a . + /// + Ignore = 1, +} diff --git a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs index 68893d366a..bc4d127ad9 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs @@ -135,4 +135,7 @@ public record MapperConfiguration /// Can be overwritten on specific enums via mapping method configurations. /// public EnumNamingStrategy? EnumNamingStrategy { get; init; } + + /// + public QueryableProjectionNullHandling? QueryableProjectionNullHandling { get; init; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs index 4d625f49f3..a509868356 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs @@ -27,6 +27,7 @@ public static MapperConfiguration Merge(MapperConfiguration highPriority, Mapper PreferParameterlessConstructors = highPriority.PreferParameterlessConstructors ?? lowPriority.PreferParameterlessConstructors, AutoUserMappings = highPriority.AutoUserMappings ?? lowPriority.AutoUserMappings, EnumNamingStrategy = highPriority.EnumNamingStrategy ?? lowPriority.EnumNamingStrategy, + QueryableProjectionNullHandling = highPriority.QueryableProjectionNullHandling ?? lowPriority.QueryableProjectionNullHandling, }; } @@ -104,6 +105,11 @@ public static MapperAttribute MergeToAttribute(MapperConfiguration mapperConfigu mapper.EnumNamingStrategy = mapperConfiguration.EnumNamingStrategy ?? defaultMapperConfiguration.EnumNamingStrategy ?? mapper.EnumNamingStrategy; + mapper.QueryableProjectionNullHandling = + mapperConfiguration.QueryableProjectionNullHandling + ?? defaultMapperConfiguration.QueryableProjectionNullHandling + ?? mapper.QueryableProjectionNullHandling; + return mapper; } } diff --git a/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs b/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs index 2491f7d24b..529eb579a4 100644 --- a/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs +++ b/test/Riok.Mapperly.Abstractions.Tests/_snapshots/PublicApiTest.PublicApiHasNotChanged.verified.cs @@ -140,6 +140,7 @@ public MapperAttribute() { } public Riok.Mapperly.Abstractions.MemberVisibility IncludedMembers { get; set; } public bool PreferParameterlessConstructors { get; set; } public Riok.Mapperly.Abstractions.PropertyNameMappingStrategy PropertyNameMappingStrategy { get; set; } + public Riok.Mapperly.Abstractions.QueryableProjectionNullHandling QueryableProjectionNullHandling { get; set; } public Riok.Mapperly.Abstractions.RequiredMappingStrategy RequiredEnumMappingStrategy { get; set; } public Riok.Mapperly.Abstractions.RequiredMappingStrategy RequiredMappingStrategy { get; set; } public Riok.Mapperly.Abstractions.StackCloningStrategy StackCloningStrategy { get; set; } @@ -277,6 +278,11 @@ public enum PropertyNameMappingStrategy SnakeCase = 2, UpperSnakeCase = 3, } + public enum QueryableProjectionNullHandling + { + NullSafe = 0, + Ignore = 1, + } [System.Flags] public enum RequiredMappingStrategy { From 10390f9083011ecdec2042255f37feb6a7e254ce Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 22:31:35 +0200 Subject: [PATCH 2/7] feat: skip queryable projection null handling when QueryableProjectionNullHandling.Ignore --- .../MemberMappingBuilder.cs | 10 ++++- .../NullMappedMemberSourceValue.cs | 17 ++++++-- .../QueryableProjectionNullHandlingTest.cs | 43 +++++++++++++++++++ test/Riok.Mapperly.Tests/TestSourceBuilder.cs | 1 + .../TestSourceBuilderOptions.cs | 3 +- 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs index 1f6f950fe2..a73b227835 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; +using Riok.Mapperly.Abstractions; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.MemberMappings; @@ -173,8 +174,12 @@ private static NullMappedMemberSourceValue BuildInlineNullHandlingMapping( ITypeSymbol targetMemberType ) { + var ignoreNullHandling = + ctx.BuilderContext.IsExpression + && ctx.BuilderContext.Configuration.Mapper.QueryableProjectionNullHandling == QueryableProjectionNullHandling.Ignore; + var nullFallback = NullFallbackValue.Default; - if (!delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable()) + if (!ignoreNullHandling && !delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable()) { nullFallback = ctx.BuilderContext.GetNullFallbackValue(targetMemberType); } @@ -184,7 +189,8 @@ ITypeSymbol targetMemberType sourcePath.BuildGetter(ctx.BuilderContext), targetMemberType, nullFallback, - !ctx.BuilderContext.IsExpression + !ctx.BuilderContext.IsExpression, + ignoreNullHandling ); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs index da4dbe9e8e..b37b0e1844 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs @@ -17,15 +17,25 @@ public class NullMappedMemberSourceValue( MemberPathGetter sourceGetter, ITypeSymbol targetType, NullFallbackValue nullFallback, - bool useNullConditionalAccess + bool useNullConditionalAccess, + bool ignoreNullHandling = false ) : ISourceValue { private readonly INewInstanceMapping _delegateMapping = delegateMapping; private readonly NullFallbackValue _nullFallback = nullFallback; private readonly MemberPathGetter _sourceGetter = sourceGetter; + private readonly bool _ignoreNullHandling = ignoreNullHandling; public ExpressionSyntax Build(TypeMappingBuildContext ctx) { + // if null handling should be ignored (e.g. queryable projections with + // QueryableProjectionNullHandling.Ignore), emit a straight member access. + if (_ignoreNullHandling) + { + ctx = ctx.WithSource(_sourceGetter.BuildAccess(ctx.Source)); + return _delegateMapping.Build(ctx); + } + // if the source is not nullable, return it directly. if (!_sourceGetter.MemberPath.IsAnyReadNullable()) { @@ -79,8 +89,9 @@ public override bool Equals(object? obj) var other = (NullMappedMemberSourceValue)obj; return _delegateMapping.Equals(other._delegateMapping) && _nullFallback == other._nullFallback - && _sourceGetter.Equals(other._sourceGetter); + && _sourceGetter.Equals(other._sourceGetter) + && _ignoreNullHandling == other._ignoreNullHandling; } - public override int GetHashCode() => HashCode.Combine(_delegateMapping, _nullFallback, _sourceGetter); + public override int GetHashCode() => HashCode.Combine(_delegateMapping, _nullFallback, _sourceGetter, _ignoreNullHandling); } diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs new file mode 100644 index 0000000000..1d0e82eae1 --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs @@ -0,0 +1,43 @@ +using Riok.Mapperly.Abstractions; + +namespace Riok.Mapperly.Tests.Mapping; + +public class QueryableProjectionNullHandlingTest +{ + [Fact] + public void IgnoreShouldSkipNullHandlingForNullableMember() + { + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public C? Value { get; set; } }", + "class B { public D Value { get; set; } }", + "class C { public int Id { get; set; } }", + "class D { public int Id { get; set; } }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowInfoDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + #nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Value = new global::D() + { + Id = x.Value.Id, + }, + } + ); + #nullable enable + """ + ); + } +} diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs index 5861f4b988..b95b60311a 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs @@ -107,6 +107,7 @@ private static string BuildAttribute(TestSourceBuilderOptions options) Attribute(options.IncludedConstructors), Attribute(options.PreferParameterlessConstructors), Attribute(options.AutoUserMappings), + Attribute(options.QueryableProjectionNullHandling), }.WhereNotNull(); return $"[Mapper({string.Join(", ", attrs)})]"; diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs index 6e166ff415..bbe1a10bb8 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs @@ -24,7 +24,8 @@ public record TestSourceBuilderOptions( MemberVisibility? IncludedConstructors = null, bool Static = false, bool PreferParameterlessConstructors = true, - bool AutoUserMappings = true + bool AutoUserMappings = true, + QueryableProjectionNullHandling? QueryableProjectionNullHandling = null ) { public const string DefaultMapperClassName = "Mapper"; From b99bbca19925a880ed7c65d863c24097dd6e8a42 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 22:33:19 +0200 Subject: [PATCH 3/7] fix: register QueryableProjectionNullHandling as compiler-visible property --- src/Riok.Mapperly/Riok.Mapperly.targets | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Riok.Mapperly/Riok.Mapperly.targets b/src/Riok.Mapperly/Riok.Mapperly.targets index 6a1a0f4e24..80c5719eb0 100644 --- a/src/Riok.Mapperly/Riok.Mapperly.targets +++ b/src/Riok.Mapperly/Riok.Mapperly.targets @@ -23,5 +23,6 @@ + From ac8d93f906e051408f48aef039e8de2e42f0bf27 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 22:40:51 +0200 Subject: [PATCH 4/7] test: cover ignored projection of nullable navigation to non-nullable dto (#2350) --- .../QueryableProjectionNullHandlingTest.cs | 20 ++++++++++++++++ ...ForNullableNavigation#Mapper.g.verified.cs | 19 +++++++++++++++ ...lessCtorForNullableNavigation.verified.txt | 24 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation#Mapper.g.verified.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs index 1d0e82eae1..6baab214e1 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs @@ -40,4 +40,24 @@ TestSourceBuilderOptions.Default with """ ); } + + [Fact] + public Task IgnoreShouldNotRequireParameterlessCtorForNullableNavigation() + { + // https://github.com/riok/mapperly/issues/2350 + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public C? Nested { get; set; } }", + "class B { public D Nested { get; set; } }", + "class C { public int Id { get; set; } }", + "class D { public D(int id) { Id = id; } public int Id { get; } }" + ); + + return TestHelper.VerifyGenerator(source); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation#Mapper.g.verified.cs new file mode 100644 index 0000000000..031eca90ca --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation#Mapper.g.verified.cs @@ -0,0 +1,19 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Nested = new global::D(x.Nested.Id), + } + ); +#nullable enable + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt new file mode 100644 index 0000000000..2d3de32ae0 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt @@ -0,0 +1,24 @@ +{ + Diagnostics: [ + { + Location: /* +{ + private partial System.Linq.IQueryable Map(System.Linq.IQueryable source); + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +} +*/ + : (11,4)-(11,84), + Message: Mapping the nullable source property Nested of A to the target property Nested of B which is not nullable, + Severity: Info, + WarningLevel: 1, + Descriptor: { + Id: RMG089, + Title: Mapping nullable source to non-nullable target member, + MessageFormat: Mapping the nullable source property {0} of {1} to the target property {2} of {3} which is not nullable, + Category: Mapper, + DefaultSeverity: Info, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file From da20668c112f8175e3bd8a3b39279c447eda4207 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 22:48:57 +0200 Subject: [PATCH 5/7] feat: suppress projection nullability diagnostic when projection null handling is ignored --- .../MemberMappingBuilder.cs | 5 ++ .../QueryableProjectionNullHandlingTest.cs | 51 +++++++++++++++++++ ...extShouldMatchEnabled#Mapper.g.verified.cs | 22 ++++++++ ...lessCtorForNullableNavigation.verified.txt | 24 --------- 4 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreDisabledNullableContextShouldMatchEnabled#Mapper.g.verified.cs delete mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs index a73b227835..8a1339badb 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs @@ -91,8 +91,13 @@ public static bool TryBuild( var memberSourceNullable = memberMappingInfo.IsSourceNullable; var delegateSourceNullable = delegateMapping.SourceType.IsNullable(); + var ignoreProjectionNulls = + ctx.BuilderContext.IsExpression + && ctx.BuilderContext.Configuration.Mapper.QueryableProjectionNullHandling == QueryableProjectionNullHandling.Ignore; + if ( memberMappingInfo.Configuration?.SuppressNullMismatchDiagnostic != true + && !ignoreProjectionNulls && memberSourceNullable && !memberTargetAcceptsNull && !(delegateSourceNullable && !delegateTargetNullable) diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs index 6baab214e1..9bf0c5c70f 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs @@ -60,4 +60,55 @@ TestSourceBuilderOptions.Default with return TestHelper.VerifyGenerator(source); } + + [Fact] + public void IgnoreShouldNotReportNullableSourceDiagnostic() + { + // https://github.com/riok/mapperly/issues/1293 + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public string? Name { get; set; } }", + "class B { public string Name { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + #nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Name = x.Name, + } + ); + #nullable enable + """ + ); + } + + [Fact] + public Task IgnoreDisabledNullableContextShouldMatchEnabled() + { + // https://github.com/riok/mapperly/issues/1293 — output must not depend on the nullable context + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public string? Name { get; set; } }", + "class B { public string Name { get; set; } }" + ); + + return TestHelper.VerifyGenerator(source, TestHelperOptions.DisabledNullable); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreDisabledNullableContextShouldMatchEnabled#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreDisabledNullableContextShouldMatchEnabled#Mapper.g.verified.cs new file mode 100644 index 0000000000..ea06718d01 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreDisabledNullableContextShouldMatchEnabled#Mapper.g.verified.cs @@ -0,0 +1,22 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(source))] + private partial global::System.Linq.IQueryable? Map(global::System.Linq.IQueryable? source) + { + if (source == null) + return default; +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Name = x.Name, + } + ); +#nullable enable + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt deleted file mode 100644 index 2d3de32ae0..0000000000 --- a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldNotRequireParameterlessCtorForNullableNavigation.verified.txt +++ /dev/null @@ -1,24 +0,0 @@ -{ - Diagnostics: [ - { - Location: /* -{ - private partial System.Linq.IQueryable Map(System.Linq.IQueryable source); - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -} -*/ - : (11,4)-(11,84), - Message: Mapping the nullable source property Nested of A to the target property Nested of B which is not nullable, - Severity: Info, - WarningLevel: 1, - Descriptor: { - Id: RMG089, - Title: Mapping nullable source to non-nullable target member, - MessageFormat: Mapping the nullable source property {0} of {1} to the target property {2} of {3} which is not nullable, - Category: Mapper, - DefaultSeverity: Info, - IsEnabledByDefault: true - } - } - ] -} \ No newline at end of file From 5d5e9db883d7750977d40be340491464e6985891 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 23:17:54 +0200 Subject: [PATCH 6/7] docs: document QueryableProjectionNullHandling option --- .../configuration/queryable-projections.mdx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/docs/configuration/queryable-projections.mdx b/docs/docs/configuration/queryable-projections.mdx index 55874bdd17..e41b7216de 100644 --- a/docs/docs/configuration/queryable-projections.mdx +++ b/docs/docs/configuration/queryable-projections.mdx @@ -56,6 +56,59 @@ such mappings have several limitations: - Deep cloning is not applied ::: +## Null handling + +By default, Mapperly emits null-safe projections: nullable source members are guarded so mapping a +`null` value does not throw. When a projection is translated to SQL by a relational provider (such as +Entity Framework Core), these guards become `CASE WHEN` expressions that can prevent the database from +using indexes. Mapping a nullable source member to a non-nullable target member additionally reports +the `RMG089` diagnostic. + +If your projections are only ever executed by a relational provider (where the lambda is translated to +a query and never run in memory), you can skip this null handling with +`QueryableProjectionNullHandling.Ignore`: + + + + +```csharp +// highlight-start +[Mapper(QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore)] +// highlight-end +public static partial class CarMapper +{ + public static partial IQueryable ProjectToDto(this IQueryable q); +} +``` + + + + +```csharp +// with Ignore: source members are projected directly +q.Select(x => new CarDto() { OwnerName = x.Owner.Name }); + +// default (NullSafe): nullable members are guarded +q.Select(x => new CarDto() { OwnerName = x.Owner != null ? x.Owner.Name : default }); +``` + + + + +With `Ignore`, Mapperly projects source members directly, producing cleaner SQL and skipping the +related nullability diagnostics. The generated projection is then identical regardless of the nullable +reference type context. + +:::warning +`QueryableProjectionNullHandling.Ignore` assumes source members are non-`null`. Using it with an +in-memory `IQueryable` (e.g. LINQ to Objects) where a source value is actually `null` throws a +`NullReferenceException`. Only use it with providers that translate the projection to a query, such as +Entity Framework Core. +::: + +The default is `QueryableProjectionNullHandling.NullSafe`, which keeps the null-safe behavior described +above. + ## Property configurations To configure property mappings add partial mapping method definitions with attributes as needed. From ca5b189d6730514bc0ab99be7661b237797d5c71 Mon Sep 17 00:00:00 2001 From: Federico Dipuma Date: Mon, 13 Jul 2026 23:32:22 +0200 Subject: [PATCH 7/7] fix: unwrap nullable value types in ignored projection null handling and add coverage - NullMappedMemberSourceValue.Build: the Ignore short-circuit branch now requests addValuePropertyOnNullable so nullable value-type members (e.g. int? -> int) emit x.Member.Value instead of non-compiling x.Member. - MemberMappingBuilder: extract the duplicated IsIgnoredProjectionNullHandling predicate used for diagnostic suppression and inline null-handling mapping. - Add regression test for the value-type unwrap plus Verify coverage for a deeply nested nullable reference path and a nullable collection member under QueryableProjectionNullHandling.Ignore. --- .../MemberMappingBuilder.cs | 11 ++- .../NullMappedMemberSourceValue.cs | 2 +- .../QueryableProjectionNullHandlingTest.cs | 72 +++++++++++++++++++ ...dNullablePathDirectly#Mapper.g.verified.cs | 19 +++++ ...lableCollectionMember#Mapper.g.verified.cs | 27 +++++++ 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectDeeplyNestedNullablePathDirectly#Mapper.g.verified.cs create mode 100644 test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectNullableCollectionMember#Mapper.g.verified.cs diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs index 8a1339badb..2e25e7e07b 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/MemberMappingBuilder.cs @@ -91,9 +91,7 @@ public static bool TryBuild( var memberSourceNullable = memberMappingInfo.IsSourceNullable; var delegateSourceNullable = delegateMapping.SourceType.IsNullable(); - var ignoreProjectionNulls = - ctx.BuilderContext.IsExpression - && ctx.BuilderContext.Configuration.Mapper.QueryableProjectionNullHandling == QueryableProjectionNullHandling.Ignore; + var ignoreProjectionNulls = IsIgnoredProjectionNullHandling(ctx.BuilderContext); if ( memberMappingInfo.Configuration?.SuppressNullMismatchDiagnostic != true @@ -142,6 +140,9 @@ public static bool TryBuild( return true; } + private static bool IsIgnoredProjectionNullHandling(MappingBuilderContext ctx) => + ctx.IsExpression && ctx.Configuration.Mapper.QueryableProjectionNullHandling == QueryableProjectionNullHandling.Ignore; + private static bool ValidateLoopMapping( IMembersBuilderContext ctx, INewInstanceMapping delegateMapping, @@ -179,9 +180,7 @@ private static NullMappedMemberSourceValue BuildInlineNullHandlingMapping( ITypeSymbol targetMemberType ) { - var ignoreNullHandling = - ctx.BuilderContext.IsExpression - && ctx.BuilderContext.Configuration.Mapper.QueryableProjectionNullHandling == QueryableProjectionNullHandling.Ignore; + var ignoreNullHandling = IsIgnoredProjectionNullHandling(ctx.BuilderContext); var nullFallback = NullFallbackValue.Default; if (!ignoreNullHandling && !delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable()) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs index b37b0e1844..8292c13e2d 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/MemberMappings/SourceValue/NullMappedMemberSourceValue.cs @@ -32,7 +32,7 @@ public ExpressionSyntax Build(TypeMappingBuildContext ctx) // QueryableProjectionNullHandling.Ignore), emit a straight member access. if (_ignoreNullHandling) { - ctx = ctx.WithSource(_sourceGetter.BuildAccess(ctx.Source)); + ctx = ctx.WithSource(_sourceGetter.BuildAccess(ctx.Source, addValuePropertyOnNullable: true)); return _delegateMapping.Build(ctx); } diff --git a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs index 9bf0c5c70f..9993522614 100644 --- a/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/QueryableProjectionNullHandlingTest.cs @@ -111,4 +111,76 @@ TestSourceBuilderOptions.Default with return TestHelper.VerifyGenerator(source, TestHelperOptions.DisabledNullable); } + + [Fact] + public void IgnoreShouldUnwrapNullableValueTypeMember() + { + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public int? IntValue { get; set; } }", + "class B { public int IntValue { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + #nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + IntValue = x.IntValue.Value, + } + ); + #nullable enable + """ + ); + } + + [Fact] + public Task IgnoreShouldProjectDeeplyNestedNullablePathDirectly() + { + // relies on Mapperly's automatic flattening (Owner.Address.City => OwnerAddressCity), + // as MapProperty configuration is not supported directly on queryable projection mappings (RMG065). + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public C? Owner { get; set; } }", + "class B { public string OwnerAddressCity { get; set; } }", + "class C { public Address? Address { get; set; } }", + "class Address { public string City { get; set; } }" + ); + + return TestHelper.VerifyGenerator(source); + } + + [Fact] + public Task IgnoreShouldProjectNullableCollectionMember() + { + var source = TestSourceBuilder.Mapping( + "System.Linq.IQueryable", + "System.Linq.IQueryable", + TestSourceBuilderOptions.Default with + { + QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore, + }, + "class A { public System.Collections.Generic.List? Items { get; set; } }", + "class B { public System.Collections.Generic.List Items { get; set; } }", + "class C { public int Id { get; set; } }", + "class D { public int Id { get; set; } }" + ); + + return TestHelper.VerifyGenerator(source); + } } diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectDeeplyNestedNullablePathDirectly#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectDeeplyNestedNullablePathDirectly#Mapper.g.verified.cs new file mode 100644 index 0000000000..8dbfb7844a --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectDeeplyNestedNullablePathDirectly#Mapper.g.verified.cs @@ -0,0 +1,19 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + OwnerAddressCity = x.Owner.Address.City, + } + ); +#nullable enable + } +} \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectNullableCollectionMember#Mapper.g.verified.cs b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectNullableCollectionMember#Mapper.g.verified.cs new file mode 100644 index 0000000000..72a1afd930 --- /dev/null +++ b/test/Riok.Mapperly.Tests/_snapshots/QueryableProjectionNullHandlingTest.IgnoreShouldProjectNullableCollectionMember#Mapper.g.verified.cs @@ -0,0 +1,27 @@ +//HintName: Mapper.g.cs +// +#nullable enable +public partial class Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "0.0.1.0")] + private partial global::System.Linq.IQueryable Map(global::System.Linq.IQueryable source) + { +#nullable disable + return global::System.Linq.Queryable.Select( + source, + x => new global::B() + { + Items = global::System.Linq.Enumerable.ToList( + global::System.Linq.Enumerable.Select( + x.Items, + x1 => new global::D() + { + Id = x1.Id, + } + ) + ), + } + ); +#nullable enable + } +} \ No newline at end of file