Skip to content

Commit 79d0160

Browse files
SwevoCopilot
andcommitted
feat: v1.11.0 — reverse mapping (Reverse = true on [Map] and [MapFrom])
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f081bad commit 79d0160

4 files changed

Lines changed: 342 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); version
66

77
---
88

9+
## [1.11.0] — 2026-06-25
10+
11+
### Added
12+
13+
- **Reverse mapping generation** — set `Reverse = true` on `[Map]` or `[MapFrom]` to emit a second mapping method in the opposite direction (for example `dto.ToOrder()` alongside `order.ToOrderDto()`).
14+
- **Reverse-aware property rules** — reverse generation now respects `[MapIgnore]`, `[MapProperty]`, constructor-mapped destinations, and only reverses nested/collection members when a matching reverse mapping exists.
15+
- **AM007 diagnostic** — warns when `Reverse = true` is requested but no reverse properties can be generated.
16+
17+
---
18+
919
## [1.9.0] — 2026-06-25
1020

1121
### Added

src/AutoMap/AutoMap.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<NoWarn>$(NoWarn);NU5017;NU5128;RS2008;RS1035;RS1038;RS1019</NoWarn>
1212

1313
<PackageId>AutoMap.Generator</PackageId>
14-
<Version>1.10.0</Version>
14+
<Version>1.11.0</Version>
1515
<Authors>Justin Bannister</Authors>
1616
<Copyright>Copyright (c) 2026 Justin Bannister</Copyright>
1717
<Description>Compile-time object mapping for .NET using Roslyn source generators. Add [Map(typeof(Dto))] to your domain class — AutoMap generates strongly-typed ToDto() extension methods at build time. Zero runtime overhead, no reflection, no AutoMapper dependency. Supports [MapFrom], [MapIgnore], [MapProperty], records, and structs.</Description>
@@ -27,7 +27,7 @@
2727
<IncludeSymbols>false</IncludeSymbols>
2828
<Deterministic>true</Deterministic>
2929
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
30-
<PackageReleaseNotes>1.10.0: AM004 code-fix provider (Add [MapIgnore] lightbulb). 1.9.0: Collection-level extension methods (orders.ToOrderDtos()). 1.8.0: [TrimStrings], [MapFormat], IMapFrom&lt;T&gt; convention, partial On{Method} hooks, Strict mode. 1.7.0: [MapWhen] conditional mapping. 1.6.0: Cross-enum mapping + [MapEnum]. 1.5.0: Flattening + [MapDefault]. 1.4.0: [MapWith]. 1.3.0: Constructor mapping. 1.2.0: Reverse + IAutoMapper. 1.1.0: Nested + collections. 1.0.0: Initial.</PackageReleaseNotes>
30+
<PackageReleaseNotes>1.11.0: Reverse = true on [Map] and [MapFrom], including reverse nested/collection resolution and AM007 warnings. 1.10.0: AM004 code-fix provider (Add [MapIgnore] lightbulb). 1.9.0: Collection-level extension methods (orders.ToOrderDtos()). 1.8.0: [TrimStrings], [MapFormat], IMapFrom&lt;T&gt; convention, partial On{Method} hooks, Strict mode. 1.7.0: [MapWhen] conditional mapping. 1.6.0: Cross-enum mapping + [MapEnum]. 1.5.0: Flattening + [MapDefault]. 1.4.0: [MapWith]. 1.3.0: Constructor mapping. 1.2.0: Reverse + IAutoMapper. 1.1.0: Nested + collections. 1.0.0: Initial.</PackageReleaseNotes>
3131
</PropertyGroup>
3232
<ItemGroup>
3333
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" PrivateAssets="all" />

src/AutoMap/AutoMapGenerator.cs

Lines changed: 203 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ public interface IAutoMapper<in TSource, out TResult>
222222
isEnabledByDefault: true,
223223
helpLinkUri: "https://github.com/Swevo/AutoMap.Generator#am006");
224224

