Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2e3a97e
test(enum): reproduce enum by-name mapping with different underlying …
trejjam Apr 14, 2026
b8b4e15
feat(abstractions): add MapperNoInliningAttribute
trejjam Apr 14, 2026
02b4f4b
feat(abstractions): add NoInlining property to MapperAttribute
trejjam Apr 14, 2026
c234b30
test: update public API snapshot for MapperNoInlining
trejjam Apr 14, 2026
867ec80
feat: implement MapperNoInlining inlining prevention
trejjam Apr 14, 2026
230ccdc
docs: document MapperNoInlining and NoInlining
trejjam Apr 14, 2026
9a57c03
fix: remove impossible null check for method containing type
trejjam Apr 15, 2026
63ff646
Refactor TryInlineMapping
trejjam Apr 18, 2026
7e139c1
refactor: pass NoInlining through IUserMapping instead of symbol lookup
trejjam Apr 18, 2026
73f1bff
refactor: rename NoInlining to NoExpressionInlining
trejjam Apr 24, 2026
7f5d54d
feat: promote RMG032 to error and short-circuit enum fallback
trejjam Apr 24, 2026
7181f99
docs: note RMG032 promotion to error in 5.0 migration guide
trejjam Apr 24, 2026
5c3c4b3
docs: move inlining section from mapper.mdx to queryable-projections.mdx
trejjam Apr 24, 2026
005f39d
refactor: read mapper-level NoExpressionInlining through ctx.Configur…
trejjam Jun 12, 2026
f3de520
Update ProjectionMapper to use the NoExpressionInlining
trejjam Jun 13, 2026
58eacaa
📝 docs: use AsAsyncEnumerable in non-inlined projection example
trejjam Jul 18, 2026
83d002f
🐛 fix: never inline mappings declared in other assemblies
trejjam Jul 18, 2026
9aeaf61
🐛 fix: honor MapperDefaults NoExpressionInlining for external mappers
trejjam Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/docs/breaking-changes/5-0.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ description: How to upgrade to Mapperly 5.0 and a list of all its breaking chang
- Copy constructors are no longer selected over constructors annotated with `[MapperConstructor]` or when `[MapProperty]` configurations are present.
- Generic and runtime target type mappings now correctly match derived types handled by a child `[MapDerivedType]` mapping.
- The default severity of `RMG068` (cannot inline user implemented queryable expression mapping) has been raised from info to warning, see below.
- `RMG032` is now an error instead of a warning. Queryable projection mappings that relied on the previous fallback behavior must opt out of inlining, see below.

## Inaccessible members from other assemblies included

Expand Down Expand Up @@ -179,6 +180,33 @@ public partial Dto Map(int[]? source)
}
```

## `RMG032` promoted to error

Mapperly previously emitted `RMG032` as a warning when a queryable projection required an enum mapping strategy that cannot be translated to an expression tree
(`ByName`, `ByValueCheckDefined`, explicit enum mappings, ignored enum values, or cast-based mappings between enums with different underlying types).
When this happened, Mapperly silently fell back to a cast-based mapping which produced incorrect code at runtime and cascading false `RMG037` / `RMG038` diagnostics.

Starting with v5.0, `RMG032` is reported as an error and no fallback mapping is produced.
To opt out of inlining and keep the referenced mapping method as-is in the expression tree, apply the new `[MapperNoExpressionInlining]` attribute or
set `Mapper(NoExpressionInlining = true)` on the callee mapper.
See the [queryable projections documentation](../configuration/queryable-projections.mdx) for more details.

```csharp
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
public static partial class EnumMapper
{
[MapperNoExpressionInlining]
public static partial TargetStatus MapToStatus(this SourceStatus source);
}

// or, per mapper
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName, NoExpressionInlining = true)]
public static partial class EnumMapper
{
public static partial TargetStatus MapToStatus(this SourceStatus source);
}
```

## Constructor selection

Previously, when a type had a copy constructor (a single-parameter constructor accepting the same type),
Expand Down
106 changes: 106 additions & 0 deletions docs/docs/configuration/queryable-projections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,112 @@ public static partial class CarMapper
It is important that the types in the user-implemented mapping method match the types of the objects to be mapped exactly.
Otherwise, Mapperly cannot resolve the user-implemented mapping methods.

## Controlling inlining

Mapperly automatically tries to inline referenced mapping methods into expression trees for queryable projections.
In some cases, this inlining can cause issues — for example, when enum mappings with different underlying types
produce false diagnostics in expression context.

You can prevent inlining of specific methods using the `MapperNoExpressionInlining` attribute,
or prevent inlining of all methods in a mapper using the `NoExpressionInlining` property on the `Mapper` attribute.

### Per-method opt-out

```csharp
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
public static partial class EnumMapper
{
// highlight-start
[MapperNoExpressionInlining]
// highlight-end
public static partial TargetStatus MapToStatus(this SourceStatus source);
}

