Skip to content

Commit 157a306

Browse files
authored
fix(mappers): dot-notation field overrides now resolve through collection element types (#92)
1 parent 15d0820 commit 157a306

15 files changed

Lines changed: 879 additions & 218 deletions

docs/examples/nested-mapping.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,34 @@ public class Product
5959
public static partial class OrderMapper { }
6060
```
6161

62+
## Collections with Field Overrides
63+
64+
Dot-notation overrides work through collection properties by targeting the element type:
65+
66+
```csharp
67+
[DynamoMapper]
68+
[DynamoField("Items.ProductId", AttributeName = "product_id")]
69+
[DynamoField("Items.CreatedAt", Format = "yyyy-MM-dd")]
70+
public static partial class OrderMapper
71+
{
72+
public static partial Dictionary<string, AttributeValue> ToItem(Order source);
73+
public static partial Order FromItem(Dictionary<string, AttributeValue> item);
74+
}
75+
76+
public class Order
77+
{
78+
public string Id { get; set; }
79+
public List<LineItem> Items { get; set; }
80+
}
81+
82+
public class LineItem
83+
{
84+
public string ProductId { get; set; }
85+
public DateTime CreatedAt { get; set; }
86+
}
87+
```
88+
89+
The same pattern works for arrays (`LineItem[]`) and dictionary values
90+
(`Dictionary<string, LineItem>`).
91+
6292
See `examples/DynamoMapper.Nested/Program.cs` for the full walkthrough.

docs/usage/field-configuration.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@ Notes:
3838
- Dot-notation overrides force inline mapping for the nested path.
3939
- Invalid paths emit `DM0008`.
4040

41+
### Collection Element Members
42+
43+
The same dot-notation syntax works when the intermediate segment is a collection (`List<T>`, `T[]`,
44+
`Dictionary<string, T>`, etc.). The path traverses into the **element type** of the collection:
45+
46+
```csharp
47+
[DynamoMapper]
48+
[DynamoField("Contacts.VerifiedAt", Format = "yyyy-MM-dd")]
49+
[DynamoField("Contacts.Name", AttributeName = "contact_name")]
50+
public static partial class CustomerMapper
51+
{
52+
public static partial Dictionary<string, AttributeValue> ToItem(Customer source);
53+
public static partial Customer FromItem(Dictionary<string, AttributeValue> item);
54+
}
55+
56+
public class Customer
57+
{
58+
public string Id { get; set; }
59+
public List<CustomerContact> Contacts { get; set; }
60+
}
61+
62+
public class CustomerContact
63+
{
64+
public string Name { get; set; }
65+
public DateTime VerifiedAt { get; set; }
66+
}
67+
```
68+
69+
Notes:
70+
71+
- The override applies to every element in the collection — there is no per-index syntax.
72+
- An invalid property name on the element type still emits `DM0008`.
73+
- Dictionary value types are also supported: `"ProductMap.CreatedAt"` targets `CreatedAt` on
74+
the value type of `Dictionary<string, OrderItem>`.
75+
4176
## Supported Options
4277

4378
| Option | Description |

skills/dynamo-mapper/references/core-usage.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ Use `[DynamoIgnore(memberName)]` to skip one or both directions.
5454
- `FromModel` skips model -> item
5555
- `ToModel` skips item -> model
5656

57-
Dot notation works for nested members like `"ShippingAddress.Line1"`.
57+
Dot notation works for nested members like `"ShippingAddress.Line1"` and for collection element
58+
members like `"Contacts.VerifiedAt"` (where `Contacts` is `List<CustomerContact>`).
5859

5960
## Constructors
6061

skills/dynamo-mapper/references/diagnostics.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
- `DM0005` incompatible `DynamoKind` -> remove the override or use a compatible kind
1010
- `DM0006` nested cycle -> break the cycle, ignore a back-reference, or custom-convert one side
1111
- `DM0007` unsupported nested member -> fix or ignore that nested member
12-
- `DM0008` invalid dot path -> fix the path, or include base properties if inheritance is involved
12+
- `DM0008` invalid dot path -> fix the path; paths can traverse nested objects and collection
13+
element types (`"Items.ProductId"` targets `ProductId` on the element of `Items`); include base
14+
properties if inheritance is involved
1315
- `DM0009` helper rendering limit -> likely generator issue
1416
- `DM0101` no mapper methods found -> add a valid `To*` or `From*` method
1517
- `DM0102` mismatched model types -> make both directions use the same model type

src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ private static DiagnosticInfo[] ValidateDotNotationPaths(
287287
propertyType = nullableType.TypeArguments[0];
288288
}
289289

290+
// Unwrap collection element type — dot-notation can target element members
291+
var collectionInfo = CollectionTypeAnalyzer.Analyze(propertyType, context);
292+
if (collectionInfo is not null)
293+
propertyType = collectionInfo.ElementType;
294+
290295
currentType = propertyType;
291296
}
292297
}

src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
using LayeredCraft.DynamoMapper.Generator.PropertyMapping.Models;
33
using LayeredCraft.DynamoMapper.Runtime;
44
using Microsoft.CodeAnalysis;
5-
using WellKnownType = LayeredCraft.DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType;
5+
using WellKnownType =
6+
LayeredCraft.DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType;
67

78
namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping;
89

@@ -16,6 +17,7 @@ internal readonly record struct ElementTypeValidationResult(
1617
NestedMappingInfo? NestedMapping,
1718
DiagnosticInfo? Error
1819
);
20+
1921
/// <summary>
2022
/// Analyzes a type to determine if it's a collection type and returns metadata about it.
2123
/// </summary>
@@ -44,8 +46,10 @@ internal readonly record struct ElementTypeValidationResult(
4446
return null;
4547

4648
// Check for Dictionary<TKey, TValue> or IDictionary<TKey, TValue>
47-
var dictionaryType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_Dictionary_2);
48-
var iDictionaryType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IDictionary_2);
49+
var dictionaryType =
50+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_Dictionary_2);
51+
var iDictionaryType =
52+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IDictionary_2);
4953

5054
if (IsOrImplements(namedType, dictionaryType) || IsOrImplements(namedType, iDictionaryType))
5155
{
@@ -66,7 +70,8 @@ internal readonly record struct ElementTypeValidationResult(
6670
}
6771

6872
// Check for HashSet<T> or ISet<T>
69-
var hashSetType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_HashSet_1);
73+
var hashSetType =
74+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_HashSet_1);
7075
var iSetType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ISet_1);
7176

7277
if (IsOrImplements(namedType, hashSetType) || IsOrImplements(namedType, iSetType))
@@ -92,14 +97,16 @@ internal readonly record struct ElementTypeValidationResult(
9297

9398
// Check for List<T>, IList<T>, ICollection<T>, or IEnumerable<T>
9499
var listType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_List_1);
95-
var iListType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IList_1);
96-
var iCollectionType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ICollection_1);
97-
var iEnumerableType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IEnumerable_1);
98-
99-
if (IsOrImplements(namedType, listType)
100-
|| IsOrImplements(namedType, iListType)
101-
|| IsOrImplements(namedType, iCollectionType)
102-
|| IsOrImplements(namedType, iEnumerableType))
100+
var iListType =
101+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IList_1);
102+
var iCollectionType =
103+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ICollection_1);
104+
var iEnumerableType =
105+
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IEnumerable_1);
106+
107+
if (IsOrImplements(namedType, listType) || IsOrImplements(namedType, iListType) ||
108+
IsOrImplements(namedType, iCollectionType) ||
109+
IsOrImplements(namedType, iEnumerableType))
103110
{
104111
// Extract element type: List<T>
105112
if (namedType.TypeArguments.Length == 1)
@@ -141,8 +148,7 @@ internal static bool IsValidElementType(ITypeSymbol elementType, GeneratorContex
141148
/// <param name="context">The generator context.</param>
142149
/// <returns>A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives.</returns>
143150
internal static ElementTypeValidationResult ValidateElementType(
144-
ITypeSymbol elementType,
145-
GeneratorContext context
151+
ITypeSymbol elementType, GeneratorContext context
146152
)
147153
{
148154
var nestedContext = NestedAnalysisContext.Create(context, context.MapperRegistry);
@@ -161,16 +167,17 @@ GeneratorContext context
161167
/// <param name="nestedContext">The nested analysis context to preserve ancestor tracking.</param>
162168
/// <returns>A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives.</returns>
163169
internal static ElementTypeValidationResult ValidateElementType(
164-
ITypeSymbol elementType,
165-
NestedAnalysisContext nestedContext
170+
ITypeSymbol elementType, NestedAnalysisContext nestedContext
166171
)
167172
{
168173
var context = nestedContext.Context;
169174

170175
// Unwrap Nullable<T> - nullable elements are allowed
171176
var underlyingType = elementType;
172-
if (elementType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableType
173-
&& nullableType.TypeArguments.Length == 1)
177+
if (elementType is INamedTypeSymbol
178+
{
179+
OriginalDefinition.SpecialType: SpecialType.System_Nullable_T,
180+
} nullableType && nullableType.TypeArguments.Length == 1)
174181
{
175182
underlyingType = nullableType.TypeArguments[0];
176183
}
@@ -200,7 +207,8 @@ NestedAnalysisContext nestedContext
200207
return new ElementTypeValidationResult(true, null, null);
201208

202209
// DateTimeOffset
203-
var dateTimeOffsetType = context.WellKnownTypes.Get(WellKnownType.System_DateTimeOffset);
210+
var dateTimeOffsetType =
211+
context.WellKnownTypes.Get(WellKnownType.System_DateTimeOffset);
204212
if (SymbolEqualityComparer.Default.Equals(namedType, dateTimeOffsetType))
205213
return new ElementTypeValidationResult(true, null, null);
206214

@@ -215,8 +223,8 @@ NestedAnalysisContext nestedContext
215223
}
216224

217225
// Check for byte[] (valid for BS - Binary Set)
218-
if (underlyingType is IArrayTypeSymbol arrayType
219-
&& arrayType.ElementType.SpecialType == SpecialType.System_Byte)
226+
if (underlyingType is IArrayTypeSymbol arrayType &&
227+
arrayType.ElementType.SpecialType == SpecialType.System_Byte)
220228
{
221229
return new ElementTypeValidationResult(true, null, null);
222230
}
@@ -225,12 +233,10 @@ NestedAnalysisContext nestedContext
225233
if (Analyze(underlyingType, context) is not null)
226234
return new ElementTypeValidationResult(false, null, null);
227235

228-
// Try to analyze as a nested object
229-
var nestedResult = NestedObjectTypeAnalyzer.Analyze(
230-
underlyingType,
231-
"element", // property name doesn't matter for element type analysis
232-
nestedContext
233-
);
236+
// Try to analyze as a nested object. Use AnalyzeElementType instead of Analyze so the
237+
// caller-supplied path prefix is not further modified by a dummy property name.
238+
var nestedResult =
239+
NestedObjectTypeAnalyzer.AnalyzeElementType(underlyingType, nestedContext);
234240

235241
if (!nestedResult.IsSuccess)
236242
return new ElementTypeValidationResult(false, null, nestedResult.Error);
@@ -254,8 +260,10 @@ NestedAnalysisContext nestedContext
254260
{
255261
// Unwrap Nullable<T> if present
256262
var underlyingType = elementType;
257-
if (elementType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableType
258-
&& nullableType.TypeArguments.Length == 1)
263+
if (elementType is INamedTypeSymbol
264+
{
265+
OriginalDefinition.SpecialType: SpecialType.System_Nullable_T,
266+
} nullableType && nullableType.TypeArguments.Length == 1)
259267
{
260268
underlyingType = nullableType.TypeArguments[0];
261269
}
@@ -278,8 +286,8 @@ NestedAnalysisContext nestedContext
278286
}
279287

280288
// byte[] → BS
281-
if (underlyingType is IArrayTypeSymbol arrayType
282-
&& arrayType.ElementType.SpecialType == SpecialType.System_Byte)
289+
if (underlyingType is IArrayTypeSymbol arrayType &&
290+
arrayType.ElementType.SpecialType == SpecialType.System_Byte)
283291
{
284292
return DynamoKind.BS;
285293
}
@@ -291,7 +299,9 @@ NestedAnalysisContext nestedContext
291299
/// <summary>
292300
/// Checks if a type matches or implements a generic type definition.
293301
/// </summary>
294-
private static bool IsOrImplements(INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition)
302+
private static bool IsOrImplements(
303+
INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition
304+
)
295305
{
296306
if (genericTypeDefinition == null)
297307
return false;
@@ -301,7 +311,8 @@ private static bool IsOrImplements(INamedTypeSymbol type, INamedTypeSymbol? gene
301311
return true;
302312

303313
// Check if any interface matches
304-
return type.AllInterfaces.Any(i =>
305-
SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, genericTypeDefinition));
314+
return type.AllInterfaces.Any(
315+
i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, genericTypeDefinition)
316+
);
306317
}
307318
}

src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,52 @@ namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping;
1414
/// </summary>
1515
internal static class NestedObjectTypeAnalyzer
1616
{
17+
/// <summary>
18+
/// Analyzes a collection element type to determine if it's a nested object and how it should
19+
/// be mapped. Unlike <see cref="Analyze" />, this method does not append a property name to the
20+
/// context path, so that the caller can pre-set the correct path prefix (e.g. "Contacts") and have
21+
/// field overrides like "Contacts.VerifiedAt" resolve correctly.
22+
/// </summary>
23+
/// <param name="type">The element type to analyze.</param>
24+
/// <param name="nestedContext">
25+
/// The nested analysis context, with CurrentPath already set to the
26+
/// collection property's path.
27+
/// </param>
28+
internal static DiagnosticResult<NestedMappingInfo?> AnalyzeElementType(
29+
ITypeSymbol type, NestedAnalysisContext nestedContext
30+
)
31+
{
32+
nestedContext.Context.ThrowIfCancellationRequested();
33+
34+
if (!IsNestedObjectType(type, nestedContext.Context))
35+
return DiagnosticResult<NestedMappingInfo?>.Success(null);
36+
37+
if (nestedContext.WouldCreateCycle(type))
38+
return DiagnosticResult<NestedMappingInfo?>.Failure(
39+
DiagnosticDescriptors.CycleDetectedInNestedType,
40+
type.Locations.FirstOrDefault()?.CreateLocationInfo(),
41+
"element",
42+
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
43+
);
44+
45+
if (nestedContext.HasOverridesForCurrentPath())
46+
return AnalyzeForInline(type, nestedContext);
47+
48+
if (nestedContext.Registry.TryGetMapper(type, out var mapperReference) &&
49+
mapperReference != null)
50+
{
51+
var requiresTo = nestedContext.Context.HasToItemMethod;
52+
var requiresFrom = nestedContext.Context.HasFromItemMethod;
53+
if ((!requiresTo || mapperReference.HasToItemMethod) &&
54+
(!requiresFrom || mapperReference.HasFromItemMethod))
55+
return DiagnosticResult<NestedMappingInfo?>.Success(
56+
new MapperBasedNesting(mapperReference)
57+
);
58+
}
59+
60+
return AnalyzeForInline(type, nestedContext);
61+
}
62+
1763
/// <summary>
1864
/// Analyzes a type to determine if it's a nested object and how it should be mapped.
1965
/// </summary>
@@ -135,14 +181,13 @@ private static bool IsWellKnownNonNestedType(INamedTypeSymbol type, GeneratorCon
135181
/// </summary>
136182
private static IPropertySymbol[] GetMappableProperties(
137183
INamedTypeSymbol type, GeneratorContext context
138-
) =>
139-
PropertySymbolLookup.GetProperties(
140-
type,
141-
context.MapperOptions.IncludeBaseClassProperties,
142-
static (p, declaringType) =>
143-
!p.IsStatic && !p.IsIndexer && (p.GetMethod != null || p.SetMethod != null) &&
144-
!(declaringType.IsRecord && p.Name == "EqualityContract")
145-
);
184+
) => PropertySymbolLookup.GetProperties(
185+
type,
186+
context.MapperOptions.IncludeBaseClassProperties,
187+
static (p, declaringType) =>
188+
!p.IsStatic && !p.IsIndexer && (p.GetMethod != null || p.SetMethod != null) &&
189+
!(declaringType.IsRecord && p.Name == "EqualityContract")
190+
);
146191

147192
/// <summary>
148193
/// Analyzes a type for inline code generation, recursively building property specs.
@@ -225,10 +270,12 @@ private static IPropertySymbol[] GetMappableProperties(
225270
CollectionTypeAnalyzer.Analyze(underlyingType, nestedContext.Context);
226271
if (collectionInfo != null)
227272
{
273+
// Include the collection property's name in the context path so that
274+
// element-level overrides (e.g. "Contacts.VerifiedAt") resolve correctly.
228275
var validation =
229276
CollectionTypeAnalyzer.ValidateElementType(
230277
collectionInfo.ElementType,
231-
contextWithAncestor
278+
contextWithAncestor.WithPath(property.Name)
232279
);
233280

234281
if (validation.Error is not null)

0 commit comments

Comments
 (0)