Skip to content
53 changes: 53 additions & 0 deletions docs/docs/configuration/queryable-projections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

<Tabs>
<TabItem value="definition" label="Mapper definition">

```csharp
// highlight-start
[Mapper(QueryableProjectionNullHandling = QueryableProjectionNullHandling.Ignore)]
// highlight-end
public static partial class CarMapper
{
public static partial IQueryable<CarDto> ProjectToDto(this IQueryable<Car> q);
}
```

</TabItem>
<TabItem value="generated" label="Generated projection">

```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 });
```

</TabItem>
</Tabs>

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.
Expand Down
6 changes: 6 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,10 @@ public class MapperAttribute : Attribute
/// partial methods are discovered.
/// </summary>
public bool AutoUserMappings { get; set; } = true;

/// <summary>
/// Controls how <c>null</c> values are handled in <see cref="System.Linq.IQueryable{T}"/> projection mappings.
/// Defaults to <see cref="QueryableProjectionNullHandling.NullSafe"/>.
/// </summary>
public QueryableProjectionNullHandling QueryableProjectionNullHandling { get; set; } = QueryableProjectionNullHandling.NullSafe;
}
19 changes: 19 additions & 0 deletions src/Riok.Mapperly.Abstractions/QueryableProjectionNullHandling.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Controls how <c>null</c> values are handled in <see cref="System.Linq.IQueryable{T}"/> projection mappings.
/// </summary>
public enum QueryableProjectionNullHandling
{
/// <summary>
/// Emits null-safe projections based on the nullable reference type annotations (default).
/// </summary>
NullSafe = 0,

/// <summary>
/// Skips null handling in queryable projections, even when the source may be <c>null</c>.
/// 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 <see cref="System.NullReferenceException"/>.
/// </summary>
Ignore = 1,
}
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,7 @@ public record MapperConfiguration
/// Can be overwritten on specific enums via mapping method configurations.
/// </summary>
public EnumNamingStrategy? EnumNamingStrategy { get; init; }

/// <inheritdoc cref="MapperAttribute.QueryableProjectionNullHandling"/>
public QueryableProjectionNullHandling? QueryableProjectionNullHandling { get; init; }
}
6 changes: 6 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -90,8 +91,11 @@ public static bool TryBuild(
var memberSourceNullable = memberMappingInfo.IsSourceNullable;
var delegateSourceNullable = delegateMapping.SourceType.IsNullable();

var ignoreProjectionNulls = IsIgnoredProjectionNullHandling(ctx.BuilderContext);

if (
memberMappingInfo.Configuration?.SuppressNullMismatchDiagnostic != true
&& !ignoreProjectionNulls
&& memberSourceNullable
&& !memberTargetAcceptsNull
&& !(delegateSourceNullable && !delegateTargetNullable)
Expand Down Expand Up @@ -136,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<IMapping> ctx,
INewInstanceMapping delegateMapping,
Expand Down Expand Up @@ -173,8 +180,10 @@ private static NullMappedMemberSourceValue BuildInlineNullHandlingMapping(
ITypeSymbol targetMemberType
)
{
var ignoreNullHandling = IsIgnoredProjectionNullHandling(ctx.BuilderContext);

var nullFallback = NullFallbackValue.Default;
if (!delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable())
if (!ignoreNullHandling && !delegateMapping.SourceType.IsNullable() && sourcePath.IsAnyReadNullable())
{
nullFallback = ctx.BuilderContext.GetNullFallbackValue(targetMemberType);
}
Expand All @@ -184,7 +193,8 @@ ITypeSymbol targetMemberType
sourcePath.BuildGetter(ctx.BuilderContext),
targetMemberType,
nullFallback,
!ctx.BuilderContext.IsExpression
!ctx.BuilderContext.IsExpression,
ignoreNullHandling
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, addValuePropertyOnNullable: true));
return _delegateMapping.Build(ctx);
}

// if the source is not nullable, return it directly.
if (!_sourceGetter.MemberPath.IsAnyReadNullable())
{
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/Riok.Mapperly/Riok.Mapperly.targets
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
<CompilerVisibleProperty Include="MapperlyIncludedConstructors" />
<CompilerVisibleProperty Include="MapperlyPreferParameterlessConstructors" />
<CompilerVisibleProperty Include="MapperlyAutoUserMappings" />
<CompilerVisibleProperty Include="MapperlyQueryableProjectionNullHandling" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -277,6 +278,11 @@ public enum PropertyNameMappingStrategy
SnakeCase = 2,
UpperSnakeCase = 3,
}
public enum QueryableProjectionNullHandling
{
NullSafe = 0,
Ignore = 1,
}
[System.Flags]
public enum RequiredMappingStrategy
{
Expand Down
Loading