Skip to content

Commit

Permalink
feat: constructor mapping support for MapProperty attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
martinothamar committed Jan 22, 2023
1 parent d78c564 commit 9385086
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 36 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
bin/
obj/

.vs/

# verify received files
*.received.*

Expand Down
55 changes: 55 additions & 0 deletions docs/docs/02-configuration/07-ctor-mappings.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,58 @@ Mapperly resolves the constructor to be used by the following priorities:

The first constructor which allows the mapping of all parameters is used.
Constructor parameters are mapped in a case insensitive matter.

## Manual mapping configuration

Manual mapping of source properties to constructor parameters can be configured with the `MapProperty` attribute.
See example below

```csharp
public class Car
{
// highlight-start
public string ModelName { get; set; }
// highlight-end
}

public record CarDto(string Model);

[Mapper]
public partial class CarMapper
{
// highlight-start
[MapProperty(nameof(Car.ModelName), nameof(CarDto.Model))]
// highlight-end
public partial CarDto ToDto(Car car);
}
```

If the target type is a normal class, where the constructor parameter name not necessarily matches the property name, you can identify the correct parameter name using a string literal.

```csharp
public class Car
{
public string ModelName { get; set; }
}

public class CarDto
{
// highlight-start
public CarDto(string model)
// highlight-end
{
ModelName = model;
}

public string ModelName { get; }
}

[Mapper]
public partial class CarMapper
{
// highlight-start
[MapProperty(nameof(Car.ModelName), "model")]
// highlight-end
public partial CarDto ToDto(Car car);
}
```
2 changes: 2 additions & 0 deletions src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ RMG025 | Mapper | Error | To use reference handling it needs to be enabled
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
Original file line number Diff line number Diff line change
Expand Up @@ -197,40 +197,86 @@ private static bool TryBuildConstructorMapping(
mappedTargetPropertyNames = new HashSet<string>();
var skippedOptionalParam = false;
foreach (var parameter in ctor.Parameters)
{
if (!PropertyPath.TryFind(
ctx.Mapping.SourceType,
MemberPathCandidateBuilder.BuildMemberPathCandidates(parameter.Name),
ctx.IgnoredSourcePropertyNames,
StringComparer.OrdinalIgnoreCase,
out var sourcePath))
{
if (!parameter.IsOptional)
return false;

skippedOptionalParam = true;
continue;
}

// nullability is handled inside the property mapping
var paramType = parameter.Type.WithNullableAnnotation(parameter.NullableAnnotation);
var delegateMapping = ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType)
?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType);
if (delegateMapping == null)
{
if (!parameter.IsOptional)
return false;

skippedOptionalParam = true;
continue;
}

var propertyMapping = new NullPropertyMapping(delegateMapping, sourcePath, ctx.BuilderContext.GetNullFallbackValue(paramType));
var ctorMapping = new ConstructorParameterMapping(parameter, propertyMapping, skippedOptionalParam);
constructorParameterMappings.Add(ctorMapping);
{
if (!TryFindConstructorParameterSourcePath(ctx, parameter, out var sourcePath))
{
if (!parameter.IsOptional)
return false;

skippedOptionalParam = true;
continue;
}

// nullability is handled inside the property mapping
var paramType = parameter.Type.WithNullableAnnotation(parameter.NullableAnnotation);
var delegateMapping = ctx.BuilderContext.FindMapping(sourcePath.MemberType, paramType)
?? ctx.BuilderContext.FindOrBuildMapping(sourcePath.Member.Type.NonNullable(), paramType);
if (delegateMapping == null)
{
if (!parameter.IsOptional)
return false;

skippedOptionalParam = true;
continue;
}

var propertyMapping = new NullPropertyMapping(delegateMapping, sourcePath, ctx.BuilderContext.GetNullFallbackValue(paramType));
var ctorMapping = new ConstructorParameterMapping(parameter, propertyMapping, skippedOptionalParam);
constructorParameterMappings.Add(ctorMapping);
mappedTargetPropertyNames.Add(parameter.Name);
}

return true;
}

private static bool TryFindConstructorParameterSourcePath(
NewInstanceMappingBuilderContext ctx,
IParameterSymbol parameter,
[NotNullWhen(true)] out PropertyPath? sourcePath)
{
sourcePath = null;

if (!ctx.PropertyConfigsByRootTargetName.TryGetValue(parameter.Name, out var propertyConfigs))
{
return PropertyPath.TryFind(
ctx.Mapping.SourceType,
MemberPathCandidateBuilder.BuildMemberPathCandidates(parameter.Name),
ctx.IgnoredSourcePropertyNames,
StringComparer.OrdinalIgnoreCase,
out sourcePath
);
}

if (propertyConfigs.Count > 1)
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.MultipleConfigurationsForConstructorParameter,
parameter.Type,
parameter.Name);
}

var propertyConfig = propertyConfigs.First();
if (propertyConfig.Target.Count > 1)
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.ConstructorParameterDoesNotSupportPaths,
parameter.Type,
string.Join(".", propertyConfig.Target));
return false;
}

