Skip to content

Commit

Permalink
feat: add UnsafeMemberAccessor for private property and fields
Browse files Browse the repository at this point in the history
  • Loading branch information
TimothyMakkison committed Oct 24, 2023
1 parent 0f61698 commit e925792
Show file tree
Hide file tree
Showing 80 changed files with 1,876 additions and 120 deletions.
1 change: 1 addition & 0 deletions .csharpierrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ preprocessorSymbolSets:
- "SYSTEM_PRIVATE_CORELIB"
- "NETSTANDARD2_0"
- "ROSLYN4_4_OR_GREATER"
- "ROSLYN4_7_OR_GREATER"
- "NET6_0_OR_GREATER"
- "NET7_0_OR_GREATER"
- "NET8_0_OR_GREATER"
2 changes: 1 addition & 1 deletion docs/docs/configuration/analyzer-diagnostics.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 14
sidebar_position: 15
description: A list of all analyzer diagnostics used by Mapperly and how to configure them.
---

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configuration/conversions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 13
sidebar_position: 14
description: A list of conversions supported by Mapperly
---

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configuration/generated-source.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 15
sidebar_position: 16
description: How to inspect and check in the generated source into a version control system (VCS, GIT, ...)
---

Expand Down
62 changes: 62 additions & 0 deletions docs/docs/configuration/private-member-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
sidebar_position: 12
description: Private member mapping
---

# Private member mapping

As of .NET 8.0, Mapperly supports mapping members that are normally inaccessible like `private` or `protected` properties. This is made possible by using the [UnsafeAccessorAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute) which lets Mapperly access normally inaccessible members with zero overhead while being completely AOT safe.

By default `IncludedMembers` is set to `MemberVisibility.AllAccessible` which will configure Mapperly to map members of all accessibility levels as long as they are ordinarily accessible. To enable unsafe accessor usage, set `IncludedMembers` to `MemberVisibility.All`. Mapperly will then try to map members of all accessibilities, including ones that are not usually visible to external types.

```csharp
public class Fruit
{
private bool _isSeeded;

public string Name { get; set; }

private int Sweetness { get; set; }
}

// highlight-start
[Mapper(IncludedMembers = MemberVisibility.All)]
// highlight-end
public partial class FruitMapper
{
public partial FruitDto ToDto(Fruit fruit);
}
```

## Generated code

```csharp
public partial class FruitMapper
{
private partial global::FruitDto ToDto(global::Fruit source)
{
var target = new global::FruitDto();
target.GetIsSeeded1() = source.GetIsSeeded();
target.Name = source.Name;
target.SetSweetness(source.GetSweetness());
return target;
}
}

static file class UnsafeAccessor
{
[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = "_isSeeded")]
public static extern ref bool GetSeeded(this global::Fruit target);

[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = "_isSeeded")]
public static extern ref bool GetSeeded1(this global::FruitDto target);

[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Method, Name = "get_Sweetness")]
public static extern int GetSweetness(this global::Fruit source);

[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Method, Name = "set_Sweetness")]
public static extern void SetSweetness(this global::FruitDto target, int value);
}
```

Here Mapperly generates a file scoped class containing extension method for each internal member for both the source and target. Mapperly then uses the extension methods to get and set the members. Note that this uses zero reflection and is as performant as using an ordinary property or field.
2 changes: 1 addition & 1 deletion docs/docs/configuration/queryable-projections.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 12
sidebar_position: 13
description: Use queryable projections to map queryable objects and optimize ORM performance
---

Expand Down
5 changes: 5 additions & 0 deletions src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,9 @@ public class MapperAttribute : Attribute
/// By default this is <see cref="RequiredMappingStrategy.Both"/>, emitting warnings for unmapped source and target members.
/// </summary>
public RequiredMappingStrategy RequiredMappingStrategy { get; set; } = RequiredMappingStrategy.Both;

/// <summary>
/// Determines the access level of members that Mapperly will map.
/// </summary>
public MemberVisibility IncludedMembers { get; set; } = MemberVisibility.AllAccessible;
}
45 changes: 45 additions & 0 deletions src/Riok.Mapperly.Abstractions/MemberVisibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Riok.Mapperly.Abstractions;

