Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/examples/nested-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,34 @@ public class Product
public static partial class OrderMapper { }
```

## Collections with Field Overrides

Dot-notation overrides work through collection properties by targeting the element type:

```csharp
[DynamoMapper]
[DynamoField("Items.ProductId", AttributeName = "product_id")]
[DynamoField("Items.CreatedAt", Format = "yyyy-MM-dd")]
public static partial class OrderMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Order source);
public static partial Order FromItem(Dictionary<string, AttributeValue> item);
}

public class Order
{
public string Id { get; set; }
public List<LineItem> Items { get; set; }
}

public class LineItem
{
public string ProductId { get; set; }
public DateTime CreatedAt { get; set; }
}
```

The same pattern works for arrays (`LineItem[]`) and dictionary values
(`Dictionary<string, LineItem>`).

See `examples/DynamoMapper.Nested/Program.cs` for the full walkthrough.
35 changes: 35 additions & 0 deletions docs/usage/field-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ Notes:
- Dot-notation overrides force inline mapping for the nested path.
- Invalid paths emit `DM0008`.

### Collection Element Members

The same dot-notation syntax works when the intermediate segment is a collection (`List<T>`, `T[]`,
`Dictionary<string, T>`, etc.). The path traverses into the **element type** of the collection:

```csharp
[DynamoMapper]
[DynamoField("Contacts.VerifiedAt", Format = "yyyy-MM-dd")]
[DynamoField("Contacts.Name", AttributeName = "contact_name")]
public static partial class CustomerMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Customer source);
public static partial Customer FromItem(Dictionary<string, AttributeValue> item);
}

public class Customer
{
public string Id { get; set; }
public List<CustomerContact> Contacts { get; set; }
}

