From e4605577ad4688cb9de05979c9894bd45345a1c9 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 12 Apr 2026 20:57:14 -0400 Subject: [PATCH 01/12] feat(mapper): add support for lifecycle hooks in generated mappers - Introduced lifecycle hooks `BeforeToItem`, `AfterToItem`, `BeforeFromItem`, and `AfterFromItem`. - Added validation diagnostics for hook signatures, static declaration, and parameter type checks. - Updated templates to incorporate hooks when present. - Extended `MapperClassInfo` model to support hook-related metadata. --- .../Diagnostics/DiagnosticDescriptors.cs | 30 +++++++++++++++++++ .../Models/MapperClassInfo.cs | 25 ++++++++++++++-- .../Templates/Mapper.scriban | 23 ++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs index 106437e2..1ae46371 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -115,4 +115,34 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Error, true ); + + internal static readonly DiagnosticDescriptor InvalidHookSignature = + new( + "DM0401", + "Hook signature doesn't match expected format", + "The method '{0}' does not match the expected hook signature for '{1}'", + UsageCategory, + DiagnosticSeverity.Warning, + true + ); + + internal static readonly DiagnosticDescriptor HookNotStatic = + new( + "DM0402", + "Hook method is not static", + "The hook method '{0}' must be declared as static", + UsageCategory, + DiagnosticSeverity.Warning, + true + ); + + internal static readonly DiagnosticDescriptor HookParameterTypeMismatch = + new( + "DM0403", + "Hook parameter types don't match entity type", + "The hook method '{0}' parameter types must match the entity type '{1}'", + UsageCategory, + DiagnosticSeverity.Warning, + true + ); } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs index 48c008fc..7ed5e169 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs @@ -12,8 +12,13 @@ internal sealed record MapperClassInfo( string? ToItemSignature, string? FromItemSignature, string? FromItemParameterName, + string? ToItemParameterName, LocationInfo? Location, - EquatableArray HelperMethods + EquatableArray HelperMethods, + bool HasBeforeToItem, + bool HasAfterToItem, + bool HasBeforeFromItem, + bool HasAfterFromItem ); internal static class MapperClassInfoExtensions @@ -68,9 +73,16 @@ internal static class MapperClassInfoExtensions var fromItemParameterName = fromItemMethod?.Parameters.FirstOrDefault()?.Name; + var toItemParameterName = + toItemMethod?.Parameters.FirstOrDefault()?.Name; + + var hasBeforeToItem = IsHookPresent(methods, "BeforeToItem", 2); + var hasAfterToItem = IsHookPresent(methods, "AfterToItem", 2); + var hasBeforeFromItem = IsHookPresent(methods, "BeforeFromItem", 1); + var hasAfterFromItem = IsHookPresent(methods, "AfterFromItem", 2); return DiagnosticResult<(MapperClassInfo, ITypeSymbol)>.Success( - (new MapperClassInfo(classSymbol.Name, namespaceStatement, classSignature, toItemSignature, fromItemSignature, fromItemParameterName, context.TargetNode.CreateLocationInfo(), new EquatableArray()), + (new MapperClassInfo(classSymbol.Name, namespaceStatement, classSignature, toItemSignature, fromItemSignature, fromItemParameterName, toItemParameterName, context.TargetNode.CreateLocationInfo(), new EquatableArray(), hasBeforeToItem, hasAfterToItem, hasBeforeFromItem, hasAfterFromItem), modelType) ); } @@ -165,4 +177,13 @@ private static string GetMethodSignature(IMethodSymbol method) return $"{accessibility} {modifiers}partial {returnType} {method.Name}({extensionMethod}{parameterType} {parameterName})"; } + + private static bool IsHookPresent(IMethodSymbol[] methods, string name, int paramCount) => + methods.Any(m => + m.Name == name && + m.IsStatic && + m.ReturnsVoid && + m.Parameters.Length == paramCount && + (m.IsPartialDefinition || m.PartialDefinitionPart != null) + ); } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban index 5ae232c4..65cac886 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban +++ b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban @@ -22,12 +22,29 @@ using Amazon.DynamoDBv2.Model; { {{~ if mapper_class.to_item_signature != null ~}} {{ generated_code_attribute }} + {{~ if mapper_class.has_before_to_item || mapper_class.has_after_to_item ~}} + {{ mapper_class.to_item_signature }} + { + var item = new Dictionary({{ dictionary_capacity }}); + {{~ if mapper_class.has_before_to_item ~}} + BeforeToItem({{ mapper_class.to_item_parameter_name }}, item); + {{~ end ~}} + {{~ for assignment in to_assignments ~}} + item{{ assignment }}; + {{~ end ~}} + {{~ if mapper_class.has_after_to_item ~}} + AfterToItem({{ mapper_class.to_item_parameter_name }}, item); + {{~ end ~}} + return item; + } + {{~ else ~}} {{ mapper_class.to_item_signature }} => new Dictionary({{ dictionary_capacity }}){{ if dictionary_capacity == 0 }};{{ end }} {{~ for assignment in to_assignments ~}} {{ assignment }}{{ if for.last }};{{ end }} {{~ end ~}} {{~ end ~}} + {{~ end ~}} {{~ if mapper_class.to_item_signature != null && mapper_class.from_item_signature != null ~}} {{~ end ~}} @@ -35,6 +52,9 @@ using Amazon.DynamoDBv2.Model; {{ generated_code_attribute }} {{ mapper_class.from_item_signature }} { + {{~ if mapper_class.has_before_from_item ~}} + BeforeFromItem({{ mapper_class.from_item_parameter_name }}); + {{~ end ~}} {{~ if model_class.constructor != null ~}} {{~ # Constructor-based initialization ~}} {{~ if from_init_assignments.size == 0 ~}} @@ -71,6 +91,9 @@ using Amazon.DynamoDBv2.Model; {{~ for assignment in from_assignments ~}} {{ assignment }} {{~ end ~}} + {{~ if mapper_class.has_after_from_item ~}} + AfterFromItem({{ mapper_class.from_item_parameter_name }}, ref {{ model_var_name }}); + {{~ end ~}} return {{ model_var_name }}; } {{~ end ~}} From 8af657481a6558158fe9ed98af490e51d7162a91 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 12 Apr 2026 20:57:24 -0400 Subject: [PATCH 02/12] test(hooks): add comprehensive tests for lifecycle hooks in mappers - Added unit tests for all hook combinations: BeforeToItem, AfterToItem, BeforeFromItem, and AfterFromItem. - Verified preservation of expression bodies when no hooks are used. - Added snapshot test cases for all scenarios to ensure generated code accuracy. - Covered edge cases for mappers with partial hooks and varying source fields. --- .../HooksVerifyTests.cs | 222 ++++++++++++++++++ ...fterFromItem_Only#UserMapper.g.verified.cs | 41 ++++ ...terToItem_Only#ProductMapper.g.verified.cs | 42 ++++ ...ts.Hooks_AllFour#OrderMapper.g.verified.cs | 47 ++++ ...oreFromItem_Only#OrderMapper.g.verified.cs | 39 +++ ...oreToItem_Only#ProductMapper.g.verified.cs | 42 ++++ ...ExpressionBody#ProductMapper.g.verified.cs | 38 +++ ...ithAfterToItem#ProductMapper.g.verified.cs | 30 +++ 8 files changed, 501 insertions(+) create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterFromItem_Only#UserMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterToItem_Only#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AllFour#OrderMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeFromItem_Only#OrderMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeToItem_Only#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NoHooks_PreservesExpressionBody#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ToItemOnly_Mapper_WithAfterToItem#ProductMapper.g.verified.cs diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs new file mode 100644 index 00000000..da0dc02f --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs @@ -0,0 +1,222 @@ +namespace LayeredCraft.DynamoMapper.Generators.Tests; + +public class HooksVerifyTests +{ + [Fact] + public async Task Hooks_NoHooks_PreservesExpressionBody() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + public static partial Product FromItem(Dictionary item); + } + + public class Product + { + public string Name { get; set; } + public decimal Price { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_AfterToItem_Only() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + public static partial Product FromItem(Dictionary item); + + static partial void AfterToItem(Product source, Dictionary item); + } + + public class Product + { + public string Name { get; set; } + public decimal Price { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_BeforeToItem_Only() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + public static partial Product FromItem(Dictionary item); + + static partial void BeforeToItem(Product source, Dictionary item); + } + + public class Product + { + public string Name { get; set; } + public decimal Price { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_AfterFromItem_Only() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class UserMapper + { + public static partial Dictionary ToItem(User source); + public static partial User FromItem(Dictionary item); + + static partial void AfterFromItem(Dictionary item, ref User entity); + } + + public class User + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_BeforeFromItem_Only() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class OrderMapper + { + public static partial Dictionary ToItem(Order source); + public static partial Order FromItem(Dictionary item); + + static partial void BeforeFromItem(Dictionary item); + } + + public class Order + { + public string CustomerId { get; set; } + public decimal Total { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_AllFour() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class OrderMapper + { + public static partial Dictionary ToItem(Order source); + public static partial Order FromItem(Dictionary item); + + static partial void BeforeToItem(Order source, Dictionary item); + static partial void AfterToItem(Order source, Dictionary item); + static partial void BeforeFromItem(Dictionary item); + static partial void AfterFromItem(Dictionary item, ref Order entity); + } + + public class Order + { + public string CustomerId { get; set; } + public string OrderId { get; set; } + public decimal Total { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_ToItemOnly_Mapper_WithAfterToItem() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + + static partial void AfterToItem(Product source, Dictionary item); + } + + public class Product + { + public string Name { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterFromItem_Only#UserMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterFromItem_Only#UserMapper.g.verified.cs new file mode 100644 index 00000000..f4f38bab --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterFromItem_Only#UserMapper.g.verified.cs @@ -0,0 +1,41 @@ +//HintName: UserMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class UserMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.User source) => + new Dictionary(3) + .SetString("firstName", source.FirstName, false, true) + .SetString("lastName", source.LastName, false, true) + .SetString("email", source.Email, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.User FromItem(global::System.Collections.Generic.Dictionary item) + { + var user = new global::MyNamespace.User + { + FirstName = item.GetString("firstName", Requiredness.InferFromNullability), + LastName = item.GetString("lastName", Requiredness.InferFromNullability), + Email = item.GetString("email", Requiredness.InferFromNullability), + }; + AfterFromItem(item, ref user); + return user; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterToItem_Only#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterToItem_Only#ProductMapper.g.verified.cs new file mode 100644 index 00000000..dbce5157 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AfterToItem_Only#ProductMapper.g.verified.cs @@ -0,0 +1,42 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) + { + var item = new Dictionary(2); + item.SetString("name", source.Name, false, true); + item.SetDecimal("price", source.Price, false, true); + AfterToItem(source, item); + return item; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Product FromItem(global::System.Collections.Generic.Dictionary item) + { + var product = new global::MyNamespace.Product + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Price = item.GetDecimal("price", Requiredness.InferFromNullability), + }; + return product; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AllFour#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AllFour#OrderMapper.g.verified.cs new file mode 100644 index 00000000..26d4bd66 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_AllFour#OrderMapper.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OrderMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class OrderMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) + { + var item = new Dictionary(3); + BeforeToItem(source, item); + item.SetString("customerId", source.CustomerId, false, true); + item.SetString("orderId", source.OrderId, false, true); + item.SetDecimal("total", source.Total, false, true); + AfterToItem(source, item); + return item; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) + { + BeforeFromItem(item); + var order = new global::MyNamespace.Order + { + CustomerId = item.GetString("customerId", Requiredness.InferFromNullability), + OrderId = item.GetString("orderId", Requiredness.InferFromNullability), + Total = item.GetDecimal("total", Requiredness.InferFromNullability), + }; + AfterFromItem(item, ref order); + return order; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeFromItem_Only#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeFromItem_Only#OrderMapper.g.verified.cs new file mode 100644 index 00000000..e199b2bf --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeFromItem_Only#OrderMapper.g.verified.cs @@ -0,0 +1,39 @@ +//HintName: OrderMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class OrderMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => + new Dictionary(2) + .SetString("customerId", source.CustomerId, false, true) + .SetDecimal("total", source.Total, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) + { + BeforeFromItem(item); + var order = new global::MyNamespace.Order + { + CustomerId = item.GetString("customerId", Requiredness.InferFromNullability), + Total = item.GetDecimal("total", Requiredness.InferFromNullability), + }; + return order; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeToItem_Only#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeToItem_Only#ProductMapper.g.verified.cs new file mode 100644 index 00000000..34094f97 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_BeforeToItem_Only#ProductMapper.g.verified.cs @@ -0,0 +1,42 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) + { + var item = new Dictionary(2); + BeforeToItem(source, item); + item.SetString("name", source.Name, false, true); + item.SetDecimal("price", source.Price, false, true); + return item; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Product FromItem(global::System.Collections.Generic.Dictionary item) + { + var product = new global::MyNamespace.Product + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Price = item.GetDecimal("price", Requiredness.InferFromNullability), + }; + return product; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NoHooks_PreservesExpressionBody#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NoHooks_PreservesExpressionBody#ProductMapper.g.verified.cs new file mode 100644 index 00000000..5f03392e --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NoHooks_PreservesExpressionBody#ProductMapper.g.verified.cs @@ -0,0 +1,38 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(2) + .SetString("name", source.Name, false, true) + .SetDecimal("price", source.Price, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Product FromItem(global::System.Collections.Generic.Dictionary item) + { + var product = new global::MyNamespace.Product + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Price = item.GetDecimal("price", Requiredness.InferFromNullability), + }; + return product; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ToItemOnly_Mapper_WithAfterToItem#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ToItemOnly_Mapper_WithAfterToItem#ProductMapper.g.verified.cs new file mode 100644 index 00000000..40905b05 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ToItemOnly_Mapper_WithAfterToItem#ProductMapper.g.verified.cs @@ -0,0 +1,30 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) + { + var item = new Dictionary(1); + item.SetString("name", source.Name, false, true); + AfterToItem(source, item); + return item; + } +} From a30db0ee8141eb435be0cf15d0a9667fafde58c1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 12 Apr 2026 20:57:30 -0400 Subject: [PATCH 03/12] feat(diagnostics): introduce new rules for hook signature validation - Added DM0401 to validate hook signature format. - Added DM0402 to ensure hook methods are static. - Added DM0403 to validate parameter types in hooks. - Updated AnalyzerReleases.Unshipped.md with the new diagnostic rules. --- .../AnalyzerReleases.Unshipped.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md index ba7bf250..ff1793ef 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md +++ b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md @@ -16,3 +16,11 @@ DM0009 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | DM0101 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed DM0102 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed DM0103 | LayeredCraft.DynamoMapper.Usage | Error | DynamoMapper.Usage | Error | Category changed + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +DM0401 | LayeredCraft.DynamoMapper.Usage | Warning | Hook signature doesn't match expected format +DM0402 | LayeredCraft.DynamoMapper.Usage | Warning | Hook method is not static +DM0403 | LayeredCraft.DynamoMapper.Usage | Warning | Hook parameter types don't match entity type From de904196b53fcc98789ff702dde7481854af4186 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 12 Apr 2026 20:58:47 -0400 Subject: [PATCH 04/12] docs(hooks): enhance customization hooks documentation - Expanded explanation of zero-cost abstraction for unimplemented hooks. - Clarified behavior for generated code with and without declared hooks. - Updated Phase 2 section to note planned DSL-based hook configuration. - Added examples and diagnostics for common hook signature errors. --- docs/usage/customization-hooks.md | 62 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/docs/usage/customization-hooks.md b/docs/usage/customization-hooks.md index e4a4be9f..e6ad9793 100644 --- a/docs/usage/customization-hooks.md +++ b/docs/usage/customization-hooks.md @@ -501,15 +501,30 @@ Hooks execute in a deterministic, predictable order: ### Zero-Cost Abstraction -Unimplemented hooks compile away completely: +The generator only emits hook calls when it detects a hook declaration in your mapper class. When no hooks are declared, `ToItem` is generated as a compact expression body with no overhead: ```csharp -// If no hooks are implemented: +// Generated when no hooks are declared: public static partial Dictionary ToItem(Product source) => new Dictionary(1) .SetGuid("productId", source.ProductId, false, true); ``` +When a hook is declared (even without an implementation), the generator switches to block-body form and emits the call: + +```csharp +// Generated when AfterToItem is declared: +public static partial Dictionary ToItem(Product source) +{ + var item = new Dictionary(1); + item.SetGuid("productId", source.ProductId, false, true); + AfterToItem(source, item); + return item; +} +``` + +If you declare a hook but never implement it, C# `partial void` semantics remove the call at compile time — zero runtime overhead. + ### No Reflection Hooks are statically bound at compile time. No runtime discovery or reflection overhead. @@ -520,34 +535,9 @@ Generated code reuses the same item dictionary instance across hook calls. ## DSL Integration (Phase 2) -In Phase 2, hooks can be configured via DSL (though partial methods remain the recommended approach): +> **Not yet implemented.** DSL-based hook configuration is planned for Phase 2. -```csharp -[DynamoMapper] -public static partial class OrderMapper -{ - public static partial Dictionary ToItem(Order source); - public static partial Order FromItem(Dictionary item); - - static partial void Configure(DynamoMapBuilder map) - { - map.BeforeToItem((source, item) => - { - // Limited DSL hook support - item.SetString("pk", $"CUSTOMER#{source.CustomerId}"); - }); - } - - // Partial method hooks are still supported and recommended for complex logic - static partial void AfterToItem(Order source, Dictionary item) - { - item.SetString("sk", $"ORDER#{source.OrderId}"); - item.SetString("recordType", "Order"); - } -} -``` - -Note: DSL hooks have limited expression support. Partial method hooks are more powerful and flexible. +In Phase 2, hooks will also be configurable via a fluent DSL. Partial method hooks will remain the recommended approach for complex logic. ## Best Practices @@ -616,8 +606,22 @@ Note: DSL hooks have limited expression support. Partial method hooks are more p The generator validates hook signatures and emits diagnostics for common errors: - **DM0401**: Hook signature doesn't match expected format + ```csharp + // Wrong: too many parameters + static partial void AfterToItem(Product source, Dictionary item, string extra); + ``` + - **DM0402**: Hook method is not static + ```csharp + // Wrong: missing static + partial void AfterToItem(Product source, Dictionary item); + ``` + - **DM0403**: Hook parameter types don't match entity type + ```csharp + // Wrong: first parameter is string, not Product + static partial void AfterToItem(string source, Dictionary item); + ``` ## See Also From 844d98fb37fe0e04e17dfe589e55f306bac41a1d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 12 Apr 2026 21:01:19 -0400 Subject: [PATCH 05/12] test(integration-tests): add integration tests for HooksIntegrationModelMapper - Added comprehensive tests for all hook scenarios: BeforeToItem, AfterToItem, BeforeFromItem, AfterFromItem. - Verified round-trip consistency of mapped properties and hook-added keys. - Ensured unmapped properties like NormalizedName are ignored in ToItem and derived in FromItem. - Covered edge cases for entity type validation and lifecycle marker injection. --- .../HooksMapperTests.cs | 175 ++++++++++++++++++ .../HooksModel.cs | 87 +++++++++ 2 files changed, 262 insertions(+) create mode 100644 test/LayeredCraft.DynamoMapper.IntegrationTests/HooksMapperTests.cs create mode 100644 test/LayeredCraft.DynamoMapper.IntegrationTests/HooksModel.cs diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksMapperTests.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksMapperTests.cs new file mode 100644 index 00000000..fb2aa3e5 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksMapperTests.cs @@ -0,0 +1,175 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.IntegrationTests; + +public class HooksMapperTests +{ + // ── ToItem ──────────────────────────────────────────────────────────────── + + [Fact] + public void ToItem_AfterToItem_InjectsPkAndSk() + { + var model = new HooksIntegrationModel { Id = "abc-123", Name = "Widget" }; + + var item = HooksIntegrationModelMapper.ToItem(model); + + item["pk"].S.Should().Be("MODEL#abc-123"); + item["sk"].S.Should().Be("METADATA"); + } + + [Fact] + public void ToItem_BeforeToItem_RunsBeforePropertyMapping() + { + // BeforeToItem sets _lifecyclePhase = "before-mapping". + // AfterToItem overwrites it to "after-mapping". + // If BeforeToItem never ran, the key won't exist at all (or be stale). + // If AfterToItem ran last, the final value is "after-mapping". + var model = new HooksIntegrationModel { Id = "x", Name = "Y" }; + + var item = HooksIntegrationModelMapper.ToItem(model); + + item["_lifecyclePhase"].S.Should().Be("after-mapping"); + } + + [Fact] + public void ToItem_PropertyMappingRunsBetweenHooks() + { + // Mapped properties (id, name) must be present alongside hook-injected keys. + var model = new HooksIntegrationModel { Id = "my-id", Name = "my-name" }; + + var item = HooksIntegrationModelMapper.ToItem(model); + + // Properties from mapper + item["id"].S.Should().Be("my-id"); + item["name"].S.Should().Be("my-name"); + // Keys from AfterToItem hook + item["pk"].S.Should().Be("MODEL#my-id"); + item["sk"].S.Should().Be("METADATA"); + // Lifecycle marker (set by BeforeToItem, overwritten by AfterToItem) + item["_lifecyclePhase"].S.Should().Be("after-mapping"); + } + + [Fact] + public void ToItem_NormalizedName_IsNotMapped() + { + // NormalizedName is marked [DynamoIgnore] — must not appear in the item. + var model = new HooksIntegrationModel + { + Id = "id", + Name = "name", + NormalizedName = "SHOULD-NOT-APPEAR", + }; + + var item = HooksIntegrationModelMapper.ToItem(model); + + item.Should().NotContainKey("normalizedName"); + } + + // ── FromItem ────────────────────────────────────────────────────────────── + + [Fact] + public void FromItem_AfterFromItem_PopulatesNormalizedName() + { + var item = new Dictionary + { + ["id"] = new() { S = "some-id" }, + ["name"] = new() { S = "Widget Pro" }, + }; + + var entity = HooksIntegrationModelMapper.FromItem(item); + + entity.NormalizedName.Should().Be("WIDGET PRO"); + } + + [Fact] + public void FromItem_AfterFromItem_NormalizedNameDerivedFromMappedName() + { + // Verifies AfterFromItem receives the already-mapped entity, not a blank one. + var item = new Dictionary + { + ["id"] = new() { S = "id-1" }, + ["name"] = new() { S = "hello world" }, + }; + + var entity = HooksIntegrationModelMapper.FromItem(item); + + entity.Name.Should().Be("hello world"); + entity.NormalizedName.Should().Be("HELLO WORLD"); + } + + [Fact] + public void FromItem_BeforeFromItem_AcceptsMatchingEntityType() + { + // BeforeFromItem only throws for mismatched entityType — matching type must succeed. + var item = new Dictionary + { + ["id"] = new() { S = "id-1" }, + ["name"] = new() { S = "Test" }, + ["entityType"] = new() { S = "HooksIntegrationModel" }, + }; + + var act = () => HooksIntegrationModelMapper.FromItem(item); + + act.Should().NotThrow(); + } + + [Fact] + public void FromItem_BeforeFromItem_ThrowsOnEntityTypeMismatch() + { + // BeforeFromItem validates entityType — wrong type must throw before any mapping occurs. + var item = new Dictionary + { + ["id"] = new() { S = "id-1" }, + ["name"] = new() { S = "Test" }, + ["entityType"] = new() { S = "SomeOtherEntity" }, + }; + + var act = () => HooksIntegrationModelMapper.FromItem(item); + + act.Should().Throw() + .WithMessage("*SomeOtherEntity*"); + } + + // ── Round-trip ──────────────────────────────────────────────────────────── + + [Fact] + public void RoundTrip_MappedPropertiesPreserved() + { + var original = new HooksIntegrationModel { Id = "round-trip-id", Name = "Round Trip" }; + + var item = HooksIntegrationModelMapper.ToItem(original); + var restored = HooksIntegrationModelMapper.FromItem(item); + + restored.Id.Should().Be(original.Id); + restored.Name.Should().Be(original.Name); + } + + [Fact] + public void RoundTrip_AfterFromItem_PopulatesNormalizedNameFromRestoredName() + { + var original = new HooksIntegrationModel { Id = "id", Name = "round trip name" }; + + var item = HooksIntegrationModelMapper.ToItem(original); + var restored = HooksIntegrationModelMapper.FromItem(item); + + // NormalizedName is never stored in DynamoDB — AfterFromItem computes it each time + restored.NormalizedName.Should().Be("ROUND TRIP NAME"); + } + + [Fact] + public void RoundTrip_HookInjectedKeys_PresentInItem_NotOnEntity() + { + var original = new HooksIntegrationModel { Id = "pk-test", Name = "Test" }; + + var item = HooksIntegrationModelMapper.ToItem(original); + var restored = HooksIntegrationModelMapper.FromItem(item); + + // pk/sk exist in DynamoDB item (injected by AfterToItem) + item.Should().ContainKey("pk"); + item.Should().ContainKey("sk"); + + // But they don't exist on the entity — the model has no pk/sk properties + restored.Id.Should().Be(original.Id); + restored.Name.Should().Be(original.Name); + } +} diff --git a/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksModel.cs b/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksModel.cs new file mode 100644 index 00000000..b47923a6 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.IntegrationTests/HooksModel.cs @@ -0,0 +1,87 @@ +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Runtime; + +namespace LayeredCraft.DynamoMapper.IntegrationTests; + +/// +/// Model for testing customization hooks. Has a computed property () +/// that is ignored by the mapper and populated only via AfterFromItem. +/// +public sealed class HooksIntegrationModel +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + /// + /// Not mapped to DynamoDB. Populated by the AfterFromItem hook. + /// + public string NormalizedName { get; set; } = string.Empty; +} + +[DynamoMapper] +[DynamoIgnore(nameof(HooksIntegrationModel.NormalizedName))] +public static partial class HooksIntegrationModelMapper +{ + public static partial Dictionary ToItem(HooksIntegrationModel source); + + public static partial HooksIntegrationModel FromItem(Dictionary item); + + // Hook declarations — implementations are below + static partial void BeforeToItem(HooksIntegrationModel source, Dictionary item); + + static partial void AfterToItem(HooksIntegrationModel source, Dictionary item); + + static partial void BeforeFromItem(Dictionary item); + + static partial void AfterFromItem(Dictionary item, ref HooksIntegrationModel entity); +} + +public static partial class HooksIntegrationModelMapper +{ + /// + /// Runs before property mapping. Item is empty at this point. + /// Adds a lifecycle marker to verify execution order. + /// + static partial void BeforeToItem(HooksIntegrationModel source, Dictionary item) + { + item.SetString("_lifecyclePhase", "before-mapping"); + } + + /// + /// Runs after property mapping. Adds single-table design keys and overwrites the + /// lifecycle marker to verify AfterToItem runs after BeforeToItem and property mapping. + /// + static partial void AfterToItem(HooksIntegrationModel source, Dictionary item) + { + // Inject single-table design keys — the primary AfterToItem use case + item.SetString("pk", $"MODEL#{source.Id}"); + item.SetString("sk", "METADATA"); + + // Overwrite the lifecycle marker set by BeforeToItem to prove AfterToItem runs after + item.SetString("_lifecyclePhase", "after-mapping"); + } + + /// + /// Runs before property mapping during FromItem. Validates the entity type + /// so that items from different entity types are rejected early. + /// + static partial void BeforeFromItem(Dictionary item) + { + if (item.TryGetValue("entityType", out var typeAttr) && typeAttr.S != "HooksIntegrationModel") + throw new InvalidOperationException( + $"BeforeFromItem: expected entity type 'HooksIntegrationModel', got '{typeAttr.S}'" + ); + } + + /// + /// Runs after object construction. Populates the computed + /// property from the mapped value. + /// + static partial void AfterFromItem( + Dictionary item, ref HooksIntegrationModel entity + ) + { + entity.NormalizedName = entity.Name.ToUpperInvariant(); + } +} From cb2c9307da860b1ff652a1bf7ac5925edc551afe Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 08:54:35 -0400 Subject: [PATCH 06/12] fix(generators): remove path-specific ToItem semicolon sentinel --- .../Templates/Mapper.scriban | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban index 65cac886..d079bd0a 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban +++ b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban @@ -39,7 +39,7 @@ using Amazon.DynamoDBv2.Model; } {{~ else ~}} {{ mapper_class.to_item_signature }} => - new Dictionary({{ dictionary_capacity }}){{ if dictionary_capacity == 0 }};{{ end }} + new Dictionary({{ dictionary_capacity }}){{ if to_assignments.size == 0 }};{{ end }} {{~ for assignment in to_assignments ~}} {{ assignment }}{{ if for.last }};{{ end }} {{~ end ~}} From b9deb2c71037a2f2476c67f4a16c848cf3d86f38 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 08:54:40 -0400 Subject: [PATCH 07/12] docs(generators): document diagnostic ID range conventions --- .config/dotnet-tools.json | 13 +++++++++++++ .../Diagnostics/DiagnosticDescriptors.cs | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .config/dotnet-tools.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..71804ec0 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "jetbrains.resharper.globaltools": { + "version": "2025.3.3", + "commands": [ + "jb" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs index 1ae46371..f7adf904 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -6,6 +6,11 @@ internal static class DiagnosticDescriptors { private const string UsageCategory = "LayeredCraft.DynamoMapper.Usage"; + // Diagnostic ID ranges: + // DM000x: Property and type mapping diagnostics + // DM010x: Mapper and model-shape diagnostics + // DM040x: Hook declaration diagnostics + internal static readonly DiagnosticDescriptor CannotConvertFromAttributeValue = new( "DM0001", From b415c8eb5e23934c6bd81d571ea89e1e3cc13237 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 08:54:47 -0400 Subject: [PATCH 08/12] feat(config): add JetBrains ReSharper Global Tools to .config - Added `.config/dotnet-tools.json` to configure ReSharper global tools. - Updated `.gitignore` to include `.config` directory exclusion. --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index d6e05123..2c57636d 100644 --- a/.gitignore +++ b/.gitignore @@ -422,8 +422,6 @@ FodyWeavers.xsd .idea -.config - # macOS .DS_Store .DS_Store? From ad0a04392c508099c32e167e321a50c534257962 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 08:55:01 -0400 Subject: [PATCH 09/12] fix(generators): validate hook signatures and surface DM040x diagnostics --- .../Models/MapperClassInfo.cs | 249 ++++++++++++++++-- .../Models/MapperInfo.cs | 6 +- 2 files changed, 236 insertions(+), 19 deletions(-) diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs index 7ed5e169..7981fe61 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperClassInfo.cs @@ -1,6 +1,8 @@ using LayeredCraft.DynamoMapper.Generator.Diagnostics; using LayeredCraft.SourceGeneratorTools.Types; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using WellKnownType = LayeredCraft.DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType; namespace LayeredCraft.DynamoMapper.Generator.Models; @@ -28,9 +30,11 @@ internal static class MapperClassInfoExtensions extension(MapperClassInfo) { - internal static DiagnosticResult<(MapperClassInfo, ITypeSymbol)> CreateAndResolveModelType( - INamedTypeSymbol classSymbol, GeneratorContext context - ) + internal static DiagnosticResult<( + MapperClassInfo MapperClass, + ITypeSymbol ModelType, + EquatableArray Diagnostics + )> CreateAndResolveModelType(INamedTypeSymbol classSymbol, GeneratorContext context) { context.ThrowIfCancellationRequested(); @@ -76,14 +80,51 @@ internal static class MapperClassInfoExtensions var toItemParameterName = toItemMethod?.Parameters.FirstOrDefault()?.Name; - var hasBeforeToItem = IsHookPresent(methods, "BeforeToItem", 2); - var hasAfterToItem = IsHookPresent(methods, "AfterToItem", 2); - var hasBeforeFromItem = IsHookPresent(methods, "BeforeFromItem", 1); - var hasAfterFromItem = IsHookPresent(methods, "AfterFromItem", 2); + var hookAnalysis = AnalyzeHooks(methods, modelType, context); + + var hasBeforeToItem = toItemMethod is not null && hookAnalysis.HasBeforeToItem; + var hasAfterToItem = toItemMethod is not null && hookAnalysis.HasAfterToItem; + var hasBeforeFromItem = + fromItemMethod is not null && hookAnalysis.HasBeforeFromItem; + var hasAfterFromItem = + fromItemMethod is not null && hookAnalysis.HasAfterFromItem; + + if ((hasBeforeToItem || hasAfterToItem) && toItemParameterName is null) + return DiagnosticResult<( + MapperClassInfo MapperClass, + ITypeSymbol ModelType, + EquatableArray Diagnostics + )>.Failure( + DiagnosticDescriptors.InvalidHookSignature, + classSymbol.CreateLocationInfo(), + "ToItem", + "ToItem" + ); - return DiagnosticResult<(MapperClassInfo, ITypeSymbol)>.Success( - (new MapperClassInfo(classSymbol.Name, namespaceStatement, classSignature, toItemSignature, fromItemSignature, fromItemParameterName, toItemParameterName, context.TargetNode.CreateLocationInfo(), new EquatableArray(), hasBeforeToItem, hasAfterToItem, hasBeforeFromItem, hasAfterFromItem), - modelType) + return DiagnosticResult<( + MapperClassInfo MapperClass, + ITypeSymbol ModelType, + EquatableArray Diagnostics + )>.Success( + ( + new MapperClassInfo( + classSymbol.Name, + namespaceStatement, + classSignature, + toItemSignature, + fromItemSignature, + fromItemParameterName, + toItemParameterName, + context.TargetNode.CreateLocationInfo(), + new EquatableArray(), + hasBeforeToItem, + hasAfterToItem, + hasBeforeFromItem, + hasAfterFromItem + ), + modelType, + hookAnalysis.Diagnostics + ) ); } ); @@ -145,6 +186,48 @@ private static bool IsFromMethod(IMethodSymbol method, GeneratorContext context) IsPartialDefinition: true, PartialImplementationPart: null, Parameters.Length: 1, } && IsAttributeValueDictionary(method.Parameters[0].Type, context); + private sealed record HookAnalysisResult( + bool HasBeforeToItem, + bool HasAfterToItem, + bool HasBeforeFromItem, + bool HasAfterFromItem, + EquatableArray Diagnostics + ); + + private static HookAnalysisResult AnalyzeHooks( + IMethodSymbol[] methods, + ITypeSymbol modelType, + GeneratorContext context + ) + { + var diagnostics = new List(); + + var hasBeforeToItem = IsHookPresent(methods, "BeforeToItem", modelType, context, diagnostics); + var hasAfterToItem = IsHookPresent(methods, "AfterToItem", modelType, context, diagnostics); + var hasBeforeFromItem = IsHookPresent( + methods, + "BeforeFromItem", + modelType, + context, + diagnostics + ); + var hasAfterFromItem = IsHookPresent( + methods, + "AfterFromItem", + modelType, + context, + diagnostics + ); + + return new HookAnalysisResult( + hasBeforeToItem, + hasAfterToItem, + hasBeforeFromItem, + hasAfterFromItem, + diagnostics.ToEquatableArray() + ); + } + private static bool IsAttributeValueDictionary(ITypeSymbol type, GeneratorContext context) => type is INamedTypeSymbol { IsGenericType: true } namedType && context.WellKnownTypes.IsType( namedType.ConstructedFrom, @@ -178,12 +261,144 @@ private static string GetMethodSignature(IMethodSymbol method) $"{accessibility} {modifiers}partial {returnType} {method.Name}({extensionMethod}{parameterType} {parameterName})"; } - private static bool IsHookPresent(IMethodSymbol[] methods, string name, int paramCount) => - methods.Any(m => - m.Name == name && - m.IsStatic && - m.ReturnsVoid && - m.Parameters.Length == paramCount && - (m.IsPartialDefinition || m.PartialDefinitionPart != null) + private static bool IsHookPresent( + IEnumerable methods, + string name, + ITypeSymbol modelType, + GeneratorContext context, + List diagnostics + ) + { + var matchingMethods = methods.Where(m => m.Name == name).ToArray(); + var hasValidHook = false; + + foreach (var method in matchingMethods) + { + if (!method.IsStatic) + { + diagnostics.Add( + new DiagnosticInfo( + DiagnosticDescriptors.HookNotStatic, + method.CreateLocationInfo(), + method.Name + ) + ); + continue; + } + + if (!method.ReturnsVoid || !IsPartialHookMethod(method)) + { + diagnostics.Add( + new DiagnosticInfo( + DiagnosticDescriptors.InvalidHookSignature, + method.CreateLocationInfo(), + method.Name, + name + ) + ); + continue; + } + + if (!HasExpectedParameterCount(method, name) || !HasExpectedRefKinds(method, name)) + { + diagnostics.Add( + new DiagnosticInfo( + DiagnosticDescriptors.InvalidHookSignature, + method.CreateLocationInfo(), + method.Name, + name + ) + ); + continue; + } + + if (!HasExpectedParameterTypes(method, name, modelType, context)) + { + diagnostics.Add( + new DiagnosticInfo( + DiagnosticDescriptors.HookParameterTypeMismatch, + method.CreateLocationInfo(), + method.Name, + modelType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ) + ); + continue; + } + + hasValidHook = true; + } + + return hasValidHook; + } + + private static bool IsPartialHookMethod(IMethodSymbol method) + { + if ( + method.IsPartialDefinition || + method.PartialDefinitionPart is not null || + method.PartialImplementationPart is not null + ) + return true; + + return method.DeclaringSyntaxReferences.Any(reference => + reference.GetSyntax() is MethodDeclarationSyntax { Modifiers: var modifiers } && + modifiers.Any(SyntaxKind.PartialKeyword) ); + } + + private static bool HasExpectedParameterCount(IMethodSymbol method, string hookName) => + method.Parameters.Length == + hookName switch + { + "BeforeToItem" => 2, + "AfterToItem" => 2, + "BeforeFromItem" => 1, + "AfterFromItem" => 2, + _ => -1, + }; + + private static bool HasExpectedRefKinds(IMethodSymbol method, string hookName) + { + if (method.Parameters.Length == 0) + return false; + + return hookName switch + { + "BeforeToItem" => + method.Parameters[0].RefKind == RefKind.None && + method.Parameters[1].RefKind == RefKind.None, + "AfterToItem" => + method.Parameters[0].RefKind == RefKind.None && + method.Parameters[1].RefKind == RefKind.None, + "BeforeFromItem" => method.Parameters[0].RefKind == RefKind.None, + "AfterFromItem" => + method.Parameters[0].RefKind == RefKind.None && + method.Parameters[1].RefKind == RefKind.Ref, + _ => false, + }; + } + + private static bool HasExpectedParameterTypes( + IMethodSymbol method, + string hookName, + ITypeSymbol modelType, + GeneratorContext context + ) => + hookName switch + { + "BeforeToItem" => + IsModelType(method.Parameters[0].Type, modelType) && + IsAttributeValueDictionary(method.Parameters[1].Type, context), + "AfterToItem" => + IsModelType(method.Parameters[0].Type, modelType) && + IsAttributeValueDictionary(method.Parameters[1].Type, context), + "BeforeFromItem" => IsAttributeValueDictionary(method.Parameters[0].Type, context), + "AfterFromItem" => + IsAttributeValueDictionary(method.Parameters[0].Type, context) && + IsModelType(method.Parameters[1].Type, modelType), + _ => false, + }; + + private static bool IsModelType(ITypeSymbol hookType, ITypeSymbol modelType) => + SymbolEqualityComparer.Default.Equals(hookType, modelType); } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperInfo.cs index e270b57b..33efbdbe 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/MapperInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/MapperInfo.cs @@ -13,6 +13,8 @@ internal sealed record MapperInfo( HelperMethodRegistry? HelperRegistry ) { + // Context and HelperRegistry are intentionally excluded from equality because they are + // runtime pipeline artifacts and not stable value inputs for incremental generation. public bool Equals(MapperInfo? other) => other is not null && MapperClass == other.MapperClass && ModelClass == other.ModelClass && Diagnostics == other.Diagnostics; @@ -35,7 +37,7 @@ internal static MapperInfo Create(INamedTypeSymbol classSymbol, GeneratorContext if (!mapperResult.IsSuccess) return MapperInfo.CreateWithDiagnostics([mapperResult.Error!], context); - var (mapperClassInfo, modelTypeSymbol) = mapperResult.Value; + var (mapperClassInfo, modelTypeSymbol, mapperDiagnostics) = mapperResult.Value; // Set method context flags so property validation knows which methods exist context.HasToItemMethod = mapperClassInfo.ToItemSignature != null; @@ -62,7 +64,7 @@ mapperClassInfo with return new MapperInfo( updatedMapperClassInfo, modelClassInfo, - diagnosticInfos.ToEquatableArray(), + mapperDiagnostics.Concat(diagnosticInfos).ToEquatableArray(), context, helperRegistry ); From 1c75201fc4492781fbecc7e9b4d1e47add7bd69d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 08:55:11 -0400 Subject: [PATCH 10/12] test(generators): cover hook diagnostics and from-only hook scenarios --- .../HooksVerifyTests.cs | 192 ++++++++++++++++++ ..._BothFromHooks#ProductMapper.g.verified.cs | 33 +++ ...ook_IsDetected#ProductMapper.g.verified.cs | 30 +++ ...uldWarn_DM0401#ProductMapper.g.verified.cs | 26 +++ ...idSignature_ShouldWarn_DM0401.verified.txt | 18 ++ ...uldWarn_DM0402#ProductMapper.g.verified.cs | 26 +++ ...nStaticHook_ShouldWarn_DM0402.verified.txt | 18 ++ ...uldWarn_DM0403#ProductMapper.g.verified.cs | 26 +++ ...ameterTypes_ShouldWarn_DM0403.verified.txt | 18 ++ ...BothToHooks#EmptyModelMapper.g.verified.cs | 30 +++ 10 files changed, 417 insertions(+) create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_FromOnly_BothFromHooks#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ImplementationOnlyExtendedPartialHook_IsDetected#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401.verified.txt create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402.verified.txt create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403#ProductMapper.g.verified.cs create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403.verified.txt create mode 100644 test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ZeroPropertyToItem_WithBothToHooks#EmptyModelMapper.g.verified.cs diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs index da0dc02f..8151c455 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/HooksVerifyTests.cs @@ -219,4 +219,196 @@ public class Product }, TestContext.Current.CancellationToken ); + + [Fact] + public async Task Hooks_FromOnly_BothFromHooks() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Product FromItem(Dictionary item); + + static partial void BeforeFromItem(Dictionary item); + static partial void AfterFromItem(Dictionary item, ref Product entity); + } + + public class Product + { + public string Name { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_ZeroPropertyToItem_WithBothToHooks() => await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class EmptyModelMapper + { + public static partial Dictionary ToItem(EmptyModel source); + + static partial void BeforeToItem(EmptyModel source, Dictionary item); + static partial void AfterToItem(EmptyModel source, Dictionary item); + } + + public class EmptyModel + { + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_ImplementationOnlyExtendedPartialHook_IsDetected() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + + private static partial void AfterToItem(Product source, Dictionary item); + } + + public static partial class ProductMapper + { + private static partial void AfterToItem(Product source, Dictionary item) { } + } + + public class Product + { + public string Name { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_NonStaticHook_ShouldWarn_DM0402() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public partial class ProductMapper + { + public partial Dictionary ToItem(Product source); + + partial void BeforeToItem(Product source, Dictionary item); + } + + public class Product + { + public string Name { get; set; } + } + """, + ExpectedDiagnosticId = "DM0402", + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_WrongParameterTypes_ShouldWarn_DM0403() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + + static partial void BeforeToItem(Order source, Dictionary item); + } + + public class Product + { + public string Name { get; set; } + } + + public class Order + { + public string Id { get; set; } + } + """, + ExpectedDiagnosticId = "DM0403", + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Hooks_InvalidSignature_ShouldWarn_DM0401() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = + """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using LayeredCraft.DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ProductMapper + { + public static partial Dictionary ToItem(Product source); + + static partial void BeforeToItem(Product source); + } + + public class Product + { + public string Name { get; set; } + } + """, + ExpectedDiagnosticId = "DM0401", + }, + TestContext.Current.CancellationToken + ); } diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_FromOnly_BothFromHooks#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_FromOnly_BothFromHooks#ProductMapper.g.verified.cs new file mode 100644 index 00000000..0d053164 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_FromOnly_BothFromHooks#ProductMapper.g.verified.cs @@ -0,0 +1,33 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Product FromItem(global::System.Collections.Generic.Dictionary item) + { + BeforeFromItem(item); + var product = new global::MyNamespace.Product + { + Name = item.GetString("name", Requiredness.InferFromNullability), + }; + AfterFromItem(item, ref product); + return product; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ImplementationOnlyExtendedPartialHook_IsDetected#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ImplementationOnlyExtendedPartialHook_IsDetected#ProductMapper.g.verified.cs new file mode 100644 index 00000000..903985f6 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ImplementationOnlyExtendedPartialHook_IsDetected#ProductMapper.g.verified.cs @@ -0,0 +1,30 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) + { + var item = new Dictionary(1); + item.SetString("name", source.Name, false, true); + AfterToItem(source, item); + return item; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401#ProductMapper.g.verified.cs new file mode 100644 index 00000000..6f6a27a7 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401#ProductMapper.g.verified.cs @@ -0,0 +1,26 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(1) + .SetString("name", source.Name, false, true); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401.verified.txt new file mode 100644 index 00000000..3f80201f --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_InvalidSignature_ShouldWarn_DM0401.verified.txt @@ -0,0 +1,18 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (11,24)-(11,36), + Message: The method 'BeforeToItem' does not match the expected hook signature for 'BeforeToItem', + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: DM0401, + Title: Hook signature doesn't match expected format, + MessageFormat: The method '{0}' does not match the expected hook signature for '{1}', + Category: LayeredCraft.DynamoMapper.Usage, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } + ] +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402#ProductMapper.g.verified.cs new file mode 100644 index 00000000..d48f3f73 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402#ProductMapper.g.verified.cs @@ -0,0 +1,26 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(1) + .SetString("name", source.Name, false, true); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402.verified.txt new file mode 100644 index 00000000..abe74f2b --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_NonStaticHook_ShouldWarn_DM0402.verified.txt @@ -0,0 +1,18 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (11,17)-(11,29), + Message: The hook method 'BeforeToItem' must be declared as static, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: DM0402, + Title: Hook method is not static, + MessageFormat: The hook method '{0}' must be declared as static, + Category: LayeredCraft.DynamoMapper.Usage, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } + ] +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403#ProductMapper.g.verified.cs new file mode 100644 index 00000000..6f6a27a7 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403#ProductMapper.g.verified.cs @@ -0,0 +1,26 @@ +//HintName: ProductMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(1) + .SetString("name", source.Name, false, true); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403.verified.txt new file mode 100644 index 00000000..722a5b9e --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_WrongParameterTypes_ShouldWarn_DM0403.verified.txt @@ -0,0 +1,18 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (11,24)-(11,36), + Message: The hook method 'BeforeToItem' parameter types must match the entity type 'global::MyNamespace.Product', + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: DM0403, + Title: Hook parameter types don't match entity type, + MessageFormat: The hook method '{0}' parameter types must match the entity type '{1}', + Category: LayeredCraft.DynamoMapper.Usage, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } + ] +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ZeroPropertyToItem_WithBothToHooks#EmptyModelMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ZeroPropertyToItem_WithBothToHooks#EmptyModelMapper.g.verified.cs new file mode 100644 index 00000000..87ea1847 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/HooksVerifyTests.Hooks_ZeroPropertyToItem_WithBothToHooks#EmptyModelMapper.g.verified.cs @@ -0,0 +1,30 @@ +//HintName: EmptyModelMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using LayeredCraft.DynamoMapper.Runtime; +using System.Collections.Generic; +using System.Linq; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class EmptyModelMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.EmptyModel source) + { + var item = new Dictionary(0); + BeforeToItem(source, item); + AfterToItem(source, item); + return item; + } +} From a0b170fc76f33a5b38ae11c1f6e20d11d8b4692c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 09:01:27 -0400 Subject: [PATCH 11/12] docs(hooks): add detailed lifecycle hooks documentation - Added `references/hooks.md` to cover hook definitions, rules, and execution order. - Updated `diagnostics.md` to include DM0401, DM0402, and DM0403 as hook-related diagnostics. - Clarified lifecycle hook behavior in `core-usage.md` and `gotchas.md`. - Revised `SKILL.md` to include hooks as an extension point for pre/post mapping logic. --- skills/dynamo-mapper/SKILL.md | 16 +++++-- skills/dynamo-mapper/references/core-usage.md | 24 ++++++++++- .../dynamo-mapper/references/diagnostics.md | 9 ++++ skills/dynamo-mapper/references/gotchas.md | 6 +-- skills/dynamo-mapper/references/hooks.md | 42 +++++++++++++++++++ 5 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 skills/dynamo-mapper/references/hooks.md diff --git a/skills/dynamo-mapper/SKILL.md b/skills/dynamo-mapper/SKILL.md index 2ca5b771..565f26fb 100644 --- a/skills/dynamo-mapper/SKILL.md +++ b/skills/dynamo-mapper/SKILL.md @@ -1,6 +1,6 @@ --- name: dynamo-mapper -description: Use this skill when you need to write or explain DynamoMapper mappings for DynamoDB `AttributeValue` items in C#. It covers how to declare mapper classes, how `DynamoMapper`, `DynamoField`, `DynamoIgnore`, and `DynamoMapperConstructor` behave, what types and nested shapes are supported, how custom conversion really works, and how to troubleshoot DynamoMapper diagnostics and common gotchas without relying on stale docs. +description: Use this skill when you need to write or explain DynamoMapper mappings for DynamoDB `AttributeValue` items in C#. It covers how to declare mapper classes, how `DynamoMapper`, `DynamoField`, `DynamoIgnore`, and `DynamoMapperConstructor` behave, how lifecycle hooks work, what types and nested shapes are supported, how custom conversion really works, and how to troubleshoot DynamoMapper diagnostics and common gotchas without relying on stale docs. --- # DynamoMapper @@ -16,6 +16,8 @@ Use this skill when generating or explaining DynamoMapper code. - One-way mappers are valid: `To*` only or `From*` only. - Domain models usually stay clean except for optional `[DynamoMapperConstructor]` on a constructor. - Nested object mapping is implemented and tested. +- Lifecycle hooks are implemented and validated (`BeforeToItem`, `AfterToItem`, + `BeforeFromItem`, `AfterFromItem`). - Some public docs are stale; use `references/gotchas.md` when behavior seems surprising. ## Choose a path @@ -25,6 +27,8 @@ Use this skill when generating or explaining DynamoMapper code. - Read `references/type-matrix.md` for supported types, collection rules, nested shapes, and hard limits. - Read `references/diagnostics.md` for generator diagnostics and the most likely fixes. +- Read `references/hooks.md` for hook signatures, call order, generation behavior, and + hook-specific diagnostics. - Read `references/gotchas.md` for stale-doc traps and the non-obvious rules most likely to cause bad guidance. @@ -32,16 +36,19 @@ Use this skill when generating or explaining DynamoMapper code. 1. Identify whether the task is mapper authoring, supported-type lookup, or diagnostics. 2. Read the matching reference file before making assumptions. -3. If the task touches nested mapping, converters, or hooks, check `references/gotchas.md` before +3. If the task touches hooks, read `references/hooks.md` first. +4. If the task touches nested mapping or converters, check `references/gotchas.md` before answering. -4. Keep answers concrete and code-oriented. +5. Keep answers concrete and code-oriented. ## High-risk misunderstandings - 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. -- Check `references/gotchas.md` before teaching hooks or custom converter signatures. +- Do not invent hook signatures; all hooks must be `static partial void` with exact parameter + shapes. +- `AfterFromItem` requires `ref` on the entity parameter. - Do not assume every unsupported converter setup becomes a DynamoMapper diagnostic; some become normal C# compile errors. ## Reference map @@ -49,4 +56,5 @@ Use this skill when generating or explaining DynamoMapper code. - `references/core-usage.md` - `references/type-matrix.md` - `references/diagnostics.md` +- `references/hooks.md` - `references/gotchas.md` diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md index 678a6102..3e7d1e75 100644 --- a/skills/dynamo-mapper/references/core-usage.md +++ b/skills/dynamo-mapper/references/core-usage.md @@ -93,6 +93,28 @@ Use mapper-class static methods through `[DynamoField(ToMethod = ..., FromMethod - stale docs describe the wrong converter signatures - bad converter wiring may fail as normal C# compile errors instead of DynamoMapper diagnostics +## Lifecycle hooks + +Hooks are optional extension points on the mapper class for pre/post mapping logic. + +- `BeforeToItem(T source, Dictionary item)` +- `AfterToItem(T source, Dictionary item)` +- `BeforeFromItem(Dictionary item)` +- `AfterFromItem(Dictionary item, ref T entity)` + +Rules: + +- hooks must be `static partial void` +- hook names are exact (`BeforeToItem`, `AfterToItem`, `BeforeFromItem`, `AfterFromItem`) +- hooks can be declared/implemented in another part of the same partial mapper class +- one-way mappers only emit hooks for the generated direction +- no `To*` hooks means `To*` can stay expression-bodied + +Order: + +- `To*`: create item -> `BeforeToItem` -> property mapping -> `AfterToItem` -> return item +- `From*`: `BeforeFromItem` -> property mapping/object construction -> `AfterFromItem` -> return + ## Requiredness and defaults - missing required root scalar values throw at runtime @@ -104,4 +126,4 @@ Use mapper-class static methods through `[DynamoField(ToMethod = ..., FromMethod - Put configuration on the mapper, not the POCO. - Use `[DynamoField]` before inventing extra mapping layers. - Use `[DynamoMapperConstructor]` when constructor choice is ambiguous. -- Do not recommend lifecycle hooks as current behavior. +- Use lifecycle hooks for DynamoDB-specific concerns such as PK/SK composition, TTL, and metadata. diff --git a/skills/dynamo-mapper/references/diagnostics.md b/skills/dynamo-mapper/references/diagnostics.md index b3ee1324..322dccc4 100644 --- a/skills/dynamo-mapper/references/diagnostics.md +++ b/skills/dynamo-mapper/references/diagnostics.md @@ -16,6 +16,15 @@ - `DM0101` no mapper methods found -> add a valid `To*` or `From*` method - `DM0102` mismatched model types -> make both directions use the same model type - `DM0103` multiple constructor attributes -> leave only one `[DynamoMapperConstructor]` +- `DM0401` invalid hook signature -> use exact hook name/signature and `void` return type +- `DM0402` hook not static -> declare hook as `static` +- `DM0403` hook parameter type mismatch -> use the mapper model type `T` and expected dictionary/ref + +## Hook diagnostics (all warnings) + +- `DM0401` covers wrong parameter count, wrong `ref` usage, non-void return type, or non-partial hook +- `DM0402` is emitted when a hook method is not static +- `DM0403` is emitted when hook parameter types do not match mapper model type/dictionary shape ## Important non-diagnostic failure mode diff --git a/skills/dynamo-mapper/references/gotchas.md b/skills/dynamo-mapper/references/gotchas.md index 4abf8b17..94bc7b8d 100644 --- a/skills/dynamo-mapper/references/gotchas.md +++ b/skills/dynamo-mapper/references/gotchas.md @@ -4,7 +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 teach lifecycle hooks as currently implemented behavior. +- Do not invent lifecycle hook signatures from memory. - Do not use the old property-level converter signatures from stale docs. - Do not assume every converter mistake becomes a DynamoMapper diagnostic. @@ -22,7 +22,7 @@ ## Stale-doc corrections - nested mapping is supported -- hook docs are stale for current behavior +- lifecycle hooks are implemented with strict signature validation - static converter docs are stale on signatures and constraints - some prose docs mention diagnostics that do not exist @@ -30,5 +30,5 @@ - prefer simple mapper classes - prefer supported scalar and collection shapes -- avoid promising hook behavior +- use the exact four hook names and signatures when hooks are needed - avoid inventing converter signatures from memory diff --git a/skills/dynamo-mapper/references/hooks.md b/skills/dynamo-mapper/references/hooks.md new file mode 100644 index 00000000..5360f1c6 --- /dev/null +++ b/skills/dynamo-mapper/references/hooks.md @@ -0,0 +1,42 @@ +# Hooks + +## Supported lifecycle hooks + +Hooks are optional `static partial void` methods on the mapper class. + +- `BeforeToItem(T source, Dictionary item)` +- `AfterToItem(T source, Dictionary item)` +- `BeforeFromItem(Dictionary item)` +- `AfterFromItem(Dictionary item, ref T entity)` + +Where `T` is the mapper model type. + +## Generation behavior + +- hooks are discovered by exact method names above +- hooks can be declared and implemented in separate parts of the same partial mapper class +- one-way mappers are supported; only hooks for generated directions are emitted +- no `To*` hooks keeps `To*` expression-bodied +- any `To*` hook switches `To*` generation to block body + +## Execution order + +- `To*`: create dictionary -> `BeforeToItem` -> map members -> `AfterToItem` -> return +- `From*`: `BeforeFromItem` -> map/construct model -> `AfterFromItem` -> return + +## Validation diagnostics + +- `DM0401` invalid hook signature + - wrong parameter count + - wrong `ref` usage (`AfterFromItem` must use `ref` on entity) + - non-void return type + - non-partial hook method +- `DM0402` hook is not static +- `DM0403` hook parameter types do not match mapper model/dictionary requirements + +These diagnostics are warnings. + +## Safe guidance + +- use hooks for DynamoDB-specific concerns (PK/SK composition, TTL, metadata, normalization) +- keep hook logic focused; avoid placing domain business workflows in hooks From dfa771b26f4fe0ef3505eead9f913264b6b6fa4f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Mon, 13 Apr 2026 09:01:48 -0400 Subject: [PATCH 12/12] chore(build): bump version prefix to 1.4.0 - Updated `Directory.Build.props` to increase `` from 1.3.0 to 1.4.0. --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 42c6e052..307a95a4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.3.0 + 1.4.0 MIT