/// <summary>
/// Determines what member accessibility Mapperly will attempt to map.
/// </summary>
[Flags]
public enum MemberVisibility
{
/// <summary>
/// Maps all accessible members.
/// </summary>
AllAccessible = All | Accessible,

/// <summary>
/// Maps all members, even members which are not directly accessible by the mapper are mapped
/// by using accessors with the UnsafeAccessorAttribute. This can only be used for .NET 8.0 and later.
/// </summary>
All = Public | Internal | Protected | Private,

/// <summary>
/// Maps only accessible members.
/// If not set, the UnsafeAccessorAttribute is used to generate mappings for inaccessible members.
/// </summary>
Accessible = 1 << 0,

/// <summary>
/// Maps public members.
/// </summary>
Public = 1 << 1,

/// <summary>
/// Maps internal members.
/// </summary>
Internal = 1 << 2,

/// <summary>
/// Maps protected members.
/// </summary>
Protected = 1 << 3,

/// <summary>
/// Maps private members.
/// </summary>
Private = 1 << 4,
}
10 changes: 10 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,13 @@ Riok.Mapperly.Abstractions.RequiredMappingStrategy.Both = -1 -> Riok.Mapperly.Ab
Riok.Mapperly.Abstractions.RequiredMappingStrategy.None = 0 -> Riok.Mapperly.Abstractions.RequiredMappingStrategy
Riok.Mapperly.Abstractions.RequiredMappingStrategy.Source = 1 -> Riok.Mapperly.Abstractions.RequiredMappingStrategy
Riok.Mapperly.Abstractions.RequiredMappingStrategy.Target = 2 -> Riok.Mapperly.Abstractions.RequiredMappingStrategy
Riok.Mapperly.Abstractions.MapperAttribute.IncludedMembers.get -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MapperAttribute.IncludedMembers.set -> void
Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Accessible = 1 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.All = Riok.Mapperly.Abstractions.MemberVisibility.Public | Riok.Mapperly.Abstractions.MemberVisibility.Internal | Riok.Mapperly.Abstractions.MemberVisibility.Protected | Riok.Mapperly.Abstractions.MemberVisibility.Private -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.AllAccessible = Riok.Mapperly.Abstractions.MemberVisibility.Accessible | Riok.Mapperly.Abstractions.MemberVisibility.All -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Internal = 4 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Private = 16 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Protected = 8 -> Riok.Mapperly.Abstractions.MemberVisibility
Riok.Mapperly.Abstractions.MemberVisibility.Public = 2 -> Riok.Mapperly.Abstractions.MemberVisibility
1 change: 1 addition & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,4 @@ Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RMG051 | Mapper | Warning | Invalid ignore source member found, nested ignores are not supported
RMG052 | Mapper | Warning | Invalid ignore target member found, nested ignores are not supported
RMG053 | Mapper | Error | The flag MemberVisibility.Accessible cannot be disabled, this feature requires .NET 8.0 or greater
5 changes: 5 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,9 @@ public record MapperConfiguration
/// By default this is <see cref="RequiredMappingStrategy.Both"/>, emitting warnings for unmapped source and target members.
/// </summary>
public RequiredMappingStrategy? RequiredMappingStrategy { get; init; }

/// <summary>
/// Determines the access level of members that Mapperly will map.
/// </summary>
public MemberVisibility? IncludedMembers { get; init; }
}
3 changes: 3 additions & 0 deletions src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public static MapperAttribute Merge(MapperConfiguration mapperConfiguration, Map
?? defaultMapperConfiguration.RequiredMappingStrategy
?? mapper.RequiredMappingStrategy;

mapper.IncludedMembers =
mapperConfiguration.IncludedMembers ?? defaultMapperConfiguration.IncludedMembers ?? mapper.IncludedMembers;

return mapper;
}
}
40 changes: 39 additions & 1 deletion src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Abstractions.ReferenceHandling;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Descriptors.ExternalMappings;
Expand All @@ -21,6 +22,8 @@ public class DescriptorBuilder
private readonly MappingBodyBuilder _mappingBodyBuilder;
private readonly SimpleMappingBuilderContext _builderContext;
private readonly List<Diagnostic> _diagnostics = new();
private readonly UnsafeAccessorContext _unsafeAccessorContext;
private readonly MapperConfigurationReader _configurationReader;

private ObjectFactoryCollection _objectFactories = ObjectFactoryCollection.Empty;