public class CustomerContact
{
public string Name { get; set; }
public DateTime VerifiedAt { get; set; }
}
```

Notes:

- The override applies to every element in the collection β€” there is no per-index syntax.
- An invalid property name on the element type still emits `DM0008`.
- Dictionary value types are also supported: `"ProductMap.CreatedAt"` targets `CreatedAt` on
the value type of `Dictionary<string, OrderItem>`.

## Supported Options

| Option | Description |
Expand Down
3 changes: 2 additions & 1 deletion skills/dynamo-mapper/references/core-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Use `[DynamoIgnore(memberName)]` to skip one or both directions.
- `FromModel` skips model -> item
- `ToModel` skips item -> model

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

## Constructors

Expand Down
4 changes: 3 additions & 1 deletion skills/dynamo-mapper/references/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
- `DM0005` incompatible `DynamoKind` -> remove the override or use a compatible kind
- `DM0006` nested cycle -> break the cycle, ignore a back-reference, or custom-convert one side
- `DM0007` unsupported nested member -> fix or ignore that nested member
- `DM0008` invalid dot path -> fix the path, or include base properties if inheritance is involved
- `DM0008` invalid dot path -> fix the path; paths can traverse nested objects and collection
element types (`"Items.ProductId"` targets `ProductId` on the element of `Items`); include base
properties if inheritance is involved
- `DM0009` helper rendering limit -> likely generator issue
- `DM0101` no mapper methods found -> add a valid `To*` or `From*` method
- `DM0102` mismatched model types -> make both directions use the same model type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ private static DiagnosticInfo[] ValidateDotNotationPaths(
propertyType = nullableType.TypeArguments[0];
}

// Unwrap collection element type β€” dot-notation can target element members
var collectionInfo = CollectionTypeAnalyzer.Analyze(propertyType, context);
if (collectionInfo is not null)
propertyType = collectionInfo.ElementType;

currentType = propertyType;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
using LayeredCraft.DynamoMapper.Generator.PropertyMapping.Models;
using LayeredCraft.DynamoMapper.Runtime;
using Microsoft.CodeAnalysis;
using WellKnownType = LayeredCraft.DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType;
using WellKnownType =
LayeredCraft.DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType;

namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping;

Expand All @@ -16,6 +17,7 @@ internal readonly record struct ElementTypeValidationResult(
NestedMappingInfo? NestedMapping,
DiagnosticInfo? Error
);

/// <summary>
/// Analyzes a type to determine if it's a collection type and returns metadata about it.
/// </summary>
Expand Down Expand Up @@ -44,8 +46,10 @@ internal readonly record struct ElementTypeValidationResult(
return null;

// Check for Dictionary<TKey, TValue> or IDictionary<TKey, TValue>
var dictionaryType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_Dictionary_2);
var iDictionaryType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IDictionary_2);
var dictionaryType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_Dictionary_2);
var iDictionaryType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IDictionary_2);

if (IsOrImplements(namedType, dictionaryType) || IsOrImplements(namedType, iDictionaryType))
{
Expand All @@ -66,7 +70,8 @@ internal readonly record struct ElementTypeValidationResult(
}

// Check for HashSet<T> or ISet<T>
var hashSetType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_HashSet_1);
var hashSetType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_HashSet_1);
var iSetType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ISet_1);

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

// Check for List<T>, IList<T>, ICollection<T>, or IEnumerable<T>
var listType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_List_1);
var iListType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IList_1);
var iCollectionType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ICollection_1);
var iEnumerableType = context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IEnumerable_1);

if (IsOrImplements(namedType, listType)
|| IsOrImplements(namedType, iListType)
|| IsOrImplements(namedType, iCollectionType)
|| IsOrImplements(namedType, iEnumerableType))
var iListType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IList_1);
var iCollectionType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_ICollection_1);
var iEnumerableType =
context.WellKnownTypes.Get(WellKnownType.System_Collections_Generic_IEnumerable_1);

if (IsOrImplements(namedType, listType) || IsOrImplements(namedType, iListType) ||
IsOrImplements(namedType, iCollectionType) ||
IsOrImplements(namedType, iEnumerableType))
{
// Extract element type: List<T>
if (namedType.TypeArguments.Length == 1)
Expand Down Expand Up @@ -141,8 +148,7 @@ internal static bool IsValidElementType(ITypeSymbol elementType, GeneratorContex
/// <param name="context">The generator context.</param>
/// <returns>A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives.</returns>
internal static ElementTypeValidationResult ValidateElementType(
ITypeSymbol elementType,
GeneratorContext context
ITypeSymbol elementType, GeneratorContext context
)
{
var nestedContext = NestedAnalysisContext.Create(context, context.MapperRegistry);
Expand All @@ -161,16 +167,17 @@ GeneratorContext context
/// <param name="nestedContext">The nested analysis context to preserve ancestor tracking.</param>
/// <returns>A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives.</returns>
internal static ElementTypeValidationResult ValidateElementType(
ITypeSymbol elementType,
NestedAnalysisContext nestedContext
ITypeSymbol elementType, NestedAnalysisContext nestedContext
)
{
var context = nestedContext.Context;

// Unwrap Nullable<T> - nullable elements are allowed
var underlyingType = elementType;
if (elementType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableType
&& nullableType.TypeArguments.Length == 1)
if (elementType is INamedTypeSymbol
{
OriginalDefinition.SpecialType: SpecialType.System_Nullable_T,
} nullableType && nullableType.TypeArguments.Length == 1)
{
underlyingType = nullableType.TypeArguments[0];
}
Expand Down Expand Up @@ -200,7 +207,8 @@ NestedAnalysisContext nestedContext
return new ElementTypeValidationResult(true, null, null);

// DateTimeOffset
var dateTimeOffsetType = context.WellKnownTypes.Get(WellKnownType.System_DateTimeOffset);
var dateTimeOffsetType =
context.WellKnownTypes.Get(WellKnownType.System_DateTimeOffset);
if (SymbolEqualityComparer.Default.Equals(namedType, dateTimeOffsetType))
return new ElementTypeValidationResult(true, null, null);

Expand All @@ -215,8 +223,8 @@ NestedAnalysisContext nestedContext
}

// Check for byte[] (valid for BS - Binary Set)
if (underlyingType is IArrayTypeSymbol arrayType
&& arrayType.ElementType.SpecialType == SpecialType.System_Byte)
if (underlyingType is IArrayTypeSymbol arrayType &&
arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return new ElementTypeValidationResult(true, null, null);
}
Expand All @@ -225,12 +233,10 @@ NestedAnalysisContext nestedContext
if (Analyze(underlyingType, context) is not null)
return new ElementTypeValidationResult(false, null, null);

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

if (!nestedResult.IsSuccess)
return new ElementTypeValidationResult(false, null, nestedResult.Error);
Expand All @@ -254,8 +260,10 @@ NestedAnalysisContext nestedContext
{
// Unwrap Nullable<T> if present
var underlyingType = elementType;
if (elementType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableType
&& nullableType.TypeArguments.Length == 1)
if (elementType is INamedTypeSymbol
{
OriginalDefinition.SpecialType: SpecialType.System_Nullable_T,
} nullableType && nullableType.TypeArguments.Length == 1)
{
underlyingType = nullableType.TypeArguments[0];
}
Expand All @@ -278,8 +286,8 @@ NestedAnalysisContext nestedContext
}

// byte[] β†’ BS
if (underlyingType is IArrayTypeSymbol arrayType
&& arrayType.ElementType.SpecialType == SpecialType.System_Byte)
if (underlyingType is IArrayTypeSymbol arrayType &&
arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return DynamoKind.BS;
}
Expand All @@ -291,7 +299,9 @@ NestedAnalysisContext nestedContext
/// <summary>
/// Checks if a type matches or implements a generic type definition.
/// </summary>
private static bool IsOrImplements(INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition)
private static bool IsOrImplements(
INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition
)
{
if (genericTypeDefinition == null)
return false;
Expand All @@ -301,7 +311,8 @@ private static bool IsOrImplements(INamedTypeSymbol type, INamedTypeSymbol? gene
return true;

// Check if any interface matches
return type.AllInterfaces.Any(i =>
SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, genericTypeDefinition));
return type.AllInterfaces.Any(
i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, genericTypeDefinition)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,52 @@ namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping;
/// </summary>
internal static class NestedObjectTypeAnalyzer
{
/// <summary>
/// Analyzes a collection element type to determine if it's a nested object and how it should
/// be mapped. Unlike <see cref="Analyze" />, this method does not append a property name to the
/// context path, so that the caller can pre-set the correct path prefix (e.g. "Contacts") and have
/// field overrides like "Contacts.VerifiedAt" resolve correctly.
/// </summary>
/// <param name="type">The element type to analyze.</param>
/// <param name="nestedContext">
/// The nested analysis context, with CurrentPath already set to the
/// collection property's path.
/// </param>
internal static DiagnosticResult<NestedMappingInfo?> AnalyzeElementType(
ITypeSymbol type, NestedAnalysisContext nestedContext
)
{
nestedContext.Context.ThrowIfCancellationRequested();

if (!IsNestedObjectType(type, nestedContext.Context))
return DiagnosticResult<NestedMappingInfo?>.Success(null);

if (nestedContext.WouldCreateCycle(type))
return DiagnosticResult<NestedMappingInfo?>.Failure(
DiagnosticDescriptors.CycleDetectedInNestedType,
type.Locations.FirstOrDefault()?.CreateLocationInfo(),
"element",
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
);

if (nestedContext.HasOverridesForCurrentPath())
return AnalyzeForInline(type, nestedContext);

if (nestedContext.Registry.TryGetMapper(type, out var mapperReference) &&
mapperReference != null)
{
var requiresTo = nestedContext.Context.HasToItemMethod;
var requiresFrom = nestedContext.Context.HasFromItemMethod;
if ((!requiresTo || mapperReference.HasToItemMethod) &&
(!requiresFrom || mapperReference.HasFromItemMethod))
return DiagnosticResult<NestedMappingInfo?>.Success(
new MapperBasedNesting(mapperReference)
);
}

return AnalyzeForInline(type, nestedContext);
}

/// <summary>
/// Analyzes a type to determine if it's a nested object and how it should be mapped.
/// </summary>
Expand Down Expand Up @@ -135,14 +181,13 @@ private static bool IsWellKnownNonNestedType(INamedTypeSymbol type, GeneratorCon
/// </summary>
private static IPropertySymbol[] GetMappableProperties(
INamedTypeSymbol type, GeneratorContext context
) =>
PropertySymbolLookup.GetProperties(
type,
context.MapperOptions.IncludeBaseClassProperties,
static (p, declaringType) =>
!p.IsStatic && !p.IsIndexer && (p.GetMethod != null || p.SetMethod != null) &&
!(declaringType.IsRecord && p.Name == "EqualityContract")
);
) => PropertySymbolLookup.GetProperties(
type,
context.MapperOptions.IncludeBaseClassProperties,
static (p, declaringType) =>
!p.IsStatic && !p.IsIndexer && (p.GetMethod != null || p.SetMethod != null) &&
!(declaringType.IsRecord && p.Name == "EqualityContract")
);

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

if (validation.Error is not null)
Expand Down
Loading
Loading