diff --git a/.editorconfig b/.editorconfig index b9761641..65e516a5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,6 +16,6 @@ trim_trailing_whitespace = true indent_size = 4 max_line_length = 120 -[*HostFactoryResolver.cs] +[{*HostFactoryResolver.cs,AnalyzerReleases.Shipped.md,AnalyzerReleases.Unshipped.md}] ij_formatter_enabled = false resharper_disable_formatter = true \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index c6432058..42c6e052 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.2.0 + 1.3.0 MIT diff --git a/docs/core-concepts/how-it-works.md b/docs/core-concepts/how-it-works.md index d84f6553..fb65091e 100644 --- a/docs/core-concepts/how-it-works.md +++ b/docs/core-concepts/how-it-works.md @@ -248,16 +248,20 @@ static partial void AfterToItem(Product source, Dictionary `NS` - `byte[]` -> `BS` +When a set-like CLR collection uses another supported element type such as `Guid`, +`DateTimeOffset`, `TimeSpan`, or enums, DynamoMapper stores it as `L` and materializes it back +into the declared CLR set shape on read. + ## Nested shapes Supported: diff --git a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Shipped.md b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Shipped.md index 2080dcdd..2e6e7a57 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Shipped.md +++ b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Shipped.md @@ -1,3 +1,20 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +## Release 1.2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +DM0001 | DynamoMapper.Usage | Error | +DM0003 | DynamoMapper.Usage | Error | +DM0004 | DynamoMapper.Usage | Error | +DM0005 | DynamoMapper.Usage | Error | +DM0006 | DynamoMapper.Usage | Error | +DM0007 | DynamoMapper.Usage | Error | +DM0008 | DynamoMapper.Usage | Error | +DM0009 | DynamoMapper.Usage | Error | +DM0101 | DynamoMapper.Usage | Error | +DM0102 | DynamoMapper.Usage | Error | +DM0103 | DynamoMapper.Usage | Error | diff --git a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md index 0865b58a..ba7bf250 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md +++ b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md @@ -1,18 +1,18 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md -### New Rules +### Changed Rules - Rule ID | Category | Severity | Notes ----------|--------------------|----------|----------------------- - DM0001 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0003 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0004 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0005 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0006 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0007 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0008 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0009 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0101 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0102 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0103 | DynamoMapper.Usage | Error | DiagnosticDescriptors \ No newline at end of file +Rule ID | New Category | New Severity | Old Category | Old Severity | Notes +--------|--------------|--------------|--------------|--------------|------ +DM0001 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0003 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0004 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0005 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0006 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0007 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0008 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0009 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0101 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0102 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed +DM0103 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs index d7dc596f..c64f8f36 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/CollectionTypeAnalyzer.cs @@ -40,7 +40,7 @@ internal readonly record struct ElementTypeValidationResult( ElementType: elementType, TargetKind: DynamoKind.L, KeyType: null, - IsArray: true + CollectionReadMaterialization.Array ); } @@ -66,8 +66,7 @@ internal readonly record struct ElementTypeValidationResult( Category: CollectionCategory.Map, ElementType: valueType, TargetKind: DynamoKind.M, - KeyType: keyType, - IsArray: false + keyType ); } } @@ -92,9 +91,17 @@ internal readonly record struct ElementTypeValidationResult( ElementType: elementType, TargetKind: setKind.Value, KeyType: null, - IsArray: false + CollectionReadMaterialization.HashSet ); } + + return new CollectionInfo( + CollectionCategory.List, + elementType, + DynamoKind.L, + null, + CollectionReadMaterialization.HashSet + ); } } @@ -120,8 +127,7 @@ internal readonly record struct ElementTypeValidationResult( Category: CollectionCategory.List, ElementType: elementType, TargetKind: DynamoKind.L, - KeyType: null, - IsArray: false + null ); } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/CollectionInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/CollectionInfo.cs index ecca1583..fb01ba25 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/CollectionInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/CollectionInfo.cs @@ -16,8 +16,9 @@ namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping.Models; /// /// For map types only, the key type (must be string). /// -/// -/// True if the original property type is an array (T[]), false otherwise. +/// +/// Describes any additional CLR-shape materialization required after deserializing the +/// DynamoDB representation. /// /// /// For collections of nested objects, contains the nested mapping info for the element type. @@ -28,10 +29,21 @@ internal sealed record CollectionInfo( ITypeSymbol ElementType, DynamoKind TargetKind, ITypeSymbol? KeyType = null, - bool IsArray = false, + CollectionReadMaterialization ReadMaterialization = CollectionReadMaterialization.None, NestedMappingInfo? ElementNestedMapping = null ); +/// +/// Describes how a deserialized collection result should be materialized back into the +/// declared CLR shape. +/// +internal enum CollectionReadMaterialization +{ + None, + Array, + HashSet, +} + /// /// Categorizes collection types by their DynamoDB mapping behavior. /// diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs index 0f166c76..ef829217 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs @@ -304,7 +304,13 @@ private static IPropertySymbol[] GetMappableProperties( } // Create collection strategy (simplified for nested objects) - var collectionStrategy = CreateCollectionStrategy(collectionInfo, propertyType); + var collectionStrategy = + CreateCollectionStrategy( + collectionInfo, + propertyType, + fieldOptions, + nestedContext.Context + ); propertySpecs.Add( new NestedPropertySpec( property.Name, @@ -532,7 +538,8 @@ GeneratorContext context /// Creates a collection type mapping strategy. /// private static TypeMappingStrategy CreateCollectionStrategy( - CollectionInfo collectionInfo, ITypeSymbol originalType + CollectionInfo collectionInfo, ITypeSymbol originalType, DynamoFieldOptions? fieldOptions, + GeneratorContext context ) { var isNullable = originalType.NullableAnnotation == NullableAnnotation.Annotated; @@ -560,16 +567,61 @@ private static TypeMappingStrategy CreateCollectionStrategy( ), }; + var (fromArgs, toArgs) = + GetCollectionElementTypeSpecificArgs(collectionInfo.ElementType, fieldOptions, context); + return new TypeMappingStrategy( typeName, genericArg, nullableModifier, - [], - [], + fromArgs, + toArgs, KindOverride: collectionInfo.TargetKind ); } + private static (string[] FromArgs, string[] ToArgs) GetCollectionElementTypeSpecificArgs( + ITypeSymbol elementType, DynamoFieldOptions? fieldOptions, GeneratorContext context + ) + { + var underlyingType = UnwrapNullable(elementType); + + return underlyingType switch + { + { SpecialType: SpecialType.System_DateTime } => CreateCollectionFormatArgs( + fieldOptions?.Format ?? context.MapperOptions.DateTimeFormat + ), + INamedTypeSymbol t when context.WellKnownTypes.IsType( + t, + WellKnownTypeData.WellKnownType.System_DateTimeOffset + ) => CreateCollectionFormatArgs( + fieldOptions?.Format ?? context.MapperOptions.DateTimeFormat + ), + INamedTypeSymbol t when context.WellKnownTypes.IsType( + t, + WellKnownTypeData.WellKnownType.System_Guid + ) => CreateCollectionFormatArgs( + fieldOptions?.Format ?? context.MapperOptions.GuidFormat + ), + INamedTypeSymbol t when context.WellKnownTypes.IsType( + t, + WellKnownTypeData.WellKnownType.System_TimeSpan + ) => CreateCollectionFormatArgs( + fieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat + ), + INamedTypeSymbol { TypeKind: TypeKind.Enum } => CreateCollectionFormatArgs( + fieldOptions?.Format ?? context.MapperOptions.EnumFormat + ), + _ => ([], []), + }; + } + + private static (string[] FromArgs, string[] ToArgs) CreateCollectionFormatArgs(string format) + { + var arg = $"\"{format}\""; + return ([arg], [arg]); + } + /// /// Unwraps Nullable{T} to get the underlying type. /// diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs index 74d5fb67..7ed7085d 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs @@ -173,12 +173,14 @@ private static string RenderFromInitAssignment( ? $"{spec.FromItemMethod.MethodName}({args})" // Custom: MethodName(item) : $"{context.MapperOptions.FromMethodParameterName}.{spec.FromItemMethod.MethodName}{spec.TypeStrategy!.GenericArgument}({args})"; // Standard: item.GetXxx(args) - // For array properties, append .ToArray() to convert the List to an array - // GetList returns List, but if the property is T[], we need to convert it - var isArrayProperty = analysis.PropertyType.TypeKind == TypeKind.Array; - if (isArrayProperty && !spec.FromItemMethod.IsCustomMethod) + if (!spec.FromItemMethod.IsCustomMethod) { - methodCall += ".ToArray()"; + methodCall = + ApplyReadMaterialization( + methodCall, + spec.TypeStrategy?.CollectionInfo, + analysis.Nullability.IsNullableType + ); } return $"{spec.PropertyName} = {methodCall},"; @@ -212,14 +214,17 @@ GeneratorContext context var methodCall = $"Try{spec.FromItemMethod.MethodName}{spec.TypeStrategy!.GenericArgument}({args})"; - // For array properties, append .ToArray() to convert the List to an array - // GetList returns List, but if the property is T[], we need to convert it - var isArrayProperty = analysis.PropertyType.TypeKind == TypeKind.Array; - var toArray = - isArrayProperty && !spec.FromItemMethod.IsCustomMethod ? ".ToArray()" : string.Empty; + var materializedVar = + spec.FromItemMethod.IsCustomMethod + ? $"var{index}!" + : ApplyReadMaterialization( + $"var{index}!", + spec.TypeStrategy?.CollectionInfo, + analysis.Nullability.IsNullableType + ); return - $"if ({context.MapperOptions.FromMethodParameterName}.{methodCall}) {modelVarName}.{spec.PropertyName} = var{index}!{toArray};"; + $"if ({context.MapperOptions.FromMethodParameterName}.{methodCall}) {modelVarName}.{spec.PropertyName} = {materializedVar};"; } /// @@ -266,16 +271,58 @@ private static string RenderConstructorArgument( ? $"{spec.FromItemMethod.MethodName}({args})" // Custom: MethodName(item) : $"{context.MapperOptions.FromMethodParameterName}.{spec.FromItemMethod.MethodName}{spec.TypeStrategy!.GenericArgument}({args})"; // Standard: item.GetXxx(args) - // For array properties, append .ToArray() to convert the List to an array - var isArrayProperty = analysis.PropertyType.TypeKind == TypeKind.Array; - if (isArrayProperty && !spec.FromItemMethod.IsCustomMethod) + if (!spec.FromItemMethod.IsCustomMethod) { - methodCall += ".ToArray()"; + methodCall = + ApplyReadMaterialization( + methodCall, + spec.TypeStrategy?.CollectionInfo, + analysis.Nullability.IsNullableType + ); } return methodCall; } + private static string ApplyReadMaterialization( + string expression, CollectionInfo? collectionInfo, bool isNullableType + ) + { + if (collectionInfo is null) + return expression; + + return collectionInfo.ReadMaterialization switch + { + CollectionReadMaterialization.None => expression, + CollectionReadMaterialization.Array => isNullableType + ? $"{expression}?.ToArray()" + : $"{expression}.ToArray()", + CollectionReadMaterialization.HashSet => + collectionInfo.Category == CollectionCategory.List + ? MaterializeHashSet( + expression, + collectionInfo.ElementType.QualifiedName, + isNullableType + ) + : expression, + _ => throw new InvalidOperationException( + $"Unexpected read materialization: {collectionInfo.ReadMaterialization}" + ), + }; + } + + private static string MaterializeHashSet( + string expression, string elementTypeName, bool isNullableType + ) + { + var constructorExpression = + $"new global::System.Collections.Generic.HashSet<{elementTypeName}>({expression})"; + + return isNullableType + ? $"{expression} is null ? null : {constructorExpression}" + : constructorExpression; + } + #region Nested Object Rendering /// @@ -1056,11 +1103,10 @@ private static string RenderNestedListFromInitAssignment( var selectExpr = $"{varName}List.Select(av => {elementConverter}).ToList()"; - // For arrays, add .ToArray() - var toArray = collectionInfo.IsArray ? ".ToArray()" : ""; + var materializedValue = ApplyReadMaterialization(selectExpr, collectionInfo, false); return - $"{spec.PropertyName} = {itemParam}.TryGetValue(\"{spec.Key}\", out var {varName}Attr) && {varName}Attr.L is {{ }} {varName}List ? {selectExpr}{toArray} : {fallback},"; + $"{spec.PropertyName} = {itemParam}.TryGetValue(\"{spec.Key}\", out var {varName}Attr) && {varName}Attr.L is {{ }} {varName}List ? {materializedValue} : {fallback},"; } /// @@ -1149,11 +1195,10 @@ HelperMethodRegistry helperRegistry var selectExpr = $"{varName}List.Select(av => {elementConverter}).ToList()"; - // For arrays, add .ToArray() - var toArray = collectionInfo.IsArray ? ".ToArray()" : ""; + var materializedValue = ApplyReadMaterialization(selectExpr, collectionInfo, false); return - $"if ({itemParam}.TryGetValue(\"{spec.Key}\", out var {varName}Attr) && {varName}Attr.L is {{ }} {varName}List) {modelVarName}.{spec.PropertyName} = {selectExpr}{toArray};"; + $"if ({itemParam}.TryGetValue(\"{spec.Key}\", out var {varName}Attr) && {varName}Attr.L is {{ }} {varName}List) {modelVarName}.{spec.PropertyName} = {materializedValue};"; } /// diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs index da87d12b..bb3709bb 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/TypeMappingStrategyResolver.cs @@ -355,13 +355,23 @@ private static DiagnosticResult CreateEnumStrategy( ), }; - // Build strategy - collections are nullable at collection level, not element level - var strategy = CreateStrategy(typeName, analysis.Nullability, genericArg); + var (fromArgs, toArgs) = + GetCollectionElementTypeSpecificArgs(collectionInfo.ElementType, analysis, context); + + var strategy = + new TypeMappingStrategy( + typeName, + genericArg, + analysis.Nullability.IsNullableType ? "Nullable" : string.Empty, + fromArgs, + toArgs + ); // Apply Kind override if present (or use inferred kind) return strategy with { KindOverride = analysis.FieldOptions?.Kind ?? collectionInfo.TargetKind, + CollectionInfo = collectionInfo, }; } @@ -425,4 +435,51 @@ PropertyAnalysis analysis return DiagnosticResult.Success(strategy); } + + private static (string[] FromArgs, string[] ToArgs) GetCollectionElementTypeSpecificArgs( + ITypeSymbol elementType, PropertyAnalysis analysis, GeneratorContext context + ) + { + var underlyingType = UnwrapNullable(elementType); + + return underlyingType switch + { + { SpecialType: SpecialType.System_DateTime } => CreateCollectionFormatArgs( + analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat + ), + INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_DateTimeOffset, context) + => CreateCollectionFormatArgs( + analysis.FieldOptions?.Format ?? context.MapperOptions.DateTimeFormat + ), + INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_Guid, context) => + CreateCollectionFormatArgs( + analysis.FieldOptions?.Format ?? context.MapperOptions.GuidFormat + ), + INamedTypeSymbol t when t.IsAssignableTo(WellKnownType.System_TimeSpan, context) => + CreateCollectionFormatArgs( + analysis.FieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat + ), + INamedTypeSymbol { TypeKind: TypeKind.Enum } => CreateCollectionFormatArgs( + analysis.FieldOptions?.Format ?? context.MapperOptions.EnumFormat + ), + _ => ([], []), + }; + } + + private static (string[] FromArgs, string[] ToArgs) CreateCollectionFormatArgs(string format) + { + var arg = $"\"{format}\""; + return ([arg], [arg]); + } + + private static ITypeSymbol UnwrapNullable(ITypeSymbol type) + { + if (type is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + } nullableType && nullableType.TypeArguments.Length == 1) + return nullableType.TypeArguments[0]; + + return type; + } } diff --git a/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/CollectionAttributeValueExtensions.cs b/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/CollectionAttributeValueExtensions.cs index 55c2a2ef..1aacca03 100644 --- a/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/CollectionAttributeValueExtensions.cs +++ b/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/CollectionAttributeValueExtensions.cs @@ -35,6 +35,13 @@ public bool TryGetList( string key, out List value, Requiredness requiredness = Requiredness.InferFromNullability, DynamoKind kind = DynamoKind.L + ) => attributes.TryGetList(key, out value, null, requiredness, kind); + + /// Tries to get a list of elements from the attribute dictionary using an exact format. + public bool TryGetList( + string key, out List value, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.L ) { value = []; @@ -46,7 +53,13 @@ public bool TryGetList( var list = attributeValue!.L ?? []; value = - list.Select(Dictionary.ConvertFromAttributeValue) + list.Select( + av => + Dictionary.ConvertFromAttributeValue( + av, + format + ) + ) .ToList(); return true; } @@ -67,6 +80,13 @@ public List GetList( DynamoKind kind = DynamoKind.L ) => attributes.TryGetList(key, out var value, requiredness, kind) ? value : []; + /// Gets a list of elements from the attribute dictionary using an exact format. + public List GetList( + string key, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.L + ) => attributes.TryGetList(key, out var value, format, requiredness, kind) ? value : []; + /// Tries to get a nullable list of elements from the attribute dictionary. /// The attribute key to retrieve. /// The nullable list value when found. @@ -83,6 +103,16 @@ public bool TryGetNullableList( string key, out List? value, Requiredness requiredness = Requiredness.InferFromNullability, DynamoKind kind = DynamoKind.L + ) => attributes.TryGetNullableList(key, out value, null, requiredness, kind); + + /// + /// Tries to get a nullable list of elements from the attribute dictionary using an exact + /// format. + /// + public bool TryGetNullableList( + string key, out List? value, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.L ) { value = null; @@ -94,7 +124,13 @@ public bool TryGetNullableList( var list = attributeValue!.L ?? []; value = - list.Select(Dictionary.ConvertFromAttributeValue) + list.Select( + av => + Dictionary.ConvertFromAttributeValue( + av, + format + ) + ) .ToList(); return true; } @@ -117,10 +153,25 @@ public bool TryGetNullableList( ? value : null; + /// Gets a nullable list from the attribute dictionary using an exact format. + public List? GetNullableList( + string key, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.L + ) => attributes.TryGetNullableList(key, out var value, format, requiredness, kind) + ? value + : null; + /// Sets a list in the attribute dictionary. public Dictionary SetList( string key, IEnumerable? value, bool omitEmptyStrings = false, bool omitNullStrings = true, DynamoKind kind = DynamoKind.L + ) => attributes.SetList(key, value, null, omitEmptyStrings, omitNullStrings, kind); + + /// Sets a list in the attribute dictionary using an exact element format. + public Dictionary SetList( + string key, IEnumerable? value, string? format, bool omitEmptyStrings = false, + bool omitNullStrings = true, DynamoKind kind = DynamoKind.L ) { if (value is null && omitNullStrings) @@ -133,7 +184,14 @@ public Dictionary SetList( } var list = - value.Select(Dictionary.ConvertToAttributeValue).ToList(); + value.Select( + element => + Dictionary.ConvertToAttributeValue( + element, + format + ) + ) + .ToList(); // Empty lists ARE allowed in DynamoDB - respect omitEmptyStrings flag if (list.Count == 0 && omitEmptyStrings) @@ -161,6 +219,13 @@ public bool TryGetMap( string key, out Dictionary value, Requiredness requiredness = Requiredness.InferFromNullability, DynamoKind kind = DynamoKind.M + ) => attributes.TryGetMap(key, out value, null, requiredness, kind); + + /// Tries to get a map from the attribute dictionary using an exact element format. + public bool TryGetMap( + string key, out Dictionary value, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.M ) { value = new Dictionary(); @@ -175,7 +240,10 @@ public bool TryGetMap( map.ToDictionary( kvp => kvp.Key, kvp => - Dictionary.ConvertFromAttributeValue(kvp.Value) + Dictionary.ConvertFromAttributeValue( + kvp.Value, + format + ) ); return true; } @@ -198,6 +266,15 @@ public Dictionary GetMap( ? value : new Dictionary(); + /// Gets a map from the attribute dictionary using an exact element format. + public Dictionary GetMap( + string key, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.M + ) => attributes.TryGetMap(key, out var value, format, requiredness, kind) + ? value + : new Dictionary(); + /// Tries to get a nullable map from the attribute dictionary. /// The attribute key to retrieve. /// The nullable map value when found. @@ -214,6 +291,13 @@ public bool TryGetNullableMap( string key, out Dictionary? value, Requiredness requiredness = Requiredness.InferFromNullability, DynamoKind kind = DynamoKind.M + ) => attributes.TryGetNullableMap(key, out value, null, requiredness, kind); + + /// Tries to get a nullable map from the attribute dictionary using an exact element format. + public bool TryGetNullableMap( + string key, out Dictionary? value, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.M ) { value = null; @@ -228,7 +312,10 @@ public bool TryGetNullableMap( map.ToDictionary( kvp => kvp.Key, kvp => - Dictionary.ConvertFromAttributeValue(kvp.Value) + Dictionary.ConvertFromAttributeValue( + kvp.Value, + format + ) ); return true; } @@ -249,10 +336,26 @@ public bool TryGetNullableMap( DynamoKind kind = DynamoKind.M ) => attributes.TryGetNullableMap(key, out var value, requiredness, kind) ? value : null; + /// Gets a nullable map from the attribute dictionary using an exact element format. + public Dictionary? GetNullableMap( + string key, string? format, + Requiredness requiredness = Requiredness.InferFromNullability, + DynamoKind kind = DynamoKind.M + ) => attributes.TryGetNullableMap(key, out var value, format, requiredness, kind) + ? value + : null; + /// Sets a map in the attribute dictionary. public Dictionary SetMap( string key, IDictionary? value, bool omitEmptyStrings = false, bool omitNullStrings = true, DynamoKind kind = DynamoKind.M + ) => attributes.SetMap(key, value, null, omitEmptyStrings, omitNullStrings, kind); + + /// Sets a map in the attribute dictionary using an exact element format. + public Dictionary SetMap( + string key, IDictionary? value, string? format, + bool omitEmptyStrings = false, bool omitNullStrings = true, + DynamoKind kind = DynamoKind.M ) { if (value is null && omitNullStrings) @@ -267,7 +370,11 @@ public Dictionary SetMap( var map = value.ToDictionary( kvp => kvp.Key, - kvp => Dictionary.ConvertToAttributeValue(kvp.Value) + kvp => + Dictionary.ConvertToAttributeValue( + kvp.Value, + format + ) ); // Empty maps ARE allowed in DynamoDB - respect omitEmptyStrings flag @@ -650,7 +757,7 @@ public Dictionary SetBinarySet( /// Converts an AttributeValue to a strongly-typed value. /// Handles NULL AttributeValues for nullable elements. /// - private static T ConvertFromAttributeValue(AttributeValue av) + private static T ConvertFromAttributeValue(AttributeValue av, string? format = null) { // Handle NULL AttributeValues (for nullable elements in collections) if (av.NULL is true) @@ -708,28 +815,41 @@ private static T ConvertFromAttributeValue(AttributeValue av) // DateTime if (underlyingType == typeof(DateTime)) - return (T)(object)DateTime.Parse( - av.GetString(DynamoKind.S), - CultureInfo.InvariantCulture - ); + return (T)(object)(format is null + ? DateTime.Parse(av.GetString(DynamoKind.S), CultureInfo.InvariantCulture) + : DateTime.ParseExact( + av.GetString(DynamoKind.S), + format, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind + )); // DateTimeOffset if (underlyingType == typeof(DateTimeOffset)) - return (T)(object)DateTimeOffset.Parse( - av.GetString(DynamoKind.S), - CultureInfo.InvariantCulture - ); + return (T)(object)(format is null + ? DateTimeOffset.Parse(av.GetString(DynamoKind.S), CultureInfo.InvariantCulture) + : DateTimeOffset.ParseExact( + av.GetString(DynamoKind.S), + format, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind + )); // TimeSpan if (underlyingType == typeof(TimeSpan)) - return (T)(object)TimeSpan.Parse( - av.GetString(DynamoKind.S), - CultureInfo.InvariantCulture - ); + return (T)(object)(format is null + ? TimeSpan.Parse(av.GetString(DynamoKind.S), CultureInfo.InvariantCulture) + : TimeSpan.ParseExact( + av.GetString(DynamoKind.S), + format, + CultureInfo.InvariantCulture + )); // Guid if (underlyingType == typeof(Guid)) - return (T)(object)Guid.Parse(av.GetString(DynamoKind.S)); + return (T)(object)(format is null + ? Guid.Parse(av.GetString(DynamoKind.S)) + : Guid.ParseExact(av.GetString(DynamoKind.S), format)); // Enum if (underlyingType.IsEnum) @@ -762,7 +882,7 @@ private static T ConvertFromAttributeValue(AttributeValue av) /// Converts a strongly-typed value to an AttributeValue. /// Handles null values by creating NULL AttributeValues. /// - private static AttributeValue ConvertToAttributeValue(T? value) + private static AttributeValue ConvertToAttributeValue(T? value, string? format = null) { // Handle null values (for nullable elements in collections) if (value is null) @@ -804,22 +924,31 @@ private static AttributeValue ConvertToAttributeValue(T? value) // DateTime if (underlyingType == typeof(DateTime)) - return ((DateTime)(object)value).ToString("O", CultureInfo.InvariantCulture) + return ((DateTime)(object)value).ToString( + format ?? "O", + CultureInfo.InvariantCulture + ) .ToAttributeValue(DynamoKind.S); // DateTimeOffset if (underlyingType == typeof(DateTimeOffset)) - return ((DateTimeOffset)(object)value).ToString("O", CultureInfo.InvariantCulture) + return ((DateTimeOffset)(object)value).ToString( + format ?? "O", + CultureInfo.InvariantCulture + ) .ToAttributeValue(DynamoKind.S); // TimeSpan if (underlyingType == typeof(TimeSpan)) - return ((TimeSpan)(object)value).ToString("c", CultureInfo.InvariantCulture) + return ((TimeSpan)(object)value).ToString( + format ?? "c", + CultureInfo.InvariantCulture + ) .ToAttributeValue(DynamoKind.S); // Guid if (underlyingType == typeof(Guid)) - return ((Guid)(object)value).ToString().ToAttributeValue(DynamoKind.S); + return ((Guid)(object)value).ToString(format ?? "D").ToAttributeValue(DynamoKind.S); // byte[] if (underlyingType == typeof(byte[])) @@ -844,7 +973,9 @@ private static AttributeValue ConvertToAttributeValue(T? value) // Enum return underlyingType.IsEnum - ? (value.ToString() ?? string.Empty).ToAttributeValue(DynamoKind.S) + ? (((Enum)(object)value).ToString(format) ?? string.Empty).ToAttributeValue( + DynamoKind.S + ) : throw new NotSupportedException( $"Type {typeof(T)} is not supported as a collection element" ); diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionVerifyTests.cs index 7255e35f..0f2f917b 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionVerifyTests.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/CollectionVerifyTests.cs @@ -3,11 +3,11 @@ namespace LayeredCraft.DynamoMapper.Generators.Tests; public class CollectionVerifyTests { [Fact] - public async Task Collection_ListOfString() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_ListOfString() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -27,16 +27,16 @@ public class Entity public List Tags { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_ArrayOfString() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_ArrayOfString() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -56,16 +56,16 @@ public class Entity public string[] Tags { get; set; } = Array.Empty(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_ListOfInt() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_ListOfInt() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -85,18 +85,16 @@ public class Entity public List Scores { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); - - // ==================== DICTIONARY TESTS ==================== + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_DictionaryStringToInt() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_GuidShapes() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -113,19 +111,23 @@ public static partial class ExampleEntityMapper public class Entity { - public Dictionary Metadata { get; set; } = new(); + public List GuidList { get; set; } = new(); + public Guid[] GuidArray { get; set; } = Array.Empty(); + public Dictionary GuidMap { get; set; } = new(); + public IEnumerable GuidSequence { get; set; } = Array.Empty(); + public HashSet GuidSet { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_DictionaryStringToString() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_FormattedScalarShapes() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -133,7 +135,13 @@ await GeneratorTestHelpers.Verify( namespace MyNamespace; - [DynamoMapper] + public enum Status + { + Draft = 1, + Published = 2, + } + + [DynamoMapper(GuidFormat = "N", TimeSpanFormat = "G", EnumFormat = "D")] public static partial class ExampleEntityMapper { public static partial Dictionary ToItem(Entity source); @@ -142,21 +150,23 @@ public static partial class ExampleEntityMapper public class Entity { - public Dictionary Attributes { get; set; } = new(); + public List GuidList { get; set; } = new(); + public Dictionary Durations { get; set; } = new(); + public HashSet Statuses { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); - // ==================== SET TESTS ==================== + // ==================== DICTIONARY TESTS ==================== [Fact] - public async Task Collection_HashSetOfString() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_DictionaryStringToInt() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -173,19 +183,19 @@ public static partial class ExampleEntityMapper public class Entity { - public HashSet Categories { get; set; } = new(); + public Dictionary Metadata { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_HashSetOfInt() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_DictionaryStringToString() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -202,19 +212,21 @@ public static partial class ExampleEntityMapper public class Entity { - public HashSet Numbers { get; set; } = new(); + public Dictionary Attributes { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); + + // ==================== SET TESTS ==================== [Fact] - public async Task Collection_HashSetOfByteArray() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_HashSetOfString() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -231,21 +243,19 @@ public static partial class ExampleEntityMapper public class Entity { - public HashSet Payloads { get; set; } = new(); + public HashSet Categories { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); - - // ==================== INTERFACE TESTS ==================== + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_IEnumerableOfString() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_HashSetOfInt() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -262,21 +272,19 @@ public static partial class ExampleEntityMapper public class Entity { - public IEnumerable Items { get; set; } = new List(); + public HashSet Numbers { get; set; } = new(); } """, - }, - TestContext.Current.CancellationToken - ); - - // ==================== NEGATIVE TESTS ==================== + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Collection_NestedList_ShouldFail() => - await GeneratorTestHelpers.VerifyFailure( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_HashSetOfByteArray() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -293,20 +301,21 @@ public static partial class ExampleEntityMapper public class Entity { - public List> NestedList { get; set; } = new(); + public HashSet Payloads { get; set; } = new(); } """, - ExpectedDiagnosticId = "DM0003", - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); + + // ==================== INTERFACE TESTS ==================== [Fact] - public async Task Collection_NestedObjectElementType_ShouldSucceed() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Collection_IEnumerableOfString() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -323,14 +332,77 @@ public static partial class ExampleEntityMapper public class Entity { - public List Items { get; set; } = new(); - } - - public class CustomClass - { - public string Name { get; set; } + public IEnumerable Items { get; set; } = new List(); } """, + }, + TestContext.Current.CancellationToken + ); + + // ==================== NEGATIVE TESTS ==================== + + [Fact] + public async Task Collection_NestedList_ShouldFail() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ExampleEntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public List> NestedList { get; set; } = new(); + } + """, + ExpectedDiagnosticId = "DM0003", + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Collection_NestedObjectElementType_ShouldSucceed() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ExampleEntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public List Items { get; set; } = new(); + } + + public class CustomClass + { + public string Name { get; set; } + } + """, }, TestContext.Current.CancellationToken ); @@ -340,26 +412,27 @@ public async Task Collection_DictionaryWithIntKey_ShouldFail() => await GeneratorTestHelpers.VerifyFailure( new VerifyTestOptions { - SourceCode = """ - using System; - using System.Collections.Generic; - using Amazon.DynamoDBv2.Model; - using LayeredCraft.DynamoMapper.Runtime; - - namespace MyNamespace; - - [DynamoMapper] - public static partial class ExampleEntityMapper - { - public static partial Dictionary ToItem(Entity source); - public static partial Entity FromItem(Dictionary item); - } - - public class Entity - { - public Dictionary InvalidDict { get; set; } = new(); - } - """, + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ExampleEntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public Dictionary InvalidDict { get; set; } = new(); + } + """, ExpectedDiagnosticId = "DM0004", }, TestContext.Current.CancellationToken @@ -370,27 +443,28 @@ public async Task Collection_IncompatibleKindOverride_ShouldFail() => await GeneratorTestHelpers.VerifyFailure( new VerifyTestOptions { - SourceCode = """ - using System; - using System.Collections.Generic; - using Amazon.DynamoDBv2.Model; - using LayeredCraft.DynamoMapper.Runtime; - - namespace MyNamespace; - - [DynamoMapper] - [DynamoField(nameof(Entity.Numbers), Kind = DynamoKind.S)] - public static partial class ExampleEntityMapper - { - public static partial Dictionary ToItem(Entity source); - public static partial Entity FromItem(Dictionary item); - } - - public class Entity - { - public List Numbers { get; set; } = new(); - } - """, + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField(nameof(Entity.Numbers), Kind = DynamoKind.S)] + public static partial class ExampleEntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public List Numbers { get; set; } = new(); + } + """, ExpectedDiagnosticId = "DM0005", }, TestContext.Current.CancellationToken diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_FormattedScalarShapes#ExampleEntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_FormattedScalarShapes#ExampleEntityMapper.g.verified.cs new file mode 100644 index 00000000..8d9dfeed --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_FormattedScalarShapes#ExampleEntityMapper.g.verified.cs @@ -0,0 +1,38 @@ +//HintName: ExampleEntityMapper.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 ExampleEntityMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Entity source) => + new Dictionary(3) + .SetList("guidList", source.GuidList, "N", false, true, DynamoKind.L) + .SetMap("durations", source.Durations, "G", false, true, DynamoKind.M) + .SetList("statuses", source.Statuses, "D", false, true, DynamoKind.L); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Entity FromItem(global::System.Collections.Generic.Dictionary item) + { + var entity = new global::MyNamespace.Entity(); + if (item.TryGetList("guidList", out var var0, format: "N", Requiredness.InferFromNullability, DynamoKind.L)) entity.GuidList = var0!; + if (item.TryGetMap("durations", out var var1, format: "G", Requiredness.InferFromNullability, DynamoKind.M)) entity.Durations = var1!; + if (item.TryGetList("statuses", out var var2, format: "D", Requiredness.InferFromNullability, DynamoKind.L)) entity.Statuses = new global::System.Collections.Generic.HashSet(var2!); + return entity; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_GuidShapes#ExampleEntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_GuidShapes#ExampleEntityMapper.g.verified.cs new file mode 100644 index 00000000..5c8bc42f --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_GuidShapes#ExampleEntityMapper.g.verified.cs @@ -0,0 +1,42 @@ +//HintName: ExampleEntityMapper.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 ExampleEntityMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Entity source) => + new Dictionary(5) + .SetList("guidList", source.GuidList, "D", false, true, DynamoKind.L) + .SetList("guidArray", source.GuidArray, "D", false, true, DynamoKind.L) + .SetMap("guidMap", source.GuidMap, "D", false, true, DynamoKind.M) + .SetList("guidSequence", source.GuidSequence, "D", false, true, DynamoKind.L) + .SetList("guidSet", source.GuidSet, "D", false, true, DynamoKind.L); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Entity FromItem(global::System.Collections.Generic.Dictionary item) + { + var entity = new global::MyNamespace.Entity(); + if (item.TryGetList("guidList", out var var0, format: "D", Requiredness.InferFromNullability, DynamoKind.L)) entity.GuidList = var0!; + if (item.TryGetList("guidArray", out var var1, format: "D", Requiredness.InferFromNullability, DynamoKind.L)) entity.GuidArray = var1!.ToArray(); + if (item.TryGetMap("guidMap", out var var2, format: "D", Requiredness.InferFromNullability, DynamoKind.M)) entity.GuidMap = var2!; + if (item.TryGetList("guidSequence", out var var3, format: "D", Requiredness.InferFromNullability, DynamoKind.L)) entity.GuidSequence = var3!; + if (item.TryGetList("guidSet", out var var4, format: "D", Requiredness.InferFromNullability, DynamoKind.L)) entity.GuidSet = new global::System.Collections.Generic.HashSet(var4!); + return entity; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ScalarBinaryByteArray#ExampleEntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ScalarBinaryByteArray#ExampleEntityMapper.g.verified.cs index 54ae3e99..cf96ba4f 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ScalarBinaryByteArray#ExampleEntityMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ScalarBinaryByteArray#ExampleEntityMapper.g.verified.cs @@ -28,7 +28,7 @@ public static partial class ExampleEntityMapper public static partial global::MyNamespace.ExampleEntity FromItem(global::System.Collections.Generic.Dictionary item) { var exampleEntity = new global::MyNamespace.ExampleEntity(); - if (item.TryGetBinary("payload", out var var0, Requiredness.InferFromNullability)) exampleEntity.Payload = var0!.ToArray(); + if (item.TryGetBinary("payload", out var var0, Requiredness.InferFromNullability)) exampleEntity.Payload = var0!; return exampleEntity; } } diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalMapperTests.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalMapperTests.cs index b7fd6e6a..f6f4dcf6 100644 --- a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalMapperTests.cs +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalMapperTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Amazon.DynamoDBv2.Model; namespace LayeredCraft.DynamoMapper.IntegrationTests; @@ -36,6 +37,102 @@ public void Canonical_RoundTrip_PreservesValues() CanonicalModelAssertions.AssertEquivalent(expected, actual); } + [Fact] + public void Canonical_RoundTrip_PreservesGuidCollectionShapes() + { + var expected = CanonicalModelFactory.CreateModel(); + + var item = CanonicalIntegrationModelMapper.ToItem(expected); + var actual = CanonicalIntegrationModelMapper.FromItem(item); + + item["relatedIds"] + .L.Select(value => value.S) + .Should() + .Equal(expected.RelatedIds.Select(value => value.ToString("D"))); + item["legacyIds"] + .L.Select(value => value.S) + .Should() + .Equal(expected.LegacyIds.Select(value => value.ToString("D"))); + item["alternateIds"] + .L.Select(value => value.S) + .Should() + .Equal(expected.AlternateIds.Select(value => value.ToString("D"))); + item["uniqueIds"] + .L.Select(value => value.S) + .Should() + .BeEquivalentTo(expected.UniqueIds.Select(value => value.ToString("D"))); + item["contactIdsByRole"] + .M.ToDictionary(pair => pair.Key, pair => pair.Value.S) + .Should() + .Equal( + expected.ContactIdsByRole.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString("D") + ) + ); + + actual.RelatedIds.Should().Equal(expected.RelatedIds); + actual.LegacyIds.Should().Equal(expected.LegacyIds); + actual.AlternateIds.Should().Equal(expected.AlternateIds); + actual.UniqueIds.Should().BeEquivalentTo(expected.UniqueIds); + actual.ContactIdsByRole.Should().Equal(expected.ContactIdsByRole); + } + + [Fact] + public void Canonical_RoundTrip_PreservesFormattedScalarCollections() + { + var expected = + new CanonicalFormattedCollectionModel + { + RelatedIds = + [ + Guid.Parse("12121212-3434-5656-7878-909090909090"), + Guid.Parse("21212121-4343-6565-8787-010101010101"), + ], + DurationsByName = + new Dictionary + { + ["short"] = + TimeSpan.Parse("1:02:03.004", CultureInfo.InvariantCulture), + ["long"] = + TimeSpan.Parse("2:03:04:05.006", CultureInfo.InvariantCulture), + }, + Statuses = + [ + CanonicalFormattedStatus.Draft, CanonicalFormattedStatus.Published, + ], + }; + + var item = CanonicalFormattedCollectionModelMapper.ToItem(expected); + var actual = CanonicalFormattedCollectionModelMapper.FromItem(item); + + item["relatedIds"] + .L.Select(value => value.S) + .Should() + .Equal(expected.RelatedIds.Select(value => value.ToString("N"))); + item["durationsByName"] + .M.ToDictionary(pair => pair.Key, pair => pair.Value.S) + .Should() + .Equal( + expected.DurationsByName.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString("G", CultureInfo.InvariantCulture) + ) + ); + item["statuses"] + .L.Select(value => value.S) + .Should() + .BeEquivalentTo( + expected.Statuses.Select( + value => ((int)value).ToString(CultureInfo.InvariantCulture) + ) + ); + + actual.RelatedIds.Should().Equal(expected.RelatedIds); + actual.DurationsByName.Should().Equal(expected.DurationsByName); + actual.Statuses.Should().BeEquivalentTo(expected.Statuses); + } + [Fact] public void Canonical_RoundTrip_PreservesBinaryShapes() { diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModel.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModel.cs index d69359eb..ac8b69ef 100644 --- a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModel.cs +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModel.cs @@ -76,8 +76,18 @@ public sealed class CanonicalIntegrationModel public IEnumerable Aliases { get; set; } = []; + public List RelatedIds { get; set; } = []; + + public Guid[] LegacyIds { get; set; } = []; + + public IEnumerable AlternateIds { get; set; } = []; + + public HashSet UniqueIds { get; set; } = []; + public Dictionary PriceByMarket { get; set; } = []; + public Dictionary ContactIdsByRole { get; set; } = []; + public HashSet Labels { get; set; } = []; public HashSet ImportanceCodes { get; set; } = []; @@ -130,3 +140,30 @@ public static partial CanonicalBinaryStreamScalarModel FromItem( Dictionary item ); } + +public enum CanonicalFormattedStatus +{ + Draft = 1, + Published = 2, +} + +public sealed class CanonicalFormattedCollectionModel +{ + public List RelatedIds { get; set; } = []; + + public Dictionary DurationsByName { get; set; } = []; + + public HashSet Statuses { get; set; } = []; +} + +[DynamoMapper(GuidFormat = "N", TimeSpanFormat = "G", EnumFormat = "D")] +public static partial class CanonicalFormattedCollectionModelMapper +{ + public static partial Dictionary ToItem( + CanonicalFormattedCollectionModel source + ); + + public static partial CanonicalFormattedCollectionModel FromItem( + Dictionary item + ); +} diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelAssertions.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelAssertions.cs index 00ed211c..16edd462 100644 --- a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelAssertions.cs +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelAssertions.cs @@ -27,7 +27,12 @@ internal static void AssertEquivalent( actual.Tags.Should().Equal(expected.Tags); actual.Scores.Should().Equal(expected.Scores); actual.Aliases.Should().Equal(expected.Aliases); + actual.RelatedIds.Should().Equal(expected.RelatedIds); + actual.LegacyIds.Should().Equal(expected.LegacyIds); + actual.AlternateIds.Should().Equal(expected.AlternateIds); + actual.UniqueIds.Should().BeEquivalentTo(expected.UniqueIds); actual.PriceByMarket.Should().Equal(expected.PriceByMarket); + actual.ContactIdsByRole.Should().Equal(expected.ContactIdsByRole); actual.Labels.Should().BeEquivalentTo(expected.Labels); actual.ImportanceCodes.Should().BeEquivalentTo(expected.ImportanceCodes); @@ -79,7 +84,12 @@ internal static void AssertExpectedItemShape(Dictionary "tags", "scores", "aliases", + "relatedIds", + "legacyIds", + "alternateIds", + "uniqueIds", "priceByMarket", + "contactIdsByRole", "labels", "importanceCodes", "payloadVersions", @@ -109,9 +119,30 @@ internal static void AssertExpectedItemShape(Dictionary item["tags"].L.Select(value => value.S).Should().Equal("alpha", "beta", "gamma"); item["scores"].L.Select(value => value.N).Should().Equal("7", "8", "9"); item["aliases"].L.Select(value => value.S).Should().Equal("first", "second"); + item["relatedIds"] + .L.Select(value => value.S) + .Should() + .Equal("12121212-3434-5656-7878-909090909090", "21212121-4343-6565-8787-010101010101"); + item["legacyIds"] + .L.Select(value => value.S) + .Should() + .Equal("31313131-4545-6767-8989-121212121212", "41414141-5656-7878-9090-232323232323"); + item["alternateIds"] + .L.Select(value => value.S) + .Should() + .Equal("51515151-6767-8989-0101-343434343434", "61616161-7878-9090-1212-454545454545"); + item["uniqueIds"] + .L.Select(value => value.S) + .Should() + .BeEquivalentTo( + "91919191-0101-2323-4545-787878787878", + "a1a1a1a1-b2b2-c3c3-d4d4-e5e5e5e5e5e5" + ); item["priceByMarket"].M["us"].N.Should().Be("12.34"); item["priceByMarket"].M["eu"].N.Should().Be("56.78"); + item["contactIdsByRole"].M["owner"].S.Should().Be("71717171-8989-0101-2323-565656565656"); + item["contactIdsByRole"].M["backup"].S.Should().Be("81818181-9090-1212-3434-676767676767"); item["labels"].SS.Should().BeEquivalentTo("new", "sale"); item["importanceCodes"].NS.Should().BeEquivalentTo("10", "20"); diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelFactory.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelFactory.cs index 8f9e277b..130a6db7 100644 --- a/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelFactory.cs +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/CanonicalModelFactory.cs @@ -24,7 +24,33 @@ internal static class CanonicalModelFactory Tags = ["alpha", "beta", "gamma"], Scores = [7, 8, 9], Aliases = ["first", "second"], + RelatedIds = + [ + Guid.Parse("12121212-3434-5656-7878-909090909090"), + Guid.Parse("21212121-4343-6565-8787-010101010101"), + ], + LegacyIds = + [ + Guid.Parse("31313131-4545-6767-8989-121212121212"), + Guid.Parse("41414141-5656-7878-9090-232323232323"), + ], + AlternateIds = + [ + Guid.Parse("51515151-6767-8989-0101-343434343434"), + Guid.Parse("61616161-7878-9090-1212-454545454545"), + ], + UniqueIds = + [ + Guid.Parse("91919191-0101-2323-4545-787878787878"), + Guid.Parse("a1a1a1a1-b2b2-c3c3-d4d4-e5e5e5e5e5e5"), + ], PriceByMarket = new Dictionary { ["us"] = 12.34m, ["eu"] = 56.78m }, + ContactIdsByRole = + new Dictionary + { + ["owner"] = Guid.Parse("71717171-8989-0101-2323-565656565656"), + ["backup"] = Guid.Parse("81818181-9090-1212-3434-676767676767"), + }, Labels = ["new", "sale"], ImportanceCodes = [10, 20], PayloadVersions = [new byte[] { 0, 1, 2 }, new byte[] { 3, 4, 5 }], @@ -129,6 +155,42 @@ internal static Dictionary CreateItem() new AttributeValue { S = "second" }, ], }, + ["relatedIds"] = + new() + { + L = + [ + new AttributeValue { S = "12121212-3434-5656-7878-909090909090" }, + new AttributeValue { S = "21212121-4343-6565-8787-010101010101" }, + ], + }, + ["legacyIds"] = + new() + { + L = + [ + new AttributeValue { S = "31313131-4545-6767-8989-121212121212" }, + new AttributeValue { S = "41414141-5656-7878-9090-232323232323" }, + ], + }, + ["alternateIds"] = + new() + { + L = + [ + new AttributeValue { S = "51515151-6767-8989-0101-343434343434" }, + new AttributeValue { S = "61616161-7878-9090-1212-454545454545" }, + ], + }, + ["uniqueIds"] = + new() + { + L = + [ + new AttributeValue { S = "91919191-0101-2323-4545-787878787878" }, + new AttributeValue { S = "a1a1a1a1-b2b2-c3c3-d4d4-e5e5e5e5e5e5" }, + ], + }, ["priceByMarket"] = new() { @@ -139,6 +201,18 @@ internal static Dictionary CreateItem() ["eu"] = new() { N = "56.78" }, }, }, + ["contactIdsByRole"] = + new() + { + M = + new Dictionary + { + ["owner"] = + new() { S = "71717171-8989-0101-2323-565656565656" }, + ["backup"] = + new() { S = "81818181-9090-1212-3434-676767676767" }, + }, + }, ["labels"] = new() { SS = ["new", "sale"] }, ["importanceCodes"] = new() { NS = ["10", "20"] }, ["payloadVersions"] = diff --git a/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.CollectionTests.cs b/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.CollectionTests.cs index 360354b4..c4f5e46f 100644 --- a/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.CollectionTests.cs +++ b/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.CollectionTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Amazon.DynamoDBv2.Model; namespace LayeredCraft.DynamoMapper.Runtime.Tests; @@ -442,6 +443,27 @@ public void List_RoundTrip_WithBinary() Assert.Equal(original[1], result[1]); } + [Fact] + public void List_RoundTrip_WithGuidFormat() + { + var attributes = new Dictionary(); + var original = + new List + { + Guid.Parse("12121212-3434-5656-7878-909090909090"), + Guid.Parse("21212121-4343-6565-8787-010101010101"), + }; + + attributes.SetList("ids", original, "N"); + var result = attributes.GetList("ids", "N"); + + Assert.Equal( + original.Select(value => value.ToString("N")), + attributes["ids"].L.Select(value => value.S) + ); + Assert.Equal(original, result); + } + // ==================== MAP TESTS ==================== [Fact] @@ -620,6 +642,30 @@ public void Map_RoundTrip_WithStreams() Assert.Equal(new byte[] { 4, 5, 6 }, ((MemoryStream)result["full"]).ToArray()); } + [Fact] + public void Map_RoundTrip_WithTimeSpanFormat() + { + var attributes = new Dictionary(); + var original = + new Dictionary + { + ["short"] = TimeSpan.Parse("1:02:03.004", CultureInfo.InvariantCulture), + ["long"] = TimeSpan.Parse("2:03:04:05.006", CultureInfo.InvariantCulture), + }; + + attributes.SetMap("durations", original, "G"); + var result = attributes.GetMap("durations", "G"); + + Assert.Equal( + original.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToString("G", CultureInfo.InvariantCulture) + ), + attributes["durations"].M.ToDictionary(pair => pair.Key, pair => pair.Value.S) + ); + Assert.Equal(original, result); + } + // ==================== STRING SET TESTS ==================== [Fact]