225+
private static readonly DiagnosticDescriptor AM007 = new DiagnosticDescriptor(
226+
"AM007",
227+
"Reverse mapping could not be generated",
228+
"Reverse = true specified for mapping from '{0}' to '{1}', but no reverse properties could be generated",
229+
"AutoMap",
230+
DiagnosticSeverity.Warning,
231+
isEnabledByDefault: true,
232+
helpLinkUri: "https://github.com/Swevo/AutoMap.Generator#am007");
233+
225234
// Strict-mode variants (same codes, Error severity — used when [Map(Strict = true)])
226235
private static readonly DiagnosticDescriptor AM001_Strict = new DiagnosticDescriptor(
227236
"AM001", "No properties mapped",
@@ -327,7 +336,7 @@ private static ImmutableArray<MappingInfo> TransformAttributes(
327336

328337
// When Reverse = true, also generate the opposite direction
329338
if (reverse)
330-
builder.Add(BuildMappingInfo(destSymbol, sourceSymbol, null, ctx.SemanticModel.Compilation, strict));
339+
builder.Add(BuildReverseMappingInfo(sourceSymbol, destSymbol, ctx.SemanticModel.Compilation, strict));
331340
}
332341

333342
return builder.ToImmutable();
@@ -365,7 +374,8 @@ private static MappingInfo BuildMappingInfo(
365374
INamedTypeSymbol destSymbol,
366375
string? methodName,
367376
Compilation compilation,
368-
bool isStrict = false)
377+
bool isStrict = false,
378+
bool reverse = false)
369379
{
370380
var sourceFqn = sourceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
371381
var destFqn = destSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
@@ -589,6 +599,8 @@ private static MappingInfo BuildMappingInfo(
589599
}
590600

591601
// AM001 moved to GenerateSource (needs all mappings to know if deferred props resolve)
602+
var matchedMembers = mappings.Count + ctorParams.Count(p => p.SourcePropertyName != null);
603+
592604
return new MappingInfo(
593605
sourceFqn, destFqn, effectiveMethod,
594606
mappings.ToImmutable(),
@@ -597,7 +609,176 @@ private static MappingInfo BuildMappingInfo(
597609
sourceSymbol.IsValueType,
598610
ctorParams,
599611
trimStrings,
600-
isStrict);
612+
isStrict,
613+
reverse,
614+
matchedMembers);
615+
}
616+
617+
private static MappingInfo BuildReverseMappingInfo(
618+
INamedTypeSymbol originalSourceSymbol,
619+
INamedTypeSymbol originalDestSymbol,
620+
Compilation compilation,
621+
bool isStrict = false)
622+
{
623+
var reverseSourceSymbol = originalDestSymbol;
624+
var reverseDestSymbol = originalSourceSymbol;
625+
626+
var sourceFqn = reverseSourceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
627+
var destFqn = reverseDestSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
628+
var methodName = "To" + originalSourceSymbol.Name;
629+
630+
bool trimStrings = HasAttribute(reverseSourceSymbol, "AutoMap.TrimStringsAttribute")
631+
|| HasAttribute(reverseDestSymbol, "AutoMap.TrimStringsAttribute");
632+
633+
var sourceProps = BuildReadablePropLookup(reverseSourceSymbol);
634+
var reverseLookup = new Dictionary<string, (string SourcePropertyName, bool SkipReverse)>(StringComparer.OrdinalIgnoreCase);
635+
636+
foreach (var forwardDestProp in GetAllProperties(originalDestSymbol))
637+
{
638+
if (forwardDestProp.IsStatic || forwardDestProp.IsIndexer) continue;
639+
if (forwardDestProp.GetMethod?.DeclaredAccessibility != Accessibility.Public) continue;
640+
641+
string lookupName = forwardDestProp.Name;
642+
bool skipReverse = false;
643+
644+
foreach (var a in forwardDestProp.GetAttributes())
645+
{
646+
var fqn = a.AttributeClass?.ToDisplayString();
647+
if (fqn == "AutoMap.MapIgnoreAttribute" || fqn == "AutoMap.MapWithAttribute")
648+
skipReverse = true;
649+
else if (fqn == "AutoMap.MapPropertyAttribute" && a.ConstructorArguments.Length > 0)
650+
lookupName = a.ConstructorArguments[0].Value as string ?? lookupName;
651+
}
652+
653+
if (!reverseLookup.ContainsKey(lookupName))
654+
reverseLookup[lookupName] = (forwardDestProp.Name, skipReverse);
655+
}
656+
657+
var mappings = ImmutableArray.CreateBuilder<PropertyMapping>();
658+
var unresolvedProperties = ImmutableArray.CreateBuilder<UnresolvedProperty>();
659+
var diagnostics = ImmutableArray.CreateBuilder<DiagnosticInfo>();
660+
661+
foreach (var destProp in GetAllProperties(reverseDestSymbol))
662+
{
663+
if (destProp.IsStatic || destProp.IsIndexer) continue;
664+
665+
var setter = destProp.SetMethod;
666+
if (setter == null || setter.DeclaredAccessibility != Accessibility.Public) continue;
667+
668+
if (!reverseLookup.TryGetValue(destProp.Name, out var reverseInfo) || reverseInfo.SkipReverse) continue;
669+
if (!sourceProps.TryGetValue(reverseInfo.SourcePropertyName, out var srcProp)) continue;
670+
671+
var csharp = compilation as Microsoft.CodeAnalysis.CSharp.CSharpCompilation;
672+
bool typeCompatible;
673+
if (csharp != null)
674+
{
675+
var conv = csharp.ClassifyConversion(srcProp.Type, destProp.Type);
676+
typeCompatible = conv.IsIdentity || conv.IsImplicit;
677+
}
678+
else
679+
{
680+
var srcTypeFqn = srcProp.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).TrimEnd('?');
681+
var destTypeFqn = destProp.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).TrimEnd('?');
682+
typeCompatible = srcTypeFqn == destTypeFqn;
683+
}
684+
685+
if (typeCompatible)
686+
{
687+
var finalExpr = trimStrings && IsStringType(destProp.Type)
688+
? $"src.{srcProp.Name}?.Trim()"
689+
: null;
690+
mappings.Add(new PropertyMapping(destProp.Name, srcProp.Name, finalExpr));
691+
}
692+
else if (srcProp.Type.TypeKind == TypeKind.Enum
693+
&& destProp.Type.TypeKind == TypeKind.Enum
694+
&& srcProp.Type is INamedTypeSymbol srcEnum
695+
&& destProp.Type is INamedTypeSymbol destEnum)
696+
{
697+
var switchExpr = BuildEnumSwitchExpression(
698+
$"src.{srcProp.Name}", srcEnum, destEnum,
699+
destEnum.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
700+
diagnostics);
701+
mappings.Add(new PropertyMapping(destProp.Name, srcProp.Name, switchExpr));
702+
}
703+
else
704+
{
705+
var unresolved = BuildUnresolvedProperty(destProp.Name, srcProp.Name, srcProp.Type, destProp.Type);
706+
if (unresolved != null)
707+
unresolvedProperties.Add(unresolved);
708+
}
709+
}
710+
711+
bool hasParamlessCtor = false;
712+
foreach (var ctor in reverseDestSymbol.Constructors)
713+
{
714+
if (!ctor.IsStatic && ctor.DeclaredAccessibility == Accessibility.Public
715+
&& ctor.Parameters.Length == 0)
716+
{ hasParamlessCtor = true; break; }
717+
}
718+
719+
bool hasMapConstructorAttr = HasAttribute(reverseDestSymbol, "AutoMap.MapConstructorAttribute");
720+
var ctorParams = ImmutableArray<CtorParamMapping>.Empty;
721+
722+
if (!hasParamlessCtor || hasMapConstructorAttr)
723+
{
724+
IMethodSymbol? bestCtor = null;
725+
foreach (var ctor in reverseDestSymbol.Constructors)
726+
{
727+
if (ctor.IsStatic || ctor.DeclaredAccessibility != Accessibility.Public) continue;
728+
if (bestCtor == null || ctor.Parameters.Length > bestCtor.Parameters.Length)
729+
bestCtor = ctor;
730+
}
731+
732+
if (bestCtor != null)
733+
{
734+
var ctorBuilder = ImmutableArray.CreateBuilder<CtorParamMapping>(bestCtor.Parameters.Length);
735+
var ctorParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
736+
737+
foreach (var param in bestCtor.Parameters)
738+
{
739+
IPropertySymbol? matchedProp = null;
740+
if (reverseLookup.TryGetValue(param.Name, out var reverseInfo)
741+
&& !reverseInfo.SkipReverse)
742+
{
743+
sourceProps.TryGetValue(reverseInfo.SourcePropertyName, out matchedProp);
744+
}
745+
746+
if (matchedProp == null)
747+
{
748+
diagnostics.Add(new DiagnosticInfo("AM005",
749+
ImmutableArray.Create(param.Name, reverseDestSymbol.Name, reverseSourceSymbol.Name)));
750+
ctorBuilder.Add(new CtorParamMapping(param.Name, null));
751+
}
752+
else
753+
{
754+
ctorBuilder.Add(new CtorParamMapping(param.Name, matchedProp.Name));
755+
ctorParamNames.Add(matchedProp.Name);
756+
}
757+
}
758+
759+
ctorParams = ctorBuilder.ToImmutable();
760+
761+
var filtered = ImmutableArray.CreateBuilder<PropertyMapping>();
762+
foreach (var pm in mappings)
763+
if (!ctorParamNames.Contains(pm.SourcePropertyName))
764+
filtered.Add(pm);
765+
mappings = filtered;
766+
}
767+
}
768+
769+
var matchedMembers = mappings.Count + ctorParams.Count(p => p.SourcePropertyName != null);
770+
771+
return new MappingInfo(
772+
sourceFqn, destFqn, methodName,
773+
mappings.ToImmutable(),
774+
unresolvedProperties.ToImmutable(),
775+
diagnostics.ToImmutable(),
776+
reverseSourceSymbol.IsValueType,
777+
ctorParams,
778+
trimStrings,
779+
isStrict,
780+
reverse: true,
781+
matchedMembers: matchedMembers);
601782
}
602783

