From 591999a5fa0ddca912a251bd65ac7f87f7b48eec Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 20:24:58 -0400 Subject: [PATCH 1/2] feat(mappers): add dot-notation support for nested collection element properties - Implement `AnalyzeElementType` to correctly resolve path prefixes for collection elements. - Update `TypeMappingStrategyResolver` to include collection property paths in context. - Add `DM0008` diagnostic for invalid properties within collection element paths. - Enhance documentation with examples of collection element dot-notation mappings. - Include test cases validating dot-notation overrides for nested list, array, and dictionary elements. --- docs/examples/nested-mapping.md | 30 ++ docs/usage/field-configuration.md | 35 ++ .../Models/ModelClassInfo.cs | 5 + .../PropertyMapping/CollectionTypeAnalyzer.cs | 79 ++-- .../NestedObjectTypeAnalyzer.cs | 65 +++- .../TypeMappingStrategyResolver.cs | 353 +++++++++--------- .../CollectionDotNotationVerifyTests.cs | 231 ++++++++++++ ...NotationOverride#OrderMapper.g.verified.cs | 55 +++ ...tationOverride#CatalogMapper.g.verified.cs | 55 +++ ...buteNameOverride#OrderMapper.g.verified.cs | 55 +++ ...ormatOverride#CustomerMapper.g.verified.cs | 55 +++ ...entProperty_ShouldFail_DM0008.verified.txt | 17 + ...tionOverrides#CustomerMapper.g.verified.cs | 55 +++ 13 files changed, 874 insertions(+), 216 deletions(-) create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionDotNotationVerifyTests.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ArrayOfNestedObjects_WithDotNotationOverride#OrderMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_DictionaryOfNestedObjects_WithDotNotationOverride#CatalogMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationAttributeNameOverride#OrderMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationFormatOverride#CustomerMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotPath_InvalidCollectionElementProperty_ShouldFail_DM0008.verified.txt create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithMultipleDotNotationOverrides#CustomerMapper.g.verified.cs diff --git a/docs/examples/nested-mapping.md b/docs/examples/nested-mapping.md index 08359df..2d6e054 100644 --- a/docs/examples/nested-mapping.md +++ b/docs/examples/nested-mapping.md @@ -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 ToItem(Order source); + public static partial Order FromItem(Dictionary item); +} + +public class Order +{ + public string Id { get; set; } + public List 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`). + See `examples/DynamoMapper.Nested/Program.cs` for the full walkthrough. diff --git a/docs/usage/field-configuration.md b/docs/usage/field-configuration.md index 0ca2612..c40d1cd 100644 --- a/docs/usage/field-configuration.md +++ b/docs/usage/field-configuration.md @@ -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[]`, +`Dictionary`, 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 ToItem(Customer source); + public static partial Customer FromItem(Dictionary item); +} + +public class Customer +{ + public string Id { get; set; } + public List 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`. + ## Supported Options | Option | Description | diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs index 2db8d52..3c01671 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs @@ -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; } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs index 8eb794a..c41a5df 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs @@ -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; @@ -16,6 +17,7 @@ internal readonly record struct ElementTypeValidationResult( NestedMappingInfo? NestedMapping, DiagnosticInfo? Error ); + /// /// Analyzes a type to determine if it's a collection type and returns metadata about it. /// @@ -44,8 +46,10 @@ internal readonly record struct ElementTypeValidationResult( return null; // Check for Dictionary or IDictionary - 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)) { @@ -66,7 +70,8 @@ internal readonly record struct ElementTypeValidationResult( } // Check for HashSet or ISet - 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)) @@ -92,14 +97,16 @@ internal readonly record struct ElementTypeValidationResult( // Check for List, IList, ICollection, or IEnumerable 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 if (namedType.TypeArguments.Length == 1) @@ -141,8 +148,7 @@ internal static bool IsValidElementType(ITypeSymbol elementType, GeneratorContex /// The generator context. /// A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives. internal static ElementTypeValidationResult ValidateElementType( - ITypeSymbol elementType, - GeneratorContext context + ITypeSymbol elementType, GeneratorContext context ) { var nestedContext = NestedAnalysisContext.Create(context, context.MapperRegistry); @@ -161,16 +167,17 @@ GeneratorContext context /// The nested analysis context to preserve ancestor tracking. /// A tuple of (isValid, nestedMappingInfo). nestedMappingInfo is null for primitives. internal static ElementTypeValidationResult ValidateElementType( - ITypeSymbol elementType, - NestedAnalysisContext nestedContext + ITypeSymbol elementType, NestedAnalysisContext nestedContext ) { var context = nestedContext.Context; // Unwrap Nullable - 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]; } @@ -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); @@ -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); } @@ -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); @@ -254,8 +260,10 @@ NestedAnalysisContext nestedContext { // Unwrap Nullable 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]; } @@ -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; } @@ -291,7 +299,9 @@ NestedAnalysisContext nestedContext /// /// Checks if a type matches or implements a generic type definition. /// - private static bool IsOrImplements(INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition) + private static bool IsOrImplements( + INamedTypeSymbol type, INamedTypeSymbol? genericTypeDefinition + ) { if (genericTypeDefinition == null) return false; @@ -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) + ); } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs index 3bce2f1..8210528 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs @@ -14,6 +14,52 @@ namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping; /// internal static class NestedObjectTypeAnalyzer { + /// + /// Analyzes a collection element type to determine if it's a nested object and how it should + /// be mapped. Unlike , 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. + /// + /// The element type to analyze. + /// + /// The nested analysis context, with CurrentPath already set to the + /// collection property's path. + /// + internal static DiagnosticResult AnalyzeElementType( + ITypeSymbol type, NestedAnalysisContext nestedContext + ) + { + nestedContext.Context.ThrowIfCancellationRequested(); + + if (!IsNestedObjectType(type, nestedContext.Context)) + return DiagnosticResult.Success(null); + + if (nestedContext.WouldCreateCycle(type)) + return DiagnosticResult.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.Success( + new MapperBasedNesting(mapperReference) + ); + } + + return AnalyzeForInline(type, nestedContext); + } + /// /// Analyzes a type to determine if it's a nested object and how it should be mapped. /// @@ -135,14 +181,13 @@ private static bool IsWellKnownNonNestedType(INamedTypeSymbol type, GeneratorCon /// 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") + ); /// /// Analyzes a type for inline code generation, recursively building property specs. @@ -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) diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs index 6512c33..311ef18 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs @@ -3,7 +3,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; @@ -21,8 +22,7 @@ internal static class TypeMappingStrategyResolver /// types. /// internal static DiagnosticResult Resolve( - PropertyAnalysis analysis, - GeneratorContext context + PropertyAnalysis analysis, GeneratorContext context ) { context.ThrowIfCancellationRequested(); @@ -34,14 +34,12 @@ GeneratorContext context // Skip validation if property won't be used in any generated methods // Also skip if a custom method is provided for that direction (no auto-mapping needed) var willBeUsedInToItem = - context.HasToItemMethod - && analysis.HasGetter - && analysis.FieldOptions?.ToMethod == null; + context.HasToItemMethod && analysis.HasGetter && + analysis.FieldOptions?.ToMethod == null; var willBeUsedInFromItem = - context.HasFromItemMethod - && analysis.HasSetter - && analysis.FieldOptions?.FromMethod == null; + context.HasFromItemMethod && analysis.HasSetter && + analysis.FieldOptions?.FromMethod == null; if (!willBeUsedInToItem && !willBeUsedInFromItem) return DiagnosticResult.Success(null); @@ -55,10 +53,8 @@ GeneratorContext context ignoreOptions.Ignore is IgnoreMapping.All or IgnoreMapping.ToModel; // If property should be ignored in all relevant directions, skip mapping - if ( - (!willBeUsedInToItem || shouldIgnoreToItem) - && (!willBeUsedInFromItem || shouldIgnoreFromItem) - ) + if ((!willBeUsedInToItem || shouldIgnoreToItem) && + (!willBeUsedInFromItem || shouldIgnoreFromItem)) return DiagnosticResult.Success(null); } @@ -76,11 +72,13 @@ GeneratorContext context { nestedContext = nestedContext.WithAncestor(context.RootModelType); } - var nestedResult = NestedObjectTypeAnalyzer.Analyze( - analysis.UnderlyingType, - analysis.PropertyName, - nestedContext - ); + + var nestedResult = + NestedObjectTypeAnalyzer.Analyze( + analysis.UnderlyingType, + analysis.PropertyName, + nestedContext + ); if (!nestedResult.IsSuccess) { @@ -93,85 +91,92 @@ GeneratorContext context } // Resolve the base type mapping strategy (existing logic unchanged) - var strategyResult = analysis.UnderlyingType switch - { - { SpecialType: SpecialType.System_String } => CreateStrategy( - "String", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Boolean } => CreateStrategy( - "Bool", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Int32 } => CreateStrategy( - "Int", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Int64 } => CreateStrategy( - "Long", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Single } => CreateStrategy( - "Float", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Double } => CreateStrategy( - "Double", - analysis.Nullability - ), - { SpecialType: SpecialType.System_Decimal } => CreateStrategy( - "Decimal", - analysis.Nullability - ), - { SpecialType: SpecialType.System_DateTime } => CreateStrategy( - "DateTime", - analysis.Nullability, - fromArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"", - toArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"" - ), - INamedTypeSymbol t - when t.IsAssignableTo(WellKnownType.System_DateTimeOffset, context) => - CreateStrategy( - "DateTimeOffset", - analysis.Nullability, - fromArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"", - toArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"" + var strategyResult = + analysis.UnderlyingType switch + { + { SpecialType: SpecialType.System_String } => CreateStrategy( + "String", + analysis.Nullability ), - INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_Guid, context) => - CreateStrategy( - "Guid", - analysis.Nullability, - fromArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.GuidFormat}\"", - toArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.GuidFormat}\"" + { SpecialType: SpecialType.System_Boolean } => CreateStrategy( + "Bool", + analysis.Nullability + ), + { SpecialType: SpecialType.System_Int32 } => CreateStrategy( + "Int", + analysis.Nullability + ), + { SpecialType: SpecialType.System_Int64 } => CreateStrategy( + "Long", + analysis.Nullability + ), + { SpecialType: SpecialType.System_Single } => CreateStrategy( + "Float", + analysis.Nullability + ), + { SpecialType: SpecialType.System_Double } => CreateStrategy( + "Double", + analysis.Nullability ), - INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_TimeSpan, context) => - CreateStrategy( - "TimeSpan", + { SpecialType: SpecialType.System_Decimal } => CreateStrategy( + "Decimal", + analysis.Nullability + ), + { SpecialType: SpecialType.System_DateTime } => CreateStrategy( + "DateTime", analysis.Nullability, - fromArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\"", - toArg: $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\"" + fromArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"", + toArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"" ), - INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType => CreateEnumStrategy( - enumType, - analysis, - context - ), - _ => DiagnosticResult.Failure( - DiagnosticDescriptors.CannotConvertFromAttributeValue, - analysis.PropertyType.Locations.FirstOrDefault()?.CreateLocationInfo(), - analysis.PropertyName, - analysis.PropertyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - ), - }; + INamedTypeSymbol t when + t.IsAssignableTo(WellKnownType.System_DateTimeOffset, context) => + CreateStrategy( + "DateTimeOffset", + analysis.Nullability, + fromArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"", + toArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat}\"" + ), + INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_Guid, context) => + CreateStrategy( + "Guid", + analysis.Nullability, + fromArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.GuidFormat}\"", + toArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.GuidFormat}\"" + ), + INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_TimeSpan, context) => + CreateStrategy( + "TimeSpan", + analysis.Nullability, + fromArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\"", + toArg: + $"\"{analysis.FieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\"" + ), + INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType => CreateEnumStrategy( + enumType, + analysis, + context + ), + _ => DiagnosticResult.Failure( + DiagnosticDescriptors.CannotConvertFromAttributeValue, + analysis.PropertyType.Locations.FirstOrDefault()?.CreateLocationInfo(), + analysis.PropertyName, + analysis.PropertyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ), + }; // If type resolution succeeded and Kind override exists, augment the strategy - return strategyResult.Bind(strategy => - analysis.FieldOptions?.Kind is { } kind - ? strategy with - { - KindOverride = kind, - } - : strategy + return strategyResult.Bind( + strategy => + analysis.FieldOptions?.Kind is { } kind + ? strategy with { KindOverride = kind } + : strategy ); } @@ -182,11 +187,8 @@ INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_TimeSpan, context) /// Optional argument for FromItem method (e.g., format string). /// Optional argument for ToItem method (e.g., format string). private static TypeMappingStrategy CreateStrategy( - string typeName, - PropertyNullabilityInfo nullability, - string genericArg = "", - string? fromArg = null, - string? toArg = null + string typeName, PropertyNullabilityInfo nullability, string genericArg = "", + string? fromArg = null, string? toArg = null ) { var nullableModifier = nullability.IsNullableType ? "Nullable" : string.Empty; @@ -202,9 +204,7 @@ private static TypeMappingStrategy CreateStrategy( /// values and format strings. /// private static DiagnosticResult CreateEnumStrategy( - INamedTypeSymbol enumType, - PropertyAnalysis analysis, - GeneratorContext context + INamedTypeSymbol enumType, PropertyAnalysis analysis, GeneratorContext context ) { var enumName = enumType.QualifiedName; @@ -217,9 +217,10 @@ GeneratorContext context // Non-nullable enums need a default value in FromItem // Nullable enums don't need a default value (they can be null) - string[] fromArgs = analysis.Nullability.IsNullableType - ? [enumFormat] - : [$"{enumName}.{enumType.MemberNames.First()}", enumFormat]; + string[] fromArgs = + analysis.Nullability.IsNullableType + ? [enumFormat] + : [$"{enumName}.{enumType.MemberNames.First()}", enumFormat]; string[] toArgs = [enumFormat]; @@ -230,16 +231,23 @@ GeneratorContext context /// Creates a type mapping strategy for collection types (lists, maps, sets). /// private static DiagnosticResult CreateCollectionStrategy( - CollectionInfo collectionInfo, - PropertyAnalysis analysis, - GeneratorContext context + CollectionInfo collectionInfo, PropertyAnalysis analysis, GeneratorContext context ) { - // Validate element type - can be primitive or nested object - var elementValidation = CollectionTypeAnalyzer.ValidateElementType( - collectionInfo.ElementType, - context - ); + // Validate element type - can be primitive or nested object. + // Build a context with the collection property's path prefix so that + // dot-notation overrides like "Contacts.VerifiedAt" are found when the + // element type's properties are resolved. + var elementNestedContext = + NestedAnalysisContext.Create(context, context.MapperRegistry) + .WithPath(analysis.PropertyName); + if (context.RootModelType is not null) + elementNestedContext = elementNestedContext.WithAncestor(context.RootModelType); + var elementValidation = + CollectionTypeAnalyzer.ValidateElementType( + collectionInfo.ElementType, + elementNestedContext + ); if (elementValidation.Error is not null) return DiagnosticResult.Failure(elementValidation.Error); @@ -257,10 +265,8 @@ GeneratorContext context var elementNestedMapping = elementValidation.NestedMapping; // For maps, validate key type is string - if ( - collectionInfo.Category == CollectionCategory.Map - && collectionInfo.KeyType?.SpecialType != SpecialType.System_String - ) + if (collectionInfo.Category == CollectionCategory.Map && + collectionInfo.KeyType?.SpecialType != SpecialType.System_String) { return DiagnosticResult.Failure( DiagnosticDescriptors.DictionaryKeyMustBeString, @@ -284,14 +290,15 @@ GeneratorContext context // Validate Kind override compatibility if present if (analysis.FieldOptions?.Kind is { } kindOverride) { - var isCompatible = (collectionInfo.Category, kindOverride) switch - { - (CollectionCategory.List, DynamoKind.L) => true, - (CollectionCategory.Map, DynamoKind.M) => true, - (CollectionCategory.Set, DynamoKind.SS or DynamoKind.NS or DynamoKind.BS) => - kindOverride == collectionInfo.TargetKind, - _ => false, - }; + var isCompatible = + (collectionInfo.Category, kindOverride) switch + { + (CollectionCategory.List, DynamoKind.L) => true, + (CollectionCategory.Map, DynamoKind.M) => true, + (CollectionCategory.Set, DynamoKind.SS or DynamoKind.NS or DynamoKind.BS) => + kindOverride == collectionInfo.TargetKind, + _ => false, + }; if (!isCompatible) { @@ -308,34 +315,31 @@ GeneratorContext context // If element type is a nested object, use special handling if (elementNestedMapping is not null) { - return CreateNestedCollectionStrategy( - collectionInfo, - elementNestedMapping, - analysis - ); + return CreateNestedCollectionStrategy(collectionInfo, elementNestedMapping, analysis); } // Determine TypeName and GenericArgument for method name resolution - var (typeName, genericArg) = collectionInfo.Category switch - { - CollectionCategory.List => ( - "List", - $"<{collectionInfo.ElementType.ToDisplayString()}>" - ), - CollectionCategory.Map => ("Map", $"<{collectionInfo.ElementType.ToDisplayString()}>"), - CollectionCategory.Set => collectionInfo.TargetKind switch + var (typeName, genericArg) = + collectionInfo.Category switch { - DynamoKind.SS => ("StringSet", ""), - DynamoKind.NS => ("NumberSet", $"<{collectionInfo.ElementType.ToDisplayString()}>"), - DynamoKind.BS => ("BinarySet", ""), + CollectionCategory.List => ("List", + $"<{collectionInfo.ElementType.ToDisplayString()}>"), + CollectionCategory.Map => ("Map", + $"<{collectionInfo.ElementType.ToDisplayString()}>"), + CollectionCategory.Set => collectionInfo.TargetKind switch + { + DynamoKind.SS => ("StringSet", ""), + DynamoKind.NS => ("NumberSet", + $"<{collectionInfo.ElementType.ToDisplayString()}>"), + DynamoKind.BS => ("BinarySet", ""), + _ => throw new InvalidOperationException( + $"Unexpected set kind: {collectionInfo.TargetKind}" + ), + }, _ => throw new InvalidOperationException( - $"Unexpected set kind: {collectionInfo.TargetKind}" + $"Unexpected category: {collectionInfo.Category}" ), - }, - _ => throw new InvalidOperationException( - $"Unexpected category: {collectionInfo.Category}" - ), - }; + }; // Build strategy - collections are nullable at collection level, not element level var strategy = CreateStrategy(typeName, analysis.Nullability, genericArg); @@ -351,31 +355,34 @@ GeneratorContext context /// Creates a type mapping strategy for collections of nested objects. /// private static DiagnosticResult CreateNestedCollectionStrategy( - CollectionInfo collectionInfo, - NestedMappingInfo elementNestedMapping, + CollectionInfo collectionInfo, NestedMappingInfo elementNestedMapping, PropertyAnalysis analysis ) { var nullableModifier = analysis.Nullability.IsNullableType ? "Nullable" : ""; // Use special type names for nested collections - var typeName = collectionInfo.Category switch - { - CollectionCategory.List => "NestedList", - CollectionCategory.Map => "NestedMap", - _ => throw new InvalidOperationException($"Unexpected category for nested collection: {collectionInfo.Category}") - }; + var typeName = + collectionInfo.Category switch + { + CollectionCategory.List => "NestedList", + CollectionCategory.Map => "NestedMap", + _ => throw new InvalidOperationException( + $"Unexpected category for nested collection: {collectionInfo.Category}" + ), + }; - var strategy = new TypeMappingStrategy( - TypeName: typeName, - GenericArgument: $"<{collectionInfo.ElementType.ToDisplayString()}>", - NullableModifier: nullableModifier, - FromTypeSpecificArgs: [], - ToTypeSpecificArgs: [], - KindOverride: collectionInfo.TargetKind, - NestedMapping: elementNestedMapping, - CollectionInfo: collectionInfo with { ElementNestedMapping = elementNestedMapping } - ); + var strategy = + new TypeMappingStrategy( + typeName, + $"<{collectionInfo.ElementType.ToDisplayString()}>", + nullableModifier, + [], + [], + collectionInfo.TargetKind, + elementNestedMapping, + collectionInfo with { ElementNestedMapping = elementNestedMapping } + ); return DiagnosticResult.Success(strategy); } @@ -384,23 +391,23 @@ PropertyAnalysis analysis /// Creates a type mapping strategy for nested object types. /// private static DiagnosticResult CreateNestedObjectStrategy( - NestedMappingInfo nestedMapping, - PropertyAnalysis analysis + NestedMappingInfo nestedMapping, PropertyAnalysis analysis ) { var nullableModifier = analysis.Nullability.IsNullableType ? "Nullable" : ""; // NestedObject is a special type name that signals code generation // to use nested object handling rather than scalar Get/Set methods - var strategy = new TypeMappingStrategy( - TypeName: "NestedObject", - GenericArgument: "", - NullableModifier: nullableModifier, - FromTypeSpecificArgs: [], - ToTypeSpecificArgs: [], - KindOverride: DynamoKind.M, // Nested objects are always DynamoDB maps - NestedMapping: nestedMapping - ); + var strategy = + new TypeMappingStrategy( + "NestedObject", + "", + nullableModifier, + [], + [], + DynamoKind.M, // Nested objects are always DynamoDB maps + nestedMapping + ); return DiagnosticResult.Success(strategy); } diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionDotNotationVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionDotNotationVerifyTests.cs new file mode 100644 index 0000000..2fa36c8 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionDotNotationVerifyTests.cs @@ -0,0 +1,231 @@ +namespace LayeredCraft.DynamoMapper.Generators.Tests; + +public class CollectionDotNotationVerifyTests +{ + [Fact] + public async Task Collection_ListOfNestedObjects_WithDotNotationFormatOverride() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Contacts.VerifiedAt", Format = "yyyy-MM-dd")] + public static partial class CustomerMapper + { + public static partial Dictionary ToItem(Customer source); + public static partial Customer FromItem(Dictionary item); + } + + public class Customer + { + public string Id { get; set; } + public List Contacts { get; set; } + } + + public class CustomerContact + { + public string Name { get; set; } + public DateTime VerifiedAt { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Collection_ListOfNestedObjects_WithDotNotationAttributeNameOverride() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Items.ProductId", AttributeName = "product_id")] + public static partial class OrderMapper + { + public static partial Dictionary ToItem(Order source); + public static partial Order FromItem(Dictionary item); + } + + public class Order + { + public string Id { get; set; } + public List Items { get; set; } + } + + public class LineItem + { + public string ProductId { get; set; } + public int Quantity { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Collection_ArrayOfNestedObjects_WithDotNotationOverride() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Items.ProductId", AttributeName = "product_id")] + public static partial class OrderMapper + { + public static partial Dictionary ToItem(Order source); + public static partial Order FromItem(Dictionary item); + } + + public class Order + { + public string Id { get; set; } + public LineItem[] Items { get; set; } + } + + public class LineItem + { + public string ProductId { get; set; } + public int Quantity { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Collection_DictionaryOfNestedObjects_WithDotNotationOverride() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("ProductMap.CreatedAt", Format = "yyyy-MM-dd")] + public static partial class CatalogMapper + { + public static partial Dictionary ToItem(Catalog source); + public static partial Catalog FromItem(Dictionary item); + } + + public class Catalog + { + public string Id { get; set; } + public Dictionary ProductMap { get; set; } + } + + public class OrderItem + { + public string Name { get; set; } + public DateTime CreatedAt { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Collection_ListOfNestedObjects_WithMultipleDotNotationOverrides() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Contacts.Name", AttributeName = "contact_name")] + [DynamoField("Contacts.VerifiedAt", Format = "yyyy-MM-dd")] + public static partial class CustomerMapper + { + public static partial Dictionary ToItem(Customer source); + public static partial Customer FromItem(Dictionary item); + } + + public class Customer + { + public string Id { get; set; } + public List Contacts { get; set; } + } + + public class CustomerContact + { + public string Name { get; set; } + public DateTime VerifiedAt { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task + Collection_ListOfNestedObjects_WithDotPath_InvalidCollectionElementProperty_ShouldFail_DM0008() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Contacts.NonExistentProp", AttributeName = "bad_path")] + public static partial class CustomerMapper + { + public static partial Dictionary ToItem(Customer source); + public static partial Customer FromItem(Dictionary item); + } + + public class Customer + { + public string Id { get; set; } + public List Contacts { get; set; } + } + + public class CustomerContact + { + public string Name { get; set; } + } + """, + ExpectedDiagnosticId = "DM0008", + }, + TestContext.Current.CancellationToken + ); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ArrayOfNestedObjects_WithDotNotationOverride#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ArrayOfNestedObjects_WithDotNotationOverride#OrderMapper.g.verified.cs new file mode 100644 index 0000000..8c12117 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ArrayOfNestedObjects_WithDotNotationOverride#OrderMapper.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: OrderMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class OrderMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .Set("items", new AttributeValue { L = source.Items.Select(x => new AttributeValue { M = ToItem_LineItem(x) }).ToList() }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) + { + var order = new global::MyNamespace.Order + { + Id = item.GetString("id", Requiredness.InferFromNullability), + Items = item.TryGetValue("items", out var itemsAttr) && itemsAttr.L is { } itemsList ? itemsList.Select(av => FromItem_LineItem(av.M)).ToList().ToArray() : [], + }; + return order; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_LineItem(global::MyNamespace.LineItem lineitem) => + new Dictionary(2) + .SetString("product_id", lineitem.ProductId, false, true) + .SetInt("quantity", lineitem.Quantity, false, true); + + private static global::MyNamespace.LineItem FromItem_LineItem(Dictionary map) + { + return new global::MyNamespace.LineItem + { + ProductId = map.GetString("product_id", Requiredness.Optional), + Quantity = map.GetInt("quantity", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_DictionaryOfNestedObjects_WithDotNotationOverride#CatalogMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_DictionaryOfNestedObjects_WithDotNotationOverride#CatalogMapper.g.verified.cs new file mode 100644 index 0000000..4aa2954 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_DictionaryOfNestedObjects_WithDotNotationOverride#CatalogMapper.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: CatalogMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class CatalogMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Catalog source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .Set("productMap", new AttributeValue { M = source.ProductMap.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = ToItem_OrderItem(kvp.Value) }) }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Catalog FromItem(global::System.Collections.Generic.Dictionary item) + { + var catalog = new global::MyNamespace.Catalog + { + Id = item.GetString("id", Requiredness.InferFromNullability), + ProductMap = item.TryGetValue("productMap", out var productmapAttr) && productmapAttr.M is { } productmapMap ? productmapMap.ToDictionary(kvp => kvp.Key, kvp => FromItem_OrderItem(kvp.Value.M)) : [], + }; + return catalog; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_OrderItem(global::MyNamespace.OrderItem orderitem) => + new Dictionary(2) + .SetString("name", orderitem.Name, false, true) + .SetDateTime("createdAt", orderitem.CreatedAt, "yyyy-MM-dd", false, true); + + private static global::MyNamespace.OrderItem FromItem_OrderItem(Dictionary map) + { + return new global::MyNamespace.OrderItem + { + Name = map.GetString("name", Requiredness.Optional), + CreatedAt = map.GetDateTime("createdAt", format: "yyyy-MM-dd", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationAttributeNameOverride#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationAttributeNameOverride#OrderMapper.g.verified.cs new file mode 100644 index 0000000..edceb39 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationAttributeNameOverride#OrderMapper.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: OrderMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class OrderMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .Set("items", new AttributeValue { L = source.Items.Select(x => new AttributeValue { M = ToItem_LineItem(x) }).ToList() }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) + { + var order = new global::MyNamespace.Order + { + Id = item.GetString("id", Requiredness.InferFromNullability), + Items = item.TryGetValue("items", out var itemsAttr) && itemsAttr.L is { } itemsList ? itemsList.Select(av => FromItem_LineItem(av.M)).ToList() : [], + }; + return order; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_LineItem(global::MyNamespace.LineItem lineitem) => + new Dictionary(2) + .SetString("product_id", lineitem.ProductId, false, true) + .SetInt("quantity", lineitem.Quantity, false, true); + + private static global::MyNamespace.LineItem FromItem_LineItem(Dictionary map) + { + return new global::MyNamespace.LineItem + { + ProductId = map.GetString("product_id", Requiredness.Optional), + Quantity = map.GetInt("quantity", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationFormatOverride#CustomerMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationFormatOverride#CustomerMapper.g.verified.cs new file mode 100644 index 0000000..d7bbc61 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotNotationFormatOverride#CustomerMapper.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: CustomerMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class CustomerMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Customer source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .Set("contacts", new AttributeValue { L = source.Contacts.Select(x => new AttributeValue { M = ToItem_CustomerContact(x) }).ToList() }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Customer FromItem(global::System.Collections.Generic.Dictionary item) + { + var customer = new global::MyNamespace.Customer + { + Id = item.GetString("id", Requiredness.InferFromNullability), + Contacts = item.TryGetValue("contacts", out var contactsAttr) && contactsAttr.L is { } contactsList ? contactsList.Select(av => FromItem_CustomerContact(av.M)).ToList() : [], + }; + return customer; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_CustomerContact(global::MyNamespace.CustomerContact customercontact) => + new Dictionary(2) + .SetString("name", customercontact.Name, false, true) + .SetDateTime("verifiedAt", customercontact.VerifiedAt, "yyyy-MM-dd", false, true); + + private static global::MyNamespace.CustomerContact FromItem_CustomerContact(Dictionary map) + { + return new global::MyNamespace.CustomerContact + { + Name = map.GetString("name", Requiredness.Optional), + VerifiedAt = map.GetDateTime("verifiedAt", format: "yyyy-MM-dd", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotPath_InvalidCollectionElementProperty_ShouldFail_DM0008.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotPath_InvalidCollectionElementProperty_ShouldFail_DM0008.verified.txt new file mode 100644 index 0000000..9353709 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithDotPath_InvalidCollectionElementProperty_ShouldFail_DM0008.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (6,0)-(12,1), + Message: The dot-notation path 'Contacts.NonExistentProp' is invalid. Property 'NonExistentProp' not found on type 'CustomerContact'., + Severity: Error, + Descriptor: { + Id: DM0008, + Title: Invalid dot-notation path, + MessageFormat: The dot-notation path '{0}' is invalid. Property '{1}' not found on type '{2}'., + Category: LayeredCraft.DynamoMapper.Usage, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithMultipleDotNotationOverrides#CustomerMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithMultipleDotNotationOverrides#CustomerMapper.g.verified.cs new file mode 100644 index 0000000..2d476be --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionDotNotationVerifyTests.Collection_ListOfNestedObjects_WithMultipleDotNotationOverrides#CustomerMapper.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: CustomerMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class CustomerMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Customer source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .Set("contacts", new AttributeValue { L = source.Contacts.Select(x => new AttributeValue { M = ToItem_CustomerContact(x) }).ToList() }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Customer FromItem(global::System.Collections.Generic.Dictionary item) + { + var customer = new global::MyNamespace.Customer + { + Id = item.GetString("id", Requiredness.InferFromNullability), + Contacts = item.TryGetValue("contacts", out var contactsAttr) && contactsAttr.L is { } contactsList ? contactsList.Select(av => FromItem_CustomerContact(av.M)).ToList() : [], + }; + return customer; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_CustomerContact(global::MyNamespace.CustomerContact customercontact) => + new Dictionary(2) + .SetString("contact_name", customercontact.Name, false, true) + .SetDateTime("verifiedAt", customercontact.VerifiedAt, "yyyy-MM-dd", false, true); + + private static global::MyNamespace.CustomerContact FromItem_CustomerContact(Dictionary map) + { + return new global::MyNamespace.CustomerContact + { + Name = map.GetString("contact_name", Requiredness.Optional), + VerifiedAt = map.GetDateTime("verifiedAt", format: "yyyy-MM-dd", Requiredness.Optional), + }; + } + +} From 759f5b0aa580f8a5097aa0f068a07f8a92014105 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 20:36:50 -0400 Subject: [PATCH 2/2] docs(mappers): update references for collection element dot-notation support - Extend documentation to clarify dot-notation usage for collection element properties. - Update `DM0008` diagnostic details with support for nested collection element paths. --- skills/dynamo-mapper/references/core-usage.md | 3 ++- skills/dynamo-mapper/references/diagnostics.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md index 9ccfc54..5d88877 100644 --- a/skills/dynamo-mapper/references/core-usage.md +++ b/skills/dynamo-mapper/references/core-usage.md @@ -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`). ## Constructors diff --git a/skills/dynamo-mapper/references/diagnostics.md b/skills/dynamo-mapper/references/diagnostics.md index 778994a..b3ee132 100644 --- a/skills/dynamo-mapper/references/diagnostics.md +++ b/skills/dynamo-mapper/references/diagnostics.md @@ -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