Skip to content

Commit

Permalink
feat: Support IQueryable projection mappings (#287)
Browse files Browse the repository at this point in the history
  • Loading branch information
latonz authored Mar 23, 2023
1 parent 06a9f9a commit c2a338f
Show file tree
Hide file tree
Showing 109 changed files with 3,385 additions and 441 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import GeneratedCarMapperSource from '!!raw-loader!../../src/data/generated/samp

This example will show you what kind of code Mapperly generates.
It is based on the [Mapperly sample](https://github.com/riok/mapperly/tree/main/samples/Riok.Mapperly.Sample).
To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/13-generated-source.mdx).
To view the generated code of your own mapper, refer to the [generated source configuration](../02-configuration/14-generated-source.mdx).

## The source classes

Expand Down
73 changes: 73 additions & 0 deletions docs/docs/02-configuration/11-queryable-projections.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Tabs from '@theme/Tabs';

# IQueryable projections

Mapperly does support `IQueryable<T>` projections:

<!-- do not indent this, it won't work, https://stackoverflow.com/a/67579641/3302887 -->

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

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

</TabItem>
<TabItem value="usage" label="Usage">

```csharp
var dtos = await DbContext.Cars
.Where(...)
// highlight-start
.ProjectToDto()
// highlight-end
.ToListAsync();
```

</TabItem>
</Tabs>

This is useful in combination with Entity Framework and other ORM solutions which expose `IQueryable<T>`.
Only fields present in the target class will be retrieved from the database.

:::info

Since queryable projection mappings use `System.Linq.Expressions.Expression<T>` under the hood,
such mappings have several limitations:

- Object factories are not applied
- Constructors with unmatched optional parameters are ignored
- `ThrowOnPropertyMappingNullMismatch` is ignored
- User implemented mappings are not supported
- Enum mappings do not support the `ByName` strategy
- Reference handling is not supported
- Nullable reference types are disabled

:::

## Property configurations

To configure property mappings add partial mapping method definitions with attributes as needed.
Set these methods to private to hide them from callers.

```csharp
[Mapper]
public static partial class CarMapper
{
// highlight-start
public static partial IQueryable<CarDto> ProjectToDto(this IQueryable<Car> q);
// highlight-end
// highlight-start
[MapProperty(nameof(Car.Manufacturer), nameof(CarDto.Producer)]
// highlight-end
private static partial CarDto Map(Car car);
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Mapperly implements several types of automatic conversions (in order of priority
| Name | Description | Conditions |
| -------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Direct assignment | Directly assigns the source object to the target | Source type is assignable to the target type and `UseDeepCloning` is `false` |
| Queryable | Projects the source queryable to the target queryable | Source and target types are `IQueryable<>` |
| Dictionary | Maps a source dictionary to an enumerable target | Source type is an `IDictionary<,>` or an `IReadOnlyDictionary<,>` |
| Enumerable | Maps an enumerable source to an enumerable target | Source type is an `IEnumerable<>` |
| Implicit cast | Implicit cast operator | An implicit cast operator is defined to cast from the source type to the target type |
Expand Down Expand Up @@ -47,3 +48,18 @@ public partial class CarMapper
...
}
```

## Enable only specific automatic conversions

To enable only specific conversion types, set `EnabledConversions` the conversion type to enable:

```csharp
// This disables conversions using the ToString() method, which is enabled by default:
// highlight-start
[Mapper(EnabledConversions = MappingConversionType.Constructor | MappingConversionType.ExplicitCast)]
// highlight-end
public partial class CarMapper
{
...
}
```
19 changes: 19 additions & 0 deletions src/Riok.Mapperly.Abstractions/MappingConversionType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ public enum MappingConversionType
/// </summary>
DateTimeToTimeOnly = 1 << 9,

/// <summary>
/// If the source and the target is a <see cref="IQueryable{T}"/>.
/// Only uses object initializers and inlines the mapping code.
/// </summary>
Queryable = 1 << 10,

/// <summary>
/// If the source and the target is an <see cref="IEnumerable{T}"/>
/// Maps each element individually.
/// </summary>
Enumerable = 1 << 11,

/// <summary>
/// If the source and targets are <see cref="IDictionary{TKey,TValue}"/>
/// or <see cref="IReadOnlyDictionary{TKey,TValue}"/>.
/// Maps each <see cref="KeyValuePair{TKey,TValue}"/> individually.
/// </summary>
Dictionary = 1 << 12,

/// <summary>
/// Enables all supported conversions.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Riok.Mapperly.Abstractions.MappingConversionType.All = -1 -> Riok.Mapperly.Abstr
Riok.Mapperly.Abstractions.MappingConversionType.Constructor = 1 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.DateTimeToDateOnly = 256 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.DateTimeToTimeOnly = 512 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.Dictionary = 4096 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.Enumerable = 2048 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.EnumToEnum = 128 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.EnumToString = 64 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.ExplicitCast = 4 -> Riok.Mapperly.Abstractions.MappingConversionType
Expand All @@ -60,6 +62,7 @@ Riok.Mapperly.Abstractions.MappingConversionType.None = 0 -> Riok.Mapperly.Abstr
Riok.Mapperly.Abstractions.MappingConversionType.ParseMethod = 8 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.StringToEnum = 32 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.ToStringMethod = 16 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MappingConversionType.Queryable = 1024 -> Riok.Mapperly.Abstractions.MappingConversionType
Riok.Mapperly.Abstractions.MapperAttribute.UseReferenceHandling.get -> bool
Riok.Mapperly.Abstractions.MapperAttribute.UseReferenceHandling.set -> void
Riok.Mapperly.Abstractions.ReferenceHandling.Internal.PreserveReferenceHandler
Expand Down
11 changes: 11 additions & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,14 @@ Rule ID | Category | Severity | Notes
RMG026 | Mapper | Info | Cannot map from indexed property
RMG027 | Mapper | Warning | A constructor parameter can have one configuration at max
RMG028 | Mapper | Error | Constructor parameter cannot handle target paths

## Release 2.8

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RMG029 | Mapper | Error | Queryable projection mappings do not support reference handling
RMG030 | Mapper | Error | Reference loop detected while mapping to an init only property
RMG031 | Mapper | Warning | Reference loop detected while mapping to a constructor property
RMG032 | Mapper | Warning | The enum mapping strategy ByName cannot be used in projection mappings
52 changes: 52 additions & 0 deletions src/Riok.Mapperly/Descriptors/Configuration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;

namespace Riok.Mapperly.Descriptors;

public class Configuration
{
/// <summary>
/// Default configurations, used if a configuration is required for a mapping
/// but no configuration is provided by the user.
/// These are the default configurations registered for each configuration attribute (eg. the <see cref="MapEnumAttribute"/>).
/// Usually these are derived from the <see cref="MapperAttribute"/> or default values.
/// </summary>
private readonly Dictionary<Type, Attribute> _defaultConfigurations = new();

private readonly Compilation _compilation;

public Configuration(Compilation compilation, INamedTypeSymbol mapperSymbol)
{
_compilation = compilation;
Mapper = AttributeDataAccessor.AccessFirstOrDefault<MapperAttribute>(compilation, mapperSymbol) ?? new();
InitDefaultConfigurations();
}

public MapperAttribute Mapper { get; }

public T GetOrDefault<T>(IMethodSymbol? userSymbol)
where T : Attribute
{
return ListConfiguration<T>(userSymbol).FirstOrDefault()
?? (T)_defaultConfigurations[typeof(T)];
}

public IEnumerable<T> ListConfiguration<T>(IMethodSymbol? userSymbol)
where T : Attribute
{
return userSymbol == null
? Enumerable.Empty<T>()
: AttributeDataAccessor.Access<T>(_compilation, userSymbol);
}

private void InitDefaultConfigurations()
{
_defaultConfigurations.Add(
typeof(MapEnumAttribute),
new MapEnumAttribute(Mapper.EnumMappingStrategy)
{
IgnoreCase = Mapper.EnumMappingIgnoreCase
});
}
}
84 changes: 24 additions & 60 deletions src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.MappingBodyBuilders;
using Riok.Mapperly.Descriptors.MappingBuilders;
using Riok.Mapperly.Descriptors.ObjectFactories;
Expand All @@ -11,62 +9,31 @@ namespace Riok.Mapperly.Descriptors;

public class DescriptorBuilder
{
private readonly SourceProductionContext _context;
private readonly ITypeSymbol _mapperSymbol;
private readonly MapperDescriptor _mapperDescriptor;

// default configurations, used a configuration is needed but no configuration is provided by the user
// these are the default configurations registered for each configuration attribute.
// Usually these are derived from the mapper attribute or default values.
private readonly Dictionary<Type, Attribute> _defaultConfigurations = new();

private readonly MappingCollection _mappings = new();
private readonly MethodNameBuilder _methodNameBuilder = new();
private readonly MappingBodyBuilder _mappingBodyBuilder;
private readonly SimpleMappingBuilderContext _builderContext;

private ObjectFactoryCollection _objectFactories = ObjectFactoryCollection.Empty;

public DescriptorBuilder(
SourceProductionContext sourceContext,
Compilation compilation,
ClassDeclarationSyntax mapperSyntax,
INamedTypeSymbol mapperSymbol)
{
_mapperSymbol = mapperSymbol;
_context = sourceContext;
_mapperDescriptor = new MapperDescriptor(mapperSyntax, mapperSymbol, _methodNameBuilder);
_mappingBodyBuilder = new MappingBodyBuilder(_mappings);
Compilation = compilation;
WellKnownTypes = new WellKnownTypes(Compilation);
MappingBuilder = new MappingBuilder(this, _mappings);
ExistingTargetMappingBuilder = new ExistingTargetMappingBuilder(this, _mappings);
MapperConfiguration = Configure();
}

internal IReadOnlyDictionary<Type, Attribute> DefaultConfigurations => _defaultConfigurations;

internal Compilation Compilation { get; }

internal WellKnownTypes WellKnownTypes { get; }

internal ObjectFactoryCollection ObjectFactories { get; private set; } = ObjectFactoryCollection.Empty;

public MapperAttribute MapperConfiguration { get; }

public MappingBuilder MappingBuilder { get; }

public ExistingTargetMappingBuilder ExistingTargetMappingBuilder { get; }

private MapperAttribute Configure()
{
var mapperAttribute = AttributeDataAccessor.AccessFirstOrDefault<MapperAttribute>(Compilation, _mapperSymbol) ?? new();
if (!_mapperSymbol.ContainingNamespace.IsGlobalNamespace)
{
_mapperDescriptor.Namespace = _mapperSymbol.ContainingNamespace.ToDisplayString();
}

_defaultConfigurations.Add(
typeof(MapEnumAttribute),
new MapEnumAttribute(mapperAttribute.EnumMappingStrategy) { IgnoreCase = mapperAttribute.EnumMappingIgnoreCase });
return mapperAttribute;
_builderContext = new SimpleMappingBuilderContext(
compilation,
new Configuration(compilation, mapperSymbol),
new WellKnownTypes(compilation),
_mapperDescriptor,
sourceContext,
new MappingBuilder(_mappings),
new ExistingTargetMappingBuilder(_mappings));
}

public MapperDescriptor Build()
Expand All @@ -83,31 +50,28 @@ public MapperDescriptor Build()

private void ExtractObjectFactories()
{
var ctx = new SimpleMappingBuilderContext(this);
ObjectFactories = ObjectFactoryBuilder.ExtractObjectFactories(ctx, _mapperSymbol);
_objectFactories = ObjectFactoryBuilder.ExtractObjectFactories(_builderContext, _mapperDescriptor.Symbol);
}

internal void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location, params object[] messageArgs)
=> _context.ReportDiagnostic(Diagnostic.Create(descriptor, location ?? _mapperDescriptor.Syntax.GetLocation(), messageArgs));

private void ExtractUserMappings()
{
var defaultContext = new SimpleMappingBuilderContext(this);
foreach (var userMapping in UserMethodMappingExtractor.ExtractUserMappings(defaultContext, _mapperSymbol))
foreach (var userMapping in UserMethodMappingExtractor.ExtractUserMappings(_builderContext, _mapperDescriptor.Symbol))
{
var ctx = new MappingBuilderContext(
this,
_builderContext,
_objectFactories,
userMapping.Method,
userMapping.SourceType,
userMapping.TargetType,
userMapping.Method);
_mappings.AddMapping(userMapping);
_mappings.EnqueueMappingToBuildBody(userMapping, ctx);
userMapping.TargetType);

_mappings.Add(userMapping);
_mappings.EnqueueToBuildBody(userMapping, ctx);
}
}

private void ReserveMethodNames()
{
foreach (var methodSymbol in _mapperSymbol.GetAllMembers())
foreach (var methodSymbol in _mapperDescriptor.Symbol.GetAllMembers())
{
_methodNameBuilder.Reserve(methodSymbol.Name);
}
Expand All @@ -123,19 +87,19 @@ private void BuildMappingMethodNames()

private void BuildReferenceHandlingParameters()
{
if (!MapperConfiguration.UseReferenceHandling)
if (!_builderContext.MapperConfiguration.UseReferenceHandling)
return;

foreach (var methodMapping in _mappings.MethodMappings)
{
methodMapping.EnableReferenceHandling(WellKnownTypes.IReferenceHandler);
methodMapping.EnableReferenceHandling(_builderContext.Types.IReferenceHandler);
}
}

private void AddMappingsToDescriptor()
{
// add generated mappings to the mapper
foreach (var mapping in _mappings.All)
foreach (var mapping in _mappings.MethodMappings)
{
_mapperDescriptor.AddTypeMapping(mapping);
}
Expand Down
Loading

0 comments on commit c2a338f

Please sign in to comment.