Expand All @@ -34,14 +37,18 @@ MapperConfiguration defaultMapperConfiguration
_mapperDescriptor = new MapperDescriptor(mapperDeclaration, _methodNameBuilder);
_symbolAccessor = symbolAccessor;
_mappingBodyBuilder = new MappingBodyBuilder(_mappings);
_unsafeAccessorContext = new UnsafeAccessorContext(_methodNameBuilder, symbolAccessor);

var attributeAccessor = new AttributeDataAccessor(symbolAccessor);
_configurationReader = new MapperConfigurationReader(attributeAccessor, mapperDeclaration.Symbol, defaultMapperConfiguration);

_builderContext = new SimpleMappingBuilderContext(
compilationContext,
new MapperConfigurationReader(attributeAccessor, mapperDeclaration.Symbol, defaultMapperConfiguration),
_configurationReader,
_symbolAccessor,
attributeAccessor,
_mapperDescriptor,
_unsafeAccessorContext,
_diagnostics,
new MappingBuilder(_mappings),
new ExistingTargetMappingBuilder(_mappings)
Expand All @@ -50,6 +57,7 @@ MapperConfiguration defaultMapperConfiguration

public (MapperDescriptor descriptor, IReadOnlyCollection<Diagnostic> diagnostics) Build(CancellationToken cancellationToken)
{
ConfigureMemberVisibility();
ReserveMethodNames();
ExtractObjectFactories();
ExtractUserMappings();
Expand All @@ -59,9 +67,33 @@ MapperConfiguration defaultMapperConfiguration
TemplateResolver.AddRequiredTemplates(_builderContext.MapperConfiguration, _mappings, _mapperDescriptor);
BuildReferenceHandlingParameters();
AddMappingsToDescriptor();
AddAccessorsToDescriptor();
return (_mapperDescriptor, _diagnostics);
}

/// <summary>
/// If <see cref="MemberVisibility.Accessible"/> is not set and the roslyn version does not have UnsafeAccessors
/// then emit a diagnostic and update the <see cref="MemberVisibility"/> for <see cref="SymbolAccessor"/>.
/// </summary>
private void ConfigureMemberVisibility()
{
var includedMembers = _configurationReader.Mapper.IncludedMembers;
#if ROSLYN4_7_OR_GREATER
_symbolAccessor.SetMemberVisibility(includedMembers);
#else
if (includedMembers.HasFlag(MemberVisibility.Accessible))
return;

_diagnostics.Add(
Diagnostic.Create(
Riok.Mapperly.Diagnostics.DiagnosticDescriptors.UnsafeAccessorNotAvailable,
_mapperDescriptor.Syntax.GetLocation()
)
);
_symbolAccessor.SetMemberVisibility(includedMembers | MemberVisibility.Accessible);
#endif
}

private void ReserveMethodNames()
{
foreach (var methodSymbol in _symbolAccessor.GetAllMembers(_mapperDescriptor.Symbol))
Expand Down Expand Up @@ -127,4 +159,10 @@ private void AddMappingsToDescriptor()
_mapperDescriptor.AddTypeMapping(mapping);
}
}