if (!PropertyPath.TryFind(
ctx.Mapping.SourceType,
propertyConfig.Source,
out sourcePath))
{
ctx.BuilderContext.ReportDiagnostic(
DiagnosticDescriptors.MappingSourcePropertyNotFound,
propertyConfig.Source,
ctx.Mapping.SourceType);
return false;
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public void AddInitPropertyMapping(PropertyAssignmentMapping mapping)

public void AddConstructorParameterMapping(ConstructorParameterMapping mapping)
{
PropertyConfigsByRootTargetName.Remove(mapping.Parameter.Name);

SetSourcePropertyMapped(mapping.DelegateMapping.SourcePath);
Mapping.AddConstructorParameterMapping(mapping);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ namespace Riok.Mapperly.Descriptors.Mappings.PropertyMappings;

public class ConstructorParameterMapping
{
private readonly IParameterSymbol _parameter;
private readonly bool _selfOrPreviousIsUnmappedOptional;

public ConstructorParameterMapping(
Expand All @@ -15,23 +14,25 @@ public ConstructorParameterMapping(
bool selfOrPreviousIsUnmappedOptional)
{
DelegateMapping = delegateMapping;
_parameter = parameter;
Parameter = parameter;
_selfOrPreviousIsUnmappedOptional = selfOrPreviousIsUnmappedOptional;
}

public IParameterSymbol Parameter { get; }

public NullPropertyMapping DelegateMapping { get; }

public ArgumentSyntax BuildArgument(TypeMappingBuildContext ctx)
{
var argumentExpression = DelegateMapping.Build(ctx);
var arg = Argument(argumentExpression);
return _selfOrPreviousIsUnmappedOptional
? arg.WithNameColon(NameColon(_parameter.Name))
? arg.WithNameColon(NameColon(Parameter.Name))
: arg;
}

protected bool Equals(ConstructorParameterMapping other)
=> _parameter.Equals(other._parameter, SymbolEqualityComparer.Default)
=> Parameter.Equals(other.Parameter, SymbolEqualityComparer.Default)
&& DelegateMapping.Equals(other.DelegateMapping)
&& _selfOrPreviousIsUnmappedOptional == other._selfOrPreviousIsUnmappedOptional;

Expand Down Expand Up @@ -59,7 +60,7 @@ public override int GetHashCode()
{
unchecked
{
var hashCode = SymbolEqualityComparer.Default.GetHashCode(_parameter);
var hashCode = SymbolEqualityComparer.Default.GetHashCode(Parameter);
hashCode = (hashCode * 397) ^ DelegateMapping.GetHashCode();
hashCode = (hashCode * 397) ^ _selfOrPreviousIsUnmappedOptional.GetHashCode();
return hashCode;
Expand Down
16 changes: 16 additions & 0 deletions src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,20 @@ internal static class DiagnosticDescriptors
DiagnosticCategories.Mapper,
DiagnosticSeverity.Info,
true);

public static readonly DiagnosticDescriptor MultipleConfigurationsForConstructorParameter = new DiagnosticDescriptor(
"RMG027",
"A constructor parameter can have one configuration at max",
"The constructor parameter at {0}.{1} can have one configuration at max",
DiagnosticCategories.Mapper,
DiagnosticSeverity.Warning,
true);

public static readonly DiagnosticDescriptor ConstructorParameterDoesNotSupportPaths = new DiagnosticDescriptor(
"RMG028",
"Constructor parameter cannot handle target paths",
"Cannot map to constructor parameter target path {0}.{1}",
DiagnosticCategories.Mapper,
DiagnosticSeverity.Error,
true);
}
Original file line number Diff line number Diff line change
Expand Up @@ -349,5 +349,37 @@ public void RecordToRecordFlattenedNonDirectAssignmentNullHandling()
.Should()
.HaveSingleMethodBody(@"var target = new B(source.Nested == null ? throw new System.ArgumentNullException(nameof(source.Nested.Value)) : (double)source.Nested.Value);
return target;");
}

[Fact]
public void CanResolveToRecordConstructorWithMapPropertyAttribute()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapProperty(nameof(A.Id), nameof(B.Id2))] partial B ToRecord(A a);",
"class A { public string? Id { get; set; } public bool F { get; set; } }",
"record B(string? Id2, bool F);"
);

var result = TestHelper.GenerateMapper(source);
result
.Should()
.HaveSingleMethodBody(@"var target = new B(a.Id ?? default, a.F);
return target;");
}

[Fact]
public void CanResolveToClassConstructorWithMapPropertyAttribute()
{
var source = TestSourceBuilder.MapperWithBodyAndTypes(
"[MapProperty(nameof(A.Id), \"id2\")] partial B ToRecord(A a);",
"class A { public string? Id { get; set; } public bool F { get; set; } }",
"class B { public B(string? id2, bool f) { Id2 = id2; F = f; } public string? Id2 { get; set; } public bool F { get; set; } }"
);

var result = TestHelper.GenerateMapper(source);
result
.Should()
.HaveSingleMethodBody(@"var target = new B(a.Id ?? default, a.F);
return target;");
}
}

0 comments on commit 9385086

Please sign in to comment.