Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions docs/docs/configuration/mapper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ public partial class MyMapper
}
```

### Inlining
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.

I'd move this into the queryable projection documentation page.


By default, Mapperly inlines mapping methods into expression trees when possible.
Set `NoInlining` to `true` to prevent mapping methods of this mapper from being inlined into expression trees
when referenced via `UseStaticMapper`. Defaults to `false`.

```csharp
[Mapper(NoInlining = true)]
public partial class CarMapper
{
// ...
}
```

## Properties / fields

On each mapping method declaration, property and field mappings can be customized.
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 `MapperNoInlining` attribute,
or prevent inlining of all methods in a mapper using the `NoInlining` property on the `Mapper` attribute.

### Per-method opt-out

```csharp
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
public static partial class EnumMapper
{
// highlight-start
[MapperNoInlining]
// 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, NoInlining = true)]
// highlight-end
public static partial class EnumMapper
{
public static partial TargetStatus MapToStatus(this SourceStatus source);
}
```

### Runtime behavior

The `NoInlining` 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 (NoInlining = 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
.AsEnumerable() // ← 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();
```

</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
9 changes: 9 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,13 @@ 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.
/// Defaults to <c>false</c>.
/// </summary>
public bool NoInlining { get; set; }
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.

IMO we should include the fact that this only applies to expressions / queryable projection mappings in the name. The same for the separate attribute.

}
13 changes: 13 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperNoInliningAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 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 MapperNoInliningAttribute : Attribute;
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? NoInlining { get; init; }

/// <summary>
/// The default enum naming strategy.
/// Can be overwritten on specific enums via mapping method configurations.
Expand Down
3 changes: 3 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,
NoInlining = highPriority.NoInlining ?? lowPriority.NoInlining,
EnumNamingStrategy = highPriority.EnumNamingStrategy ?? lowPriority.EnumNamingStrategy,
};
}
Expand Down Expand Up @@ -101,6 +102,8 @@ public static MapperAttribute MergeToAttribute(MapperConfiguration mapperConfigu
mapper.AutoUserMappings =
mapperConfiguration.AutoUserMappings ?? defaultMapperConfiguration.AutoUserMappings ?? mapper.AutoUserMappings;

mapper.NoInlining = mapperConfiguration.NoInlining ?? defaultMapperConfiguration.NoInlining ?? mapper.NoInlining;

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ private INewInstanceMapping TryInlineMapping(INewInstanceMapping mapping)
{
return mapping switch
{
// check if NoInline is requested
IUserMapping userMapping when ShouldSkipInlining(userMapping.Method) => mapping,

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

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

private bool ShouldSkipInlining(IMethodSymbol method)
{
if (SymbolAccessor.HasAttribute<MapperNoInliningAttribute>(method))
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.

Mapperly usually only reads configuration through ctx.Configuration / from the mapping itself. IMO the correct way here would be passing it through the mapping. ctx.Configuration is available in the user mapping extractor.

return true;

var mapperAttribute = AttributeAccessor.AccessFirstOrDefault<MapperAttribute>(method.ContainingType);
return mapperAttribute?.NoInlining == true;
}

private INewInstanceMapping? InlineOrRebuild(UserImplementedMethodMapping mapping)
{
// Try inline first
Expand Down
1 change: 1 addition & 0 deletions src/Riok.Mapperly/Riok.Mapperly.targets
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@
<CompilerVisibleProperty Include="MapperlyIncludedConstructors" />
<CompilerVisibleProperty Include="MapperlyPreferParameterlessConstructors" />
<CompilerVisibleProperty Include="MapperlyAutoUserMappings" />
<CompilerVisibleProperty Include="MapperlyNoInlining" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public MapperAttribute() { }
public Riok.Mapperly.Abstractions.IgnoreObsoleteMembersStrategy IgnoreObsoleteMembersStrategy { get; set; }
public Riok.Mapperly.Abstractions.MemberVisibility IncludedConstructors { get; set; }
public Riok.Mapperly.Abstractions.MemberVisibility IncludedMembers { get; set; }
public bool NoInlining { get; set; }
public bool PreferParameterlessConstructors { get; set; }
public Riok.Mapperly.Abstractions.PropertyNameMappingStrategy PropertyNameMappingStrategy { get; set; }
public Riok.Mapperly.Abstractions.RequiredMappingStrategy RequiredEnumMappingStrategy { get; set; }
Expand Down Expand Up @@ -208,6 +209,12 @@ public MapperIgnoreTargetValueAttribute(object target) { }
}
[System.AttributeUsage(System.AttributeTargets.Method)]
[System.Diagnostics.Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapperNoInliningAttribute : System.Attribute
{
public MapperNoInliningAttribute() { }
}
[System.AttributeUsage(System.AttributeTargets.Method)]
[System.Diagnostics.Conditional("MAPPERLY_ABSTRACTIONS_SCOPE_RUNTIME")]
public sealed class MapperRequiredMappingAttribute : System.Attribute
{
public MapperRequiredMappingAttribute(Riok.Mapperly.Abstractions.RequiredMappingStrategy requiredMappingStrategy) { }
Expand Down
30 changes: 30 additions & 0 deletions test/Riok.Mapperly.Tests/Mapping/EnumTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ public void EnumToOtherEnumTypeShouldCast()
.HaveSingleMethodBody("return (global::E2)source;");
}

[Fact]
public void EnumToOtherEnumByNameWithDifferentUnderlyingTypesShouldSwitch()
{
var source = TestSourceBuilder.Mapping(
"E1",
"E2",
TestSourceBuilderOptions.Default with
{
EnumMappingStrategy = EnumMappingStrategy.ByName,
},
"enum E1 : short {A, B, C}",
"enum E2 : byte {A, B, C}"
);

TestHelper
.GenerateMapper(source)
.Should()
.HaveSingleMethodBody(
"""
return source switch
{
global::E1.A => global::E2.A,
global::E1.B => global::E2.B,
global::E1.C => global::E2.C,
_ => throw new global::System.ArgumentOutOfRangeException(nameof(source), source, "The value of enum E1 is not supported"),
};
"""
);
}

[Fact]
public void CustomClassToEnumWithBaseTypeCastShouldCast()
{
Expand Down
Loading
Loading