[Mapper]
[UseStaticMapper(typeof(EnumMapper))]
public static partial class Mapper
{
public static partial IQueryable<CarDto> ProjectToDto(this IQueryable<Car> q);
public static partial CarDto MapToDto(this Car car);
}
```

### Per-mapper opt-out

```csharp
// highlight-start
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName, NoExpressionInlining = true)]
// highlight-end
public static partial class EnumMapper
{
public static partial TargetStatus MapToStatus(this SourceStatus source);
}
```

### Runtime behavior

The `NoExpressionInlining` setting changes how the projection is materialized at runtime.

<Tabs>
<TabItem value="inline-true" label="Inlining enabled (default)" default>

With inlining enabled, Mapperly rebuilds the mapping logic directly into the expression tree.
The entire projection runs in SQL — but the rebuilt expression may produce incorrect results
for non-trivial type pairs (e.g., enum mappings with different underlying types):

```csharp
// What runs under the hood:
var dtos = await dbContext.Cars
.Select(x => new CarDto
{
// highlight-start
// Runs entirely in SQL, but the cast may be wrong
// for enums with different underlying types
Status = (TargetStatus)x.Status,
// highlight-end
})
.ToListAsync();
```

</TabItem>
<TabItem value="inline-false" label="Inlining disabled (NoExpressionInlining = true)">

With inlining disabled, the mapping method call is preserved as-is.
The ORM cannot translate it to SQL, so Entity Framework will split the query
into a server-side projection and client-side mapping:

```csharp
// What runs under the hood:
var dtos = await dbContext.Cars
.Select(x => new
{
// highlight-start
// Only the needed columns are fetched from the database
Status = x.Status,
// highlight-end
})
// highlight-start
.AsAsyncEnumerable() // ← everything before runs in SQL, everything after is client-side
// highlight-end
.Select(x => new CarDto
{
// highlight-start
Status = EnumMapper.MapToStatus(x.Status), // ← runs in C#
// highlight-end
})
.ToListAsync();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't work does it? After AsEnumerable it is of type IEnumerable<T> which doesn't have a ToListAsync (or at least not as a built in method or extension method by ef core)…

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In older C# you need to use other implementation. But since net 10 it's there: https://learn.microsoft.com/en-us/dotnet/api/system.linq.asyncenumerable.tolistasync

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, you are right. It should have been .AsAsyncEnumerable()

