From 37090578d1e4b9d591d09a710862eaa3ce2a96ea Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 09:46:20 -0400 Subject: [PATCH 01/26] feat(client): add DynamoMapper client project - Added `LayeredCraft.DynamoMapper.Client` project with initial setup. - Defined `IDynamoMapper` interface for mapping DTOs to/from DynamoDB items. - Integrated `AWSSDK.DynamoDBv2` package as a dependency. - Updated solution file to include the new project. --- LayeredCraft.DynamoMapper.slnx | 1 + .../IDynamoMapper.cs | 18 ++++++++++++++++++ .../LayeredCraft.DynamoMapper.Client.csproj | 15 +++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj diff --git a/LayeredCraft.DynamoMapper.slnx b/LayeredCraft.DynamoMapper.slnx index 7fecd2d..c559435 100644 --- a/LayeredCraft.DynamoMapper.slnx +++ b/LayeredCraft.DynamoMapper.slnx @@ -76,6 +76,7 @@ + diff --git a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs new file mode 100644 index 0000000..79fd4a9 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs @@ -0,0 +1,18 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoClient; + +/// Maps a DTO to and from a DynamoDB item representation. +/// The DTO type handled by the mapper. +public interface IDynamoMapper where TDto : class +{ + /// Converts a DTO instance into a DynamoDB item. + /// The DTO instance to convert. + /// A DynamoDB item keyed by attribute name. + static abstract Dictionary ToItem(TDto source); + + /// Creates a DTO instance from a DynamoDB item. + /// The DynamoDB item to convert. + /// The DTO created from the item. + static abstract TDto FromItem(Dictionary item); +} diff --git a/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj new file mode 100644 index 0000000..7b7e156 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + 14 + enable + enable + true + + + + + + + From 9583afbe6e0cb1124e102889eb9e5bcea64a5cb2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 11:41:43 -0400 Subject: [PATCH 02/26] chore(config): clean up and streamline .DotSettings file - Removed redundant configurations and entries in `.DotSettings`. - Improved formatting and alignment for settings keys. - Simplified the file structure by reducing unnecessary verbosity. --- LayeredCraft.DynamoMapper.sln.DotSettings | 165 ++++++---------------- 1 file changed, 42 insertions(+), 123 deletions(-) diff --git a/LayeredCraft.DynamoMapper.sln.DotSettings b/LayeredCraft.DynamoMapper.sln.DotSettings index 6f48e3e..ca99829 100644 --- a/LayeredCraft.DynamoMapper.sln.DotSettings +++ b/LayeredCraft.DynamoMapper.sln.DotSettings @@ -1,143 +1,62 @@ - - DO_NOT_SHOW - <?xml version="1.0" encoding="utf-16"?><Profile name="Full Custom Cleanup"><CppReformatCode>True</CppReformatCode><FSharpReformatCode>True</FSharpReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><XMLReformatCode>True</XMLReformatCode><VBReformatCode>True</VBReformatCode><CSReformatCode>True</CSReformatCode><CSharpReformatComments>True</CSharpReformatComments><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CppCodeStyleCleanupDescriptor ArrangeBraces="True" ArrangeAuto="True" ArrangeFunctionDeclarations="True" ArrangeNestedNamespaces="True" ArrangeTypeAliases="True" ArrangeCVQualifiers="True" ArrangeSlashesInIncludeDirectives="True" ArrangeOverridingFunctions="True" SortDefinitions="True" SortIncludeDirectives="True" SortMemberInitializers="True" /><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><Xaml.RemoveRedundantNamespaceAlias>True</Xaml.RemoveRedundantNamespaceAlias><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CppAddTypenameTemplateKeywords>True</CppAddTypenameTemplateKeywords><CppCStyleToStaticCastDescriptor>True</CppCStyleToStaticCastDescriptor><CppRedundantDereferences>True</CppRedundantDereferences><CppDeleteRedundantAccessSpecifier>True</CppDeleteRedundantAccessSpecifier><CppRemoveCastDescriptor>True</CppRemoveCastDescriptor><CppRemoveElseKeyword>True</CppRemoveElseKeyword><CppShortenQualifiedName>True</CppShortenQualifiedName><CppDeleteRedundantSpecifier>True</CppDeleteRedundantSpecifier><CppRemoveStatement>True</CppRemoveStatement><CppDeleteRedundantTypenameTemplateKeywords>True</CppDeleteRedundantTypenameTemplateKeywords><CppReplaceExpressionWithBooleanConst>True</CppReplaceExpressionWithBooleanConst><CppMakeIfConstexpr>True</CppMakeIfConstexpr><CppMakePostfixOperatorPrefix>True</CppMakePostfixOperatorPrefix><CppMakeVariableConstexpr>True</CppMakeVariableConstexpr><CppChangeSmartPointerToMakeFunction>True</CppChangeSmartPointerToMakeFunction><CppReplaceThrowWithRethrowFix>True</CppReplaceThrowWithRethrowFix><CppTypeTraitAliasDescriptor>True</CppTypeTraitAliasDescriptor><CppRemoveRedundantConditionalExpressionDescriptor>True</CppRemoveRedundantConditionalExpressionDescriptor><CppSimplifyConditionalExpressionDescriptor>True</CppSimplifyConditionalExpressionDescriptor><CppReplaceExpressionWithNullptr>True</CppReplaceExpressionWithNullptr><CppReplaceTieWithStructuredBindingDescriptor>True</CppReplaceTieWithStructuredBindingDescriptor><CppUseAssociativeContainsDescriptor>True</CppUseAssociativeContainsDescriptor><CppUseEraseAlgorithmDescriptor>True</CppUseEraseAlgorithmDescriptor><CppJoinDeclarationAndAssignmentDescriptor>True</CppJoinDeclarationAndAssignmentDescriptor><CppMakeClassFinal>True</CppMakeClassFinal><CppMakeLocalVarConstDescriptor>True</CppMakeLocalVarConstDescriptor><CppMakeMethodConst>True</CppMakeMethodConst><CppMakeMethodStatic>True</CppMakeMethodStatic><CppMakePtrOrRefParameterConst>True</CppMakePtrOrRefParameterConst><CppMakeParameterConst>True</CppMakeParameterConst><CppPassValueParameterByConstReference>True</CppPassValueParameterByConstReference><CppRemoveElaboratedTypeSpecifierDescriptor>True</CppRemoveElaboratedTypeSpecifierDescriptor><CppRemoveRedundantLambdaParameterListDescriptor>True</CppRemoveRedundantLambdaParameterListDescriptor><CppRemoveRedundantMemberInitializerDescriptor>True</CppRemoveRedundantMemberInitializerDescriptor><CppRemoveRedundantParentheses>True</CppRemoveRedundantParentheses><CppRemoveTemplateArgumentsDescriptor>True</CppRemoveTemplateArgumentsDescriptor><CppRemoveUnreachableCode>True</CppRemoveUnreachableCode><CppRemoveUnusedIncludes>True</CppRemoveUnusedIncludes><CppRemoveUnusedLambdaCaptures>True</CppRemoveUnusedLambdaCaptures><CppReplaceIfWithIfConsteval>True</CppReplaceIfWithIfConsteval><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><VBMakeFieldReadonly>True</VBMakeFieldReadonly><Xaml.RedundantFreezeAttribute>True</Xaml.RedundantFreezeAttribute><Xaml.RemoveRedundantModifiersAttribute>True</Xaml.RemoveRedundantModifiersAttribute><Xaml.RemoveRedundantNameAttribute>True</Xaml.RemoveRedundantNameAttribute><Xaml.RemoveRedundantResource>True</Xaml.RemoveRedundantResource><Xaml.RemoveRedundantCollectionProperty>True</Xaml.RemoveRedundantCollectionProperty><Xaml.RemoveRedundantAttachedPropertySetter>True</Xaml.RemoveRedundantAttachedPropertySetter><Xaml.RemoveRedundantStyledValue>True</Xaml.RemoveRedundantStyledValue><Xaml.RemoveForbiddenResourceName>True</Xaml.RemoveForbiddenResourceName><Xaml.RemoveRedundantGridDefinitionsAttribute>True</Xaml.RemoveRedundantGridDefinitionsAttribute><Xaml.RemoveRedundantUpdateSourceTriggerAttribute>True</Xaml.RemoveRedundantUpdateSourceTriggerAttribute><Xaml.RemoveRedundantBindingModeAttribute>True</Xaml.RemoveRedundantBindingModeAttribute><Xaml.RemoveRedundantGridSpanAttribut>True</Xaml.RemoveRedundantGridSpanAttribut><IDEA_SETTINGS>&lt;profile version="1.0"&gt; - &lt;option name="myName" value="Full Custom Cleanup" /&gt; - &lt;inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="UnterminatedStatementJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; -&lt;/profile&gt;</IDEA_SETTINGS><RIDER_SETTINGS>&lt;profile&gt; - &lt;Language id="CSS"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="EditorConfig"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="HCL"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="HTML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="HTTP Request"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Handlebars"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Ini"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="JSON"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Jade"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="JavaScript"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="Markdown"&gt; - &lt;Reformat&gt;false&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Mermaid"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="PowerShell"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Properties"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="RELAX-NG"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Razor"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="SQL"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="TOML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="VueExpr"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="XML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="liquid"&gt; - &lt;Reformat&gt;false&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="yaml"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; -&lt;/profile&gt;</RIDER_SETTINGS><CSharpFormatDocComments>True</CSharpFormatDocComments></Profile> - Built-in: Full Cleanup - NotRequired - NotRequired - NotRequired - NotRequired - ExpressionBody - ExpressionBody - ExpressionBody - True - False - False + + NotRequiredForBoth + True + False + False + False + TOGETHER_SAME_LINE + True + True + True + True + INSIDE + 1 + 1 + False + False False False False False + False + False False False - True - 1 - ALWAYS - ALWAYS - ALWAYS + 0 + True + NEVER + NEVER + ALWAYS False NEVER - False - False - ALWAYS_IF_MULTILINE + STRONGLY + False True True + True CHOP_IF_LONG - False - True + CHOP_IF_LONG + True + True True + True True - True - True + True + CHOP_IF_LONG + CHOP_IF_LONG CHOP_IF_LONG True CHOP_IF_LONG CHOP_IF_LONG - 2 - False - False - 2 - ByFirstAttr - True - False - True - False - False - True - False - False - True + CHOP_IF_LONG + 100 True True True - True - True \ No newline at end of file + True \ No newline at end of file From fccc662e338b5e238bc7f1fd0f0a62c0ec264493 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 11:57:58 -0400 Subject: [PATCH 03/26] feat(client): implement DynamoClient with mapper support - Added `DynamoClient` to interact with DynamoDB using registered mappers. - Introduced `DynamoClientBuilder` for flexible client configuration. - Updated `IDynamoMapper` interface to remove static methods and simplify implementation. --- .../DynamoClient.cs | 36 +++++++++++++++++++ .../DynamoClientBuilder.cs | 30 ++++++++++++++++ .../IDynamoMapper.cs | 6 ++-- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs new file mode 100644 index 0000000..50230c6 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoClient; + +public class DynamoClient +{ + private readonly ImmutableDictionary _mappers; + private readonly IAmazonDynamoDB _dynamoDbClient; + + internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB dynamoDbClient) + { + ArgumentNullException.ThrowIfNull(dynamoDbClient); + + _mappers = mappers; + _dynamoDbClient = dynamoDbClient; + } + + public IDynamoMapper GetMapper() + { + if (_mappers.TryGetValue(typeof(T), out var mapper)) + return (IDynamoMapper)mapper; + + throw new InvalidOperationException($"No mapper found for type {typeof(T)}"); + } + + public T? GetItemAsync( + string tableName, + Dictionary key, + CancellationToken cancellationToken = default) + { + var result = _dynamoDbClient.GetItemAsync(tableName, key, cancellationToken); + return result is null ? default : GetMapper().FromItem(result.Result.Item); + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs new file mode 100644 index 0000000..562026b --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; +using Amazon.DynamoDBv2; + +namespace LayeredCraft.DynamoClient; + +public class DynamoClientBuilder +{ + private readonly Dictionary _mappers = new(); + private IAmazonDynamoDB? _dynamoDbClient; + + public DynamoClientBuilder WithMapper() + where TMapper : class, IDynamoMapper, new() + { + _mappers[typeof(TDto)] = new TMapper(); + return this; + } + + public DynamoClientBuilder WithAmazonDynamoDB(IAmazonDynamoDB dynamoDbClient) + { + _dynamoDbClient = dynamoDbClient; + return this; + } + + public DynamoClient Build() + { + _dynamoDbClient ??= new AmazonDynamoDBClient(); + + return new DynamoClient(_mappers.ToImmutableDictionary(), _dynamoDbClient); + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs index 79fd4a9..1758f11 100644 --- a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs +++ b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs @@ -4,15 +4,15 @@ namespace LayeredCraft.DynamoClient; /// Maps a DTO to and from a DynamoDB item representation. /// The DTO type handled by the mapper. -public interface IDynamoMapper where TDto : class +public interface IDynamoMapper { /// Converts a DTO instance into a DynamoDB item. /// The DTO instance to convert. /// A DynamoDB item keyed by attribute name. - static abstract Dictionary ToItem(TDto source); + Dictionary ToItem(TDto source); /// Creates a DTO instance from a DynamoDB item. /// The DynamoDB item to convert. /// The DTO created from the item. - static abstract TDto FromItem(Dictionary item); + TDto FromItem(Dictionary item); } From 4578a5480efbbf743eab4ec0d4049eff459e9517 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 13:10:22 -0400 Subject: [PATCH 04/26] refactor(client): update namespace to align with DynamoMapper structure - Renamed `LayeredCraft.DynamoClient` namespace to `LayeredCraft.DynamoMapper.Client`. - Updated references in `IDynamoMapper`, `DynamoClient`, and `DynamoClientBuilder`. --- src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs | 2 +- src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs | 2 +- src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 50230c6..3a157f1 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -2,7 +2,7 @@ using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; -namespace LayeredCraft.DynamoClient; +namespace LayeredCraft.DynamoMapper.Client; public class DynamoClient { diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs index 562026b..a477140 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Amazon.DynamoDBv2; -namespace LayeredCraft.DynamoClient; +namespace LayeredCraft.DynamoMapper.Client; public class DynamoClientBuilder { diff --git a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs index 1758f11..db046b7 100644 --- a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs +++ b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs @@ -1,6 +1,6 @@ using Amazon.DynamoDBv2.Model; -namespace LayeredCraft.DynamoClient; +namespace LayeredCraft.DynamoMapper.Client; /// Maps a DTO to and from a DynamoDB item representation. /// The DTO type handled by the mapper. From 2b9c65dec91fe1f39deeb4df1b51aa5043f46cb6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 13:15:57 -0400 Subject: [PATCH 05/26] refactor(client): replace private field with public property for DynamoDB client - Updated `_dynamoDbClient` private field to `AmazonDynamoDb` public property. - Replaced references to `_dynamoDbClient` with `AmazonDynamoDb` in methods. --- src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 3a157f1..4f400ff 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -7,14 +7,15 @@ namespace LayeredCraft.DynamoMapper.Client; public class DynamoClient { private readonly ImmutableDictionary _mappers; - private readonly IAmazonDynamoDB _dynamoDbClient; + + public IAmazonDynamoDB AmazonDynamoDb { get; } internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB dynamoDbClient) { ArgumentNullException.ThrowIfNull(dynamoDbClient); _mappers = mappers; - _dynamoDbClient = dynamoDbClient; + AmazonDynamoDb = dynamoDbClient; } public IDynamoMapper GetMapper() @@ -30,7 +31,7 @@ public IDynamoMapper GetMapper() Dictionary key, CancellationToken cancellationToken = default) { - var result = _dynamoDbClient.GetItemAsync(tableName, key, cancellationToken); + var result = AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); return result is null ? default : GetMapper().FromItem(result.Result.Item); } } From 60b0ad18f13a69481f05e925db4cca187636d84d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 13:19:58 -0400 Subject: [PATCH 06/26] refactor(client): update GetItemAsync method to use async/await - Changed `GetItemAsync` to return `Task` and use `await` for asynchronous processing. - Improved null check logic by verifying `result.Item.Count` instead of relying on `Result`. --- src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 4f400ff..46bae24 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -26,12 +26,12 @@ public IDynamoMapper GetMapper() throw new InvalidOperationException($"No mapper found for type {typeof(T)}"); } - public T? GetItemAsync( + public async Task GetItemAsync( string tableName, Dictionary key, CancellationToken cancellationToken = default) { - var result = AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); - return result is null ? default : GetMapper().FromItem(result.Result.Item); + var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); + return result.Item.Count == 0 ? default : GetMapper().FromItem(result.Item); } } From d5b6c1e7379f6b0458e5effb278170069fd7f3d3 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 13:29:01 -0400 Subject: [PATCH 07/26] feat(client): add convenience methods and XML documentation to DynamoClient and its builder - Added XML documentation for all public methods and properties. - Introduced convenience methods in `DynamoClient`: `PutItemAsync`, `DeleteItemAsync`, `UpdateItemAsync`, `QueryAsync`, and `ScanAsync`. - Enhanced `DynamoClientBuilder` with descriptive summaries for method functionality. --- .../DynamoClient.cs | 91 +++++++++++++++++++ .../DynamoClientBuilder.cs | 10 ++ 2 files changed, 101 insertions(+) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 46bae24..0e9293b 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -4,10 +4,15 @@ namespace LayeredCraft.DynamoMapper.Client; +/// +/// Provides typed convenience methods for reading and writing DynamoDB items through +/// registered mappers. +/// public class DynamoClient { private readonly ImmutableDictionary _mappers; + /// Gets the underlying DynamoDB client used for all requests. public IAmazonDynamoDB AmazonDynamoDb { get; } internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB dynamoDbClient) @@ -18,6 +23,13 @@ internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB AmazonDynamoDb = dynamoDbClient; } + /// Gets the mapper registered for the specified DTO type. + /// The DTO type to retrieve a mapper for. + /// The mapper registered for . + /// + /// Thrown when no mapper has been registered for + /// . + /// public IDynamoMapper GetMapper() { if (_mappers.TryGetValue(typeof(T), out var mapper)) @@ -26,6 +38,15 @@ public IDynamoMapper GetMapper() throw new InvalidOperationException($"No mapper found for type {typeof(T)}"); } + /// Retrieves a single item by key and maps it to the specified DTO type. + /// The DTO type to map the item to. + /// The DynamoDB table name. + /// The primary key of the item to retrieve. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the mapped DTO when an item is found; otherwise, + /// . + /// public async Task GetItemAsync( string tableName, Dictionary key, @@ -34,4 +55,74 @@ public IDynamoMapper GetMapper() var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); return result.Item.Count == 0 ? default : GetMapper().FromItem(result.Item); } + + /// Saves a mapped DTO to the specified table. + /// The DTO type to write. + /// The DynamoDB table name. + /// The DTO instance to map and save. + /// The cancellation token for the asynchronous operation. + /// A task that completes when the item has been written. + public Task PutItemAsync( + string tableName, + T item, + CancellationToken cancellationToken = default) + { + var mappedItem = GetMapper().ToItem(item); + return AmazonDynamoDb.PutItemAsync(tableName, mappedItem, cancellationToken); + } + + /// Deletes a single item by key from the specified table. + /// The DynamoDB table name. + /// The primary key of the item to delete. + /// The cancellation token for the asynchronous operation. + /// A task that completes when the delete request has finished. + public Task DeleteItemAsync( + string tableName, + Dictionary key, + CancellationToken cancellationToken = default) + => AmazonDynamoDb.DeleteItemAsync(tableName, key, cancellationToken); + + /// Executes an update request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The update request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the mapped DTO when the request returns attributes; otherwise, + /// . + /// + public async Task UpdateItemAsync( + UpdateItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); + return result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + } + + /// Executes a query request and maps each returned item to the specified DTO type. + /// The DTO type to map the query results to. + /// The query request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped query results. + public async Task> QueryAsync( + QueryRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.QueryAsync(request, cancellationToken); + var mapper = GetMapper(); + return result.Items.Select(mapper.FromItem).ToArray(); + } + + /// Executes a scan request and maps each returned item to the specified DTO type. + /// The DTO type to map the scan results to. + /// The scan request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped scan results. + public async Task> ScanAsync( + ScanRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.ScanAsync(request, cancellationToken); + var mapper = GetMapper(); + return result.Items.Select(mapper.FromItem).ToArray(); + } } diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs index a477140..08ab32c 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs @@ -3,11 +3,16 @@ namespace LayeredCraft.DynamoMapper.Client; +/// Builds a with registered mappers and a DynamoDB client. public class DynamoClientBuilder { private readonly Dictionary _mappers = new(); private IAmazonDynamoDB? _dynamoDbClient; + /// Registers a mapper for the specified DTO type. + /// The DTO type handled by the mapper. + /// The mapper type to instantiate and register. + /// The current builder instance. public DynamoClientBuilder WithMapper() where TMapper : class, IDynamoMapper, new() { @@ -15,12 +20,17 @@ public DynamoClientBuilder WithMapper() return this; } + /// Uses the specified DynamoDB client when building the . + /// The DynamoDB client instance to use. + /// The current builder instance. public DynamoClientBuilder WithAmazonDynamoDB(IAmazonDynamoDB dynamoDbClient) { _dynamoDbClient = dynamoDbClient; return this; } + /// Builds a from the configured mappers and DynamoDB client. + /// A configured instance. public DynamoClient Build() { _dynamoDbClient ??= new AmazonDynamoDBClient(); From 29741653917426edf444cda9a77b9caeac6b85db Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 13:36:13 -0400 Subject: [PATCH 08/26] chore(client): suppress ReSharper warning for public members - Disabled `MemberCanBePrivate.Global` inspection in `DynamoClient.cs` to improve readability. --- src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 0e9293b..6f652f1 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -2,6 +2,8 @@ using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; +// ReSharper disable MemberCanBePrivate.Global + namespace LayeredCraft.DynamoMapper.Client; /// From 7c5e834d7da329c057041d500bb8edd7545636a9 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 14:39:21 -0400 Subject: [PATCH 09/26] feat(test): add unit test project for DynamoMapper Client - Added `LayeredCraft.DynamoMapper.Client.Tests` project with initial setup. - Included `AWSSDK.DynamoDBv2` and `xUnit` packages as dependencies. - Updated solution file to reference the new test project. - Configured `xunit.runner.json` for test execution. - Added project references for required dependencies in the test project. --- LayeredCraft.DynamoMapper.slnx | 2 ++ ...eredCraft.DynamoMapper.Client.Tests.csproj | 31 +++++++++++++++++++ .../xunit.runner.json | 3 ++ 3 files changed, 36 insertions(+) create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json diff --git a/LayeredCraft.DynamoMapper.slnx b/LayeredCraft.DynamoMapper.slnx index c559435..88876aa 100644 --- a/LayeredCraft.DynamoMapper.slnx +++ b/LayeredCraft.DynamoMapper.slnx @@ -82,6 +82,8 @@ + diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj new file mode 100644 index 0000000..ea3f74b --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + \ No newline at end of file diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json new file mode 100644 index 0000000..c2f8426 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} From 94dc4e38b1f6fa721f3e3046e30ad41680567216 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 15:39:07 -0400 Subject: [PATCH 10/26] feat(skills): add support for DynamoMapper skill - Created `.claude/skills/dynamo-mapper` directory to enable skill integration. - Introduced initial setup for DynamoMapper skill in the project. --- .claude/skills/dynamo-mapper | 1 + 1 file changed, 1 insertion(+) create mode 120000 .claude/skills/dynamo-mapper diff --git a/.claude/skills/dynamo-mapper b/.claude/skills/dynamo-mapper new file mode 120000 index 0000000..8b80185 --- /dev/null +++ b/.claude/skills/dynamo-mapper @@ -0,0 +1 @@ +../../skills/dynamo-mapper \ No newline at end of file From 86c7478b839566eca193e50bf1e8b99827be3338 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 15:40:42 -0400 Subject: [PATCH 11/26] feat(skills): add git-workflow automation skill - Added `.agents/skills/git-workflow` directory with documentation, examples, templates, and shared logic. - Introduced workflows for branching, committing, and pull requests, adhering to conventional commits. - Defined `Skill` metadata in `SKILL.md` for intent-specific routing and execution. - Included safety rules, scope detection, and file inclusion policy for robust and transparent handling. - Configured templates for pull requests, release notes, and validation reporting. - Created initial examples for feature, fix, and CI scenarios. --- .agents/skills/git-workflow/SKILL.md | 53 ++++++++ .agents/skills/git-workflow/docs/branch.md | 63 +++++++++ .agents/skills/git-workflow/docs/commit.md | 47 +++++++ .agents/skills/git-workflow/docs/pr.md | 123 ++++++++++++++++++ .../git-workflow/examples/ci-example.md | 17 +++ .../git-workflow/examples/feature-example.md | 52 ++++++++ .../git-workflow/examples/fix-example.md | 17 +++ .../git-workflow/shared/conventional-types.md | 36 +++++ .../shared/file-inclusion-policy.md | 56 ++++++++ .../git-workflow/shared/safety-rules.md | 13 ++ .../git-workflow/shared/scope-detection.md | 20 +++ .../templates/pull-request-template.md | 54 ++++++++ .../templates/release-notes-template.md | 17 +++ .claude/skills/git-workflow | 1 + skills-lock.json | 10 ++ 15 files changed, 579 insertions(+) create mode 100644 .agents/skills/git-workflow/SKILL.md create mode 100644 .agents/skills/git-workflow/docs/branch.md create mode 100644 .agents/skills/git-workflow/docs/commit.md create mode 100644 .agents/skills/git-workflow/docs/pr.md create mode 100644 .agents/skills/git-workflow/examples/ci-example.md create mode 100644 .agents/skills/git-workflow/examples/feature-example.md create mode 100644 .agents/skills/git-workflow/examples/fix-example.md create mode 100644 .agents/skills/git-workflow/shared/conventional-types.md create mode 100644 .agents/skills/git-workflow/shared/file-inclusion-policy.md create mode 100644 .agents/skills/git-workflow/shared/safety-rules.md create mode 100644 .agents/skills/git-workflow/shared/scope-detection.md create mode 100644 .agents/skills/git-workflow/templates/pull-request-template.md create mode 100644 .agents/skills/git-workflow/templates/release-notes-template.md create mode 120000 .claude/skills/git-workflow create mode 100644 skills-lock.json diff --git a/.agents/skills/git-workflow/SKILL.md b/.agents/skills/git-workflow/SKILL.md new file mode 100644 index 0000000..8c1db88 --- /dev/null +++ b/.agents/skills/git-workflow/SKILL.md @@ -0,0 +1,53 @@ +--- +name: git-workflow +description: >- + Git workflow automation for committing, branching, and opening pull requests. + Use this whenever the user asks to commit their work, create a branch, or + create/open/draft a PR. +--- + +# Git Workflow + +Use this skill whenever the user asks to: + +- commit these changes +- create a commit +- save my work +- stage and commit +- commit current work +- create a branch +- start a feature branch +- make a branch for this work +- start working on a change +- create a PR +- open a PR +- draft a PR +- prepare a pull request +- commit and open a PR +- create a branch and PR +- submit the current work + +______________________________________________________________________ + +## Shared references + +Before executing any workflow, load all four shared references: + +- [Scope Detection](shared/scope-detection.md) +- [File Inclusion Policy](shared/file-inclusion-policy.md) +- [Safety Rules](shared/safety-rules.md) +- [Conventional Types](shared/conventional-types.md) + +______________________________________________________________________ + +## Intent routing + +Based on the user's request, load exactly one workflow doc: + +| User intent | Load | +| ----------------------------------------------- | -------------------------------- | +| Commit work, save changes, stage and commit | [docs/commit.md](docs/commit.md) | +| Create a branch, start a feature branch | [docs/branch.md](docs/branch.md) | +| Create/open/draft a PR, submit the current work | [docs/pr.md](docs/pr.md) | + +When intent is ambiguous, prefer the more complete workflow. If the user says "commit and open a PR", load `docs/pr.md` — it covers the full lifecycle including commit and branch. diff --git a/.agents/skills/git-workflow/docs/branch.md b/.agents/skills/git-workflow/docs/branch.md new file mode 100644 index 0000000..77641a6 --- /dev/null +++ b/.agents/skills/git-workflow/docs/branch.md @@ -0,0 +1,63 @@ +# Branch Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [Conventional Types](../shared/conventional-types.md) +- [Safety Rules](../shared/safety-rules.md) + +______________________________________________________________________ + +## Goal + +Create a properly named branch for the current work based on inferred change intent, then switch to it. + +## Branch naming format + +``` +/- +``` + +If no scope applies: + +``` +/ +``` + +Rules: + +- lowercase only +- hyphen-separated +- concise and descriptive +- remove punctuation + +Examples: + +- `feat/core-add-pr-automation` +- `fix/github-handle-detached-head` +- `docs/update-readme` +- `ci/github-improve-release-workflow` + +## Workflow + +1. Inspect repository status and changed files +2. Infer change type (see [Conventional Types](../shared/conventional-types.md)) +3. Infer optional scope (see [Scope Detection](../shared/scope-detection.md)) +4. Generate branch name +5. Create the branch +6. Switch to the branch + +## Branch-specific safety + +If a branch with the same name already exists, append a short numeric suffix (e.g. `-2`) rather than overwriting it. + +See also [Safety Rules](../shared/safety-rules.md) for general constraints. + +## Output + +Report: + +- branch name created +- branch switched to diff --git a/.agents/skills/git-workflow/docs/commit.md b/.agents/skills/git-workflow/docs/commit.md new file mode 100644 index 0000000..07785f7 --- /dev/null +++ b/.agents/skills/git-workflow/docs/commit.md @@ -0,0 +1,47 @@ +# Commit Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [File Inclusion Policy](../shared/file-inclusion-policy.md) +- [Safety Rules](../shared/safety-rules.md) +- [Conventional Types](../shared/conventional-types.md) + +______________________________________________________________________ + +## Goal + +Create a commit representing the user's current working changes using a conventional commit format. + +## Commit format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +The description must immediately follow the colon and space. Scope is wrapped in parentheses when present: `feat(parser): add CSV support`. + +For breaking changes, append `!` after the type/scope and/or include a `BREAKING CHANGE:` footer. See [Conventional Types](../shared/conventional-types.md) for details. + +## Workflow + +1. Inspect repository status +2. Identify all modified files +3. Stage all user-modified files (see [File Inclusion Policy](../shared/file-inclusion-policy.md)) +4. Exclude only obvious junk artifacts +5. Infer `` and `` (see [Conventional Types](../shared/conventional-types.md) and [Scope Detection](../shared/scope-detection.md)) +6. Generate and create the commit + +## Output + +Report: + +- commit message used +- files committed +- any files excluded and why diff --git a/.agents/skills/git-workflow/docs/pr.md b/.agents/skills/git-workflow/docs/pr.md new file mode 100644 index 0000000..a88e341 --- /dev/null +++ b/.agents/skills/git-workflow/docs/pr.md @@ -0,0 +1,123 @@ +# PR Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [File Inclusion Policy](../shared/file-inclusion-policy.md) +- [Safety Rules](../shared/safety-rules.md) +- [Conventional Types](../shared/conventional-types.md) + +______________________________________________________________________ + +## Goal + +Prepare the current work for review and create a pull request that includes: + +- a correctly named branch +- a conventional commit message +- a PR title following the required format +- a reviewable PR body that explains what changed, why, validation, and risk + +Template location: `../templates/pull-request-template.md` + +______________________________________________________________________ + +## PR title format + +``` +[optional scope]: +``` + +For breaking changes, append `!` after the type/scope: `feat(api)!: remove deprecated endpoint` + +Example: `feat(core): add automated PR workflow` + +______________________________________________________________________ + +## Branch rules + +Create a new branch if: + +- the current branch is `main` +- the repository is in detached `HEAD` + +If already on a feature branch, use the current branch. + +Branch naming follows `/-` (or `/` when no scope applies). See [Branch Workflow](branch.md) for full naming rules. + +______________________________________________________________________ + +## Execution flow + +### 1 — Inspect repository + +Determine: current branch, whether HEAD is detached, git status, modified files, diff summary, and commit history against the base branch. + +### 2 — Infer metadata + +Determine: PR type, optional scope, short description, PR title, branch name. + +### 3 — Prepare branch + +If on `main` or detached `HEAD`, create a new branch and switch to it. Otherwise stay on the current branch. + +### 4 — Commit work + +Stage all user-modified files per [File Inclusion Policy](../shared/file-inclusion-policy.md). Exclude only obvious junk. Create commit. Skip if nothing to commit. + +### 5 — Push branch + +Push to origin. Set upstream if necessary. + +### 6 — Generate PR body + +Load `../templates/pull-request-template.md` and adapt it to the actual change. + +Treat the template as a default outline, not a rigid contract. Prioritize reviewer scanability and signal quality over filling every heading. + +Required information: + +- what changed +- why it changed +- how it was validated + +Default outline (adapt as needed): + +- Summary - 2-4 sentences covering what changed and why +- Changes - grouped in the way that makes the diff easiest to review (for example by concern, subsystem, workflow, or user impact) +- Validation - concrete tests, manual verification, and confidence signals +- Breaking Changes - include only when applicable +- Related Issues - include only when applicable; do not invent issue numbers +- Release Notes - include only for user-visible or package-relevant changes +- Notes for Reviewers - include when review guidance, risks, tradeoffs, follow-up context, or requested feedback focus would help; for UI changes, include screenshots/video links when useful + +Review mode: + +- open as draft when implementation is incomplete, checks are pending, or early feedback is requested +- when draft, state what is incomplete and what feedback is being requested + +Rules: + +- omit empty sections entirely (do not include `N/A`, `None`, or `No related issues`) +- prefer fewer, high-signal sections over boilerplate +- use backticks for identifiers, commands, files, and code terms +- keep the Summary concise and focused on intent, not file-by-file trivia + +### 7 — Create PR + +Create the pull request using the generated title and body, as draft or ready-for-review based on the review mode rules above. + +______________________________________________________________________ + +## Output + +Report: + +- branch name and whether it was created +- commit message and whether a commit was created +- PR title +- PR body +- any files excluded and why +- any assumptions or blockers diff --git a/.agents/skills/git-workflow/examples/ci-example.md b/.agents/skills/git-workflow/examples/ci-example.md new file mode 100644 index 0000000..cf03352 --- /dev/null +++ b/.agents/skills/git-workflow/examples/ci-example.md @@ -0,0 +1,17 @@ +# Example: CI PR + +## Scenario + +Current work updates GitHub Actions and release automation for NuGet publishing. + +## Expected branch + +`ci/github-improve-nuget-release-workflow` + +## Expected commit + +`ci(github): improve NuGet release workflow` + +## Expected PR title + +`ci(github): improve NuGet release workflow` diff --git a/.agents/skills/git-workflow/examples/feature-example.md b/.agents/skills/git-workflow/examples/feature-example.md new file mode 100644 index 0000000..1cd8a99 --- /dev/null +++ b/.agents/skills/git-workflow/examples/feature-example.md @@ -0,0 +1,52 @@ +# Example: Feature PR + +## Scenario + +Current work adds automatic PR template loading and branch creation when running from `main`. + +## Expected branch + +`feat/core-automate-pr-workflow` + +## Expected commit + +`feat(core): automate PR workflow from main` + +## Expected PR title + +`feat(core): automate PR workflow from main` + +## Example PR body + +# 🚀 Pull Request + +## 📋 Summary + +> Adds automation for branch preparation and PR generation when opening a pull request from the current repository state. This removes manual branch setup when starting from `main` and keeps PR metadata generation consistent with inferred change intent. + +______________________________________________________________________ + +## 📝 Changes + +- Branch preparation flow + - Detects `main` and detached `HEAD` before PR creation + - Creates and switches to a generated branch only when needed +- Metadata and PR drafting + - Infers PR metadata (`type`, optional `scope`, short description) + - Loads the local PR template and builds the PR body from current repository state +- Workflow consistency + - Reuses shared scope and inclusion policy logic so commit and PR behavior stay aligned + +______________________________________________________________________ + +## 🧪 Validation + +- Build/test status: Not explicitly verified by the agent +- Manual verification performed: Reviewed repository status, branch behavior, and generated PR content paths +- Edge cases checked: Existing feature branch path and detached `HEAD` path + +______________________________________________________________________ + +## 💬 Notes for Reviewers + +> Please focus on branch creation guardrails and metadata inference fallbacks, especially when repository state is ambiguous. diff --git a/.agents/skills/git-workflow/examples/fix-example.md b/.agents/skills/git-workflow/examples/fix-example.md new file mode 100644 index 0000000..1586ac1 --- /dev/null +++ b/.agents/skills/git-workflow/examples/fix-example.md @@ -0,0 +1,17 @@ +# Example: Fix PR + +## Scenario + +Current work fixes a bug in GitHub workflow handling for detached HEAD repositories. + +## Expected branch + +`fix/github-handle-detached-head` + +## Expected commit + +`fix(github): handle detached HEAD when opening PRs` + +## Expected PR title + +`fix(github): handle detached HEAD when opening PRs` diff --git a/.agents/skills/git-workflow/shared/conventional-types.md b/.agents/skills/git-workflow/shared/conventional-types.md new file mode 100644 index 0000000..e1d4003 --- /dev/null +++ b/.agents/skills/git-workflow/shared/conventional-types.md @@ -0,0 +1,36 @@ +# Conventional Types + +Valid `` values: + +| Type | Description | SemVer impact | +| ---------- | ----------------------------------------------------------------- | ------------- | +| `feat` | Introduces a new feature | MINOR | +| `fix` | Patches a bug | PATCH | +| `build` | Changes to the build system or external dependencies | — | +| `chore` | Maintenance tasks not modifying src or test files | — | +| `ci` | Changes to CI/CD configuration or scripts | — | +| `docs` | Documentation changes only | — | +| `perf` | A code change that improves performance | — | +| `refactor` | A code change that neither fixes a bug nor adds a feature | — | +| `style` | Changes that do not affect meaning (whitespace, formatting, etc.) | — | +| `test` | Adding or updating tests | — | + +## Breaking changes + +A breaking change correlates with MAJOR in SemVer. Mark it in one of two ways: + +**Append `!` after the type/scope:** + +``` +feat(api)!: remove deprecated endpoint +``` + +**Or include a `BREAKING CHANGE:` footer:** + +``` +feat(api): remove deprecated endpoint + +BREAKING CHANGE: The /v1/users endpoint has been removed. Use /v2/users instead. +``` + +Both forms may be combined. diff --git a/.agents/skills/git-workflow/shared/file-inclusion-policy.md b/.agents/skills/git-workflow/shared/file-inclusion-policy.md new file mode 100644 index 0000000..dd60bfe --- /dev/null +++ b/.agents/skills/git-workflow/shared/file-inclusion-policy.md @@ -0,0 +1,56 @@ +# File Inclusion Policy + +Treat the working tree as the user's intent. + +## Default behavior + +Include **all user-modified files** in the commit: + +- modified files +- staged files +- unstaged files +- untracked files +- deleted files + +If the user changed a file, assume the change is intentional. + +Do **not** exclude files merely because they appear unrelated to the inferred task. + +## Allowed automatic exclusions + +Files may only be excluded if they are clearly not intended for source control: + +- `.DS_Store` +- editor swap files +- temporary files +- build output folders +- cache folders +- machine-local configuration files +- secret files that should never be committed + +Example patterns: + +``` +.DS_Store +*.swp +*.tmp +bin/ +obj/ +node_modules/ +.vscode/* +``` + +## Ambiguity rule + +If there is **any uncertainty** about whether a file should be committed: + +**Include the file.** + +Never silently omit a user-modified file. + +## Transparency rule + +If any files are excluded automatically, explicitly report: + +- which files were excluded +- the reason they were excluded diff --git a/.agents/skills/git-workflow/shared/safety-rules.md b/.agents/skills/git-workflow/shared/safety-rules.md new file mode 100644 index 0000000..7ba692f --- /dev/null +++ b/.agents/skills/git-workflow/shared/safety-rules.md @@ -0,0 +1,13 @@ +# Safety Rules + +Never: + +- rewrite history +- force push +- silently omit user-modified files +- invent issue numbers +- fabricate test results +- mark checklist items complete without evidence +- overwrite existing branches without confirmation + +If the repository state is ambiguous, choose the safest non-destructive option. diff --git a/.agents/skills/git-workflow/shared/scope-detection.md b/.agents/skills/git-workflow/shared/scope-detection.md new file mode 100644 index 0000000..95df6d0 --- /dev/null +++ b/.agents/skills/git-workflow/shared/scope-detection.md @@ -0,0 +1,20 @@ +# Scope Detection + +Infer scope from the folder containing the majority of the changes. + +| Folder | Scope | +| ----------------------------- | ------------------- | +| `.github/workflows` | `github` | +| `src/Core` | `core` | +| `src/Abstractions` | `abstractions` | +| `src/SourceGenerators` | `source-generators` | +| `src/OpenTelemetry` | `opentelemetry` | +| `tests` | `tests` | +| `test` | `testing` | +| `docs` | `docs` | +| `build` | `build` | +| dependency or package updates | `deps` | + +If multiple folders are involved, prioritize the **primary concern of the change**. + +If no mapping clearly applies, omit the scope. diff --git a/.agents/skills/git-workflow/templates/pull-request-template.md b/.agents/skills/git-workflow/templates/pull-request-template.md new file mode 100644 index 0000000..dbbb7cf --- /dev/null +++ b/.agents/skills/git-workflow/templates/pull-request-template.md @@ -0,0 +1,54 @@ +# 🚀 Pull Request + +## 📋 Summary + +> 2-4 sentences: what changed, why it changed, and the expected outcome. + +______________________________________________________________________ + +## 📝 Changes + + + + + +______________________________________________________________________ + +## 🧪 Validation + + + +- Build/test status: +- Manual verification performed: +- Edge cases checked: + +______________________________________________________________________ + +## ⚠️ Breaking Changes (Optional) + + + +- What changed: +- Previous behavior: +- New behavior: +- Migration/action needed: + +______________________________________________________________________ + +## 🧩 Related Issues (Optional) + + + + + +______________________________________________________________________ + +## 📦 Release Notes (Optional) + + + +______________________________________________________________________ + +## 💬 Notes for Reviewers (Optional) + + diff --git a/.agents/skills/git-workflow/templates/release-notes-template.md b/.agents/skills/git-workflow/templates/release-notes-template.md new file mode 100644 index 0000000..c37c9c2 --- /dev/null +++ b/.agents/skills/git-workflow/templates/release-notes-template.md @@ -0,0 +1,17 @@ +## Release Notes + +### Added + +- + +### Changed + +- + +### Fixed + +- + +### Internal + +- diff --git a/.claude/skills/git-workflow b/.claude/skills/git-workflow new file mode 120000 index 0000000..0ef95d2 --- /dev/null +++ b/.claude/skills/git-workflow @@ -0,0 +1 @@ +../../.agents/skills/git-workflow \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9d98ce1 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "git-workflow": { + "source": "LayeredCraft/skills", + "sourceType": "github", + "computedHash": "fbd78cda46da7d8753beca0d68ce6532589daa4da0a457aa709fe1e11da468fe" + } + } +} From cb90341f38e3b501700009483a66810d51f5daa5 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 16:02:27 -0400 Subject: [PATCH 12/26] feat(test): enhance DynamoMapper Client test setup with mappers and models - Added mappers (`UserProfileMapper`, `ProjectRecordMapper`, `TaskRecordMapper`) for testing DynamoMapper. - Introduced data models (`UserProfile`, `ProjectRecord`, `TaskRecord`, etc.) for testing purposes. - Set up `DynamoDbFixture` using `Testcontainers.DynamoDb` for integration tests. - Updated test project dependencies, including `Testcontainers.DynamoDb`. - Configured `LayeredCraft.DynamoMapper.Client.Tests.csproj` for analyzer and runtime references. --- Directory.Packages.props | 50 +++--- .../DynamoDbFixture.cs | 26 ++++ ...eredCraft.DynamoMapper.Client.Tests.csproj | 19 ++- .../TestDataMappers.cs | 28 ++++ .../TestDataModels.cs | 144 ++++++++++++++++++ 5 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index dd0b289..644e2c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,29 +5,31 @@ $(NoWarn);NU1507 - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs new file mode 100644 index 0000000..ba5df43 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs @@ -0,0 +1,26 @@ +using Amazon.DynamoDBv2; +using Testcontainers.DynamoDb; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class DynamoDbFixture : IAsyncLifetime +{ + public readonly DynamoDbContainer Container = + new DynamoDbBuilder("amazon/dynamodb-local:latest").Build(); + + public static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public IAmazonDynamoDB Client + { + get + { + field ??= new AmazonDynamoDBClient( + new AmazonDynamoDBConfig { ServiceURL = Container.GetConnectionString() }); + return field; + } + } + + public async ValueTask DisposeAsync() => await Container.StopAsync(CancellationToken); + + public async ValueTask InitializeAsync() => await Container.StartAsync(CancellationToken); +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj index ea3f74b..1e9f42a 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj @@ -3,6 +3,7 @@ net10.0 enable + Exe enable false true @@ -10,10 +11,7 @@ - - - - + @@ -23,9 +21,18 @@ - - + + + \ No newline at end of file diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs new file mode 100644 index 0000000..90da8a7 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs @@ -0,0 +1,28 @@ +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Runtime; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +[DynamoMapper] +public partial class UserProfileMapper : IDynamoMapper +{ + public partial Dictionary ToItem(UserProfile source); + + public partial UserProfile FromItem(Dictionary item); +} + +[DynamoMapper] +public partial class ProjectRecordMapper : IDynamoMapper +{ + public partial Dictionary ToItem(ProjectRecord source); + + public partial ProjectRecord FromItem(Dictionary item); +} + +[DynamoMapper] +public partial class TaskRecordMapper : IDynamoMapper +{ + public partial Dictionary ToItem(TaskRecord source); + + public partial TaskRecord FromItem(Dictionary item); +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs new file mode 100644 index 0000000..4ded90f --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs @@ -0,0 +1,144 @@ +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class UserProfile +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string UserId { get; init; } + + public required string Email { get; init; } + + public required string DisplayName { get; init; } + + public int Age { get; init; } + + public bool IsActive { get; init; } + + public decimal AccountBalance { get; init; } + + public required string CreatedAt { get; init; } + + public long LastLoginEpoch { get; init; } + + public required List Tags { get; init; } + + public required UserPreferences Preferences { get; init; } + + public required List LoginHistory { get; init; } + + public required byte[] ProfilePhoto { get; init; } +} + +public sealed class ProjectRecord +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string ProjectId { get; init; } + + public required string OwnerUserId { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public decimal Budget { get; init; } + + public bool IsArchived { get; init; } + + public int Priority { get; init; } + + public required string StartDate { get; init; } + + public required string DueDate { get; init; } + + public required List Labels { get; init; } + + public required ProjectSettings Settings { get; init; } + + public required ProjectMetrics Metrics { get; init; } +} + +public sealed class TaskRecord +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string TaSkId { get; init; } + + public required string ProjectId { get; init; } + + public required string AssignedUserId { get; init; } + + public required string Title { get; init; } + + public required string Notes { get; init; } + + public decimal EstimateHours { get; init; } + + public bool Completed { get; init; } + + public int Order { get; init; } + + public required string CreatedAt { get; init; } + + public required string DueAt { get; init; } + + public required List Checklist { get; init; } + + public required TaSkMetadata Metadata { get; init; } +} + +public sealed class UserPreferences +{ + public required string Theme { get; init; } + + public bool NotificationsEnabled { get; init; } + + public required string Language { get; init; } +} + +public sealed class LoginHistoryEntry +{ + public required string At { get; init; } + + public required string IpAddress { get; init; } +} + +public sealed class ProjectSettings +{ + public required string Visibility { get; init; } + + public bool AllowGuestComments { get; init; } +} + +public sealed class ProjectMetrics +{ + public int TaSkCount { get; init; } + + public int CompletedTaSkCount { get; init; } +} + +public sealed class TaSkChecklistItem +{ + public required string Text { get; init; } + + public bool Done { get; init; } +} + +public sealed class TaSkMetadata +{ + public required string Color { get; init; } + + public string? BlockedBy { get; init; } +} From 6064ca6c2bc424b063e0a17d885bdb9ffad17aec Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 16:21:15 -0400 Subject: [PATCH 13/26] feat(skills): support instance-based mappers in DynamoMapper - Updated documentation to clarify mapper classes can be instance-based or static. - Adjusted examples to include non-static mapper declarations and methods. - Added guidance to avoid assumptions that mappers or methods must be static. --- skills/dynamo-mapper/SKILL.md | 4 +++- skills/dynamo-mapper/references/core-usage.md | 11 +++++++---- skills/dynamo-mapper/references/gotchas.md | 1 + 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/skills/dynamo-mapper/SKILL.md b/skills/dynamo-mapper/SKILL.md index 2ca5b77..814178e 100644 --- a/skills/dynamo-mapper/SKILL.md +++ b/skills/dynamo-mapper/SKILL.md @@ -10,7 +10,8 @@ Use this skill when generating or explaining DynamoMapper code. ## Core truths - DynamoMapper is a C# incremental source generator for `T <-> Dictionary`. -- Configure mapping on a `static partial` mapper class marked with `[DynamoMapper]`. +- Configure mapping on a partial mapper class marked with `[DynamoMapper]`. +- Mapper classes can be instance-based or `static`; both shapes are supported. - The generator recognizes unimplemented partial methods whose names start with `To` or `From` and use the expected model/dictionary signatures. - One-way mappers are valid: `To*` only or `From*` only. @@ -41,6 +42,7 @@ Use this skill when generating or explaining DynamoMapper code. - Do not tell the user to decorate every POCO property; configuration belongs on the mapper class. - Do not assume methods must be named exactly `ToItem` and `FromItem`; the `To`/`From` prefix matters, but the generator also expects the recognized model/dictionary signatures. +- Do not assume mappers must be `static`; non-static members are valid too. - Check `references/gotchas.md` before teaching hooks or custom converter signatures. - Do not assume every unsupported converter setup becomes a DynamoMapper diagnostic; some become normal C# compile errors. diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md index 5d88877..2459c73 100644 --- a/skills/dynamo-mapper/references/core-usage.md +++ b/skills/dynamo-mapper/references/core-usage.md @@ -13,16 +13,19 @@ public sealed class Order } [DynamoMapper] -public static partial class OrderMapper +public partial class OrderMapper { - public static partial Dictionary ToItem(Order source); - public static partial Order FromItem(Dictionary item); + public partial Dictionary ToItem(Order source); + public partial Order FromItem(Dictionary item); } ``` +`static` mapper classes are also supported if you prefer static access. + ## Mapper rules -- The mapper is a `static partial class` marked with `[DynamoMapper]`. +- The mapper is a `partial class` marked with `[DynamoMapper]`. +- Mapper classes and methods may be instance-based or `static`. - `To*` methods take one model parameter and return `Dictionary`. - `From*` methods take one `Dictionary` and return the model type. - One-way mappers are valid. diff --git a/skills/dynamo-mapper/references/gotchas.md b/skills/dynamo-mapper/references/gotchas.md index 2bb7621..1a00725 100644 --- a/skills/dynamo-mapper/references/gotchas.md +++ b/skills/dynamo-mapper/references/gotchas.md @@ -4,6 +4,7 @@ - Do not tell users to decorate every domain-model property. - Do not require methods to be named exactly `ToItem` and `FromItem`. +- Do not require mapper classes or mapper methods to be `static`. - Do not teach lifecycle hooks as currently implemented behavior. - Do not use the old property-level converter signatures from stale docs. - Do not assume every converter mistake becomes a DynamoMapper diagnostic. From 69e7ecc9aebf1309f1358db2ff9ef7d249e0195d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 16:22:07 -0400 Subject: [PATCH 14/26] feat(test): add sample data and table creation logic to DynamoMapper tests - Introduced `TestDataSamples` for sample user profiles, project records, and task records. - Populated data models to improve test coverage and simulation realism. - Enhanced `DynamoDbFixture` with table creation for integration tests. - Added batch item writer to preload sample data in `DynamoDbFixture`. --- .../DynamoDbFixture.cs | 65 ++++++- .../TestDataModels.cs | 164 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs index ba5df43..a23145a 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs @@ -1,10 +1,17 @@ using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; using Testcontainers.DynamoDb; namespace LayeredCraft.DynamoMapper.Client.Tests; public sealed class DynamoDbFixture : IAsyncLifetime { + private static readonly UserProfileMapper UserProfiles = new(); + private static readonly ProjectRecordMapper ProjectRecords = new(); + private static readonly TaskRecordMapper TaskRecords = new(); + + public const string TableName = "test-data"; + public readonly DynamoDbContainer Container = new DynamoDbBuilder("amazon/dynamodb-local:latest").Build(); @@ -22,5 +29,61 @@ public IAmazonDynamoDB Client public async ValueTask DisposeAsync() => await Container.StopAsync(CancellationToken); - public async ValueTask InitializeAsync() => await Container.StartAsync(CancellationToken); + public async ValueTask InitializeAsync() + { + await Container.StartAsync(CancellationToken); + + await Client.CreateTableAsync( + new CreateTableRequest + { + TableName = TableName, + BillingMode = BillingMode.PAY_PER_REQUEST, + AttributeDefinitions = + [ + new AttributeDefinition("pk", ScalarAttributeType.S), + new AttributeDefinition("sk", ScalarAttributeType.S), + ], + KeySchema = + [ + new KeySchemaElement("pk", KeyType.HASH), + new KeySchemaElement("sk", KeyType.RANGE), + ], + }, + CancellationToken); + + var writeRequests = + TestDataSamples + .UserProfiles + .Select(UserProfiles.ToItem) + .Concat(TestDataSamples.ProjectRecords.Select(ProjectRecords.ToItem)) + .Concat(TestDataSamples.TaskRecords.Select(TaskRecords.ToItem)) + .Select(item => new WriteRequest { PutRequest = new PutRequest { Item = item } }) + .ToArray(); + + foreach (var batch in writeRequests.Chunk(25)) + await WriteBatchUntilCompleteAsync(batch); + } + + private async Task WriteBatchUntilCompleteAsync(IReadOnlyCollection batch) + { + var pending = batch.ToArray(); + + while (pending.Length > 0) + { + var response = await Client.BatchWriteItemAsync( + new BatchWriteItemRequest + { + RequestItems = + new Dictionary> + { + [TableName] = pending.ToList(), + }, + }, + CancellationToken); + + pending = response.UnprocessedItems.TryGetValue(TableName, out var unprocessed) + ? unprocessed.ToArray() + : []; + } + } } diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs index 4ded90f..0c2193e 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs @@ -142,3 +142,167 @@ public sealed class TaSkMetadata public string? BlockedBy { get; init; } } + +public static class TestDataSamples +{ + public static IReadOnlyList UserProfiles { get; } = + [ + new() + { + Pk = "USER#u-1001", + Sk = "PROFILE#u-1001", + EntityType = "UserProfile", + UserId = "u-1001", + Email = "alex.carter@example.com", + DisplayName = "Alex Carter", + Age = 34, + IsActive = true, + AccountBalance = 1520.75m, + CreatedAt = "2025-01-10T09:15:00Z", + LastLoginEpoch = 1739529600, + Tags = ["admin", "beta", "us-east-1"], + Preferences = + new UserPreferences + { + Theme = "dark", NotificationsEnabled = true, Language = "en-US", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-02-11T08:00:00Z", IpAddress = "203.0.113.10", + }, + new LoginHistoryEntry + { + At = "2025-02-12T18:45:00Z", IpAddress = "203.0.113.11", + }, + ], + ProfilePhoto = [1, 2, 3, 4, 5], + }, + new() + { + Pk = "USER#u-1002", + Sk = "PROFILE#u-1002", + EntityType = "UserProfile", + UserId = "u-1002", + Email = "maya.chen@example.com", + DisplayName = "Maya Chen", + Age = 29, + IsActive = false, + AccountBalance = 87.40m, + CreatedAt = "2024-11-03T14:20:00Z", + LastLoginEpoch = 1738771200, + Tags = ["designer", "trial"], + Preferences = + new UserPreferences + { + Theme = "light", NotificationsEnabled = false, Language = "en-GB", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-01-28T12:15:00Z", IpAddress = "198.51.100.25", + }, + new LoginHistoryEntry + { + At = "2025-02-05T07:32:00Z", IpAddress = "198.51.100.44", + }, + ], + ProfilePhoto = [10, 20, 30, 40], + }, + ]; + + public static IReadOnlyList ProjectRecords { get; } = + [ + new() + { + Pk = "USER#u-1001", + Sk = "PROJECT#p-2001", + EntityType = "ProjectRecord", + ProjectId = "p-2001", + OwnerUserId = "u-1001", + Name = "Apollo Migration", + Description = "Move customer workflows to the new platform.", + Budget = 125000.00m, + IsArchived = false, + Priority = 1, + StartDate = "2025-02-01", + DueDate = "2025-06-30", + Labels = ["migration", "high-priority", "enterprise"], + Settings = + new ProjectSettings { Visibility = "private", AllowGuestComments = false }, + Metrics = new ProjectMetrics { TaSkCount = 18, CompletedTaSkCount = 7 }, + }, + new() + { + Pk = "USER#u-1002", + Sk = "PROJECT#p-2002", + EntityType = "ProjectRecord", + ProjectId = "p-2002", + OwnerUserId = "u-1002", + Name = "Website Refresh", + Description = "Update marketing pages and design tokens.", + Budget = 18000.50m, + IsArchived = false, + Priority = 2, + StartDate = "2025-03-15", + DueDate = "2025-05-01", + Labels = ["design", "marketing"], + Settings = + new ProjectSettings { Visibility = "team", AllowGuestComments = true }, + Metrics = new ProjectMetrics { TaSkCount = 9, CompletedTaSkCount = 3 }, + }, + ]; + + public static IReadOnlyList TaskRecords { get; } = + [ + new() + { + Pk = "PROJECT#p-2001", + Sk = "TASK#t-3001", + EntityType = "TaskRecord", + TaSkId = "t-3001", + ProjectId = "p-2001", + AssignedUserId = "u-1001", + Title = "Audit existing integrations", + Notes = "Document external dependencies and rate limits.", + EstimateHours = 6.5m, + Completed = true, + Order = 1, + CreatedAt = "2025-02-02T10:00:00Z", + DueAt = "2025-02-05T17:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "List current providers", Done = true }, + new TaSkChecklistItem { Text = "Capture auth mechanisms", Done = true }, + ], + Metadata = new TaSkMetadata { Color = "green", BlockedBy = null }, + }, + new() + { + Pk = "PROJECT#p-2002", + Sk = "TASK#t-3002", + EntityType = "TaskRecord", + TaSkId = "t-3002", + ProjectId = "p-2002", + AssignedUserId = "u-1002", + Title = "Create homepage mockups", + Notes = "Deliver desktop and mobile variants for review.", + EstimateHours = 12.0m, + Completed = false, + Order = 2, + CreatedAt = "2025-03-16T09:30:00Z", + DueAt = "2025-03-20T16:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "Collect brand assets", Done = true }, + new TaSkChecklistItem { Text = "Draft hero section", Done = false }, + ], + Metadata = new TaSkMetadata + { + Color = "orange", BlockedBy = "Awaiting stakeholder feedback", + }, + }, + ]; +} From 23fb0aeea72c85c4b93f1831b73daa7830143974 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 17:09:08 -0400 Subject: [PATCH 15/26] feat(test): add integration tests for DynamoClient functionality - Introduced tests for `GetItemAsync`, `QueryAsync`, `ScanAsync`, `PutItemAsync`, `DeleteItemAsync`, and `UpdateItemAsync` methods. - Verified CRUD operations and query behaviors using seeded test data. - Enhanced test coverage for mapper handling and integration setup. - Refined null check logic in `GetItemAsync` to handle edge cases efficiently. --- .../DynamoClient.cs | 4 +- .../DynamoClientTests.cs | 161 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 6f652f1..4d938dd 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -55,7 +55,9 @@ public IDynamoMapper GetMapper() CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); - return result.Item.Count == 0 ? default : GetMapper().FromItem(result.Item); + return result.Item is null || result.Item.Count == 0 + ? default + : GetMapper().FromItem(result.Item); } /// Saves a mapped DTO to the specified table. diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs new file mode 100644 index 0000000..700091d --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -0,0 +1,161 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class DynamoClientTests(DynamoDbFixture fixture) : IClassFixture +{ + private readonly DynamoClient _client = new DynamoClientBuilder() + .WithAmazonDynamoDB(fixture.Client) + .WithMapper() + .WithMapper() + .WithMapper() + .Build(); + + [Fact] + public async Task GetItemAsync_UserProfile_ReturnsSeededItem() + { + var expected = TestDataSamples.UserProfiles[0]; + + var item = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(expected.Pk, expected.Sk), + TestContext.Current.CancellationToken); + + item.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner() + { + var expected = TestDataSamples.ProjectRecords[0]; + + var items = await _client.QueryAsync( + new QueryRequest + { + TableName = DynamoDbFixture.TableName, + KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = new() { S = expected.Pk }, + [":skPrefix"] = new() { S = "PROJECT#" }, + }, + }, + TestContext.Current.CancellationToken); + + items.Should().ContainEquivalentOf(expected); + } + + [Fact] + public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() + { + var items = await _client.ScanAsync( + new ScanRequest + { + TableName = DynamoDbFixture.TableName, + FilterExpression = "entityType = :entityType", + ExpressionAttributeValues = + new Dictionary + { + [":entityType"] = new() { S = "UserProfile" }, + }, + }, + TestContext.Current.CancellationToken); + + items.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + } + + [Fact] + public async Task PutItemAsync_ThenDeleteItemAsync_PersistsAndRemovesItem() + { + var item = new TaskRecord + { + Pk = "PROJECT#p-9999", + Sk = "TASK#t-9999", + EntityType = "TaskRecord", + TaSkId = "t-9999", + ProjectId = "p-9999", + AssignedUserId = "u-1001", + Title = "Verify client put", + Notes = "Inserted by integration test.", + EstimateHours = 2.5m, + Completed = false, + Order = 99, + CreatedAt = "2025-04-06T10:00:00Z", + DueAt = "2025-04-07T10:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "Write item", Done = true }, + new TaSkChecklistItem { Text = "Read item", Done = false }, + ], + Metadata = new TaSkMetadata { Color = "blue", BlockedBy = null }, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + item, + TestContext.Current.CancellationToken); + + var persisted = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + persisted.Should().BeEquivalentTo(item); + + await _client.DeleteItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + var deleted = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + deleted.Should().BeNull(); + } + + [Fact] + public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() + { + var existing = TestDataSamples.TaskRecords[0]; + var expected = new TaskRecord + { + Pk = existing.Pk, + Sk = existing.Sk, + EntityType = existing.EntityType, + TaSkId = existing.TaSkId, + ProjectId = existing.ProjectId, + AssignedUserId = existing.AssignedUserId, + Title = existing.Title, + Notes = "Updated by integration test.", + EstimateHours = existing.EstimateHours, + Completed = false, + Order = existing.Order, + CreatedAt = existing.CreatedAt, + DueAt = existing.DueAt, + Checklist = existing.Checklist, + Metadata = existing.Metadata, + }; + + var updated = await _client.UpdateItemAsync( + new UpdateItemRequest + { + TableName = DynamoDbFixture.TableName, + Key = CreateKey(existing.Pk, existing.Sk), + UpdateExpression = "SET notes = :notes, completed = :completed", + ExpressionAttributeValues = new Dictionary + { + [":notes"] = new() { S = "Updated by integration test." }, + [":completed"] = new() { BOOL = false }, + }, + ReturnValues = "ALL_NEW", + }, + TestContext.Current.CancellationToken); + + updated.Should().BeEquivalentTo(expected); + } + + private static Dictionary CreateKey(string pk, string sk) + => new() { ["pk"] = new AttributeValue { S = pk }, ["sk"] = new AttributeValue { S = sk } }; +} From 0067dfb360d8850e8f84d19d5120a04663ed3af9 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 6 Apr 2026 20:51:03 -0400 Subject: [PATCH 16/26] feat(client): add dependency injection support for DynamoClient - Introduced `DynamoClientServiceBuilder` for configuring `DynamoClient` in DI containers. - Added `DynamoClientServiceCollectionExtensions` to simplify `DynamoClient` registration. - Enabled mapper registration via `AddMapper` and Amazon DynamoDB client injection. - Updated `DynamoClientBuilder` to support instance-based mappers via type registration. - Enhanced tests to verify `AddDynamoClient` functionality and client resolution from DI. - Updated project dependencies to include `Microsoft.Extensions.DependencyInjection`. --- Directory.Packages.props | 4 +- ...DynamoClientServiceCollectionExtensions.cs | 44 +++++++++++++ .../DynamoClientBuilder.cs | 28 +++++++-- .../DynamoClientServiceBuilder.cs | 54 ++++++++++++++++ .../LayeredCraft.DynamoMapper.Client.csproj | 1 + .../DynamoClientTests.cs | 62 ++++++++++++++++++- ...eredCraft.DynamoMapper.Client.Tests.csproj | 3 +- 7 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 644e2c1..57a6779 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,8 @@ + + @@ -32,4 +34,4 @@ - \ No newline at end of file + diff --git a/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs new file mode 100644 index 0000000..4cc6544 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Amazon.DynamoDBv2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LayeredCraft.DynamoMapper.Client.DependencyInjection; + +/// Provides dependency injection registration helpers for . +public static class DynamoClientServiceCollectionExtensions +{ + /// + /// Registers as a singleton and applies mapper configuration + /// through the returned builder. + /// + /// The service collection to update. + /// Applies mapper registrations to the builder. + /// The service collection for further chaining. + public static IServiceCollection AddDynamoClient( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var registrations = new List<(Type DtoType, Type MapperType)>(); + var builder = new DynamoClientServiceBuilder(services, registrations); + configure(builder); + + services.TryAddSingleton(serviceProvider => + { + var dynamoDbClient = + builder.AmazonDynamoDb ?? serviceProvider.GetRequiredService(); + var clientBuilder = new DynamoClientBuilder().WithAmazonDynamoDb(dynamoDbClient); + + foreach (var registration in registrations) + { + var mapper = serviceProvider.GetRequiredService(registration.MapperType); + clientBuilder.WithMapper(registration.DtoType, mapper); + } + + return clientBuilder.Build(); + }); + return services; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs index 08ab32c..31bbfac 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs @@ -9,21 +9,30 @@ public class DynamoClientBuilder private readonly Dictionary _mappers = new(); private IAmazonDynamoDB? _dynamoDbClient; + /// Registers the specified mapper instance for the DTO type. + /// The DTO type handled by the mapper. + /// The mapper instance to register. + /// The current builder instance. + public DynamoClientBuilder WithMapper(IDynamoMapper mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + + _mappers[typeof(TDto)] = mapper; + return this; + } + /// Registers a mapper for the specified DTO type. /// The DTO type handled by the mapper. /// The mapper type to instantiate and register. /// The current builder instance. public DynamoClientBuilder WithMapper() where TMapper : class, IDynamoMapper, new() - { - _mappers[typeof(TDto)] = new TMapper(); - return this; - } + => WithMapper(new TMapper()); /// Uses the specified DynamoDB client when building the . /// The DynamoDB client instance to use. /// The current builder instance. - public DynamoClientBuilder WithAmazonDynamoDB(IAmazonDynamoDB dynamoDbClient) + public DynamoClientBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient) { _dynamoDbClient = dynamoDbClient; return this; @@ -37,4 +46,13 @@ public DynamoClient Build() return new DynamoClient(_mappers.ToImmutableDictionary(), _dynamoDbClient); } + + internal DynamoClientBuilder WithMapper(Type dtoType, object mapper) + { + ArgumentNullException.ThrowIfNull(dtoType); + ArgumentNullException.ThrowIfNull(mapper); + + _mappers[dtoType] = mapper; + return this; + } } diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs new file mode 100644 index 0000000..cdf6152 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.DynamoDBv2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LayeredCraft.DynamoMapper.Client; + +/// +/// Provides a fluent registration surface for configuring in a +/// dependency injection container. +/// +public sealed class DynamoClientServiceBuilder +{ + private readonly IList<(Type DtoType, Type MapperType)> _registrations; + + internal DynamoClientServiceBuilder( + IServiceCollection services, + IList<(Type DtoType, Type MapperType)> registrations) + { + ArgumentNullException.ThrowIfNull(services); + + Services = services; + _registrations = registrations; + } + + /// Gets the service collection being configured. + public IServiceCollection Services { get; } + + internal IAmazonDynamoDB? AmazonDynamoDb { get; private set; } + + /// Uses the specified DynamoDB client when building the . + /// The DynamoDB client instance to use. + /// The current registration builder. + public DynamoClientServiceBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient) + { + ArgumentNullException.ThrowIfNull(dynamoDbClient); + + AmazonDynamoDb = dynamoDbClient; + return this; + } + + /// Registers a mapper that should be included in the built . + /// The DTO type handled by the mapper. + /// The mapper implementation type. + /// The current registration builder. + public DynamoClientServiceBuilder AddMapper() + where TMapper : class, IDynamoMapper + { + Services.TryAddSingleton(); + _registrations.Add((typeof(TDto), typeof(TMapper))); + return this; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj index 7b7e156..b37a550 100644 --- a/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj +++ b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj @@ -10,6 +10,7 @@ + diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs index 700091d..fa9fa70 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -1,11 +1,13 @@ using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; namespace LayeredCraft.DynamoMapper.Client.Tests; public sealed class DynamoClientTests(DynamoDbFixture fixture) : IClassFixture { private readonly DynamoClient _client = new DynamoClientBuilder() - .WithAmazonDynamoDB(fixture.Client) + .WithAmazonDynamoDb(fixture.Client) .WithMapper() .WithMapper() .WithMapper() @@ -156,6 +158,64 @@ public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() updated.Should().BeEquivalentTo(expected); } + [Fact] + public async Task AddDynamoClient_ResolvesWorkingClient() + { + var services = new ServiceCollection(); + services.AddSingleton(fixture.Client); + + services.AddDynamoClient(builder => + { + builder.AddMapper(); + builder.AddMapper(); + builder.AddMapper(); + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService(); + var expected = TestDataSamples.UserProfiles[1]; + + var item = await client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(expected.Pk, expected.Sk), + TestContext.Current.CancellationToken); + + item.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClient() + { + var services = new ServiceCollection(); + + services.AddDynamoClient(builder => + { + builder.WithAmazonDynamoDb(fixture.Client); + builder.AddMapper(); + builder.AddMapper(); + builder.AddMapper(); + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService(); + var expected = TestDataSamples.ProjectRecords[1]; + + var items = await client.QueryAsync( + new QueryRequest + { + TableName = DynamoDbFixture.TableName, + KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = new() { S = expected.Pk }, + [":skPrefix"] = new() { S = "PROJECT#" }, + }, + }, + TestContext.Current.CancellationToken); + + items.Should().ContainEquivalentOf(expected); + } + private static Dictionary CreateKey(string pk, string sk) => new() { ["pk"] = new AttributeValue { S = pk }, ["sk"] = new AttributeValue { S = sk } }; } diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj index 1e9f42a..dfecfd2 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj @@ -11,6 +11,7 @@ + @@ -35,4 +36,4 @@ /> - \ No newline at end of file + From 3929cdc1a05f9127bf3498f92bdd38a1f2d0a7c3 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 11:13:31 -0400 Subject: [PATCH 17/26] feat(client): extend DynamoClient with GetItemResponse and PartiQL support - Added `GetItemResponse` model to encapsulate `GetItem` results with mapped DTO support. - Extended `DynamoClient` with `ExecuteStatementAsync` methods for PartiQL query execution. - Updated `GetItemAsync` to return `GetItemResponse` for enriched result handling. - Added integration tests for new methods including typed and raw PartiQL responses. - Enhanced existing tests to validate `MappedItem` usage in response processing. --- .../DynamoClient.cs | 30 ++++++++++++- .../Models/GetItemResponse.cs | 32 ++++++++++++++ .../DynamoClientTests.cs | 43 +++++++++++++++++-- 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 4d938dd..6410dca 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Client.Models; // ReSharper disable MemberCanBePrivate.Global @@ -49,15 +50,17 @@ public IDynamoMapper GetMapper() /// A task that returns the mapped DTO when an item is found; otherwise, /// . /// - public async Task GetItemAsync( + public async Task> GetItemAsync( string tableName, Dictionary key, CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); - return result.Item is null || result.Item.Count == 0 + var mappedItem = result.Item is null || result.Item.Count == 0 ? default : GetMapper().FromItem(result.Item); + + return new GetItemResponse(result, mappedItem); } /// Saves a mapped DTO to the specified table. @@ -129,4 +132,27 @@ public async Task> ScanAsync( var mapper = GetMapper(); return result.Items.Select(mapper.FromItem).ToArray(); } + + /// Executes a PartiQL statement against DynamoDB. + /// The PartiQL request to execute. + /// The cancellation token for the asynchronous operation. + /// The raw DynamoDB response for the executed statement. + public Task ExecuteStatementAsync( + ExecuteStatementRequest request, + CancellationToken cancellationToken = default) + => AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken); + + /// Executes a PartiQL statement and maps each returned item to the specified DTO type. + /// The DTO type to map the returned items to. + /// The PartiQL request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped statement results. + public async Task> ExecuteStatementAsync( + ExecuteStatementRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken); + var mapper = GetMapper(); + return result.Items.Select(mapper.FromItem).ToArray(); + } } diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs new file mode 100644 index 0000000..72fae05 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs @@ -0,0 +1,32 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB GetItem operation together with an optional +/// mapped DTO instance. +/// +/// The DTO type produced from the returned DynamoDB item. +/// +/// This type provides the same general response context as +/// , including consumed capacity details and +/// the raw DynamoDB item, while also exposing for typed access through a +/// registered mapper. +/// +public class GetItemResponse : GetItemResponse +{ + internal GetItemResponse(GetItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Item = response.Item; + ConsumedCapacity = response.ConsumedCapacity; + IsItemSet = response.IsItemSet; + } + + /// Gets the item returned by DynamoDB mapped to . + /// + /// This value is when the response does not contain an item or when + /// the mapped type is nullable and the mapper produces a null value. + /// + public T? MappedItem { get; } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs index fa9fa70..c4bfd32 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -23,7 +23,7 @@ public async Task GetItemAsync_UserProfile_ReturnsSeededItem() CreateKey(expected.Pk, expected.Sk), TestContext.Current.CancellationToken); - item.Should().BeEquivalentTo(expected); + item.MappedItem.Should().BeEquivalentTo(expected); } [Fact] @@ -66,6 +66,43 @@ public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() items.Should().BeEquivalentTo(TestDataSamples.UserProfiles); } + [Fact] + public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() + { + var items = await _client.ExecuteStatementAsync( + new ExecuteStatementRequest + { + Statement = $""" + SELECT * FROM "{DynamoDbFixture.TableName}" + WHERE entityType = ? + """, + Parameters = [new AttributeValue { S = "UserProfile" }], + }, + TestContext.Current.CancellationToken); + + items.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + } + + [Fact] + public async Task ExecuteStatementAsync_RawResponse_ReturnsDynamoDbItems() + { + var response = await _client.ExecuteStatementAsync( + new ExecuteStatementRequest + { + Statement = + $"SELECT * FROM \"{DynamoDbFixture.TableName}\" WHERE pk = ? AND sk = ?", + Parameters = + [ + new AttributeValue { S = TestDataSamples.UserProfiles[0].Pk }, + new AttributeValue { S = TestDataSamples.UserProfiles[0].Sk }, + ], + }, + TestContext.Current.CancellationToken); + + response.Items.Should().ContainSingle(); + response.Items[0]["entityType"].S.Should().Be("UserProfile"); + } + [Fact] public async Task PutItemAsync_ThenDeleteItemAsync_PersistsAndRemovesItem() { @@ -114,7 +151,7 @@ await _client.DeleteItemAsync( CreateKey(item.Pk, item.Sk), TestContext.Current.CancellationToken); - deleted.Should().BeNull(); + deleted.MappedItem.Should().BeNull(); } [Fact] @@ -180,7 +217,7 @@ public async Task AddDynamoClient_ResolvesWorkingClient() CreateKey(expected.Pk, expected.Sk), TestContext.Current.CancellationToken); - item.Should().BeEquivalentTo(expected); + item.MappedItem.Should().BeEquivalentTo(expected); } [Fact] From 6d4cb30e94b35d71d4a07e71c3a268180da625d2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 11:57:44 -0400 Subject: [PATCH 18/26] feat(client): update DynamoClient response handling and introduce QueryResponse model - Updated `PutItemAsync` and `DeleteItemAsync` to return detailed responses: `PutItemResponse` and `DeleteItemResponse`. - Enhanced `UpdateItemAsync` to return `UpdateItemResponse` for consistency with other methods. - Modified `QueryAsync` to return the new `QueryResponse` model, providing enriched result handling. - Added `QueryResponse` class to encapsulate raw query response details and mapped DTO items. - Improved type safety and clarity in DynamoDB result processing. --- .../DynamoClient.cs | 16 +++++----- .../Models/QueryResponse.cs | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 6410dca..3e11209 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -69,7 +69,7 @@ public async Task> GetItemAsync( /// The DTO instance to map and save. /// The cancellation token for the asynchronous operation. /// A task that completes when the item has been written. - public Task PutItemAsync( + public Task PutItemAsync( string tableName, T item, CancellationToken cancellationToken = default) @@ -83,7 +83,7 @@ public Task PutItemAsync( /// The primary key of the item to delete. /// The cancellation token for the asynchronous operation. /// A task that completes when the delete request has finished. - public Task DeleteItemAsync( + public Task DeleteItemAsync( string tableName, Dictionary key, CancellationToken cancellationToken = default) @@ -97,26 +97,24 @@ public Task DeleteItemAsync( /// A task that returns the mapped DTO when the request returns attributes; otherwise, /// . /// - public async Task UpdateItemAsync( + public async Task UpdateItemAsync( UpdateItemRequest request, CancellationToken cancellationToken = default) - { - var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); - return result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); - } + => await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); /// Executes a query request and maps each returned item to the specified DTO type. /// The DTO type to map the query results to. /// The query request to execute. /// The cancellation token for the asynchronous operation. /// A task that returns the mapped query results. - public async Task> QueryAsync( + public async Task> QueryAsync( QueryRequest request, CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.QueryAsync(request, cancellationToken); var mapper = GetMapper(); - return result.Items.Select(mapper.FromItem).ToArray(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new QueryResponse(result, mappedItems); } /// Executes a scan request and maps each returned item to the specified DTO type. diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs new file mode 100644 index 0000000..162b8b3 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs @@ -0,0 +1,31 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// Represents the result of a DynamoDB Query operation together with mapped DTO items. +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw query items, result +/// counts, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class QueryResponse : QueryResponse +{ + internal QueryResponse(QueryResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + Count = response.Count; + LastEvaluatedKey = response.LastEvaluatedKey; + ScannedCount = response.ScannedCount; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by , but each entry has + /// been projected into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} From 35ffb2a7b09c3cfff9ff46aa620d6ca6910426ce Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 12:40:16 -0400 Subject: [PATCH 19/26] feat(client): enhance response handling with ScanResponse and ExecuteStatementResponse models - Introduced `ScanResponse` and `ExecuteStatementResponse` models for enriched result handling. - Updated `DynamoClient` methods to use the new models, improving type safety and clarity. - Enhanced `UpdateItemAsync` to return mapped DTOs when attributes are present. - Updated tests to validate usage of `MappedItems` and new response models across multiple scenarios. --- .../DynamoClient.cs | 17 ++++++---- .../Models/ExecuteStatementResponse.cs | 34 +++++++++++++++++++ .../Models/QueryResponse.cs | 5 +-- .../Models/ScanResponse.cs | 32 +++++++++++++++++ .../DynamoClientTests.cs | 18 +++++----- 5 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index 3e11209..da1287f 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -97,10 +97,13 @@ public Task DeleteItemAsync( /// A task that returns the mapped DTO when the request returns attributes; otherwise, /// . /// - public async Task UpdateItemAsync( + public async Task UpdateItemAsync( UpdateItemRequest request, CancellationToken cancellationToken = default) - => await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); + { + var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); + return result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + } /// Executes a query request and maps each returned item to the specified DTO type. /// The DTO type to map the query results to. @@ -122,13 +125,14 @@ public async Task> QueryAsync( /// The scan request to execute. /// The cancellation token for the asynchronous operation. /// A task that returns the mapped scan results. - public async Task> ScanAsync( + public async Task> ScanAsync( ScanRequest request, CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.ScanAsync(request, cancellationToken); var mapper = GetMapper(); - return result.Items.Select(mapper.FromItem).ToArray(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new ScanResponse(result, mappedItems); } /// Executes a PartiQL statement against DynamoDB. @@ -145,12 +149,13 @@ public Task ExecuteStatementAsync( /// The PartiQL request to execute. /// The cancellation token for the asynchronous operation. /// A task that returns the mapped statement results. - public async Task> ExecuteStatementAsync( + public async Task> ExecuteStatementAsync( ExecuteStatementRequest request, CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken); var mapper = GetMapper(); - return result.Items.Select(mapper.FromItem).ToArray(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new ExecuteStatementResponse(result, mappedItems); } } diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs new file mode 100644 index 0000000..00a59fb --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs @@ -0,0 +1,34 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB PartiQL ExecuteStatement operation together +/// with mapped DTO items. +/// +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw statement +/// results, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class ExecuteStatementResponse : ExecuteStatementResponse +{ + internal ExecuteStatementResponse(ExecuteStatementResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + LastEvaluatedKey = response.LastEvaluatedKey; + NextToken = response.NextToken; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by + /// , but each entry has been + /// projected into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs index 162b8b3..af2c336 100644 --- a/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs +++ b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs @@ -24,8 +24,9 @@ internal QueryResponse(QueryResponse response, List mappedItems) /// Gets the items returned by DynamoDB mapped to . /// - /// This list corresponds to the raw items exposed by , but each entry has - /// been projected into the typed DTO using the registered mapper. + /// This list corresponds to the raw items exposed by + /// , but each entry has been projected + /// into the typed DTO using the registered mapper. /// public List MappedItems { get; } } diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs new file mode 100644 index 0000000..ef2b972 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs @@ -0,0 +1,32 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// Represents the result of a DynamoDB Scan operation together with mapped DTO items. +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw scan items, result +/// counts, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class ScanResponse : ScanResponse +{ + internal ScanResponse(ScanResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + Count = response.Count; + LastEvaluatedKey = response.LastEvaluatedKey; + ScannedCount = response.ScannedCount; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by + /// , but each entry has been projected + /// into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs index c4bfd32..56ce2de 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -31,7 +31,7 @@ public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner() { var expected = TestDataSamples.ProjectRecords[0]; - var items = await _client.QueryAsync( + var response = await _client.QueryAsync( new QueryRequest { TableName = DynamoDbFixture.TableName, @@ -44,13 +44,13 @@ public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner() }, TestContext.Current.CancellationToken); - items.Should().ContainEquivalentOf(expected); + response.MappedItems.Should().ContainEquivalentOf(expected); } [Fact] public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() { - var items = await _client.ScanAsync( + var response = await _client.ScanAsync( new ScanRequest { TableName = DynamoDbFixture.TableName, @@ -63,13 +63,13 @@ public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() }, TestContext.Current.CancellationToken); - items.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles); } [Fact] public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() { - var items = await _client.ExecuteStatementAsync( + var response = await _client.ExecuteStatementAsync( new ExecuteStatementRequest { Statement = $""" @@ -80,7 +80,7 @@ public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() }, TestContext.Current.CancellationToken); - items.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles); } [Fact] @@ -139,7 +139,7 @@ await _client.PutItemAsync( CreateKey(item.Pk, item.Sk), TestContext.Current.CancellationToken); - persisted.Should().BeEquivalentTo(item); + persisted.MappedItem.Should().BeEquivalentTo(item); await _client.DeleteItemAsync( DynamoDbFixture.TableName, @@ -237,7 +237,7 @@ public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClie var client = serviceProvider.GetRequiredService(); var expected = TestDataSamples.ProjectRecords[1]; - var items = await client.QueryAsync( + var response = await client.QueryAsync( new QueryRequest { TableName = DynamoDbFixture.TableName, @@ -250,7 +250,7 @@ public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClie }, TestContext.Current.CancellationToken); - items.Should().ContainEquivalentOf(expected); + response.MappedItems.Should().ContainEquivalentOf(expected); } private static Dictionary CreateKey(string pk, string sk) From 73b05ccd98dde53d25ea5427b209c85102c96434 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 13:32:24 -0400 Subject: [PATCH 20/26] feat(client, runtime, test): introduce AttributeValue conversion extensions and update usage - Added `AttributeValueConverterExtensions` with methods for converting common types to `AttributeValue`. - Refactored `DynamoClientTests` to use the new extension methods for cleaner attribute creation. - Improved test readability and reliability by replacing inline `AttributeValue` creation with extensions. - Verified compatibility with existing tests to ensure no regressions. --- .../AttributeValueConverterExtensions.cs | 229 ++++++++++++++ .../AttributeValueConverterExtensionsTests.cs | 285 ++++++++++++++++++ .../DynamoClientTests.cs | 29 +- 3 files changed, 530 insertions(+), 13 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs create mode 100644 test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs new file mode 100644 index 0000000..baece4b --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs @@ -0,0 +1,229 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Runtime; + +/// +/// Convenience extensions for converting common scalar values to +/// . +/// +public static class AttributeValueConverterExtensions +{ + extension(string? str) + { + /// Converts the string to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue() + => str is null ? new AttributeValue { NULL = true } : new AttributeValue { S = str }; + } + + extension(bool value) + { + /// Converts the boolean to a DynamoDB BOOL attribute. + public AttributeValue ToAttributeValue() => new() { BOOL = value }; + } + + extension(bool? value) + { + /// Converts the nullable boolean to a DynamoDB BOOL attribute or NULL attribute. + public AttributeValue ToAttributeValue() + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue { BOOL = value.Value }; + } + + extension(int num) + { + /// Converts the integer to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(int? num) + { + /// Converts the nullable integer to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(long num) + { + /// Converts the long integer to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(long? num) + { + /// Converts the nullable long integer to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(float num) + { + /// Converts the single-precision number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(float? num) + { + /// + /// Converts the nullable single-precision number to a DynamoDB number attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(double num) + { + /// Converts the double-precision number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(double? num) + { + /// + /// Converts the nullable double-precision number to a DynamoDB number attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(decimal num) + { + /// Converts the decimal number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(decimal? num) + { + /// Converts the nullable decimal number to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(Guid value) + { + /// Converts the GUID to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D") + => new() { S = value.ToString(format) }; + } + + extension(Guid? value) + { + /// Converts the nullable GUID to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue { S = value.Value.ToString(format) }; + } + + extension(DateTime value) + { + /// Converts the date and time to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(DateTime? value) + { + /// Converts the nullable date and time to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(DateTimeOffset value) + { + /// Converts the date and time offset to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(DateTimeOffset? value) + { + /// + /// Converts the nullable date and time offset to a DynamoDB string attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(TimeSpan value) + { + /// Converts the time span to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(TimeSpan? value) + { + /// Converts the nullable time span to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs new file mode 100644 index 0000000..b594cb0 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Runtime; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class AttributeValueConverterExtensionsTests +{ + [Fact] + public void String_ToAttributeValue_ReturnsStringAttribute() + { + var attribute = "hello".ToAttributeValue(); + + attribute.S.Should().Be("hello"); + attribute.NULL.Should().NotBeTrue(); + } + + [Fact] + public void NullableString_ToAttributeValue_ReturnsNullAttribute_WhenValueIsNull() + { + string? value = null; + + var attribute = value.ToAttributeValue(); + + AssertNullAttribute(attribute); + } + + [Fact] + public void Bool_ToAttributeValue_ReturnsBoolAttribute() + { + var attribute = true.ToAttributeValue(); + + attribute.BOOL.Should().BeTrue(); + attribute.NULL.Should().NotBeTrue(); + } + + [Fact] + public void NullableBool_ToAttributeValue_ReturnsBoolOrNullAttribute() + { + bool? value = false; + bool? nullValue = null; + + var attribute = value.ToAttributeValue(); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.BOOL.Should().BeFalse(); + attribute.NULL.Should().NotBeTrue(); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Int_ToAttributeValue_UsesInvariantCultureAndFormatString() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 12345.ToAttributeValue("N0"); + + attribute.N.Should().Be("12,345"); + } + + [Fact] + public void NullableInt_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + int? value = 255; + int? nullValue = null; + + var attribute = value.ToAttributeValue("X"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.N.Should().Be("FF"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Long_ToAttributeValue_UsesInvariantCultureAndFormatString() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 123456789L.ToAttributeValue("N0"); + + attribute.N.Should().Be("123,456,789"); + } + + [Fact] + public void NullableLong_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + long? value = 4095; + long? nullValue = null; + + var attribute = value.ToAttributeValue("X"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.N.Should().Be("FFF"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Float_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 12.5f.ToAttributeValue(); + + attribute.N.Should().Be("12.5"); + } + + [Fact] + public void NullableFloat_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + float? value = 12.5f; + float? nullValue = null; + + var attribute = value.ToAttributeValue("0.00"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.N.Should().Be("12.50"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Double_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 1234.5d.ToAttributeValue(); + + attribute.N.Should().Be("1234.5"); + } + + [Fact] + public void NullableDouble_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + double? value = 1234.5d; + double? nullValue = null; + + var attribute = value.ToAttributeValue("0.000"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.N.Should().Be("1234.500"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Decimal_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 1234.5m.ToAttributeValue(); + + attribute.N.Should().Be("1234.5"); + } + + [Fact] + public void NullableDecimal_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + decimal? value = 1234.5m; + decimal? nullValue = null; + + var attribute = value.ToAttributeValue("0.000"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.N.Should().Be("1234.500"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void Guid_ToAttributeValue_UsesDefaultDFormat() + { + var value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + + var attribute = value.ToAttributeValue(); + + attribute.S.Should().Be("00112233-4455-6677-8899-aabbccddeeff"); + } + + [Fact] + public void NullableGuid_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + Guid? value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + Guid? nullValue = null; + + var attribute = value.ToAttributeValue("N"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.S.Should().Be("00112233445566778899aabbccddeeff"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void DateTime_ToAttributeValue_UsesDefaultRoundTripFormat() + { + var value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc); + + var attribute = value.ToAttributeValue(); + + attribute.S.Should().Be(value.ToString("o", CultureInfo.InvariantCulture)); + } + + [Fact] + public void NullableDateTime_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + DateTime? value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc); + DateTime? nullValue = null; + + var attribute = value.ToAttributeValue("yyyyMMdd"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.S.Should().Be("20250407"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void DateTimeOffset_ToAttributeValue_UsesDefaultRoundTripFormat() + { + var value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.FromHours(2)); + + var attribute = value.ToAttributeValue(); + + attribute.S.Should().Be(value.ToString("o", CultureInfo.InvariantCulture)); + } + + [Fact] + public void NullableDateTimeOffset_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + DateTimeOffset? value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.Zero); + DateTimeOffset? nullValue = null; + + var attribute = value.ToAttributeValue("yyyy-MM-dd zzz"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.S.Should().Be("2025-04-07 +00:00"); + AssertNullAttribute(nullAttribute); + } + + [Fact] + public void TimeSpan_ToAttributeValue_UsesDefaultConstantFormat() + { + var value = TimeSpan.FromHours(1) + TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(3); + + var attribute = value.ToAttributeValue(); + + attribute.S.Should().Be(value.ToString("c", CultureInfo.InvariantCulture)); + } + + [Fact] + public void NullableTimeSpan_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + TimeSpan? value = + TimeSpan.FromHours(26) + TimeSpan.FromMinutes(3) + TimeSpan.FromSeconds(4); + TimeSpan? nullValue = null; + + var attribute = value.ToAttributeValue("g"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.S.Should().Be(value.Value.ToString("g", CultureInfo.InvariantCulture)); + AssertNullAttribute(nullAttribute); + } + + private static void AssertNullAttribute(AttributeValue attribute) + { + attribute.NULL.Should().BeTrue(); + attribute.S.Should().BeNull(); + attribute.N.Should().BeNull(); + attribute.BOOL.Should().BeNull(); + } + + private sealed class CultureScope : IDisposable + { + private readonly CultureInfo _originalCulture = CultureInfo.CurrentCulture; + private readonly CultureInfo _originalUiCulture = CultureInfo.CurrentUICulture; + + public CultureScope(string name) + { + var culture = CultureInfo.GetCultureInfo(name); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUiCulture; + } + } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs index 56ce2de..2baf8f9 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -1,5 +1,6 @@ using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Client.DependencyInjection; +using LayeredCraft.DynamoMapper.Runtime; using Microsoft.Extensions.DependencyInjection; namespace LayeredCraft.DynamoMapper.Client.Tests; @@ -38,8 +39,8 @@ public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner() KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", ExpressionAttributeValues = new Dictionary { - [":pk"] = new() { S = expected.Pk }, - [":skPrefix"] = new() { S = "PROJECT#" }, + [":pk"] = expected.Pk.ToAttributeValue(), + [":skPrefix"] = "PROJECT#".ToAttributeValue(), }, }, TestContext.Current.CancellationToken); @@ -58,7 +59,7 @@ public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() ExpressionAttributeValues = new Dictionary { - [":entityType"] = new() { S = "UserProfile" }, + [":entityType"] = "UserProfile".ToAttributeValue(), }, }, TestContext.Current.CancellationToken); @@ -76,7 +77,7 @@ public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() SELECT * FROM "{DynamoDbFixture.TableName}" WHERE entityType = ? """, - Parameters = [new AttributeValue { S = "UserProfile" }], + Parameters = ["UserProfile".ToAttributeValue()], }, TestContext.Current.CancellationToken); @@ -89,12 +90,14 @@ public async Task ExecuteStatementAsync_RawResponse_ReturnsDynamoDbItems() var response = await _client.ExecuteStatementAsync( new ExecuteStatementRequest { - Statement = - $"SELECT * FROM \"{DynamoDbFixture.TableName}\" WHERE pk = ? AND sk = ?", + Statement = $""" + SELECT * FROM "{DynamoDbFixture.TableName}" + WHERE pk = ? AND sk = ? + """, Parameters = [ - new AttributeValue { S = TestDataSamples.UserProfiles[0].Pk }, - new AttributeValue { S = TestDataSamples.UserProfiles[0].Sk }, + TestDataSamples.UserProfiles[0].Pk.ToAttributeValue(), + TestDataSamples.UserProfiles[0].Sk.ToAttributeValue(), ], }, TestContext.Current.CancellationToken); @@ -185,8 +188,8 @@ public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() UpdateExpression = "SET notes = :notes, completed = :completed", ExpressionAttributeValues = new Dictionary { - [":notes"] = new() { S = "Updated by integration test." }, - [":completed"] = new() { BOOL = false }, + [":notes"] = "Updated by integration test.".ToAttributeValue(), + [":completed"] = false.ToAttributeValue(), }, ReturnValues = "ALL_NEW", }, @@ -244,8 +247,8 @@ public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClie KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", ExpressionAttributeValues = new Dictionary { - [":pk"] = new() { S = expected.Pk }, - [":skPrefix"] = new() { S = "PROJECT#" }, + [":pk"] = expected.Pk.ToAttributeValue(), + [":skPrefix"] = "PROJECT#".ToAttributeValue(), }, }, TestContext.Current.CancellationToken); @@ -254,5 +257,5 @@ public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClie } private static Dictionary CreateKey(string pk, string sk) - => new() { ["pk"] = new AttributeValue { S = pk }, ["sk"] = new AttributeValue { S = sk } }; + => new() { ["pk"] = pk.ToAttributeValue(), ["sk"] = sk.ToAttributeValue() }; } From 8aa2bbe41318dd25947b626478a893875e7d36fe Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 13:34:14 -0400 Subject: [PATCH 21/26] refactor(test): update AttributeValue assertions to use BeEquivalentTo - Replaced inline assertions (`.Should().Be` and `.NotBeTrue`) with `BeEquivalentTo` for consistency. - Refactored tests to use helper methods for creating `AttributeValue` objects (e.g., `StringAttribute`, `NumberAttribute`). - Removed `AssertNullAttribute` in favor of `BeEquivalentTo(NullAttribute())`. - Improved test readability and alignment with FluentAssertions best practices. --- .../AttributeValueConverterExtensions.cs | 2 +- .../AttributeValueConverterExtensionsTests.cs | 91 ++++++++++--------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs index baece4b..24681a7 100644 --- a/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs +++ b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs @@ -2,7 +2,7 @@ using System.Globalization; using Amazon.DynamoDBv2.Model; -namespace LayeredCraft.DynamoMapper.Runtime; +namespace System; /// /// Convenience extensions for converting common scalar values to diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs index b594cb0..013b5b9 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs @@ -1,6 +1,5 @@ using System.Globalization; using Amazon.DynamoDBv2.Model; -using LayeredCraft.DynamoMapper.Runtime; namespace LayeredCraft.DynamoMapper.Client.Tests; @@ -11,8 +10,7 @@ public void String_ToAttributeValue_ReturnsStringAttribute() { var attribute = "hello".ToAttributeValue(); - attribute.S.Should().Be("hello"); - attribute.NULL.Should().NotBeTrue(); + attribute.Should().BeEquivalentTo(StringAttribute("hello")); } [Fact] @@ -22,7 +20,7 @@ public void NullableString_ToAttributeValue_ReturnsNullAttribute_WhenValueIsNull var attribute = value.ToAttributeValue(); - AssertNullAttribute(attribute); + attribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -30,8 +28,7 @@ public void Bool_ToAttributeValue_ReturnsBoolAttribute() { var attribute = true.ToAttributeValue(); - attribute.BOOL.Should().BeTrue(); - attribute.NULL.Should().NotBeTrue(); + attribute.Should().BeEquivalentTo(BoolAttribute(true)); } [Fact] @@ -43,9 +40,8 @@ public void NullableBool_ToAttributeValue_ReturnsBoolOrNullAttribute() var attribute = value.ToAttributeValue(); var nullAttribute = nullValue.ToAttributeValue(); - attribute.BOOL.Should().BeFalse(); - attribute.NULL.Should().NotBeTrue(); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(BoolAttribute(false)); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -55,7 +51,7 @@ public void Int_ToAttributeValue_UsesInvariantCultureAndFormatString() var attribute = 12345.ToAttributeValue("N0"); - attribute.N.Should().Be("12,345"); + attribute.Should().BeEquivalentTo(NumberAttribute("12,345")); } [Fact] @@ -67,8 +63,8 @@ public void NullableInt_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("X"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.N.Should().Be("FF"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(NumberAttribute("FF")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -78,7 +74,7 @@ public void Long_ToAttributeValue_UsesInvariantCultureAndFormatString() var attribute = 123456789L.ToAttributeValue("N0"); - attribute.N.Should().Be("123,456,789"); + attribute.Should().BeEquivalentTo(NumberAttribute("123,456,789")); } [Fact] @@ -90,8 +86,8 @@ public void NullableLong_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("X"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.N.Should().Be("FFF"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(NumberAttribute("FFF")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -101,7 +97,7 @@ public void Float_ToAttributeValue_UsesInvariantCulture() var attribute = 12.5f.ToAttributeValue(); - attribute.N.Should().Be("12.5"); + attribute.Should().BeEquivalentTo(NumberAttribute("12.5")); } [Fact] @@ -113,8 +109,8 @@ public void NullableFloat_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("0.00"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.N.Should().Be("12.50"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(NumberAttribute("12.50")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -124,7 +120,7 @@ public void Double_ToAttributeValue_UsesInvariantCulture() var attribute = 1234.5d.ToAttributeValue(); - attribute.N.Should().Be("1234.5"); + attribute.Should().BeEquivalentTo(NumberAttribute("1234.5")); } [Fact] @@ -136,8 +132,8 @@ public void NullableDouble_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("0.000"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.N.Should().Be("1234.500"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(NumberAttribute("1234.500")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -147,7 +143,7 @@ public void Decimal_ToAttributeValue_UsesInvariantCulture() var attribute = 1234.5m.ToAttributeValue(); - attribute.N.Should().Be("1234.5"); + attribute.Should().BeEquivalentTo(NumberAttribute("1234.5")); } [Fact] @@ -159,8 +155,8 @@ public void NullableDecimal_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("0.000"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.N.Should().Be("1234.500"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(NumberAttribute("1234.500")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -170,7 +166,7 @@ public void Guid_ToAttributeValue_UsesDefaultDFormat() var attribute = value.ToAttributeValue(); - attribute.S.Should().Be("00112233-4455-6677-8899-aabbccddeeff"); + attribute.Should().BeEquivalentTo(StringAttribute("00112233-4455-6677-8899-aabbccddeeff")); } [Fact] @@ -182,8 +178,8 @@ public void NullableGuid_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("N"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.S.Should().Be("00112233445566778899aabbccddeeff"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(StringAttribute("00112233445566778899aabbccddeeff")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -193,7 +189,9 @@ public void DateTime_ToAttributeValue_UsesDefaultRoundTripFormat() var attribute = value.ToAttributeValue(); - attribute.S.Should().Be(value.ToString("o", CultureInfo.InvariantCulture)); + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture))); } [Fact] @@ -205,8 +203,8 @@ public void NullableDateTime_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("yyyyMMdd"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.S.Should().Be("20250407"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(StringAttribute("20250407")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -216,7 +214,9 @@ public void DateTimeOffset_ToAttributeValue_UsesDefaultRoundTripFormat() var attribute = value.ToAttributeValue(); - attribute.S.Should().Be(value.ToString("o", CultureInfo.InvariantCulture)); + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture))); } [Fact] @@ -228,8 +228,8 @@ public void NullableDateTimeOffset_ToAttributeValue_UsesFormatStringOrNullAttrib var attribute = value.ToAttributeValue("yyyy-MM-dd zzz"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.S.Should().Be("2025-04-07 +00:00"); - AssertNullAttribute(nullAttribute); + attribute.Should().BeEquivalentTo(StringAttribute("2025-04-07 +00:00")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } [Fact] @@ -239,7 +239,9 @@ public void TimeSpan_ToAttributeValue_UsesDefaultConstantFormat() var attribute = value.ToAttributeValue(); - attribute.S.Should().Be(value.ToString("c", CultureInfo.InvariantCulture)); + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("c", CultureInfo.InvariantCulture))); } [Fact] @@ -252,17 +254,20 @@ public void NullableTimeSpan_ToAttributeValue_UsesFormatStringOrNullAttribute() var attribute = value.ToAttributeValue("g"); var nullAttribute = nullValue.ToAttributeValue(); - attribute.S.Should().Be(value.Value.ToString("g", CultureInfo.InvariantCulture)); - AssertNullAttribute(nullAttribute); + attribute + .Should() + .BeEquivalentTo( + StringAttribute(value.Value.ToString("g", CultureInfo.InvariantCulture))); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); } - private static void AssertNullAttribute(AttributeValue attribute) - { - attribute.NULL.Should().BeTrue(); - attribute.S.Should().BeNull(); - attribute.N.Should().BeNull(); - attribute.BOOL.Should().BeNull(); - } + private static AttributeValue StringAttribute(string value) => new() { S = value }; + + private static AttributeValue NumberAttribute(string value) => new() { N = value }; + + private static AttributeValue BoolAttribute(bool value) => new() { BOOL = value }; + + private static AttributeValue NullAttribute() => new() { NULL = true }; private sealed class CultureScope : IDisposable { From 2bc1abb9ddaf2e5d147e7b4997b7320b60576439 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 15:06:49 -0400 Subject: [PATCH 22/26] refactor(client): remove raw `ExecuteStatementAsync` method from DynamoClient - Removed `ExecuteStatementAsync` method that returned raw DynamoDB responses. - Kept the typed `ExecuteStatementAsync` method for mapped DTO handling. - Simplified the `DynamoClient` interface by eliminating unused raw response handling. --- src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index da1287f..b9ef211 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -135,15 +135,6 @@ public async Task> ScanAsync( return new ScanResponse(result, mappedItems); } - /// Executes a PartiQL statement against DynamoDB. - /// The PartiQL request to execute. - /// The cancellation token for the asynchronous operation. - /// The raw DynamoDB response for the executed statement. - public Task ExecuteStatementAsync( - ExecuteStatementRequest request, - CancellationToken cancellationToken = default) - => AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken); - /// Executes a PartiQL statement and maps each returned item to the specified DTO type. /// The DTO type to map the returned items to. /// The PartiQL request to execute. From 9095dc8a991a1effd700b12ed4d5a90e9b98e15f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 15:40:10 -0400 Subject: [PATCH 23/26] feat(client, test): add enriched response models for CRUD methods with mapped attributes - Introduced `DeleteItemResponse`, `PutItemResponse`, and `UpdateItemResponse` models. - Enhanced `DynamoClient` methods (`PutItemAsync`, `DeleteItemAsync`, `UpdateItemAsync`) to return enriched responses. - Updated integration tests to validate new response handling and mapped attributes (`MappedItem`). - Refactored test scenarios for CRUD operations to improve coverage and validation consistency. --- .../DynamoClient.cs | 55 +++++++- .../Models/DeleteItemResponse.cs | 27 ++++ .../Models/PutItemResponse.cs | 27 ++++ .../Models/UpdateItemResponse.cs | 22 +++ .../DynamoClientTests.cs | 129 ++++++++++++++++-- 5 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs create mode 100644 src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs index b9ef211..2324529 100644 --- a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -68,14 +68,35 @@ public async Task> GetItemAsync( /// The DynamoDB table name. /// The DTO instance to map and save. /// The cancellation token for the asynchronous operation. - /// A task that completes when the item has been written. - public Task PutItemAsync( + /// + /// A task that returns the DynamoDB response together with a mapped item when attributes are + /// returned. + /// + public async Task PutItemAsync( string tableName, T item, CancellationToken cancellationToken = default) { var mappedItem = GetMapper().ToItem(item); - return AmazonDynamoDb.PutItemAsync(tableName, mappedItem, cancellationToken); + return await AmazonDynamoDb.PutItemAsync(tableName, mappedItem, cancellationToken); + } + + /// Executes a put request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The put request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . + /// + public async Task> PutItemAsync( + PutItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.PutItemAsync(request, cancellationToken); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new PutItemResponse(result, mappedItem); } /// Deletes a single item by key from the specified table. @@ -89,20 +110,40 @@ public Task DeleteItemAsync( CancellationToken cancellationToken = default) => AmazonDynamoDb.DeleteItemAsync(tableName, key, cancellationToken); + /// Executes a delete request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The delete request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . + /// + public async Task> DeleteItemAsync( + DeleteItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.DeleteItemAsync(request, cancellationToken); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new DeleteItemResponse(result, mappedItem); + } + /// Executes an update request and maps returned attributes to the specified DTO type. /// The DTO type to map the returned attributes to. /// The update request to execute. /// The cancellation token for the asynchronous operation. /// - /// A task that returns the mapped DTO when the request returns attributes; otherwise, - /// . + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . /// - public async Task UpdateItemAsync( + public async Task> UpdateItemAsync( UpdateItemRequest request, CancellationToken cancellationToken = default) { var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); - return result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new UpdateItemResponse(result, mappedItem); } /// Executes a query request and maps each returned item to the specified DTO type. diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs new file mode 100644 index 0000000..7d84170 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs @@ -0,0 +1,27 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB DeleteItem operation together with optional +/// mapped returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +/// +/// DynamoDB only returns attributes for DeleteItem when +/// requests old values. In all other cases +/// is . +/// +public class DeleteItemResponse : DeleteItemResponse +{ + internal DeleteItemResponse(DeleteItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs new file mode 100644 index 0000000..d74c43e --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs @@ -0,0 +1,27 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB PutItem operation together with optional mapped +/// returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +/// +/// DynamoDB only returns attributes for PutItem when +/// requests ALL_OLD. In all other cases +/// is . +/// +public class PutItemResponse : PutItemResponse +{ + internal PutItemResponse(PutItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs new file mode 100644 index 0000000..ef12703 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs @@ -0,0 +1,22 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB UpdateItem operation together with optional +/// mapped returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +public class UpdateItemResponse : UpdateItemResponse +{ + internal UpdateItemResponse(UpdateItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs index 2baf8f9..439e073 100644 --- a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -1,6 +1,5 @@ using Amazon.DynamoDBv2.Model; using LayeredCraft.DynamoMapper.Client.DependencyInjection; -using LayeredCraft.DynamoMapper.Runtime; using Microsoft.Extensions.DependencyInjection; namespace LayeredCraft.DynamoMapper.Client.Tests; @@ -85,25 +84,22 @@ public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() } [Fact] - public async Task ExecuteStatementAsync_RawResponse_ReturnsDynamoDbItems() + public async Task ExecuteStatementAsync_UserProfile_WithKeyFilter_ReturnsMappedItem() { - var response = await _client.ExecuteStatementAsync( + var expected = TestDataSamples.UserProfiles[0]; + + var response = await _client.ExecuteStatementAsync( new ExecuteStatementRequest { Statement = $""" SELECT * FROM "{DynamoDbFixture.TableName}" WHERE pk = ? AND sk = ? """, - Parameters = - [ - TestDataSamples.UserProfiles[0].Pk.ToAttributeValue(), - TestDataSamples.UserProfiles[0].Sk.ToAttributeValue(), - ], + Parameters = [expected.Pk.ToAttributeValue(), expected.Sk.ToAttributeValue()], }, TestContext.Current.CancellationToken); - response.Items.Should().ContainSingle(); - response.Items[0]["entityType"].S.Should().Be("UserProfile"); + response.MappedItems.Should().BeEquivalentTo([expected]); } [Fact] @@ -157,6 +153,78 @@ await _client.DeleteItemAsync( deleted.MappedItem.Should().BeNull(); } + [Fact] + public async Task PutItemAsync_UserProfile_WithAllOld_ReturnsMappedOldItem() + { + var original = new UserProfile + { + Pk = "USER#u-9998", + Sk = "PROFILE#u-9998", + EntityType = "UserProfile", + UserId = "u-9998", + Email = "original@example.com", + DisplayName = "Original User", + Age = 41, + IsActive = true, + AccountBalance = 10.25m, + CreatedAt = "2025-04-01T00:00:00Z", + LastLoginEpoch = 1743465600, + Tags = ["temp", "original"], + Preferences = + new UserPreferences + { + Theme = "dark", NotificationsEnabled = true, Language = "en-US", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-04-01T00:00:00Z", IpAddress = "203.0.113.99", + }, + ], + ProfilePhoto = [9, 9, 9], + }; + var replacement = new UserProfile + { + Pk = original.Pk, + Sk = original.Sk, + EntityType = original.EntityType, + UserId = original.UserId, + Email = "updated@example.com", + DisplayName = "Updated User", + Age = original.Age, + IsActive = original.IsActive, + AccountBalance = original.AccountBalance, + CreatedAt = original.CreatedAt, + LastLoginEpoch = original.LastLoginEpoch, + Tags = original.Tags, + Preferences = original.Preferences, + LoginHistory = original.LoginHistory, + ProfilePhoto = original.ProfilePhoto, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + original, + TestContext.Current.CancellationToken); + + var response = await _client.PutItemAsync( + new PutItemRequest + { + TableName = DynamoDbFixture.TableName, + Item = _client.GetMapper().ToItem(replacement), + ReturnValues = "ALL_OLD", + }, + TestContext.Current.CancellationToken); + + response.MappedItem.Should().BeEquivalentTo(original); + + await _client.DeleteItemAsync( + DynamoDbFixture.TableName, + CreateKey(original.Pk, original.Sk), + TestContext.Current.CancellationToken); + } + [Fact] public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() { @@ -195,7 +263,46 @@ public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() }, TestContext.Current.CancellationToken); - updated.Should().BeEquivalentTo(expected); + updated.MappedItem.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task DeleteItemAsync_TaskRecord_WithAllOld_ReturnsMappedDeletedItem() + { + var existing = new TaskRecord + { + Pk = "PROJECT#p-9998", + Sk = "TASK#t-9998", + EntityType = "TaskRecord", + TaSkId = "t-9998", + ProjectId = "p-9998", + AssignedUserId = "u-1001", + Title = "Temporary task", + Notes = "Delete me", + EstimateHours = 1.5m, + Completed = false, + Order = 1, + CreatedAt = "2025-04-01T00:00:00Z", + DueAt = "2025-04-02T00:00:00Z", + Checklist = [new TaSkChecklistItem { Text = "One", Done = false }], + Metadata = new TaSkMetadata { Color = "green", BlockedBy = null }, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + existing, + TestContext.Current.CancellationToken); + + var deleted = await _client.DeleteItemAsync( + new DeleteItemRequest + { + TableName = DynamoDbFixture.TableName, + Key = CreateKey(existing.Pk, existing.Sk), + ReturnValues = "ALL_OLD", + }, + TestContext.Current.CancellationToken); + + deleted.MappedItem.Should().BeEquivalentTo(existing); } [Fact] From df04e1816bfb0b08186c460bc56a8af48b67ab8d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 15:44:45 -0400 Subject: [PATCH 24/26] feat(ci): add OpenCode configuration for automated code formatting - Introduced `.opencode/opencode.jsonc` for defining code formatting instructions and tools. - Configured `cs-jb-formatter` for formatting `.cs`, `.props`, and `.csproj` files using JetBrains cleanup tool. - Configured `mdformat` for `.md` files with MkDocs and frontmatter support. - Added file exclusions for `.agents`, `.claude`, and `.opencode` directories. --- .opencode/opencode.jsonc | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .opencode/opencode.jsonc diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..8ec3ee6 --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,45 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "CLAUDE.local.md" + ], + "formatter": { + "cs-jb-formatter": { + "command": [ + "dotnet", + "tool", + "run", + "jb", + "cleanupcode", + "--profile=Built-in: Reformat Code", + "--include=$FILE", + "--settings=LayeredCraft.DynamoMapper.sln.DotSettings" + ], + "extensions": [ + ".cs", + ".props", + ".csproj" + ] + }, + "mdformat": { + "command": [ + "uvx", + "--with", + "mdformat-mkdocs", + "--with", + "mdformat-frontmatter", + "mdformat", + "$FILE", + "--exclude", + ".agents/**", + "--exclude", + ".claude/**", + "--exclude", + ".opencode/**" + ], + "extensions": [ + ".md" + ] + } + } +} \ No newline at end of file From 7840afa8f226b1f4072796530e87ba1c7dedabc0 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 15:45:37 -0400 Subject: [PATCH 25/26] feat(hooks): add format hook for automated code cleanup support - Added `.claude/hooks/format.py` for handling C# and Markdown file formatting. - Configured `format.py` to use JetBrains cleanup tool for C# and `mdformat` for Markdown. - Integrated format hook into `.claude/settings.json` to trigger after tool usage. - Supported exclusions for `.agents`, `.claude`, and `.opencode` directories. --- .claude/hooks/format.py | 102 ++++++++++++++++++++++++++++++++++++++++ .claude/settings.json | 16 +++++++ 2 files changed, 118 insertions(+) create mode 100644 .claude/hooks/format.py create mode 100644 .claude/settings.json diff --git a/.claude/hooks/format.py b/.claude/hooks/format.py new file mode 100644 index 0000000..12cdcfb --- /dev/null +++ b/.claude/hooks/format.py @@ -0,0 +1,102 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.14" +# /// + +import json +import os +import sys +import subprocess + +DOTSETTINGS_FILE_NAME = "LayeredCraft.DynamoMapper.sln.DotSettings" + + +def main(): + try: + # Read JSON input from stdin + input_data = json.load(sys.stdin) + + cwd = input_data["cwd"] + eddited_input = input_data["tool_input"]["file_path"] + _, ext = os.path.splitext(eddited_input) + + print(f"Running code cleanup on: '{eddited_input}' in directory: '{cwd}'") + + match ext.lower(): + case ".cs" | ".csx" | ".csproj" | ".props": + csharp(cwd, eddited_input) + case ".md": + markdown(cwd, eddited_input) + case _: + print(f"Skipping unsupported file type: '{ext}'") + + sys.exit(0) + + except json.JSONDecodeError: + # Handle JSON decode errors gracefully + sys.exit(0) + except Exception: + # Exit cleanly on any other error + sys.exit(0) + + +def csharp(cwd: str, eddited_input: str) -> None: + print("======================================") + + print("Running C# code cleanup...") + + result = subprocess.run( + [ + "dotnet", + "tool", + "run", + "jb", + "cleanupcode", + "--profile=Built-in: Reformat Code", + f"--include={eddited_input}", + f"--settings={DOTSETTINGS_FILE_NAME}", + ], + cwd=cwd, + capture_output=True, + text=True, + ) + + print(result.stdout) + + print("======================================") + + +def markdown(cwd: str, eddited_input: str) -> None: + print("======================================") + + print("Running Markdown code cleanup...") + + result = subprocess.run( + [ + "uvx", + "--with", + "mdformat-mkdocs", + "--with", + "mdformat-frontmatter", + "mdformat", + eddited_input, + "--exclude", + ".agents/**", + "--exclude", + ".claude/**", + "--exclude", + ".opencode/**" + ], + cwd=cwd, + capture_output=True, + text=True, + ) + + print(result.stdout) + + print("======================================") + + +if __name__ == "__main__": + print("Running format_cs.py hook...") + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..56bc252 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "respectGitignore": false, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/format.py" + } + ] + } + ] + } +} From 4153f8b59d593c7249af8f65c88963829c30e8b9 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Tue, 7 Apr 2026 15:49:21 -0400 Subject: [PATCH 26/26] feat(hooks): use environment variable for JetBrains DotSettings file path - Updated `format.py` to retrieve `DOTSETTINGS_FILE` via environment variable for better configurability. - Added `env.DOTSETTINGS_FILE` entry in `.claude/settings.json` for default file path configuration. --- .claude/hooks/format.py | 2 +- .claude/settings.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/format.py b/.claude/hooks/format.py index 12cdcfb..f4c278a 100644 --- a/.claude/hooks/format.py +++ b/.claude/hooks/format.py @@ -54,7 +54,7 @@ def csharp(cwd: str, eddited_input: str) -> None: "cleanupcode", "--profile=Built-in: Reformat Code", f"--include={eddited_input}", - f"--settings={DOTSETTINGS_FILE_NAME}", + f"--settings={os.getenv('DOTSETTINGS_FILE')}", ], cwd=cwd, capture_output=True, diff --git a/.claude/settings.json b/.claude/settings.json index 56bc252..836a1fd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,5 +12,8 @@ ] } ] + }, + "env": { + "DOTSETTINGS_FILE": "LayeredCraft.DynamoMapper.sln.DotSettings" } }