603784
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -700,6 +881,7 @@ private static void GenerateSource(
700881
foreach (var m in mappings)
701882
{
702883
if (string.IsNullOrEmpty(m.SourceFqn)) continue;
884+
if (m.Mappings.Length == 0 && m.CtorParams.Length == 0 && m.UnresolvedProperties.Length == 0) continue;
703885
if (!knownMappings.TryGetValue(m.SourceFqn, out var destMap))
704886
knownMappings[m.SourceFqn] = destMap = new Dictionary<string, string>(StringComparer.Ordinal);
705887
destMap[m.DestFqn] = m.MethodName;
@@ -752,20 +934,29 @@ private static void GenerateSource(
752934
resolvedExtras[m] = extras;
753935
}
754936

937+
foreach (var m in mappings)
938+
{
939+
if (!m.Reverse || string.IsNullOrEmpty(m.SourceFqn)) continue;
940+
var extras = resolvedExtras.TryGetValue(m, out var ex) ? ex : null;
941+
if (m.MatchedMembers == 0 && (extras == null || extras.Count == 0))
942+
spc.ReportDiagnostic(Diagnostic.Create(AM007, null,
943+
SimpleName(m.SourceFqn), SimpleName(m.DestFqn)));
944+
}
945+
755946
// Report stored diagnostics (AM002, AM003, AM005)
756947
foreach (var m in mappings)
757948
{
758949
foreach (var d in m.Diagnostics)
759950
{
760-
var descriptor = d.Id switch { "AM002" => AM002, "AM003" => AM003, "AM005" => AM005, "AM006" => AM006, _ => AM001 };
951+
var descriptor = d.Id switch { "AM002" => AM002, "AM003" => AM003, "AM005" => AM005, "AM006" => AM006, "AM007" => AM007, _ => AM001 };
761952
spc.ReportDiagnostic(Diagnostic.Create(descriptor, null, d.Args.ToArray<object>()));
762953
}
763954
}
764955

765956
// AM001 — no properties at all (direct or resolved)
766957
foreach (var m in mappings)
767958
{
768-
if (string.IsNullOrEmpty(m.SourceFqn) || m.Diagnostics.Length > 0) continue;
959+
if (m.Reverse || string.IsNullOrEmpty(m.SourceFqn) || m.Diagnostics.Length > 0) continue;
769960
var extras = resolvedExtras.TryGetValue(m, out var ex) ? ex : null;
770961
if (m.Mappings.Length == 0 && m.CtorParams.Length == 0 && (extras == null || extras.Count == 0))
771962
{
@@ -1019,6 +1210,8 @@ internal sealed class MappingInfo
10191210
public ImmutableArray<CtorParamMapping> CtorParams { get; }
10201211
public bool TrimStrings { get; }
10211212
public bool IsStrict { get; }
1213+
public bool Reverse { get; }
1214+
public int MatchedMembers { get; }
10221215

10231216
public bool UseConstructor => CtorParams.Length > 0;
10241217

@@ -1029,14 +1222,18 @@ public MappingInfo(string sourceFqn, string destFqn, string methodName,
10291222
bool isSourceValueType,
10301223
ImmutableArray<CtorParamMapping> ctorParams = default,
10311224
bool trimStrings = false,
1032-
bool isStrict = false)
1225+
bool isStrict = false,
1226+
bool reverse = false,
1227+
int matchedMembers = 0)
10331228
{
10341229
SourceFqn = sourceFqn; DestFqn = destFqn; MethodName = methodName;
10351230
Mappings = mappings; UnresolvedProperties = unresolvedProperties;
10361231
Diagnostics = diagnostics; IsSourceValueType = isSourceValueType;
10371232
CtorParams = ctorParams.IsDefault ? ImmutableArray<CtorParamMapping>.Empty : ctorParams;
10381233
TrimStrings = trimStrings;
10391234
IsStrict = isStrict;
1235+
Reverse = reverse;
1236+
MatchedMembers = matchedMembers;
10401237
}
10411238
}
10421239

0 commit comments

Comments
 (0)