```

</TabItem>
</Tabs>

:::warning
When inlining is disabled, the mapping method call cannot be translated to SQL.
Entity Framework will evaluate it client-side, which means the relevant columns are still fetched
from the database but the mapping logic runs in C# instead of SQL.
Only use this when the default inlining behavior causes issues.
:::

## Additional parameters

Queryable projection methods can have [additional parameters](./additional-mapping-parameters.mdx).
Expand Down
11 changes: 11 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,15 @@ public class MapperAttribute : Attribute
/// partial methods are discovered.
/// </summary>
public bool AutoUserMappings { get; set; } = true;

/// <summary>
/// Whether to prevent mapping methods of this mapper from being inlined
/// into expression trees for queryable projection mappings.
/// When <c>true</c>, methods from this mapper referenced via <see cref="UseStaticMapperAttribute"/>
/// will not be inlined or rebuilt in expression context.
/// This only applies to expression / queryable projection mappings;
/// regular (non-expression) mappings are unaffected.
/// Defaults to <c>false</c>.
/// </summary>
public bool NoExpressionInlining { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Diagnostics;

namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Prevents a mapping method from being inlined into expression trees for queryable projection mappings.
/// When applied, the method call is preserved as-is instead of being rebuilt in expression context.
/// This only applies to expression / queryable projection mappings;
/// regular (non-expression) mappings are unaffected.
/// This is useful when inlining causes issues such as false enum mapping diagnostics
/// due to expression tree limitations.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
[Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapperNoExpressionInliningAttribute : Attribute;
6 changes: 6 additions & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### Changed Rules

Rule ID | New Category | New Severity | Old Category | Old Severity | Notes
--------|--------------|--------------|--------------|--------------|------
RMG032 | Mapper | Error | Mapper | Warning | The enum mapping strategy ByName, ByValueCheckDefined, explicit enum mappings and ignored enum values cannot be used in projection mappings
5 changes: 5 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ public record MapperConfiguration
/// </summary>
public bool? AutoUserMappings { get; init; }

/// <summary>
/// Whether to prevent mapping methods of this mapper from being inlined into expression trees for queryable projection mappings.
/// </summary>
public bool? NoExpressionInlining { get; init; }

/// <summary>
/// The default enum naming strategy.
/// Can be overwritten on specific enums via mapping method configurations.
Expand Down
4 changes: 4 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static MapperConfiguration Merge(MapperConfiguration highPriority, Mapper
IncludedConstructors = highPriority.IncludedConstructors ?? lowPriority.IncludedConstructors,
PreferParameterlessConstructors = highPriority.PreferParameterlessConstructors ?? lowPriority.PreferParameterlessConstructors,
AutoUserMappings = highPriority.AutoUserMappings ?? lowPriority.AutoUserMappings,
NoExpressionInlining = highPriority.NoExpressionInlining ?? lowPriority.NoExpressionInlining,
EnumNamingStrategy = highPriority.EnumNamingStrategy ?? lowPriority.EnumNamingStrategy,
};
}
Expand Down Expand Up @@ -101,6 +102,9 @@ public static MapperAttribute MergeToAttribute(MapperConfiguration mapperConfigu
mapper.AutoUserMappings =
mapperConfiguration.AutoUserMappings ?? defaultMapperConfiguration.AutoUserMappings ?? mapper.AutoUserMappings;

mapper.NoExpressionInlining =
mapperConfiguration.NoExpressionInlining ?? defaultMapperConfiguration.NoExpressionInlining ?? mapper.NoExpressionInlining;

mapper.EnumNamingStrategy =
mapperConfiguration.EnumNamingStrategy ?? defaultMapperConfiguration.EnumNamingStrategy ?? mapper.EnumNamingStrategy;

Expand Down
25 changes: 23 additions & 2 deletions src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ namespace Riok.Mapperly.Configuration;
public class MapperConfigurationReader
{
private readonly Dictionary<MappingConfigurationReference, MappingConfiguration> _resolvedConfigurations = new();
private readonly Dictionary<ISymbol, MapperAttribute> _resolvedMapperConfigurations = new(SymbolEqualityComparer.Default);
private readonly AttributeDataAccessor _dataAccessor;
private readonly MappingCollection _mappings;
private readonly GenericTypeChecker _genericTypeChecker;
private readonly DiagnosticCollection _diagnostics;
private readonly WellKnownTypes _types;
private readonly MapperConfiguration _defaultMapperConfiguration;

public MapperConfigurationReader(
AttributeDataAccessor dataAccessor,
Expand All @@ -35,9 +37,9 @@ SupportedFeatures supportedFeatures
_genericTypeChecker = genericTypeChecker;
_diagnostics = diagnostics;
_types = types;
_defaultMapperConfiguration = defaultMapperConfiguration;

var mapperConfiguration = _dataAccessor.AccessSingle<MapperAttribute, MapperConfiguration>(mapperSymbol);
var mapper = MapperConfigurationMerger.MergeToAttribute(mapperConfiguration, defaultMapperConfiguration);
var mapper = BuildMapperConfiguration(mapperSymbol);

MapperConfiguration = new MappingConfiguration(
mapper,
Expand All @@ -61,6 +63,25 @@ SupportedFeatures supportedFeatures

public MappingConfiguration MapperConfiguration { get; }

/// <summary>
/// Builds the effective <see cref="MapperAttribute"/> configuration for a mapper symbol
/// by merging its <see cref="MapperAttribute"/> with the default mapper configuration
/// (<see cref="MapperDefaultsAttribute"/> and MSBuild options).
/// </summary>
public MapperAttribute BuildMapperConfiguration(ISymbol mapperSymbol)
{
if (_resolvedMapperConfigurations.TryGetValue(mapperSymbol, out var mapper))
return mapper;

var mapperConfiguration =
_dataAccessor.AccessFirstOrDefault<MapperAttribute, MapperConfiguration>(mapperSymbol) ?? new MapperConfiguration();

mapper = MapperConfigurationMerger.MergeToAttribute(mapperConfiguration, _defaultMapperConfiguration);
_resolvedMapperConfigurations[mapperSymbol] = mapper;

return mapper;
}

public MappingConfiguration BuildFor(MappingConfigurationReference reference, bool supportsDeepCloning)
{
if (_resolvedConfigurations.TryGetValue(reference, out var resolved))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,17 @@ private INewInstanceMapping TryInlineMapping(INewInstanceMapping mapping)
{
return mapping switch
{
// check if NoInline is requested
IUserMapping { NoExpressionInlining: true } => mapping,

// methods declared in another assembly are never inlined:
// whether their syntax is accessible depends on the compilation stage
// (available for compilation references, unavailable for compiled assembly references),
// treat them always as non-inlinable to generate deterministic code
IUserMapping externalAssemblyMapping
when !SymbolEqualityComparer.Default.Equals(externalAssemblyMapping.Method.ContainingAssembly, Compilation.Assembly) =>
ReportCannotInline(mapping, externalAssemblyMapping.Method),

// inline existing mapping
UserImplementedMethodMapping implementedMapping => InlineOrRebuild(implementedMapping) ?? implementedMapping,

Expand All @@ -216,6 +227,12 @@ private INewInstanceMapping TryInlineMapping(INewInstanceMapping mapping)
};
}

private INewInstanceMapping ReportCannotInline(INewInstanceMapping mapping, IMethodSymbol method)
{
ReportDiagnostic(DiagnosticDescriptors.QueryableProjectionMappingCannotInline, method);
return mapping;
}

private INewInstanceMapping? InlineOrRebuild(UserImplementedMethodMapping mapping)
{
// Try inline first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,34 @@ public static class EnumToEnumMappingBuilder
if (!ctx.IsConversionEnabled(MappingConversionType.EnumToEnum))
return null;

// A cast-based enum mapping in expression context fails to translate
// members between enums with different underlying types (e.g. sbyte vs byte)
// because boxed value equality across primitive types returns false.
// Report RMG032 and bail out instead of cascading RMG037/RMG038.
if (ctx.IsExpression && !SymbolEqualityComparer.Default.Equals(sourceEnumType, targetEnumType))
return ReportNotSupportedInProjectionMappings(ctx);

// map enums by strategy
return ctx.Configuration.Enum.Strategy switch
{
EnumMappingStrategy.ByName when ctx.IsExpression => BuildCastMappingAndDiagnostic(ctx),
EnumMappingStrategy.ByName when ctx.IsExpression => ReportNotSupportedInProjectionMappings(ctx),
EnumMappingStrategy.ByValue when ctx is { IsExpression: true, Configuration.Enum.HasExplicitConfigurations: true } =>
BuildCastMappingAndDiagnostic(ctx),
EnumMappingStrategy.ByValueCheckDefined when ctx.IsExpression => BuildCastMappingAndDiagnostic(ctx),
ReportNotSupportedInProjectionMappings(ctx),
EnumMappingStrategy.ByValueCheckDefined when ctx.IsExpression => ReportNotSupportedInProjectionMappings(ctx),
EnumMappingStrategy.ByName => BuildNameMapping(ctx),
EnumMappingStrategy.ByValueCheckDefined => BuildEnumToEnumCastMapping(ctx, checkTargetDefined: true),
_ => BuildEnumToEnumCastMapping(ctx),
};
}

private static INewInstanceMapping BuildCastMappingAndDiagnostic(MappingBuilderContext ctx)
private static INewInstanceMapping? ReportNotSupportedInProjectionMappings(MappingBuilderContext ctx)
{
ctx.ReportDiagnostic(
DiagnosticDescriptors.EnumMappingNotSupportedInProjectionMappings,
ctx.Source.ToDisplayString(),
ctx.Target.ToDisplayString()
);
return BuildEnumToEnumCastMapping(ctx, true);
return null;
}

private static INewInstanceMapping BuildEnumToEnumCastMapping(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ internal sealed class GenericUserImplementedExistingTargetMethodMapping(
MethodParameter sourceParameter,
MethodParameter targetParameter,
MethodParameter? referenceHandlerParameter,
bool isExternal
bool isExternal,
bool noExpressionInlining
) : IExistingTargetUserMapping
{
public ITypeSymbol SourceType => sourceParameter.Type;
Expand All @@ -30,6 +31,8 @@ bool isExternal

public bool IsExternal => isExternal;

public bool NoExpressionInlining => noExpressionInlining;

public bool IsSynthetic => false;

public IEnumerable<TypeMappingKey> BuildAdditionalMappingKeys(TypeMappingConfiguration config) => [];
Expand Down Expand Up @@ -68,7 +71,8 @@ ITypeSymbol concreteTargetType
concreteTargetType,
typeArguments,
referenceHandlerParameter,
isExternal
isExternal,
noExpressionInlining
);
}
}
Loading
Loading