private void AddAccessorsToDescriptor()
{
// add generated accessors to the mapper
_mapperDescriptor.AddUnsafeAccessors(_unsafeAccessorContext.UnsafeAccessors);
}
}
8 changes: 7 additions & 1 deletion src/Riok.Mapperly/Descriptors/MapperDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Descriptors.Mappings;
using Riok.Mapperly.Descriptors.Mappings.MemberMappings.UnsafeAccess;
using Riok.Mapperly.Helpers;
using Riok.Mapperly.Symbols;
using Riok.Mapperly.Templates;
Expand All @@ -12,6 +13,7 @@ public class MapperDescriptor
{
private readonly MapperDeclaration _declaration;
private readonly List<MethodMapping> _methodMappings = new();
private readonly List<IUnsafeAccessor> _unsafeAccessors = new();
private readonly HashSet<TemplateReference> _requiredTemplates = new();

public MapperDescriptor(MapperDeclaration declaration, UniqueNameBuilder nameBuilder)
Expand Down Expand Up @@ -40,10 +42,14 @@ public MapperDescriptor(MapperDeclaration declaration, UniqueNameBuilder nameBui

public IReadOnlyCollection<MethodMapping> MethodTypeMappings => _methodMappings;

public void AddTypeMapping(MethodMapping mapping) => _methodMappings.Add(mapping);
public IReadOnlyCollection<IUnsafeAccessor> UnsafeAccessors => _unsafeAccessors;

public void AddRequiredTemplate(TemplateReference template) => _requiredTemplates.Add(template);

public void AddTypeMapping(MethodMapping mapping) => _methodMappings.Add(mapping);

public void AddUnsafeAccessors(IEnumerable<IUnsafeAccessor> accessors) => _unsafeAccessors.AddRange(accessors);

private string BuildName(INamedTypeSymbol symbol)
{
if (symbol.ContainingType == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,18 @@ private void AddNullMemberInitializers(IMemberAssignmentMappingContainer contain
continue;
}

container.AddMemberMappingContainer(new MemberNullAssignmentInitializerMapping(nullablePath));
var setterNullablePath = SetterMemberPath.Build(BuilderContext, nullablePath);

if (setterNullablePath.IsMethod)
{
var getterNullablePath = GetterMemberPath.Build(BuilderContext, nullablePath);
container.AddMemberMappingContainer(
new MethodMemberNullAssignmentInitializerMapping(setterNullablePath, getterNullablePath)
);
continue;
}

container.AddMemberMappingContainer(new MemberNullAssignmentInitializerMapping(setterNullablePath));
}
}

Expand All @@ -75,7 +86,7 @@ private MemberNullDelegateAssignmentMapping GetOrCreateNullDelegateMappingForPat
}

mapping = new MemberNullDelegateAssignmentMapping(
nullConditionSourcePath,
GetterMemberPath.Build(BuilderContext, nullConditionSourcePath),
parentMapping,
BuilderContext.MapperConfiguration.ThrowOnPropertyMappingNullMismatch,
needsNullSafeAccess
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,17 @@ MemberPath sourcePath
nullFallback = ctx.BuilderContext.GetNullFallbackValue(targetMember.Type);
}

var getterSourcePath = GetterMemberPath.Build(ctx.BuilderContext, sourcePath);
var setterTargetPath = SetterMemberPath.Build(ctx.BuilderContext, targetPath);

var memberMapping = new NullMemberMapping(
delegateMapping,
sourcePath,
getterSourcePath,
targetMember.Type,
nullFallback,
!ctx.BuilderContext.IsExpression
);
var memberAssignmentMapping = new MemberAssignmentMapping(targetPath, memberMapping);
var memberAssignmentMapping = new MemberAssignmentMapping(setterTargetPath, memberMapping);
ctx.AddInitMemberMapping(memberAssignmentMapping);
}

Expand All @@ -203,7 +206,7 @@ private static void BuildConstructorMapping(INewInstanceBuilderContext<IMapping>
// then by descending parameter count
// ctors annotated with [Obsolete] are considered last unless they have a MapperConstructor attribute set
var ctorCandidates = namedTargetType.InstanceConstructors
.Where(ctor => ctx.BuilderContext.SymbolAccessor.IsAccessible(ctor))
.Where(ctor => ctx.BuilderContext.SymbolAccessor.IsDirectlyAccessible(ctor))
.OrderByDescending(x => ctx.BuilderContext.SymbolAccessor.HasAttribute<MapperConstructorAttribute>(x))
.ThenBy(x => ctx.BuilderContext.SymbolAccessor.HasAttribute<MapperConstructorAttribute>(x))
.ThenByDescending(x => x.Parameters.Length == 0)
Expand Down Expand Up @@ -285,9 +288,11 @@ private static bool TryBuildConstructorMapping(
return false;
}

var getterSourcePath = GetterMemberPath.Build(ctx.BuilderContext, sourcePath);

var memberMapping = new NullMemberMapping(
delegateMapping,
sourcePath,
getterSourcePath,
paramType,
ctx.BuilderContext.GetNullFallbackValue(paramType),
!ctx.BuilderContext.IsExpression
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,11 @@ out HashSet<string> mappedTargetMemberNames
return false;
}

var getterSourcePath = GetterMemberPath.Build(ctx.BuilderContext, sourcePath);

var memberMapping = new NullMemberMapping(
delegateMapping,
sourcePath,
getterSourcePath,
paramType,
ctx.BuilderContext.GetNullFallbackValue(paramType),
!ctx.BuilderContext.IsExpression
Expand Down
Loading

0 comments on commit e925792

Please sign in to comment.