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

0 comments on commit c2a338f

Please sign in to comment.