diff --git a/.gitignore b/.gitignore index d856972..d6e0512 100644 --- a/.gitignore +++ b/.gitignore @@ -438,3 +438,5 @@ Thumbs.db **/*.DotSettings.user /.claude/do_not_commit/ /nupkg/ + +**/.env \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index bbfd508..6e484bb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.0.0 + 1.1.0 MIT @@ -28,8 +28,8 @@ README.md - - + + diff --git a/docs/api-reference/attributes.md b/docs/api-reference/attributes.md index 15e37a7..4082567 100644 --- a/docs/api-reference/attributes.md +++ b/docs/api-reference/attributes.md @@ -11,7 +11,7 @@ Marks a static partial class as a mapper and sets defaults. Convention = DynamoNamingConvention.CamelCase, DefaultRequiredness = Requiredness.InferFromNullability, IncludeBaseClassProperties = false, - OmitNullStrings = true, + OmitNullValues = true, OmitEmptyStrings = false, DateTimeFormat = "O", EnumFormat = "G")] @@ -27,7 +27,9 @@ Properties: - `Convention` - key naming convention - `DefaultRequiredness` - default requiredness - `IncludeBaseClassProperties` - include properties declared on base classes (opt-in) -- `OmitNullStrings` - omit null string attributes +- `OmitNullValues` - omit null values, including nested object and nested collection properties +- `OmitNullStrings` - deprecated legacy option kept for compatibility with helper-backed null + omission - `OmitEmptyStrings` - omit empty string attributes - `DateTimeFormat` - `DateTime`/`DateTimeOffset` format - `TimeSpanFormat` - `TimeSpan` format @@ -40,6 +42,8 @@ Notes: nested inline objects. - If a derived type declares a property with the same name as an inherited property, the derived property wins. +- Prefer `OmitNullValues` for mapper-level null omission. `OmitNullStrings` remains available only + as a legacy compatibility option. ## DynamoFieldAttribute @@ -57,11 +61,12 @@ public static partial class OrderMapper ``` Properties: + - `MemberName` (ctor) - target member name - `AttributeName` - DynamoDB attribute name override - `Required` - requiredness override - `Kind` - DynamoDB `DynamoKind` override -- `OmitIfNull` - omit when null +- `OmitIfNull` - omit when null, including nested object and nested collection properties - `OmitIfEmptyString` - omit when empty string - `ToMethod` / `FromMethod` - static conversion methods on the mapper class @@ -80,6 +85,7 @@ public static partial class OrderMapper ``` Properties: + - `MemberName` (ctor) - target member name - `Ignore` - `IgnoreMapping.All`, `IgnoreMapping.FromModel`, or `IgnoreMapping.ToModel` diff --git a/docs/core-concepts/how-it-works.md b/docs/core-concepts/how-it-works.md index 911ed94..d84f655 100644 --- a/docs/core-concepts/how-it-works.md +++ b/docs/core-concepts/how-it-works.md @@ -12,8 +12,8 @@ DynamoMapper is an incremental source generator that produces high-performance, DynamoMapper is built on three fundamental principles: 1. **Domain Stays Clean** - Your domain models remain free of persistence attributes -2. **Compile-Time Safety** - All mapping code is generated and validated at compile time -3. **DynamoDB-Focused** - Single-purpose library for DynamoDB attribute mapping +1. **Compile-Time Safety** - All mapping code is generated and validated at compile time +1. **DynamoDB-Focused** - Single-purpose library for DynamoDB attribute mapping ## Mapping Scope @@ -50,24 +50,28 @@ DynamoMapper uses .NET's `IIncrementalGenerator` API to analyze your code at com ### The Generation Pipeline 1. **Discovery Phase** - - Locate classes marked with `[DynamoMapper]` - - Find partial mapping methods (`ToItem`, `FromItem`) - - Collect configuration attributes -2. **Analysis Phase** - - Resolve target entity types - - Analyze properties (public, readable, writable) - - Apply naming conventions - - Validate converters and hooks +- Locate classes marked with `[DynamoMapper]` +- Find partial mapping methods (`ToItem`, `FromItem`) +- Collect configuration attributes -3. **Code Generation Phase** - - Generate `ToItem` implementation - - Generate `FromItem` implementation - - Emit diagnostics for configuration errors +1. **Analysis Phase** -4. **Compilation Phase** - - Generated code is compiled with your project - - No runtime dependencies beyond AWS SDK types +- Resolve target entity types +- Analyze properties (public, readable, writable) +- Apply naming conventions +- Validate converters and hooks + +1. **Code Generation Phase** + +- Generate `ToItem` implementation +- Generate `FromItem` implementation +- Emit diagnostics for configuration errors + +1. **Compilation Phase** + +- Generated code is compiled with your project +- No runtime dependencies beyond AWS SDK types ## Mapper Anatomy @@ -189,10 +193,13 @@ DynamoMapper uses a layered configuration model: ```csharp [DynamoMapper( Convention = DynamoNamingConvention.CamelCase, - OmitNullStrings = true, + OmitNullValues = true, DateTimeFormat = "O")] ``` +`OmitNullStrings` is still supported as a deprecated legacy option for helper-backed null omission, +but `OmitNullValues` is the preferred mapper-level setting. + ### 2. Property-Level Overrides ```csharp @@ -241,16 +248,16 @@ static partial void AfterToItem(Product source, Dictionary item, ref P ``` **Common use cases:** + - PK/SK composition for single-table design - Record type discrimination - TTL attributes @@ -332,9 +340,9 @@ static partial void AfterFromItem(Dictionary item, ref P DynamoMapper uses hooks instead of a general mapping pipeline because: 1. **Focused scope** - Only two mapping directions, not arbitrary transformations -2. **Zero overhead** - Unimplemented hooks compile away completely -3. **Type safety** - Statically bound, no reflection -4. **DynamoDB patterns** - Designed specifically for single-table design +1. **Zero overhead** - Unimplemented hooks compile away completely +1. **Type safety** - Statically bound, no reflection +1. **DynamoDB patterns** - Designed specifically for single-table design ## Diagnostics @@ -370,11 +378,11 @@ error DM0201: Static conversion method 'ToStatus' not found on mapper 'OrderMapp ### Benchmarks (Typical) -| Operation | Time | Allocations | -|-----------|------|-------------| -| ToItem (5 properties) | ~50ns | 1 (dictionary) | -| FromItem (5 properties) | ~100ns | 1 (entity) | -| With hooks | +5-10ns | 0 additional | +| Operation | Time | Allocations | +|-------------------------|---------|----------------| +| ToItem (5 properties) | ~50ns | 1 (dictionary) | +| FromItem (5 properties) | ~100ns | 1 (entity) | +| With hooks | +5-10ns | 0 additional | ## Design Constraints @@ -388,6 +396,7 @@ mapper.Configure(x => x.Property("Name").Ignore()); ``` **Why:** Compile-time configuration enables: + - Zero reflection - Faster runtime performance - Compile-time validation @@ -437,6 +446,7 @@ public static partial class ProductMapper ``` **Key points:** + - DSL is **optional** - attributes remain fully supported - DSL is **compile-time only** - no runtime evaluation - DSL and attributes can coexist - DSL takes precedence @@ -444,9 +454,9 @@ public static partial class ProductMapper ## Best Practices 1. **Use default conventions** - Override only when necessary -2. **Keep domain clean** - No DynamoDB concerns in domain models -3. **Use hooks for DynamoDB patterns** - PK/SK, TTL, record types -4. **Use static methods for conversions** - Simple, co-located, explicit +1. **Keep domain clean** - No DynamoDB concerns in domain models +1. **Use hooks for DynamoDB patterns** - PK/SK, TTL, record types +1. **Use static methods for conversions** - Simple, co-located, explicit ## See Also diff --git a/docs/usage/field-configuration.md b/docs/usage/field-configuration.md index c40d1cd..eecbd36 100644 --- a/docs/usage/field-configuration.md +++ b/docs/usage/field-configuration.md @@ -35,8 +35,11 @@ public static partial class OrderMapper ``` Notes: + - Dot-notation overrides force inline mapping for the nested path. - Invalid paths emit `DM0008`. +- `OmitIfNull` works on nested object and nested collection properties too, so paths like + `"Customer.Profile"` or `"Customer.Addresses"` can omit null containers during `ToItem`. ### Collection Element Members @@ -75,13 +78,13 @@ Notes: ## Supported Options -| Option | Description | -| --- | --- | -| `AttributeName` | Overrides the DynamoDB attribute name. | -| `Required` | Controls requiredness during `FromItem`. | -| `Kind` | Forces a specific `DynamoKind`. | -| `OmitIfNull` | Omits null values during `ToItem`. | -| `OmitIfEmptyString` | Omits empty strings during `ToItem`. | -| `ToMethod` | Uses a custom method to serialize a value. | -| `FromMethod` | Uses a custom method to deserialize a value. | -| `Format` | Overrides default format for date/time/enum conversions. | +| Option | Description | +|---------------------|----------------------------------------------------------------------------------------------| +| `AttributeName` | Overrides the DynamoDB attribute name. | +| `Required` | Controls requiredness during `FromItem`. | +| `Kind` | Forces a specific `DynamoKind`. | +| `OmitIfNull` | Omits null values during `ToItem`, including nested object and nested collection properties. | +| `OmitIfEmptyString` | Omits empty strings during `ToItem`. | +| `ToMethod` | Uses a custom method to serialize a value. | +| `FromMethod` | Uses a custom method to deserialize a value. | +| `Format` | Overrides default format for date/time/enum conversions. | diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md index 5d88877..678a610 100644 --- a/skills/dynamo-mapper/references/core-usage.md +++ b/skills/dynamo-mapper/references/core-usage.md @@ -32,7 +32,8 @@ public static partial class OrderMapper - names default to camelCase - requiredness defaults to nullability inference - base-class properties are excluded by default -- null strings are omitted by default +- `OmitNullValues` defaults to `false` +- deprecated `OmitNullStrings` still affects helper-backed nullable scalars and collections - empty strings are kept by default - format defaults are `DateTime = O`, `TimeSpan = c`, `Enum = G`, `Guid = D` @@ -57,12 +58,14 @@ Use `[DynamoIgnore(memberName)]` to skip one or both directions. Dot notation works for nested members like `"ShippingAddress.Line1"` and for collection element members like `"Contacts.VerifiedAt"` (where `Contacts` is `List`). +`OmitIfNull` applies at any depth, including nested object and nested collection properties. + ## Constructors - Put `[DynamoMapperConstructor]` on the model constructor, not the mapper. - If exactly one constructor is marked, it wins. - Otherwise DynamoMapper prefers a usable parameterless/property-init path and falls back to the - constructor with the most parameters. + constructor with the most parameters. - Constructor parameters match .NET property names, not DynamoDB attribute names. ## Nested mapping @@ -76,8 +79,11 @@ Supported nested shapes include: Selection order for a nested member: 1. dot-notation override -2. nested mapper -3. inline helper generation +1. nested mapper +1. inline helper generation + +Use `OmitNullValues` for mapper-level null omission that should also affect nested object and +nested collection containers. Treat `OmitNullStrings` as legacy compatibility. ## Custom conversion diff --git a/skills/dynamo-mapper/references/gotchas.md b/skills/dynamo-mapper/references/gotchas.md index 2bb7621..4abf8b1 100644 --- a/skills/dynamo-mapper/references/gotchas.md +++ b/skills/dynamo-mapper/references/gotchas.md @@ -16,6 +16,8 @@ - nested objects are not supported inside sets - constructor parameter matching uses .NET property names, not attribute names - empty sets are omitted because DynamoDB does not allow them +- `OmitNullStrings` is legacy and misnamed; prefer `OmitNullValues` for mapper-level null omission, + especially for nested object and nested collection containers ## Stale-doc corrections diff --git a/src/LayeredCraft.DynamoMapper.Generators/Extensions/AttributeDataExtensions.cs b/src/LayeredCraft.DynamoMapper.Generators/Extensions/AttributeDataExtensions.cs index dac3d83..6b7665d 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Extensions/AttributeDataExtensions.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Extensions/AttributeDataExtensions.cs @@ -9,37 +9,47 @@ internal static class AttributeDataExtensions { extension(AttributeData attributeData) { - internal TOptions PopulateOptions() - where TOptions : class, new() + internal TOptions PopulateOptions() where TOptions : class, new() { var options = new TOptions(); var settingsType = typeof(TOptions); - var ctorArgs = GetConstructorArgs( - attributeData.AttributeConstructor, - attributeData.ConstructorArguments - ); + var ctorArgs = + GetConstructorArgs( + attributeData.AttributeConstructor, + attributeData.ConstructorArguments + ); KeyValuePair[] combinedArgs = [ - .. attributeData.NamedArguments, - .. ctorArgs, + .. attributeData.NamedArguments, .. ctorArgs, ]; // Map named arguments (properties) foreach (var (propertyName, value) in combinedArgs) { - var property = settingsType.GetProperty( - propertyName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance - ); + var property = + settingsType.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + ); if (property is not null && property.CanWrite) { var actualValue = GetTypedConstantValue(value); var convertedValue = ConvertToPropertyType(actualValue, property.PropertyType); property.SetValue(options, convertedValue); + + var specifiedProperty = + settingsType.GetProperty( + $"{propertyName}Specified", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + ); + + if (specifiedProperty is { CanWrite: true } && + specifiedProperty.PropertyType == typeof(bool)) + specifiedProperty.SetValue(options, true); } } @@ -48,30 +58,27 @@ internal TOptions PopulateOptions() } private static IEnumerable> GetConstructorArgs( - IMethodSymbol? constructor, - ImmutableArray constructorArgs - ) => - constructor is not null - ? constructorArgs.Select( - (_, i) => - new KeyValuePair( - constructor.Parameters[i].Name.Dehumanize(), - constructorArgs[i] - ) - ) - : []; - - private static object? GetTypedConstantValue(TypedConstant constant) => - constant.Kind switch - { - TypedConstantKind.Array => constant.Values.Select(GetTypedConstantValue).ToArray(), - TypedConstantKind.Type => constant.Value as ITypeSymbol, - TypedConstantKind.Primitive => constant.Value, - TypedConstantKind.Enum => GetValidatedEnumValue(constant), - _ => throw new InvalidOperationException( - $"TypedConstant of type '{constant.Kind}' is not supported" - ), - }; + IMethodSymbol? constructor, ImmutableArray constructorArgs + ) => constructor is not null + ? constructorArgs.Select( + (_, i) => + new KeyValuePair( + constructor.Parameters[i].Name.Dehumanize(), + constructorArgs[i] + ) + ) + : []; + + private static object? GetTypedConstantValue(TypedConstant constant) => constant.Kind switch + { + TypedConstantKind.Array => constant.Values.Select(GetTypedConstantValue).ToArray(), + TypedConstantKind.Type => constant.Value as ITypeSymbol, + TypedConstantKind.Primitive => constant.Value, + TypedConstantKind.Enum => GetValidatedEnumValue(constant), + _ => throw new InvalidOperationException( + $"TypedConstant of type '{constant.Kind}' is not supported" + ), + }; private static object GetValidatedEnumValue(TypedConstant constant) { @@ -83,12 +90,12 @@ private static object GetValidatedEnumValue(TypedConstant constant) throw new InvalidOperationException("Enum value cannot be null"); // Get all declared enum field values - var validValues = enumType - .GetMembers() - .OfType() - .Where(f => f.IsConst && f.HasConstantValue) - .Select(f => f.ConstantValue) - .ToHashSet(); + var validValues = + enumType.GetMembers() + .OfType() + .Where(f => f.IsConst && f.HasConstantValue) + .Select(f => f.ConstantValue) + .ToHashSet(); if (validValues.Count == 0) throw new InvalidOperationException( @@ -96,9 +103,9 @@ private static object GetValidatedEnumValue(TypedConstant constant) ); // Check if it's a Flags enum - var isFlagsEnum = enumType - .GetAttributes() - .Any(attr => attr.AttributeClass?.ToDisplayString() == "System.FlagsAttribute"); + var isFlagsEnum = + enumType.GetAttributes() + .Any(attr => attr.AttributeClass?.ToDisplayString() == "System.FlagsAttribute"); if (isFlagsEnum) { @@ -106,9 +113,8 @@ private static object GetValidatedEnumValue(TypedConstant constant) var numericValue = Convert.ToInt64(value); // Calculate bitmask of all valid flags - var validFlagsMask = validValues - .Select(v => Convert.ToInt64(v)) - .Aggregate(0L, (acc, v) => acc | v); + var validFlagsMask = + validValues.Select(v => Convert.ToInt64(v)).Aggregate(0L, (acc, v) => acc | v); // Check if value contains only valid flag bits if ((numericValue & ~validFlagsMask) != 0) @@ -123,8 +129,8 @@ private static object GetValidatedEnumValue(TypedConstant constant) { var validList = string.Join(", ", validValues.Select(v => v?.ToString() ?? "null")); throw new ArgumentException( - $"Enum value '{value}' is not defined in type '{enumType.Name}'. " - + $"Valid values: {validList}" + $"Enum value '{value}' is not defined in type '{enumType.Name}'. " + + $"Valid values: {validList}" ); } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Options/MapperOptions.cs b/src/LayeredCraft.DynamoMapper.Generators/Options/MapperOptions.cs index 5a8d46b..843f160 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Options/MapperOptions.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Options/MapperOptions.cs @@ -13,7 +13,10 @@ internal class MapperOptions internal string EnumFormat { get; set; } = "G"; internal string GuidFormat { get; set; } = "D"; internal bool OmitEmptyStrings { get; set; } = false; + internal bool OmitNullValues { get; set; } = true; + internal bool OmitNullValuesSpecified { get; set; } internal bool OmitNullStrings { get; set; } = true; + internal bool OmitNullStringsSpecified { get; set; } internal string ToMethodParameterName { get; set; } = "source"; internal string FromMethodParameterName { get; set; } = "item"; diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/NestedMappingInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/NestedMappingInfo.cs index 2ce15fd..bba5ba4 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/NestedMappingInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/NestedMappingInfo.cs @@ -41,6 +41,8 @@ EquatableArray Properties /// The C# property name. /// The DynamoDB attribute name. /// The type mapping strategy for this property (can itself be nested). +/// Optional null omission override for this nested property. +/// Optional empty-string omission override for this nested property. /// Nullability information from property analysis. /// Whether the property has an accessible getter. /// Whether the property has an accessible setter. @@ -52,6 +54,8 @@ internal sealed record NestedPropertySpec( string PropertyName, string DynamoKey, TypeMappingStrategy? Strategy, + bool? OmitIfNull, + bool? OmitIfEmptyString, PropertyNullabilityInfo Nullability, bool HasGetter, bool HasSetter, diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs index 8210528..ddbcc9f 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/NestedObjectTypeAnalyzer.cs @@ -253,6 +253,8 @@ private static IPropertySymbol[] GetMappableProperties( property.Name, dynamoKey, scalarStrategy, + fieldOptions?.OmitIfNull, + fieldOptions?.OmitIfEmptyString, propertyAnalysis.Nullability, propertyAnalysis.HasGetter, propertyAnalysis.HasSetter, @@ -305,6 +307,8 @@ private static IPropertySymbol[] GetMappableProperties( property.Name, dynamoKey, collectionStrategy, + fieldOptions?.OmitIfNull, + fieldOptions?.OmitIfEmptyString, propertyAnalysis.Nullability, propertyAnalysis.HasGetter, propertyAnalysis.HasSetter, @@ -334,6 +338,8 @@ private static IPropertySymbol[] GetMappableProperties( property.Name, dynamoKey, null, + fieldOptions?.OmitIfNull, + fieldOptions?.OmitIfEmptyString, propertyAnalysis.Nullability, propertyAnalysis.HasGetter, propertyAnalysis.HasSetter, diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs index f607c4d..74d5fb6 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs @@ -296,13 +296,16 @@ HelperMethodRegistry helperRegistry spec, paramName, isNullable, - mapperBased.Mapper + mapperBased.Mapper, + analysis.FieldOptions?.OmitIfNull, + context ), InlineNesting inline => RenderInlineToAssignment( spec, paramName, isNullable, inline.Info, + analysis.FieldOptions?.OmitIfNull, context, helperRegistry ), @@ -317,15 +320,18 @@ HelperMethodRegistry helperRegistry /// Output: .Set("key", source.Prop is null ? new AttributeValue { NULL = true } : new AttributeValue { M = MapperName.ToItem(source.Prop) }) /// private static string RenderMapperBasedToAssignment( - PropertyMappingSpec spec, string paramName, bool isNullable, MapperReference mapper + PropertyMappingSpec spec, string paramName, bool isNullable, MapperReference mapper, + bool? omitIfNull, GeneratorContext context ) { var propAccess = $"{paramName}.{spec.PropertyName}"; + var omitNull = GetEffectiveNestedOmitNullSetting(omitIfNull, context); if (isNullable) { - return - $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {mapper.MapperFullyQualifiedName}.ToItem({propAccess}) }})"; + return omitNull + ? $".SetIfNotNull(\"{spec.Key}\", {propAccess}, value => new AttributeValue {{ M = {mapper.MapperFullyQualifiedName}.ToItem(value) }})" + : $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {mapper.MapperFullyQualifiedName}.ToItem({propAccess}) }})"; } return @@ -338,10 +344,11 @@ private static string RenderMapperBasedToAssignment( /// private static string RenderInlineToAssignment( PropertyMappingSpec spec, string paramName, bool isNullable, NestedInlineInfo inlineInfo, - GeneratorContext context, HelperMethodRegistry helperRegistry + bool? omitIfNull, GeneratorContext context, HelperMethodRegistry helperRegistry ) { var propAccess = $"{paramName}.{spec.PropertyName}"; + var omitNull = GetEffectiveNestedOmitNullSetting(omitIfNull, context); // Register the helper method and get its name var helperMethodName = @@ -352,8 +359,9 @@ private static string RenderInlineToAssignment( if (isNullable) { - return - $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {helperMethodName}({propAccess}) }})"; + return omitNull + ? $".SetIfNotNull(\"{spec.Key}\", {propAccess}, value => new AttributeValue {{ M = {helperMethodName}(value) }})" + : $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {helperMethodName}({propAccess}) }})"; } return @@ -395,6 +403,7 @@ HelperMethodRegistry helperRegistry if (prop.NestedMapping is not null) { var nestedSourcePrefix = $"{sourcePrefix}.{prop.PropertyName}"; + var omitNull = GetEffectiveNestedOmitNullSetting(prop.OmitIfNull, context); string nestedCode; if (prop.NestedMapping is MapperBasedNesting mapperBased) @@ -417,8 +426,9 @@ HelperMethodRegistry helperRegistry throw new InvalidOperationException("Unknown nested mapping type"); } - return - $".Set(\"{prop.DynamoKey}\", {nestedSourcePrefix} is null ? new AttributeValue {{ NULL = true }} : {nestedCode})"; + return omitNull + ? $".SetIfNotNull(\"{prop.DynamoKey}\", {nestedSourcePrefix}, value => {nestedCode.Replace(nestedSourcePrefix, "value")})" + : $".Set(\"{prop.DynamoKey}\", {nestedSourcePrefix} is null ? new AttributeValue {{ NULL = true }} : {nestedCode})"; } if (prop.Strategy is not null && prop.HasGetter) @@ -432,8 +442,11 @@ HelperMethodRegistry helperRegistry ? ", " + string.Join(", ", prop.Strategy.ToTypeSpecificArgs) : ""; - var omitEmpty = context.MapperOptions.OmitEmptyStrings.ToString().ToLowerInvariant(); - var omitNull = context.MapperOptions.OmitNullStrings.ToString().ToLowerInvariant(); + var omitEmpty = + (prop.OmitIfEmptyString ?? context.MapperOptions.OmitEmptyStrings).ToString() + .ToLowerInvariant(); + var omitNull = + GetEffectiveOmitNullSetting(prop.OmitIfNull, context).ToString().ToLowerInvariant(); return $".{setMethod}{genericArg}(\"{prop.DynamoKey}\", {propValue}{typeArgs}, {omitEmpty}, {omitNull})"; @@ -861,6 +874,7 @@ HelperMethodRegistry helperRegistry spec, propAccess, isNullable, + analysis.FieldOptions?.OmitIfNull, elementMapping, collectionInfo, context, @@ -870,6 +884,7 @@ HelperMethodRegistry helperRegistry spec, propAccess, isNullable, + analysis.FieldOptions?.OmitIfNull, elementMapping, context, helperRegistry @@ -885,7 +900,7 @@ HelperMethodRegistry helperRegistry /// Output: .Set("key", source.Prop?.Select(x => new AttributeValue { M = ... }).ToList()) /// private static string RenderNestedListToAssignment( - PropertyMappingSpec spec, string propAccess, bool isNullable, + PropertyMappingSpec spec, string propAccess, bool isNullable, bool? omitIfNull, NestedMappingInfo elementMapping, CollectionInfo collectionInfo, GeneratorContext context, HelperMethodRegistry helperRegistry ) @@ -902,11 +917,13 @@ HelperMethodRegistry helperRegistry var selectExpr = $"{propAccess}{(isNullable ? "?" : "")}.Select(x => {elementConverter}).ToList()"; + var omitNull = GetEffectiveNestedOmitNullSetting(omitIfNull, context); if (isNullable) { - return - $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ L = {selectExpr} }})"; + return omitNull + ? $".SetIfNotNull(\"{spec.Key}\", {propAccess}, value => new AttributeValue {{ L = value.Select(x => {elementConverter}).ToList() }})" + : $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ L = {selectExpr} }})"; } return $".Set(\"{spec.Key}\", new AttributeValue {{ L = {selectExpr} }})"; @@ -917,7 +934,7 @@ HelperMethodRegistry helperRegistry /// Output: .Set("key", source.Prop?.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = ... })) /// private static string RenderNestedMapToAssignment( - PropertyMappingSpec spec, string propAccess, bool isNullable, + PropertyMappingSpec spec, string propAccess, bool isNullable, bool? omitIfNull, NestedMappingInfo elementMapping, GeneratorContext context, HelperMethodRegistry helperRegistry ) @@ -934,16 +951,26 @@ HelperMethodRegistry helperRegistry var toDictExpr = $"{propAccess}{(isNullable ? "?" : "")}.ToDictionary(kvp => kvp.Key, kvp => {valueConverter})"; + var omitNull = GetEffectiveNestedOmitNullSetting(omitIfNull, context); if (isNullable) { - return - $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {toDictExpr} }})"; + return omitNull + ? $".SetIfNotNull(\"{spec.Key}\", {propAccess}, value => new AttributeValue {{ M = value.ToDictionary(kvp => kvp.Key, kvp => {valueConverter}) }})" + : $".Set(\"{spec.Key}\", {propAccess} is null ? new AttributeValue {{ NULL = true }} : new AttributeValue {{ M = {toDictExpr} }})"; } return $".Set(\"{spec.Key}\", new AttributeValue {{ M = {toDictExpr} }})"; } + private static bool GetEffectiveOmitNullSetting( + bool? analysisFieldOverride, GeneratorContext context + ) => PropertyMappingSpecBuilder.GetEffectiveOmitNullSetting(analysisFieldOverride, context); + + private static bool GetEffectiveNestedOmitNullSetting( + bool? fieldOverride, GeneratorContext context + ) => fieldOverride ?? PropertyMappingSpecBuilder.GetEffectiveMapperOmitNullSetting(context); + /// /// Renders the FromItem code for a nested collection (init-style assignment). /// diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingSpecBuilder.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingSpecBuilder.cs index 5add435..c7a4b30 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingSpecBuilder.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingSpecBuilder.cs @@ -17,9 +17,7 @@ internal static class PropertyMappingSpecBuilder /// The generator context. /// A property mapping specification ready for code generation. internal static PropertyMappingSpec Build( - PropertyAnalysis analysis, - TypeMappingStrategy? strategy, - GeneratorContext context + PropertyAnalysis analysis, TypeMappingStrategy? strategy, GeneratorContext context ) { var fieldOptions = analysis.FieldOptions; @@ -35,25 +33,24 @@ GeneratorContext context return BuildNestedObjectSpec(analysis, strategy, key, context); // Check if property should be ignored in specific directions - var ignoreOptions = context.IgnoreOptions.TryGetValue(analysis.PropertyName, out var opts) - ? opts - : null; + var ignoreOptions = + context.IgnoreOptions.TryGetValue(analysis.PropertyName, out var opts) ? opts : null; var shouldIgnoreToItem = ignoreOptions?.Ignore is IgnoreMapping.All or IgnoreMapping.FromModel; var shouldIgnoreFromItem = ignoreOptions?.Ignore is IgnoreMapping.All or IgnoreMapping.ToModel; // Only build methods if the mapper has them defined - var fromItemMethod = context.HasFromItemMethod - ? shouldIgnoreFromItem - ? null - : BuildFromItemMethod(analysis, strategy, key, context) - : null; - var toItemMethod = context.HasToItemMethod - ? shouldIgnoreToItem - ? null - : BuildToItemMethod(analysis, strategy, key, context) - : null; + var fromItemMethod = + context.HasFromItemMethod + ? shouldIgnoreFromItem + ? null + : BuildFromItemMethod(analysis, strategy, key, context) + : null; + var toItemMethod = + context.HasToItemMethod + ? shouldIgnoreToItem ? null : BuildToItemMethod(analysis, strategy, key, context) + : null; return new PropertyMappingSpec( analysis.PropertyName, @@ -69,8 +66,8 @@ GeneratorContext context /// specified, otherwise applies the naming convention converter. /// private static string GetAttributeKey(PropertyAnalysis analysis, GeneratorContext context) => - analysis.FieldOptions?.AttributeName - ?? context.MapperOptions.KeyNamingConventionConverter(analysis.PropertyName); + analysis.FieldOptions?.AttributeName ?? + context.MapperOptions.KeyNamingConventionConverter(analysis.PropertyName); /// /// Builds the method specification for deserialization (FromItem). Method name format: @@ -78,9 +75,7 @@ private static string GetAttributeKey(PropertyAnalysis analysis, GeneratorContex /// (optional) kind] /// private static MethodCallSpec BuildFromItemMethod( - PropertyAnalysis analysis, - [NotNull] TypeMappingStrategy? strategy, - string key, + PropertyAnalysis analysis, [NotNull] TypeMappingStrategy? strategy, string key, GeneratorContext context ) { @@ -91,11 +86,12 @@ GeneratorContext context var methodName = $"Get{strategy!.NullableModifier}{strategy.TypeName}"; - var args = new List - { - // Argument 1: The DynamoDB attribute key - new($"\"{key}\"", ArgumentSource.Key), - }; + var args = + new List + { + // Argument 1: The DynamoDB attribute key + new($"\"{key}\"", ArgumentSource.Key), + }; // Arguments 2+: Type-specific arguments (format strings, default values) // Format parameters need named parameters to avoid ambiguity with out parameters @@ -104,21 +100,20 @@ GeneratorContext context (typeArg, index) => new ArgumentSpec( // Format strings (quoted strings) need named parameters - typeArg.StartsWith("\"") - ? $"format: {typeArg}" - : typeArg, + typeArg.StartsWith("\"") ? $"format: {typeArg}" : typeArg, ArgumentSource.TypeSpecific ) ) ); // Requiredness: field override > global default - var requiredness = analysis.FieldOptions?.Required switch - { - true => Requiredness.Required, - false => Requiredness.Optional, - null => context.MapperOptions.DefaultRequiredness, - }; + var requiredness = + analysis.FieldOptions?.Required switch + { + true => Requiredness.Required, + false => Requiredness.Optional, + null => context.MapperOptions.DefaultRequiredness, + }; args.Add( new ArgumentSpec( @@ -147,9 +142,7 @@ GeneratorContext context /// args, omitEmptyStrings, omitNullStrings, (optional) kind] /// private static MethodCallSpec BuildToItemMethod( - PropertyAnalysis analysis, - [NotNull] TypeMappingStrategy? strategy, - string key, + PropertyAnalysis analysis, [NotNull] TypeMappingStrategy? strategy, string key, GeneratorContext context ) { @@ -158,27 +151,27 @@ GeneratorContext context var methodName = $"Set{strategy!.TypeName}"; var paramName = context.MapperOptions.ToMethodParameterName; - var args = new List - { - // Argument 1: The DynamoDB attribute key - new($"\"{key}\"", ArgumentSource.Key), - // Argument 2: The property value from source object - new($"{paramName}.{analysis.PropertyName}", ArgumentSource.PropertyValue), - }; + var args = + new List + { + // Argument 1: The DynamoDB attribute key + new($"\"{key}\"", ArgumentSource.Key), + // Argument 2: The property value from source object + new($"{paramName}.{analysis.PropertyName}", ArgumentSource.PropertyValue), + }; // Arguments 3+: Type-specific arguments (format strings) args.AddRange( - strategy.ToTypeSpecificArgs.Select(typeArg => new ArgumentSpec( - typeArg, - ArgumentSource.TypeSpecific - )) + strategy.ToTypeSpecificArgs.Select( + typeArg => new ArgumentSpec(typeArg, ArgumentSource.TypeSpecific) + ) ); // Omit flags: field override > global default var omitEmptyStrings = analysis.FieldOptions?.OmitIfEmptyString ?? context.MapperOptions.OmitEmptyStrings; var omitNullStrings = - analysis.FieldOptions?.OmitIfNull ?? context.MapperOptions.OmitNullStrings; + GetEffectiveOmitNullSetting(analysis.FieldOptions?.OmitIfNull, context); args.Add( new ArgumentSpec( @@ -209,32 +202,41 @@ GeneratorContext context return new MethodCallSpec(methodName, [.. args]); } + internal static bool + GetEffectiveOmitNullSetting(bool? fieldOverride, GeneratorContext context) => + fieldOverride ?? GetEffectiveMapperOmitNullSetting(context); + + internal static bool GetEffectiveMapperOmitNullSetting(GeneratorContext context) => + context.MapperOptions.OmitNullValuesSpecified + ? context.MapperOptions.OmitNullValues + : context.MapperOptions.OmitNullStrings; + /// /// Builds a property mapping spec when custom ToMethod or FromMethod is specified. Custom /// methods completely replace the standard Get/Set method calls. /// private static PropertyMappingSpec BuildWithCustomMethods( - PropertyAnalysis analysis, - TypeMappingStrategy? strategy, - DynamoFieldOptions fieldOptions, + PropertyAnalysis analysis, TypeMappingStrategy? strategy, DynamoFieldOptions fieldOptions, GeneratorContext context ) { var key = GetAttributeKey(analysis, context); // Only build FromItem method if mapper has FromItem defined - var fromItemMethod = context.HasFromItemMethod - ? fieldOptions.FromMethod is not null - ? BuildCustomFromItemMethod(fieldOptions.FromMethod, context) - : BuildFromItemMethod(analysis, strategy, key, context) - : null; + var fromItemMethod = + context.HasFromItemMethod + ? fieldOptions.FromMethod is not null + ? BuildCustomFromItemMethod(fieldOptions.FromMethod, context) + : BuildFromItemMethod(analysis, strategy, key, context) + : null; // Only build ToItem method if mapper has ToItem defined - var toItemMethod = context.HasToItemMethod - ? fieldOptions.ToMethod is not null - ? BuildCustomToItemMethod(fieldOptions.ToMethod, analysis, context) - : BuildToItemMethod(analysis, strategy, key, context) - : null; + var toItemMethod = + context.HasToItemMethod + ? fieldOptions.ToMethod is not null + ? BuildCustomToItemMethod(fieldOptions.ToMethod, analysis, context) + : BuildToItemMethod(analysis, strategy, key, context) + : null; return new PropertyMappingSpec( analysis.PropertyName, @@ -250,17 +252,17 @@ GeneratorContext context /// dictionary as their only argument. /// private static MethodCallSpec BuildCustomFromItemMethod( - string methodName, - GeneratorContext context + string methodName, GeneratorContext context ) { - var args = new[] - { - new ArgumentSpec( - context.MapperOptions.FromMethodParameterName, - ArgumentSource.FieldOverride - ), - }; + var args = + new[] + { + new ArgumentSpec( + context.MapperOptions.FromMethodParameterName, + ArgumentSource.FieldOverride + ), + }; return new MethodCallSpec(methodName, args, true); } @@ -269,19 +271,18 @@ GeneratorContext context /// and return an AttributeValue to be used within a .Set() call. /// private static MethodCallSpec BuildCustomToItemMethod( - string methodName, - PropertyAnalysis analysis, - GeneratorContext context + string methodName, PropertyAnalysis analysis, GeneratorContext context ) { var key = GetAttributeKey(analysis, context); var paramName = context.MapperOptions.ToMethodParameterName; - var args = new[] - { - new ArgumentSpec($"\"{key}\"", ArgumentSource.Key), - new ArgumentSpec($"{methodName}({paramName})", ArgumentSource.FieldOverride), - }; + var args = + new[] + { + new ArgumentSpec($"\"{key}\"", ArgumentSource.Key), + new ArgumentSpec($"{methodName}({paramName})", ArgumentSource.FieldOverride), + }; return new MethodCallSpec("Set", args, true); } @@ -291,16 +292,13 @@ GeneratorContext context /// We pass null for methods and let the renderer handle the special case. /// private static PropertyMappingSpec BuildNestedObjectSpec( - PropertyAnalysis analysis, - TypeMappingStrategy strategy, - string key, + PropertyAnalysis analysis, TypeMappingStrategy strategy, string key, GeneratorContext context ) { // Check if property should be ignored in specific directions - var ignoreOptions = context.IgnoreOptions.TryGetValue(analysis.PropertyName, out var opts) - ? opts - : null; + var ignoreOptions = + context.IgnoreOptions.TryGetValue(analysis.PropertyName, out var opts) ? opts : null; var shouldIgnoreToItem = ignoreOptions?.Ignore is IgnoreMapping.All or IgnoreMapping.FromModel; var shouldIgnoreFromItem = @@ -312,8 +310,8 @@ GeneratorContext context analysis.PropertyName, key, strategy, - ToItemMethod: context.HasToItemMethod && !shouldIgnoreToItem ? MethodCallSpec.Placeholder : null, - FromItemMethod: context.HasFromItemMethod && !shouldIgnoreFromItem ? MethodCallSpec.Placeholder : null + context.HasToItemMethod && !shouldIgnoreToItem ? MethodCallSpec.Placeholder : null, + context.HasFromItemMethod && !shouldIgnoreFromItem ? MethodCallSpec.Placeholder : null ); } } diff --git a/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/AttributeValueExtensions.cs b/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/AttributeValueExtensions.cs index dfeb941..6e7f685 100644 --- a/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/AttributeValueExtensions.cs +++ b/src/LayeredCraft.DynamoMapper.Runtime/AttributeValueExtensions/AttributeValueExtensions.cs @@ -37,5 +37,19 @@ public Dictionary Set(string key, AttributeValue value) return attributes; } + + /// + /// Sets an in the attribute dictionary when the source value is + /// not null. + /// + public Dictionary SetIfNotNull( + string key, T? value, Func valueFactory + ) where T : class + { + if (value is not null) + attributes[key] = valueFactory(value); + + return attributes; + } } } diff --git a/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperAttribute.cs b/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperAttribute.cs index 8794540..f8e2fd9 100644 --- a/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperAttribute.cs +++ b/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperAttribute.cs @@ -37,8 +37,25 @@ public sealed class DynamoMapperAttribute : Attribute /// public bool IncludeBaseClassProperties { get; set; } = false; - /// Gets or sets whether to omit null string values from the DynamoDB item. - /// Default is true. + /// Gets or sets whether to omit null nullable values from the DynamoDB item. + /// + /// Default is true. + /// + /// Applies to null nullable values at any depth, including nested object and nested + /// collection properties. + /// + /// + public bool OmitNullValues { get; set; } = true; + + /// Gets or sets whether to omit null values via the legacy string-named option. + /// + /// Default is true. + /// + /// This option is deprecated and retained for compatibility. Use + /// for the preferred mapper-level null omission behavior. + /// + /// + [Obsolete("OmitNullStrings is deprecated. Use OmitNullValues for mapper-level null omission.")] public bool OmitNullStrings { get; set; } = true; /// Gets or sets whether to omit empty string values from the DynamoDB item. diff --git a/taskfile.yaml b/taskfile.yaml index 366e82f..957bffc 100644 --- a/taskfile.yaml +++ b/taskfile.yaml @@ -1,5 +1,8 @@ version: '3' +dotenv: + - .env + tasks: format: desc: Reformat the solution @@ -44,7 +47,16 @@ tasks: - echo "📦 Packing Packages" - dotnet pack --configuration Release --no-build --output ./nupkg - echo "✅ Packed" - + + publish: + desc: Publish the package to a package registry + silent: true + deps: [ pack ] + cmds: + - echo "🚀 Publishing" + - dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key $NUGET_API_KEY_LOCAL --skip-duplicate + - echo "✨ Published" + pack-local: desc: Package and publish to local NuGet source for use in other solutions silent: true diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/NestedObjectVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/NestedObjectVerifyTests.cs index 5d03485..8934e28 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/NestedObjectVerifyTests.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/NestedObjectVerifyTests.cs @@ -75,6 +75,80 @@ public class Address TestContext.Current.CancellationToken ); + [Fact] + public async Task NestedObject_NullableInline_OmitNullValues() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper(OmitNullValues = true)] + 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 Address? BillingAddress { get; set; } + } + + public class Address + { + public string Line1 { get; set; } + public string City { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task NestedObject_NullableInline_OmitNullValuesFalse() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper(OmitNullValues = false)] + 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 Address? BillingAddress { get; set; } + } + + public class Address + { + public string Line1 { get; set; } + public string City { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + [Fact] public async Task NestedObject_NullableMapperBased() => await GeneratorTestHelpers.Verify( new VerifyTestOptions @@ -694,6 +768,94 @@ public class LineItem TestContext.Current.CancellationToken ); + [Fact] + public async Task NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class LineItemMapper + { + public static partial Dictionary ToItem(LineItem source); + + public static partial LineItem FromItem(Dictionary item); + } + + [DynamoMapper(OmitNullValues = true)] + 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 NestedObject_DotNotationOmitIfNull_OmitsNestedProperty() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField("Level2Data.Level3Data", OmitIfNull = true)] + public static partial class Level1Mapper + { + public static partial Dictionary ToItem(Level1 source); + + public static partial Level1 FromItem(Dictionary item); + } + + public class Level1 + { + public string Id { get; set; } = string.Empty; + public Level2? Level2Data { get; set; } + } + + public class Level2 + { + public string Id { get; set; } = string.Empty; + public Level3? Level3Data { get; set; } + } + + public class Level3 + { + public string Id { get; set; } = string.Empty; + } + """, + }, + TestContext.Current.CancellationToken + ); + [Fact] public async Task NestedCollection_NullableDictionaryOfNestedObjects_Inline() => await GeneratorTestHelpers.Verify( diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/SimpleVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/SimpleVerifyTests.cs index 753f779..15a4798 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/SimpleVerifyTests.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/SimpleVerifyTests.cs @@ -3,11 +3,11 @@ namespace LayeredCraft.DynamoMapper.Generators.Tests; public class SimpleVerifyTests { [Fact] - public async Task Simple_HelloWorld() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_HelloWorld() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -27,16 +27,16 @@ public class MyDto public string Name { get; set; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_HelloWorld_ExtensionMethod() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_HelloWorld_ExtensionMethod() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -56,16 +56,16 @@ public class MyDto public string Name { get; set; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_Record() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_Record() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -85,16 +85,16 @@ public record MyDto public string Name { get; set; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_EmptyModel() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_EmptyModel() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -113,16 +113,16 @@ public class MyDto { } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_AllHelperTypes() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_AllHelperTypes() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -173,16 +173,16 @@ public enum OrderStatus Cancelled, } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_AllHelperTypes_Optional() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_AllHelperTypes_Optional() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -233,16 +233,16 @@ public enum OrderStatus Cancelled, } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_InitProperty() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_InitProperty() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -262,16 +262,16 @@ public class MyDto public string Name { get; init; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_NoSetter() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_NoSetter() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -291,16 +291,16 @@ public class MyDto public string Name { get; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_MethodNamePrefixWorks() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_MethodNamePrefixWorks() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -320,16 +320,16 @@ public class MyDto public string Name { get; set; } } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_AllOptionsSetToNonDefaultValues() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_AllOptionsSetToNonDefaultValues() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -387,16 +387,16 @@ public enum OrderStatus Cancelled, } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_PropertiesWithNoSetter() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_PropertiesWithNoSetter() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System.Collections.Generic; using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Runtime; @@ -418,16 +418,16 @@ public class MyDto public string ExpressionProperty => "ExpressionProperty"; } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] - public async Task Simple_NoToMethod() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_NoToMethod() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -447,41 +447,42 @@ public class MyDto public Type ShouldNotBeMapped => typeof(MyDto); } """, - }, - TestContext.Current.CancellationToken - ); + }, + TestContext.Current.CancellationToken + ); [Fact] public async Task Simple_OverrideOnlyOnFromMethod_NoToMethod() => await GeneratorTestHelpers.Verify( new VerifyTestOptions { - SourceCode = """ - using System; - using System.Collections.Generic; - using Amazon.DynamoDBv2.Model; - using LayeredCraft.DynamoMapper.Runtime; - - namespace MyNamespace; + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField(nameof(MyDto.ShouldNotBeMapped), FromMethod = nameof(GetType))] + public static partial class ExampleMyDtoMapper + { + public static partial MyDto FromItem(Dictionary item); - [DynamoMapper] - [DynamoField(nameof(MyDto.ShouldNotBeMapped), FromMethod = nameof(GetType))] - public static partial class ExampleMyDtoMapper - { - public static partial MyDto FromItem(Dictionary item); + public static Type GetType(Dictionary item) + { + return typeof(MyDto); + } + } - public static Type GetType(Dictionary item) + public class MyDto { - return typeof(MyDto); + public required string Name { get; set; } + public Type ShouldNotBeMapped { get; set; } } - } - - public class MyDto - { - public required string Name { get; set; } - public Type ShouldNotBeMapped { get; set; } - } - """, + """, }, TestContext.Current.CancellationToken ); @@ -491,42 +492,43 @@ public async Task Simple_OverrideOnlyOnToMethod_NoFromMethod() => await GeneratorTestHelpers.Verify( new VerifyTestOptions { - SourceCode = """ - using System; - using System.Collections.Generic; - using Amazon.DynamoDBv2.Model; - using LayeredCraft.DynamoMapper.Runtime; - - namespace MyNamespace; + SourceCode = + """ + using System; + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + [DynamoField(nameof(MyDto.ShouldNotBeMapped), ToMethod = nameof(SetType))] + public static partial class ExampleMyDtoMapper + { + public static partial Dictionary ToItem(MyDto source); - [DynamoMapper] - [DynamoField(nameof(MyDto.ShouldNotBeMapped), ToMethod = nameof(SetType))] - public static partial class ExampleMyDtoMapper - { - public static partial Dictionary ToItem(MyDto source); + public static AttributeValue SetType(MyDto source) + { + return new AttributeValue(); + } + } - public static AttributeValue SetType(MyDto source) + public class MyDto { - return new AttributeValue(); + public required string Name { get; set; } + public Type ShouldNotBeMapped { get; set; } } - } - - public class MyDto - { - public required string Name { get; set; } - public Type ShouldNotBeMapped { get; set; } - } - """, + """, }, TestContext.Current.CancellationToken ); [Fact] - public async Task Simple_DefaultValues() => - await GeneratorTestHelpers.Verify( - new VerifyTestOptions - { - SourceCode = """ + public async Task Simple_DefaultValues() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ using System; using System.Collections.Generic; using Amazon.DynamoDBv2.Model; @@ -550,6 +552,36 @@ public class User public string FullName => $"{FirstName} {LastName}"; } """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Simple_ExplicitOmitNullValuesFalse_PreservesNullsForNullableHelpers() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper(OmitNullValues = false)] + public static partial class ExampleEntityMapper + { + public static partial Dictionary ToItem(ExampleEntity source); + + public static partial ExampleEntity FromItem(Dictionary item); + } + + public class ExampleEntity + { + public bool? NullableBool { get; set; } + } + """, }, TestContext.Current.CancellationToken ); diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_Inline#CatalogMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_Inline#CatalogMapper.g.verified.cs index 6ba0cca..4c6c06d 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_Inline#CatalogMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_Inline#CatalogMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class CatalogMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Catalog source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("products", source.Products is null ? new AttributeValue { NULL = true } : new AttributeValue { M = source.Products?.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = ToItem_Product(kvp.Value) }) }); + .SetIfNotNull("products", source.Products, value => new AttributeValue { M = value.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = ToItem_Product(kvp.Value) }) }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Catalog FromItem(global::System.Collections.Generic.Dictionary item) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_MapperBased#CatalogMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_MapperBased#CatalogMapper.g.verified.cs index 823742d..38eeba4 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_MapperBased#CatalogMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableDictionaryOfNestedObjects_MapperBased#CatalogMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class CatalogMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Catalog source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("products", source.Products is null ? new AttributeValue { NULL = true } : new AttributeValue { M = source.Products?.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = global::MyNamespace.ProductMapper.ToItem(kvp.Value) }) }); + .SetIfNotNull("products", source.Products, value => new AttributeValue { M = value.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { M = global::MyNamespace.ProductMapper.ToItem(kvp.Value) }) }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Catalog FromItem(global::System.Collections.Generic.Dictionary item) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects#OrderMapper.g.verified.cs index 36d409c..cb273ca 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects#OrderMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class OrderMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("items", source.Items is null ? new AttributeValue { NULL = true } : new AttributeValue { L = source.Items?.Select(x => new AttributeValue { M = ToItem_LineItem(x) }).ToList() }); + .SetIfNotNull("items", source.Items, value => new AttributeValue { L = value.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) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_Inline#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_Inline#OrderMapper.g.verified.cs index 36d409c..cb273ca 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_Inline#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_Inline#OrderMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class OrderMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("items", source.Items is null ? new AttributeValue { NULL = true } : new AttributeValue { L = source.Items?.Select(x => new AttributeValue { M = ToItem_LineItem(x) }).ToList() }); + .SetIfNotNull("items", source.Items, value => new AttributeValue { L = value.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) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased#OrderMapper.g.verified.cs index ad79e19..b617399 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased#OrderMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class OrderMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("items", source.Items is null ? new AttributeValue { NULL = true } : new AttributeValue { L = source.Items?.Select(x => new AttributeValue { M = global::MyNamespace.LineItemMapper.ToItem(x) }).ToList() }); + .SetIfNotNull("items", source.Items, value => new AttributeValue { L = value.Select(x => new AttributeValue { M = global::MyNamespace.LineItemMapper.ToItem(x) }).ToList() }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#LineItemMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#LineItemMapper.g.verified.cs new file mode 100644 index 0000000..239acc8 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#LineItemMapper.g.verified.cs @@ -0,0 +1,38 @@ +//HintName: LineItemMapper.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 LineItemMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.LineItem source) => + new Dictionary(2) + .SetString("productId", source.ProductId, false, true) + .SetInt("quantity", source.Quantity, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.LineItem FromItem(global::System.Collections.Generic.Dictionary item) + { + var lineItem = new global::MyNamespace.LineItem + { + ProductId = item.GetString("productId", Requiredness.InferFromNullability), + Quantity = item.GetInt("quantity", Requiredness.InferFromNullability), + }; + return lineItem; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#OrderMapper.g.verified.cs new file mode 100644 index 0000000..b617399 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedCollection_NullableListOfNestedObjects_MapperBased_OmitNullValues#OrderMapper.g.verified.cs @@ -0,0 +1,38 @@ +//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) + .SetIfNotNull("items", source.Items, value => new AttributeValue { L = value.Select(x => new AttributeValue { M = global::MyNamespace.LineItemMapper.ToItem(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 => global::MyNamespace.LineItemMapper.FromItem(av.M)).ToList() : null, + }; + return order; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_DotNotationOmitIfNull_OmitsNestedProperty#Level1Mapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_DotNotationOmitIfNull_OmitsNestedProperty#Level1Mapper.g.verified.cs new file mode 100644 index 0000000..270cec6 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_DotNotationOmitIfNull_OmitsNestedProperty#Level1Mapper.g.verified.cs @@ -0,0 +1,68 @@ +//HintName: Level1Mapper.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 Level1Mapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Level1 source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .SetIfNotNull("level2Data", source.Level2Data, value => new AttributeValue { M = ToItem_Level2(value) }); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Level1 FromItem(global::System.Collections.Generic.Dictionary item) + { + var level1 = new global::MyNamespace.Level1 + { + Level2Data = item.TryGetValue("level2Data", out var level2dataAttr) && level2dataAttr.M is { } level2dataMap ? FromItem_Level2(level2dataMap) : null, + }; + if (item.TryGetString("id", out var var0, Requiredness.InferFromNullability)) level1.Id = var0!; + return level1; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_Level2(global::MyNamespace.Level2 level2) => + new Dictionary(2) + .SetString("id", level2.Id, false, true) + .SetIfNotNull("level3Data", level2.Level3Data, value => new AttributeValue { M = ToItem_Level3(value) }); + + private static global::MyNamespace.Level2 FromItem_Level2(Dictionary map) + { + var level2 = new global::MyNamespace.Level2 + { + Level3Data = map.TryGetValue("level3Data", out var map_level3dataAttr) && map_level3dataAttr.M is { } map_level3data ? FromItem_Level3(map_level3data) : null, + }; + if (map.TryGetString("id", out var var0, Requiredness.InferFromNullability)) level2.Id = var0!; + return level2; + } + + + private static Dictionary ToItem_Level3(global::MyNamespace.Level3 level3) => + new Dictionary(1) + .SetString("id", level3.Id, false, true); + + private static global::MyNamespace.Level3 FromItem_Level3(Dictionary map) + { + var level3 = new global::MyNamespace.Level3(); + if (map.TryGetString("id", out var var0, Requiredness.InferFromNullability)) level3.Id = var0!; + return level3; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultiLevel#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultiLevel#OrderMapper.g.verified.cs index b25faed..9ac03ca 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultiLevel#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultiLevel#OrderMapper.g.verified.cs @@ -41,7 +41,7 @@ public static partial class OrderMapper private static Dictionary ToItem_Customer(global::MyNamespace.Customer customer) => new Dictionary(2) .SetString("name", customer.Name, false, true) - .Set("address", customer.Address is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Address(customer.Address) }); + .SetIfNotNull("address", customer.Address, value => new AttributeValue { M = ToItem_Address(value) }); private static global::MyNamespace.Customer FromItem_Customer(Dictionary map) { diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultipleLevels#Level1Mapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultipleLevels#Level1Mapper.g.verified.cs index e5f255b..f92fb34 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultipleLevels#Level1Mapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_MultipleLevels#Level1Mapper.g.verified.cs @@ -24,7 +24,7 @@ public static partial class Level1Mapper new Dictionary(3) .SetString("id", source.Id, false, true) .SetString("name", source.Name, false, true) - .Set("level2Data", source.Level2Data is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Level2(source.Level2Data) }); + .SetIfNotNull("level2Data", source.Level2Data, value => new AttributeValue { M = ToItem_Level2(value) }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Level1 FromItem(global::System.Collections.Generic.Dictionary item) @@ -44,7 +44,7 @@ private static Dictionary ToItem_Level2(global::MyNamesp new Dictionary(3) .SetString("id", level2.Id, false, true) .SetString("description", level2.Description, false, true) - .Set("level3Data", level2.Level3Data is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Level3(level2.Level3Data) }); + .SetIfNotNull("level3Data", level2.Level3Data, value => new AttributeValue { M = ToItem_Level3(value) }); private static global::MyNamespace.Level2 FromItem_Level2(Dictionary map) { @@ -62,7 +62,7 @@ private static Dictionary ToItem_Level3(global::MyNamesp new Dictionary(3) .SetString("id", level3.Id, false, true) .SetInt("value", level3.Value, false, true) - .Set("level4Data", level3.Level4Data is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Level4(level3.Level4Data) }); + .SetIfNotNull("level4Data", level3.Level4Data, value => new AttributeValue { M = ToItem_Level4(value) }); private static global::MyNamespace.Level3 FromItem_Level3(Dictionary map) { diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline#OrderMapper.g.verified.cs index 5c477ab..793c36a 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline#OrderMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class OrderMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("billingAddress", source.BillingAddress is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Address(source.BillingAddress) }); + .SetIfNotNull("billingAddress", source.BillingAddress, value => new AttributeValue { M = ToItem_Address(value) }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValues#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValues#OrderMapper.g.verified.cs new file mode 100644 index 0000000..793c36a --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValues#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) + .SetIfNotNull("billingAddress", source.BillingAddress, value => new AttributeValue { M = ToItem_Address(value) }); + + [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), + BillingAddress = item.TryGetValue("billingAddress", out var billingaddressAttr) && billingaddressAttr.M is { } billingaddressMap ? FromItem_Address(billingaddressMap) : null, + }; + return order; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_Address(global::MyNamespace.Address address) => + new Dictionary(2) + .SetString("line1", address.Line1, false, true) + .SetString("city", address.City, false, true); + + private static global::MyNamespace.Address FromItem_Address(Dictionary map) + { + return new global::MyNamespace.Address + { + Line1 = map.GetString("line1", Requiredness.Optional), + City = map.GetString("city", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValuesFalse#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValuesFalse#OrderMapper.g.verified.cs new file mode 100644 index 0000000..c822c7e --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableInline_OmitNullValuesFalse#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, false) + .Set("billingAddress", source.BillingAddress is null ? new AttributeValue { NULL = true } : new AttributeValue { M = ToItem_Address(source.BillingAddress) }); + + [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), + BillingAddress = item.TryGetValue("billingAddress", out var billingaddressAttr) && billingaddressAttr.M is { } billingaddressMap ? FromItem_Address(billingaddressMap) : null, + }; + return order; + } + + // Helper methods for nested object mapping + + private static Dictionary ToItem_Address(global::MyNamespace.Address address) => + new Dictionary(2) + .SetString("line1", address.Line1, false, false) + .SetString("city", address.City, false, false); + + private static global::MyNamespace.Address FromItem_Address(Dictionary map) + { + return new global::MyNamespace.Address + { + Line1 = map.GetString("line1", Requiredness.Optional), + City = map.GetString("city", Requiredness.Optional), + }; + } + +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableMapperBased#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableMapperBased#OrderMapper.g.verified.cs index 8a0d4d8..714f3e6 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableMapperBased#OrderMapper.g.verified.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/NestedObjectVerifyTests.NestedObject_NullableMapperBased#OrderMapper.g.verified.cs @@ -23,7 +23,7 @@ public static partial class OrderMapper public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => new Dictionary(2) .SetString("id", source.Id, false, true) - .Set("billingAddress", source.BillingAddress is null ? new AttributeValue { NULL = true } : new AttributeValue { M = global::MyNamespace.AddressMapper.ToItem(source.BillingAddress) }); + .SetIfNotNull("billingAddress", source.BillingAddress, value => new AttributeValue { M = global::MyNamespace.AddressMapper.ToItem(value) }); [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ExplicitOmitNullValuesFalse_PreservesNullsForNullableHelpers#ExampleEntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ExplicitOmitNullValuesFalse_PreservesNullsForNullableHelpers#ExampleEntityMapper.g.verified.cs new file mode 100644 index 0000000..3cea95d --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/SimpleVerifyTests.Simple_ExplicitOmitNullValuesFalse_PreservesNullsForNullableHelpers#ExampleEntityMapper.g.verified.cs @@ -0,0 +1,36 @@ +//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.ExampleEntity source) => + new Dictionary(1) + .SetBool("nullableBool", source.NullableBool, false, false); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.ExampleEntity FromItem(global::System.Collections.Generic.Dictionary item) + { + var exampleEntity = new global::MyNamespace.ExampleEntity + { + NullableBool = item.GetNullableBool("nullableBool", Requiredness.InferFromNullability), + }; + return exampleEntity; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.DictionaryTests.cs b/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.DictionaryTests.cs new file mode 100644 index 0000000..8f196ec --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Runtime.Tests/AttributeValueExtensions.DictionaryTests.cs @@ -0,0 +1,35 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Runtime.Tests; + +public class AttributeValueExtensionsDictionaryTests +{ + [Fact] + public void SetIfNotNull_SetsAttribute_WhenValueIsNotNull() + { + var attributes = new Dictionary(); + + var result = + attributes.SetIfNotNull("name", "value", value => new AttributeValue { S = value }); + + result.Should().BeSameAs(attributes); + attributes.Should().ContainKey("name"); + attributes["name"].S.Should().Be("value"); + } + + [Fact] + public void SetIfNotNull_OmitsAttribute_WhenValueIsNull() + { + var attributes = new Dictionary(); + + var result = + attributes.SetIfNotNull( + "name", + null, + value => new AttributeValue { S = value } + ); + + result.Should().BeSameAs(attributes); + attributes.Should().BeEmpty(); + } +}