diff --git a/AGENTS.md b/AGENTS.md index ef89f0a..fbc3192 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,17 +19,54 @@ dotnet build examples/DynamoMapper.SimpleExample/DynamoMapper.SimpleExample.cspr # Run all tests across all target frameworks (net8.0, net9.0, net10.0) dotnet test -# Run tests for specific framework +# Run tests for a specific framework +# (recommended when working on a single test failure) dotnet test --framework net8.0 # Run tests with verbose output dotnet test --logger "console;verbosity=detailed" -# Run specific test (example) -dotnet test --filter "FullyQualifiedName~Simple_HelloWorld" - -# Run tests in specific project -dotnet test test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj +# Run tests in a specific project (avoid running other test projects) +dotnet test --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj + +# Discover available tests (xUnit v3 + Microsoft.Testing.Platform) +# Copy the fully-qualified test name from this output. +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --list-tests \ + --no-progress \ + --no-ansi + +# Run a single test method (exact fully-qualified name) +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-method "MyNamespace.MyTestClass.MyTestMethod" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi + +# Common filter variants +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-class "MyNamespace.MyTestClass" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi + +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-namespace "MyNamespace.Tests" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi ``` ### Taskfile Commands (if task is installed) diff --git a/CLAUDE.md b/CLAUDE.md index 7c4938a..f1cd414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,11 +29,53 @@ dotnet build # Run all tests across all target frameworks (net8.0, net9.0, net10.0) dotnet test -# Run tests for specific framework +# Run tests for a specific framework +# (recommended when working on a single test failure) dotnet test --framework net8.0 # Run tests with verbose output dotnet test --logger "console;verbosity=detailed" + +# --- xUnit v3 + Microsoft.Testing.Platform (lowest-noise filtered runs) --- + +# List available tests (discovery) +# Copy the fully-qualified test name from this output. +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --list-tests \ + --no-progress \ + --no-ansi + +# Run a single test method (exact fully-qualified name) +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-method "MyNamespace.MyTestClass.MyTestMethod" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi + +# Variants: class / namespace +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-class "MyNamespace.MyTestClass" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi + +DOTNET_NOLOGO=1 dotnet test \ + --project test/LayeredCraft.DynamoMapper.Generators.Tests/LayeredCraft.DynamoMapper.Generators.Tests.csproj \ + -f net10.0 \ + -v q \ + --filter-namespace "MyNamespace.Tests" \ + --minimum-expected-tests 1 \ + --no-progress \ + --no-ansi ``` ### Restore Dependencies diff --git a/LayeredCraft.DynamoMapper.slnx b/LayeredCraft.DynamoMapper.slnx index 8e81309..7863ac2 100644 --- a/LayeredCraft.DynamoMapper.slnx +++ b/LayeredCraft.DynamoMapper.slnx @@ -54,6 +54,7 @@ + diff --git a/docs/api-reference/attributes.md b/docs/api-reference/attributes.md index 4119a99..691bfc4 100644 --- a/docs/api-reference/attributes.md +++ b/docs/api-reference/attributes.md @@ -62,3 +62,40 @@ public static partial class OrderMapper Properties: - `MemberName` (ctor) - target member name - `Ignore` - `IgnoreMapping.All`, `IgnoreMapping.FromModel`, or `IgnoreMapping.ToModel` + +## DynamoMapperConstructorAttribute + +Marks which constructor DynamoMapper should use when generating `FromItem` for a model type. + +This attribute is applied to the **model's constructor**, not the mapper class. + +```csharp +using DynamoMapper.Runtime; + +public class User +{ + public User() + { + Id = string.Empty; + Name = string.Empty; + } + + [DynamoMapperConstructor] + public User(string id, string name) + { + Id = id; + Name = name; + } + + public string Id { get; set; } + public string Name { get; set; } +} +``` + +Rules: + +- Only one constructor can be marked with `[DynamoMapperConstructor]`. +- If multiple are marked, DynamoMapper emits diagnostic `DM0103`. + +See [Basic Mapping](../usage/basic-mapping.md#constructor-mapping-rules-fromitem) for the full +constructor selection rules and gotchas. diff --git a/docs/api-reference/generated-code.md b/docs/api-reference/generated-code.md index e4fca71..6498533 100644 --- a/docs/api-reference/generated-code.md +++ b/docs/api-reference/generated-code.md @@ -45,3 +45,30 @@ internal static partial class DuplicateResultMapper Notes: - Generated code uses `SetX`/`GetX` helpers from `DynamoMapper.Runtime`. - Class-level `[DynamoField]`/`[DynamoIgnore]` configuration influences argument values. + +## Constructor-Based `FromItem` + +If your model type uses a constructor (records, get-only properties, or an explicitly attributed +constructor), DynamoMapper will generate `FromItem` with constructor arguments. + +See [Basic Mapping](../usage/basic-mapping.md#constructor-mapping-rules-fromitem) for the full +constructor selection rules and gotchas. + +```csharp +[global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] +public static partial global::MyNamespace.Product FromItem( + global::System.Collections.Generic.Dictionary item +) +{ + var product = new global::MyNamespace.Product( + Id: item.GetString("id", Requiredness.InferFromNullability), + Name: item.GetString("name", Requiredness.InferFromNullability) + ) + { + Price = item.GetDecimal("price", Requiredness.InferFromNullability), + Quantity = item.GetInt("quantity", Requiredness.InferFromNullability), + }; + + return product; +} +``` diff --git a/docs/core-concepts/how-it-works.md b/docs/core-concepts/how-it-works.md index c4bd9aa..7150881 100644 --- a/docs/core-concepts/how-it-works.md +++ b/docs/core-concepts/how-it-works.md @@ -158,6 +158,21 @@ public static partial class ProductMapper } ``` +### Object Construction (`FromItem`) + +When generating `FromItem`, DynamoMapper chooses between: + +- **Property-based construction**: `new T { Prop = ..., ... }` +- **Constructor-based construction**: `new T(arg1, arg2, ...)` (optionally with an object + initializer) + +Constructor-based construction is used for records/record structs with primary constructors and for +classes where read-only properties must be populated through a constructor. You can also explicitly +choose a constructor using `[DynamoMapperConstructor]`. + +See [Basic Mapping](../usage/basic-mapping.md#constructor-mapping-rules-fromitem) for the full +selection rules. + ### Key Characteristics - **Direct property access** - No reflection diff --git a/docs/examples/index.md b/docs/examples/index.md index 99709b8..bedff11 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,3 +1,10 @@ -# Index +# Examples -Documentation coming soon. See [Phase 1 Requirements](../roadmap/phase-1.md) for detailed specifications. +These example projects show DynamoMapper features end-to-end. + +## Included Examples + +- `examples/DynamoMapper.SimpleExample` - Minimal mapper + usage +- `examples/DynamoMapper.FieldLevelOverride` - Field-level overrides via `[DynamoField]` +- `examples/DynamoMapper.MapperConstructor` - Constructor/record support and + `[DynamoMapperConstructor]` diff --git a/docs/roadmap/phase-1.md b/docs/roadmap/phase-1.md index 224bb41..80c3f80 100644 --- a/docs/roadmap/phase-1.md +++ b/docs/roadmap/phase-1.md @@ -131,7 +131,8 @@ public static partial class JediCharacterMapper - **records** (with settable properties) - `init` setters supported (generator uses object initializer) -**Not required for Phase 1:** mapping to constructors / positional records. +Constructor/record-based deserialization is supported via constructor selection and +`[DynamoMapperConstructor]`. ### 4.2 Property Inclusion Rules (Defaults) By default, generator maps: diff --git a/docs/usage/basic-mapping.md b/docs/usage/basic-mapping.md index fadd698..faaa056 100644 --- a/docs/usage/basic-mapping.md +++ b/docs/usage/basic-mapping.md @@ -1,3 +1,159 @@ # Basic Mapping -Documentation coming soon. See [Phase 1 Requirements](../roadmap/phase-1.md) for detailed specifications. +This page covers the default mapping behavior of DynamoMapper and how the generator creates +instances during `FromItem`. + +## Supported Model Shapes + +DynamoMapper supports these common patterns: + +- **Classes with settable properties** (`set`) and/or `init` setters +- **Classes with regular constructors** (the generator can choose a constructor automatically) +- **Classes with get-only properties**, when a constructor can populate them +- **Records / record structs** with primary constructors + +The mapper itself remains the same: + +```csharp +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; +using DynamoMapper.Runtime; + +namespace MyApp.Data; + +[DynamoMapper] +public static partial class PersonMapper +{ + public static partial Dictionary ToItem(Person source); + + public static partial Person FromItem(Dictionary item); +} +``` + +## Object Construction During `FromItem` + +When generating `FromItem`, DynamoMapper chooses between: + +- **Property-based construction**: `new T { Prop = ..., ... }` plus optional post-construction + assignments. +- **Constructor-based construction**: `new T(arg1, arg2, ...)` (optionally combined with an object + initializer for settable/`init` properties). + +## Constructor Mapping Rules (`FromItem`) + +Constructor selection is deterministic and follows these priorities. + +### 1) When Constructor Selection Runs + +Constructor selection is only evaluated when the mapper defines a `FromItem(...)` method. + +### 2) Selection Priority + +1. **Explicit constructor wins** + + If exactly one constructor is marked with `[DynamoMapperConstructor]`, DynamoMapper uses that + constructor. + + If multiple constructors are marked, DynamoMapper emits diagnostic `DM0103`. + +2. **No parameterless constructor** + + If the type has no parameterless constructor, DynamoMapper must use a non-parameterless + constructor and selects the constructor with the most parameters. + +3. **Parameterless constructor exists (prefer property initialization when possible)** + + If a parameterless constructor exists and all relevant properties can be populated via setters + (`set` or `init`), DynamoMapper uses property-based construction (`new T { ... }`). + + Getter-only properties only force constructor-based deserialization if there is a constructor + parameter that can populate them. + +4. **Otherwise, use the constructor with the most parameters** + + This typically happens when the model has one or more getter-only properties that can be + populated via constructor parameters. + +### 3) Parameter Matching + +Constructor parameters are matched to properties by a case-insensitive name comparison (e.g. +`firstName` matches `FirstName`). + +!!! note + + Getter-only and computed properties have different behavior depending on mapping direction: + + - **ToItem (model → item):** included if the property has a getter and its type is mappable. + - **FromItem (item → model):** can only be populated if DynamoMapper can supply the value via a + constructor parameter; otherwise it is ignored. + +### 4) How Values Are Applied (Constructor Args vs Initializers) + +When a constructor is selected: + +- Properties matched to constructor parameters are emitted as named constructor arguments. +- Remaining settable/`init` properties are emitted in an object initializer. +- Some optional settable properties with default initializers may be assigned after construction to + avoid overwriting defaults when the DynamoDB attribute is missing. + +## Gotchas + +- `[DynamoMapperConstructor]` can only be applied to one constructor; multiple will emit `DM0103`. +- Constructor parameter matching is based on the .NET property name (case-insensitive). It is not + based on DynamoDB `AttributeName` overrides. +- Adding a constructor parameter that matches a previously computed/get-only property can change + whether DynamoMapper uses constructor-based deserialization. +- If the selected constructor contains required parameters that do not correspond to mappable + properties, the generated `FromItem` code may fail to compile because DynamoMapper cannot supply + arguments. + +## Examples + +### Record With Primary Constructor + +```csharp +public record Person(string FirstName, string LastName, int Age); +``` + +`FromItem` will construct the record using its primary constructor. + +### Class With Get-Only Properties + +If your class has get-only properties, DynamoMapper can deserialize using a constructor: + +```csharp +using DynamoMapper.Runtime; + +public class Person +{ + [DynamoMapperConstructor] + public Person(string firstName, string lastName, int age) + { + FirstName = firstName; + LastName = lastName; + Age = age; + } + + public string FirstName { get; } + public string LastName { get; } + public int Age { get; } +} +``` + +### Hybrid: Constructor + Object Initializer + +If some values come from the constructor and others are settable/`init`, DynamoMapper can generate a +hybrid `FromItem`: + +```csharp +public record Product(string Id, string Name) +{ + public decimal Price { get; set; } + public int Quantity { get; init; } +} +``` + +Generated code will call the constructor for `Id`/`Name` and use an object initializer for `Price` +and `Quantity`. + +See `examples/DynamoMapper.MapperConstructor` for a working sample. diff --git a/examples/DynamoMapper.MapperConstructor/ClassWithConstructor.cs b/examples/DynamoMapper.MapperConstructor/ClassWithConstructor.cs new file mode 100644 index 0000000..7c35f1d --- /dev/null +++ b/examples/DynamoMapper.MapperConstructor/ClassWithConstructor.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; +using DynamoMapper.Runtime; + +namespace DynamoMapper.MapperConstructor; + +[DynamoMapper] +public static partial class PersonClassMapper +{ + public static partial Dictionary ToItem(PersonClass personClass); + + public static partial PersonClass FromItem(Dictionary item); +} + +public class PersonClass +{ + [DynamoMapperConstructor] + public PersonClass(string firstName, string lastName, int age) + { + FirstName = firstName; + LastName = lastName; + Age = age; + } + + public string FirstName { get; } + public string LastName { get; } + public int Age { get; } +} diff --git a/examples/DynamoMapper.MapperConstructor/DynamoMapper.MapperConstructor.csproj b/examples/DynamoMapper.MapperConstructor/DynamoMapper.MapperConstructor.csproj new file mode 100644 index 0000000..18e521d --- /dev/null +++ b/examples/DynamoMapper.MapperConstructor/DynamoMapper.MapperConstructor.csproj @@ -0,0 +1,27 @@ + + + Exe + net10.0 + 14 + enable + false + + + $(NoWarn);CS1591 + + + + + + + + + diff --git a/examples/DynamoMapper.MapperConstructor/Program.cs b/examples/DynamoMapper.MapperConstructor/Program.cs new file mode 100644 index 0000000..3e16f54 --- /dev/null +++ b/examples/DynamoMapper.MapperConstructor/Program.cs @@ -0,0 +1,5 @@ +// See https://aka.ms/new-console-template for more information + +using System; + +Console.WriteLine("Hello, World!"); diff --git a/examples/DynamoMapper.MapperConstructor/RecordMapper.cs b/examples/DynamoMapper.MapperConstructor/RecordMapper.cs new file mode 100644 index 0000000..c037c48 --- /dev/null +++ b/examples/DynamoMapper.MapperConstructor/RecordMapper.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; +using DynamoMapper.Runtime; + +namespace DynamoMapper.MapperConstructor; + +[DynamoMapper] +public static partial class PersonRecordMapper +{ + public static partial Dictionary ToItem(PersonRecord personRecord); + + public static partial PersonRecord FromItem(Dictionary item); + + // public static void Do() + // { + // var person = new PersonRecord + // { + // FirstName = "John", + // LastName = "Doe", + // Age = 30, + // }; + // } +} + +public record PersonRecord(string FirstName, string LastName, int Age); diff --git a/global.json b/global.json new file mode 100644 index 0000000..eb470ca --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "rollForward": "latestMinor", + "version": "10.0.102" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md index d117794..7d11dad 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md +++ b/src/LayeredCraft.DynamoMapper.Generators/AnalyzerReleases.Unshipped.md @@ -6,5 +6,9 @@ Rule ID | Category | Severity | Notes ---------|--------------------|----------|----------------------- DM0001 | DynamoMapper.Usage | Error | DiagnosticDescriptors + DM0003 | DynamoMapper.Usage | Error | DiagnosticDescriptors + DM0004 | DynamoMapper.Usage | Error | DiagnosticDescriptors + DM0005 | DynamoMapper.Usage | Error | DiagnosticDescriptors DM0101 | DynamoMapper.Usage | Error | DiagnosticDescriptors - DM0102 | DynamoMapper.Usage | Error | DiagnosticDescriptors \ No newline at end of file + DM0102 | DynamoMapper.Usage | Error | DiagnosticDescriptors + DM0103 | DynamoMapper.Usage | Error | DiagnosticDescriptors \ No newline at end of file diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/ConstructorSelector.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/ConstructorSelector.cs new file mode 100644 index 0000000..6b2741f --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/ConstructorSelector.cs @@ -0,0 +1,248 @@ +using DynamoMapper.Generator.ConstructorMapping.Models; +using DynamoMapper.Generator.Diagnostics; +using DynamoMapper.Generator.Models; +using DynamoMapper.Generator.PropertyMapping; +using LayeredCraft.SourceGeneratorTools.Types; +using Microsoft.CodeAnalysis; +using WellKnownType = DynamoMapper.Generator.WellKnownTypes.WellKnownTypeData.WellKnownType; + +namespace DynamoMapper.Generator.ConstructorMapping; + +/// +/// Centralized logic for selecting which constructor to use for deserialization. +/// Implements the constructor selection priority rules. +/// +internal static class ConstructorSelector +{ + /// + /// Selects constructor and determines how each property should be initialized. + /// Returns null if parameterless constructor + property initialization should be used. + /// + /// The model type symbol. + /// All properties on the model type. + /// The generator context. + /// + /// Success with null = use property initialization only. + /// Success with ConstructorSelectionResult = use specified constructor. + /// Failure with diagnostic = constructor selection error. + /// + internal static DiagnosticResult Select( + ITypeSymbol modelType, + IPropertySymbol[] properties, + GeneratorContext context + ) + { + context.ThrowIfCancellationRequested(); + + // Get all instance constructors + var constructors = modelType + .GetMembers() + .OfType() + .Where(m => m.MethodKind == MethodKind.Constructor && !m.IsStatic) + .ToArray(); + + if (constructors.Length == 0) + return DiagnosticResult.Success(null); + + // PRIORITY 1: Check for attributed constructors + var attributedResult = FindAttributedConstructor(constructors, context); + if (!attributedResult.IsSuccess) + return DiagnosticResult.Failure(attributedResult.Error!); + + if (attributedResult.Value is not null) + { + return AnalyzeConstructorSelection(attributedResult.Value, properties, true, context); + } + + // PRIORITY 2: Check if parameterless constructor exists + var hasParameterlessConstructor = constructors.Any(c => c.Parameters.Length == 0); + + if (!hasParameterlessConstructor) + { + // NO parameterless constructor → MUST use a non-parameterless constructor + var selectedConstructor = SelectConstructorWithMostParameters(constructors); + return AnalyzeConstructorSelection(selectedConstructor, properties, false, context); + } + + // PRIORITY 3: Parameterless constructor exists - check deserialization accessibility + // Ignore computed/read-only properties that can't be populated from the item anyway. + if (AllPropertiesAccessible(properties, constructors)) + { + // Use property initialization (parameterless + object initializer) + return DiagnosticResult.Success(null); + } + + // PRIORITY 4: Has read-only properties - use constructor with most parameters + var selectedConstructor2 = SelectConstructorWithMostParameters(constructors); + return AnalyzeConstructorSelection(selectedConstructor2, properties, false, context); + } + + /// + /// Finds the constructor with [DynamoMapperConstructor] attribute. + /// Returns error DM0103 if multiple attributed constructors found. + /// + private static DiagnosticResult FindAttributedConstructor( + IMethodSymbol[] constructors, + GeneratorContext context + ) + { + var attributeType = context.WellKnownTypes.Get( + WellKnownType.DynamoMapper_Runtime_DynamoMapperConstructorAttribute + ); + + if (attributeType is null) + return DiagnosticResult.Success(null); + + var attributedConstructors = constructors + .Where(c => + c.GetAttributes() + .Any(attr => + SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType) + ) + ) + .ToArray(); + + if (attributedConstructors.Length == 0) + return DiagnosticResult.Success(null); + + if (attributedConstructors.Length > 1) + { + // DM0103: Multiple [DynamoMapperConstructor] attributes found + return DiagnosticResult.Failure( + DiagnosticDescriptors.MultipleConstructorsWithAttribute, + attributedConstructors[1].Locations.FirstOrDefault()?.CreateLocationInfo(), + attributedConstructors[0].ContainingType.ToDisplayString() + ); + } + + return DiagnosticResult.Success(attributedConstructors[0]); + } + + /// + /// Checks if the model can be deserialized using a parameterless constructor plus property + /// assignments (object initializer + post-construction assignments). + /// + /// + /// Read-only properties should only force constructor-based deserialization if there is a + /// constructor parameter that can populate them. Computed properties (e.g. expression-bodied + /// getters) are ignored for deserialization and should not affect constructor selection. + /// + private static bool AllPropertiesAccessible( + IPropertySymbol[] properties, + IMethodSymbol[] constructors + ) + { + foreach (var property in properties) + { + // Settable (including init-only) properties can be initialized via object initializer + // or assignment. + if (property.SetMethod is not null) + continue; + + // Read-only property: only matters for deserialization if a constructor parameter can + // populate it. + var hasMatchingConstructorParameter = constructors.Any(c => + c.Parameters.Any(p => + string.Equals(p.Name, property.Name, StringComparison.OrdinalIgnoreCase) + ) + ); + + if (hasMatchingConstructorParameter) + return false; + } + + return true; + } + + /// Selects the constructor with the most parameters. + private static IMethodSymbol SelectConstructorWithMostParameters( + IMethodSymbol[] constructors + ) => constructors.OrderByDescending(c => c.Parameters.Length).First(); + + /// Analyzes the selected constructor and determines how each property should be initialized. + private static DiagnosticResult AnalyzeConstructorSelection( + IMethodSymbol constructor, + IPropertySymbol[] properties, + bool isExplicitlyAttributed, + GeneratorContext context + ) + { + context.ThrowIfCancellationRequested(); + + // Analyze constructor parameters + var parameterAnalyses = new List(); + for (var i = 0; i < constructor.Parameters.Length; i++) + { + var param = constructor.Parameters[i]; + var memberInfoResult = MemberAnalyzer.AnalyzeParameter(param, context); + + if (!memberInfoResult.IsSuccess) + return DiagnosticResult.Failure( + memberInfoResult.Error! + ); + + // Match parameter to property (case-insensitive) + var matchedProperty = properties.FirstOrDefault(p => + string.Equals(p.Name, param.Name, StringComparison.OrdinalIgnoreCase) + ); + + parameterAnalyses.Add( + new ParameterAnalysis(memberInfoResult.Value!, i, matchedProperty) + ); + } + + var constructorAnalysis = new ConstructorAnalysis( + constructor, + new EquatableArray(parameterAnalyses.ToArray()), + isExplicitlyAttributed + ); + + // Determine initialization method for each property + var propertyModes = new List(); + foreach (var property in properties) + { + var initMethod = DetermineInitializationMethod(property, constructorAnalysis); + propertyModes.Add(new PropertyInitializationMode(property.Name, initMethod)); + } + + return new ConstructorSelectionResult( + constructorAnalysis, + new EquatableArray(propertyModes.ToArray()) + ); + } + + /// Determines how a specific property should be initialized given the selected constructor. + private static InitializationMethod DetermineInitializationMethod( + IPropertySymbol property, + ConstructorAnalysis constructor + ) + { + // Check if property matches a constructor parameter (case-insensitive) + var hasMatchingParam = constructor.Parameters.Any(p => + p.MatchedProperty is not null + && SymbolEqualityComparer.Default.Equals(p.MatchedProperty, property) + ); + + if (hasMatchingParam) + return InitializationMethod.ConstructorParameter; + + // Property not in constructor - check if it's settable + var isInitOnly = property.SetMethod?.IsInitOnly ?? false; + var hasSetter = property.SetMethod is not null; + + if (!hasSetter) + { + // Read-only property not in constructor - this shouldn't happen in valid scenarios + // but we'll treat it as constructor parameter (will likely cause a compile error) + return InitializationMethod.ConstructorParameter; + } + + // Property is settable. + // Prefer object-initializer assignments so that settable properties can be initialized + // together with init-only properties when using constructor-based instantiation. + // + // When a property has a default value and is optional, the renderer may still emit + // a post-construction TryGet assignment to avoid overwriting defaults. + return InitializationMethod.InitSyntax; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorAnalysis.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorAnalysis.cs new file mode 100644 index 0000000..0db14ff --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorAnalysis.cs @@ -0,0 +1,16 @@ +using LayeredCraft.SourceGeneratorTools.Types; +using Microsoft.CodeAnalysis; + +namespace DynamoMapper.Generator.ConstructorMapping.Models; + +/// +/// Represents the semantic analysis of a constructor, including all its parameters. +/// +/// The constructor method symbol. +/// The analyzed parameters of this constructor. +/// True if this constructor has the [DynamoMapperConstructor] attribute. +internal sealed record ConstructorAnalysis( + IMethodSymbol Constructor, + EquatableArray Parameters, + bool HasMapperConstructorAttribute +); diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorSelectionResult.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorSelectionResult.cs new file mode 100644 index 0000000..1da922d --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ConstructorSelectionResult.cs @@ -0,0 +1,13 @@ +using LayeredCraft.SourceGeneratorTools.Types; + +namespace DynamoMapper.Generator.ConstructorMapping.Models; + +/// +/// Represents the result of constructor selection, including how each property should be initialized. +/// +/// The selected constructor analysis. +/// The initialization method for each property. +internal sealed record ConstructorSelectionResult( + ConstructorAnalysis Constructor, + EquatableArray PropertyModes +); diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/InitializationMethod.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/InitializationMethod.cs new file mode 100644 index 0000000..3fee289 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/InitializationMethod.cs @@ -0,0 +1,14 @@ +namespace DynamoMapper.Generator.ConstructorMapping.Models; + +/// Specifies how a property should be initialized during object construction. +internal enum InitializationMethod +{ + /// Property is passed as a constructor parameter. + ConstructorParameter, + + /// Property is initialized using object initializer syntax (init-only properties). + InitSyntax, + + /// Property is assigned after construction (settable properties). + PostConstruction, +} diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ParameterAnalysis.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ParameterAnalysis.cs new file mode 100644 index 0000000..9dfaffa --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/ParameterAnalysis.cs @@ -0,0 +1,16 @@ +using DynamoMapper.Generator.PropertyMapping.Models; +using Microsoft.CodeAnalysis; + +namespace DynamoMapper.Generator.ConstructorMapping.Models; + +/// +/// Represents the semantic analysis of a constructor parameter, including its matched property. +/// +/// Shared member analysis (type, nullability, etc.). +/// The zero-based position of this parameter in the constructor. +/// The property this parameter corresponds to (matched via case-insensitive name comparison). +internal sealed record ParameterAnalysis( + MemberAnalysis MemberInfo, + int Ordinal, + IPropertySymbol? MatchedProperty +); diff --git a/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/PropertyInitializationMode.cs b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/PropertyInitializationMode.cs new file mode 100644 index 0000000..e4bc883 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/ConstructorMapping/Models/PropertyInitializationMode.cs @@ -0,0 +1,8 @@ +namespace DynamoMapper.Generator.ConstructorMapping.Models; + +/// +/// Describes how a specific property should be initialized when using constructor-based instantiation. +/// +/// The name of the property. +/// The initialization method to use for this property. +internal sealed record PropertyInitializationMode(string PropertyName, InitializationMethod Method); diff --git a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs index 829a43b..b97d4ee 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -18,7 +18,7 @@ internal static class DiagnosticDescriptors internal static readonly DiagnosticDescriptor UnsupportedCollectionElementType = new( "DM0003", "Collection element type not supported", - "The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements", + "The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements.", UsageCategory, DiagnosticSeverity.Error, true @@ -59,4 +59,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Error, true ); + + internal static readonly DiagnosticDescriptor MultipleConstructorsWithAttribute = new( + "DM0103", + "Multiple constructors marked with [DynamoMapperConstructor]", + "The type '{0}' has multiple constructors marked with [DynamoMapperConstructor]. Only one constructor can be marked with this attribute.", + UsageCategory, + DiagnosticSeverity.Error, + true + ); } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/ConstructorInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/ConstructorInfo.cs new file mode 100644 index 0000000..d183b91 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/ConstructorInfo.cs @@ -0,0 +1,16 @@ +using LayeredCraft.SourceGeneratorTools.Types; + +namespace DynamoMapper.Generator.Models; + +/// +/// Information about a constructor to use for deserialization. +/// +/// The constructor parameters with their rendered argument expressions. +internal sealed record ConstructorInfo(EquatableArray Parameters); + +/// +/// Information about a single constructor parameter. +/// +/// The parameter name (camelCase). +/// The rendered argument expression (e.g., "item.GetString(\"name\", ...)"). +internal sealed record ConstructorParameterInfo(string ParameterName, string RenderedArgument); diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs index f4ce5d8..4c0454f 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/ModelClassInfo.cs @@ -1,3 +1,5 @@ +using DynamoMapper.Generator.ConstructorMapping; +using DynamoMapper.Generator.ConstructorMapping.Models; using DynamoMapper.Generator.Diagnostics; using LayeredCraft.SourceGeneratorTools.Types; using Microsoft.CodeAnalysis; @@ -7,7 +9,8 @@ namespace DynamoMapper.Generator.Models; internal sealed record ModelClassInfo( string FullyQualifiedType, string VarName, - EquatableArray Properties + EquatableArray Properties, + ConstructorInfo? Constructor ); internal static class ModelClassInfoExtensions @@ -22,29 +25,180 @@ GeneratorContext context { context.ThrowIfCancellationRequested(); - var properties = modelTypeSymbol - .GetMembers() - .OfType() - .Where(p => - !p.IsStatic && (!modelTypeSymbol.IsRecord || p.Name != "EqualityContract") - ) - .ToList(); + var properties = GetModelProperties(modelTypeSymbol); - var varName = context - .MapperOptions.KeyNamingConventionConverter(modelTypeSymbol.Name) - .Map(name => name == fromItemParameterName ? name + "1" : name); + var varName = GetModelVarName(modelTypeSymbol, fromItemParameterName, context); - var (propertyInfos, propertyDiagnostics) = properties.CollectDiagnosticResults( - (propertySymbol, i) => PropertyInfo.Create(propertySymbol, varName, i, context) + var constructorSelectionResult = SelectConstructorIfNeeded( + modelTypeSymbol, + properties, + context ); + if (!constructorSelectionResult.IsSuccess) + return (null, [constructorSelectionResult.Error!]); + + var selectedConstructor = constructorSelectionResult.Value; + + var (propertyInfos, propertyInfosByIndex, propertyDiagnostics) = CreatePropertyInfos( + properties, + varName, + selectedConstructor, + context + ); + + var constructorInfo = selectedConstructor is null + ? null + : CreateConstructorInfo(selectedConstructor, properties, propertyInfosByIndex); + var modelClassInfo = new ModelClassInfo( modelTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), varName, - propertyInfos.ToEquatableArray() + new EquatableArray(propertyInfos), + constructorInfo ); - return (modelClassInfo, propertyDiagnostics.ToArray()); + return (modelClassInfo, propertyDiagnostics); } } + + private static IPropertySymbol[] GetModelProperties(ITypeSymbol modelTypeSymbol) => + modelTypeSymbol + .GetMembers() + .OfType() + .Where(p => IsMappableProperty(p, modelTypeSymbol)) + .ToArray(); + + private static bool IsMappableProperty(IPropertySymbol property, ITypeSymbol modelTypeSymbol) => + !property.IsStatic && !(modelTypeSymbol.IsRecord && property.Name == "EqualityContract"); + + private static string GetModelVarName( + ITypeSymbol modelTypeSymbol, + string? fromItemParameterName, + GeneratorContext context + ) + { + var varName = context.MapperOptions.KeyNamingConventionConverter(modelTypeSymbol.Name); + return varName == fromItemParameterName ? varName + "1" : varName; + } + + private static DiagnosticResult SelectConstructorIfNeeded( + ITypeSymbol modelTypeSymbol, + IPropertySymbol[] properties, + GeneratorContext context + ) => + context.HasFromItemMethod + ? ConstructorSelector.Select(modelTypeSymbol, properties, context) + : DiagnosticResult.Success(null); + + private static ( + PropertyInfo[] PropertyInfos, + PropertyInfo?[] PropertyInfosByIndex, + DiagnosticInfo[] Diagnostics + ) CreatePropertyInfos( + IPropertySymbol[] properties, + string modelVarName, + ConstructorSelectionResult? selectedConstructor, + GeneratorContext context + ) + { + var initMethodsByPropertyName = selectedConstructor is null + ? null + : selectedConstructor.PropertyModes.ToDictionary( + pm => pm.PropertyName, + pm => pm.Method, + StringComparer.Ordinal + ); + + var propertyDiagnosticsList = new List(); + var propertyInfosList = new List(properties.Length); + + var propertyInfosByIndex = new PropertyInfo?[properties.Length]; + + for (var i = 0; i < properties.Length; i++) + { + var property = properties[i]; + + var initMethod = InitializationMethod.InitSyntax; + if ( + initMethodsByPropertyName is not null + && initMethodsByPropertyName.TryGetValue(property.Name, out var initMethod2) + ) + initMethod = initMethod2; + + var propertyInfoResult = PropertyInfo.Create( + property, + modelVarName, + i, + initMethod, + context + ); + + if (!propertyInfoResult.IsSuccess) + { + propertyDiagnosticsList.Add(propertyInfoResult.Error!); + continue; + } + + propertyInfosList.Add(propertyInfoResult.Value!); + propertyInfosByIndex[i] = propertyInfoResult.Value!; + } + + return ( + propertyInfosList.ToArray(), + propertyInfosByIndex, + propertyDiagnosticsList.ToArray() + ); + } + + private static ConstructorInfo CreateConstructorInfo( + ConstructorSelectionResult selectedConstructor, + IPropertySymbol[] properties, + PropertyInfo?[] propertyInfosByIndex + ) + { + var propertyIndexBySymbol = CreatePropertyIndexBySymbol(properties); + + var parameterInfosList = new List( + selectedConstructor.Constructor.Constructor.Parameters.Length + ); + + foreach (var paramAnalysis in selectedConstructor.Constructor.Parameters) + { + var matchingProperty = paramAnalysis.MatchedProperty; + if (matchingProperty is null) + continue; + + if (!propertyIndexBySymbol.TryGetValue(matchingProperty, out var propertyIndex)) + continue; + + var argument = propertyInfosByIndex[propertyIndex]?.FromConstructorArgument; + + if (argument is null) + continue; + + parameterInfosList.Add( + new ConstructorParameterInfo(paramAnalysis.MemberInfo.MemberName, argument) + ); + } + + return new ConstructorInfo( + new EquatableArray(parameterInfosList.ToArray()) + ); + } + + private static Dictionary CreatePropertyIndexBySymbol( + IPropertySymbol[] properties + ) + { + var propertyIndexBySymbol = new Dictionary( + properties.Length, + SymbolEqualityComparer.Default + ); + + for (var i = 0; i < properties.Length; i++) + propertyIndexBySymbol[properties[i]] = i; + + return propertyIndexBySymbol; + } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Models/PropertyInfo.cs b/src/LayeredCraft.DynamoMapper.Generators/Models/PropertyInfo.cs index afee533..38d519a 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Models/PropertyInfo.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/Models/PropertyInfo.cs @@ -1,3 +1,4 @@ +using DynamoMapper.Generator.ConstructorMapping.Models; using DynamoMapper.Generator.Diagnostics; using DynamoMapper.Generator.PropertyMapping; using Microsoft.CodeAnalysis; @@ -7,10 +8,12 @@ namespace DynamoMapper.Generator.Models; internal sealed record PropertyInfo( string? FromAssignment, string? FromInitAssignment, - string? ToAssignments + string? ToAssignments, + // Constructor argument rendering (used when InitializationMethod is ConstructorParameter) + string? FromConstructorArgument ) { - internal static readonly PropertyInfo None = new(null, null, null); + internal static readonly PropertyInfo None = new(null, null, null, null); } internal static class PropertyInfoExtensions @@ -26,6 +29,7 @@ internal static DiagnosticResult Create( IPropertySymbol propertySymbol, string modelVarName, int index, + InitializationMethod initMethod, GeneratorContext context ) => PropertyAnalyzer @@ -50,6 +54,7 @@ is null tuple.analysis, modelVarName, index, + initMethod, context ) ) diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/MemberAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/MemberAnalyzer.cs new file mode 100644 index 0000000..0206bf3 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/MemberAnalyzer.cs @@ -0,0 +1,111 @@ +using DynamoMapper.Generator.Diagnostics; +using DynamoMapper.Generator.PropertyMapping.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DynamoMapper.Generator.PropertyMapping; + +/// +/// Performs shared semantic analysis on property and parameter symbols. Extracts common information +/// needed for type mapping without any mapping logic. +/// +internal static class MemberAnalyzer +{ + /// Analyzes a property symbol to extract shared member information. + /// The property symbol to analyze. + /// The generator context. + /// A diagnostic result containing the member analysis. + internal static DiagnosticResult AnalyzeProperty( + IPropertySymbol propertySymbol, + GeneratorContext context + ) + { + context.ThrowIfCancellationRequested(); + + var nullability = AnalyzeNullability(propertySymbol.Type); + var underlyingType = UnwrapNullableType(propertySymbol.Type); + + // Lookup field-level overrides from DynamoFieldAttribute + context.FieldOptions.TryGetValue(propertySymbol.Name, out var fieldOptions); + + var hasDefaultValue = HasPropertyDefaultValue(propertySymbol); + + return new MemberAnalysis( + propertySymbol.Name, + propertySymbol.Type, + underlyingType, + nullability, + fieldOptions, + hasDefaultValue + ); + } + + /// Analyzes a parameter symbol to extract shared member information. + /// The parameter symbol to analyze. + /// The generator context. + /// A diagnostic result containing the member analysis. + internal static DiagnosticResult AnalyzeParameter( + IParameterSymbol parameterSymbol, + GeneratorContext context + ) + { + context.ThrowIfCancellationRequested(); + + var nullability = AnalyzeNullability(parameterSymbol.Type); + var underlyingType = UnwrapNullableType(parameterSymbol.Type); + + // Parameters don't have field-level options (only properties do) + var hasDefaultValue = parameterSymbol.HasExplicitDefaultValue; + + return new MemberAnalysis( + parameterSymbol.Name, + parameterSymbol.Type, + underlyingType, + nullability, + null, // Parameters don't have DynamoFieldAttribute + hasDefaultValue + ); + } + + /// Analyzes the nullability characteristics of a type. + internal static PropertyNullabilityInfo AnalyzeNullability(ITypeSymbol type) + { + var isReferenceType = type.IsReferenceType; + var annotation = type.NullableAnnotation; + + // A type is nullable if: + // 1. It's Nullable (value type) + // 2. It's a reference type with Annotated nullable annotation + var isNullableType = + type + is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + } + || (isReferenceType && annotation == NullableAnnotation.Annotated); + + return new PropertyNullabilityInfo(isNullableType, isReferenceType, annotation); + } + + /// Checks if a property has a default value (initializer). + private static bool HasPropertyDefaultValue(IPropertySymbol property) => + property.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() + is PropertyDeclarationSyntax { Initializer: not null }; + + /// + /// Unwraps Nullable<T> to get the underlying type T. If the type is not nullable, + /// returns the type unchanged. + /// + internal static ITypeSymbol UnwrapNullableType(ITypeSymbol type) + { + if ( + type is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + } namedType + ) + return namedType.TypeArguments[0]; + + return type; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/MemberAnalysis.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/MemberAnalysis.cs new file mode 100644 index 0000000..38edeb6 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/MemberAnalysis.cs @@ -0,0 +1,22 @@ +using Microsoft.CodeAnalysis; + +namespace DynamoMapper.Generator.PropertyMapping.Models; + +/// +/// Represents semantic information that is common to both property symbols and parameter symbols. +/// This shared analysis enables code reuse in type mapping logic. +/// +/// The name of the property or parameter. +/// The full type of the member (may be nullable). +/// The underlying type, unwrapped from Nullable<T> if applicable. +/// Nullability information for the member type. +/// Field-level options from DynamoFieldAttribute (properties only, null for parameters). +/// True if the member has a default value. +internal sealed record MemberAnalysis( + string MemberName, + ITypeSymbol MemberType, + ITypeSymbol UnderlyingType, + PropertyNullabilityInfo Nullability, + DynamoFieldOptions? FieldOptions, + bool HasDefaultValue +); diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/PropertyAnalysis.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/PropertyAnalysis.cs index 7d9d3b5..4a7478c 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/PropertyAnalysis.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/Models/PropertyAnalysis.cs @@ -16,5 +16,7 @@ internal sealed record PropertyAnalysis( bool HasSetter, bool IsRequired, bool IsInitOnly, - bool HasDefaultValue + bool HasDefaultValue, + // Shared member analysis for code reuse with constructor parameters + MemberAnalysis MemberInfo ); diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyAnalyzer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyAnalyzer.cs index 6e847d0..e23a3a7 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyAnalyzer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyAnalyzer.cs @@ -1,7 +1,6 @@ using DynamoMapper.Generator.Diagnostics; using DynamoMapper.Generator.PropertyMapping.Models; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace DynamoMapper.Generator.PropertyMapping; @@ -22,74 +21,32 @@ GeneratorContext context { context.ThrowIfCancellationRequested(); - var nullability = AnalyzeNullability(propertySymbol); - var underlyingType = UnwrapNullableType(propertySymbol.Type); + // Use shared MemberAnalyzer for common analysis + var memberInfoResult = MemberAnalyzer.AnalyzeProperty(propertySymbol, context); + if (!memberInfoResult.IsSuccess) + return DiagnosticResult.Failure(memberInfoResult.Error!); - // Lookup field-level overrides from DynamoFieldAttribute - context.FieldOptions.TryGetValue(propertySymbol.Name, out var fieldOptions); + var memberInfo = memberInfoResult.Value!; - // Detect property accessors + // Detect property-specific accessors var hasGetter = propertySymbol.GetMethod is not null; var hasSetter = propertySymbol.SetMethod is not null; // includes init-only setters var isRequired = propertySymbol.IsRequired; var isInitOnly = propertySymbol.SetMethod?.IsInitOnly ?? false; - var hasDefaultValue = HasDefaultValue(propertySymbol); - return new PropertyAnalysis( - propertySymbol.Name, - propertySymbol.Type, - underlyingType, - nullability, - fieldOptions, + memberInfo.MemberName, + memberInfo.MemberType, + memberInfo.UnderlyingType, + memberInfo.Nullability, + memberInfo.FieldOptions, hasGetter, hasSetter, isRequired, isInitOnly, - hasDefaultValue + memberInfo.HasDefaultValue, + memberInfo ); } - - /// Analyzes the nullability characteristics of a property. - private static PropertyNullabilityInfo AnalyzeNullability(IPropertySymbol propertySymbol) - { - var type = propertySymbol.Type; - var isReferenceType = type.IsReferenceType; - var annotation = type.NullableAnnotation; - - // A type is nullable if: - // 1. It's Nullable (value type) - // 2. It's a reference type with Annotated nullable annotation - var isNullableType = - type - is INamedTypeSymbol - { - OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, - } - || (isReferenceType && annotation == NullableAnnotation.Annotated); - - return new PropertyNullabilityInfo(isNullableType, isReferenceType, annotation); - } - - private static bool HasDefaultValue(IPropertySymbol property) => - property.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() - is PropertyDeclarationSyntax { Initializer: not null }; - - /// - /// Unwraps Nullable<T> to get the underlying type T. If the type is not nullable, - /// returns the type unchanged. - /// - private static ITypeSymbol UnwrapNullableType(ITypeSymbol type) - { - if ( - type is INamedTypeSymbol - { - OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, - } namedType - ) - return namedType.TypeArguments[0]; - - return type; - } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs index 5d7c01c..5ef92a0 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/PropertyMapping/PropertyMappingCodeRenderer.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using DynamoMapper.Generator.ConstructorMapping.Models; using DynamoMapper.Generator.Models; using DynamoMapper.Generator.PropertyMapping.Models; using Microsoft.CodeAnalysis; @@ -17,20 +18,40 @@ internal static PropertyInfo Render( PropertyAnalysis analysis, string modelVarName, int index, + InitializationMethod initMethod, GeneratorContext context ) { + // If this property is a constructor parameter, render as constructor argument + if (initMethod == InitializationMethod.ConstructorParameter && context.HasFromItemMethod) + { + var constructorArg = spec.FromItemMethod is not null + ? RenderConstructorArgument(spec, analysis, context) + : null; + + // Constructor parameters still need ToAssignments for serialization + var toAssignment = + context.HasToItemMethod && analysis.HasGetter && spec.ToItemMethod is not null + ? RenderToAssignment(spec) + : null; + + return new PropertyInfo(null, null, toAssignment, constructorArg); + } + // Determine if we should use regular assignment vs init assignment var useRegularAssignment = spec.FromItemMethod is { IsCustomMethod: false } && analysis is { IsRequired: false, IsInitOnly: false, HasDefaultValue: true }; + // Use init syntax for InitSyntax mode, regular assignment for PostConstruction + var useInitSyntax = initMethod == InitializationMethod.InitSyntax; + // FromItem requires both: setter on property AND FromItem method exists var fromAssignment = context.HasFromItemMethod && analysis.HasSetter && spec.FromItemMethod is not null - && useRegularAssignment + && (!useInitSyntax || useRegularAssignment) ? RenderFromAssignment(spec, modelVarName, analysis, index, context) : null; @@ -38,6 +59,7 @@ GeneratorContext context context.HasFromItemMethod && analysis.HasSetter && spec.FromItemMethod is not null + && useInitSyntax && !useRegularAssignment ? RenderFromInitAssignment(spec, analysis, context) : null; @@ -48,7 +70,7 @@ GeneratorContext context ? RenderToAssignment(spec) : null; - return new PropertyInfo(fromAssignment, fromInitAssignment, toAssignments); + return new PropertyInfo(fromAssignment, fromInitAssignment, toAssignments, null); } /// @@ -146,4 +168,36 @@ private static string RenderToAssignment(PropertyMappingSpec spec) return methodCall; } + + /// + /// Renders a constructor argument expression. This is similar to FromInitAssignment but + /// without the "PropertyName = " prefix, as it's used as a constructor parameter value. + /// + private static string RenderConstructorArgument( + PropertyMappingSpec spec, + PropertyAnalysis analysis, + GeneratorContext context + ) + { + Debug.Assert(spec.FromItemMethod is not null, "FromItemMethod should not be null"); + Debug.Assert( + spec.FromItemMethod!.IsCustomMethod || spec.TypeStrategy is not null, + "TypeStrategy should not be null for standard methods" + ); + + var args = string.Join(", ", spec.FromItemMethod.Arguments.Select(a => a.Value)); + + var methodCall = spec.FromItemMethod.IsCustomMethod + ? $"{spec.FromItemMethod.MethodName}({args})" // Custom: MethodName(item) + : $"{context.MapperOptions.FromMethodParameterName}.{spec.FromItemMethod.MethodName}{spec.TypeStrategy!.GenericArgument}({args})"; // Standard: item.GetXxx(args) + + // For array properties, append .ToArray() to convert the List to an array + var isArrayProperty = analysis.PropertyType.TypeKind == TypeKind.Array; + if (isArrayProperty && !spec.FromItemMethod.IsCustomMethod) + { + methodCall += ".ToArray()"; + } + + return methodCall; + } } diff --git a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban index 56ec1dc..b02cab2 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban +++ b/src/LayeredCraft.DynamoMapper.Generators/Templates/Mapper.scriban @@ -34,6 +34,28 @@ using Amazon.DynamoDBv2.Model; {{ generated_code_attribute }} {{ mapper_class.from_item_signature }} { + {{~ if model_class.constructor != null ~}} + {{~ # Constructor-based initialization ~}} + {{~ if from_init_assignments.size == 0 ~}} + var {{ model_var_name }} = new {{ model_class.fully_qualified_type }}( + {{~ for param in model_class.constructor.parameters ~}} + {{ param.parameter_name }}: {{ param.rendered_argument }}{{ if !for.last }},{{ end }} + {{~ end ~}} + ); + {{~ else ~}} + var {{ model_var_name }} = new {{ model_class.fully_qualified_type }}( + {{~ for param in model_class.constructor.parameters ~}} + {{ param.parameter_name }}: {{ param.rendered_argument }}{{ if !for.last }},{{ end }} + {{~ end ~}} + ) + { + {{~ for assignment in from_init_assignments ~}} + {{ assignment }} + {{~ end ~}} + }; + {{~ end ~}} + {{~ else ~}} + {{~ # Property-based initialization (existing logic) ~}} {{~ if from_init_assignments.size == 0 ~}} var {{ model_var_name }} = new {{ model_class.fully_qualified_type }}(); {{~ else ~}} @@ -44,6 +66,7 @@ using Amazon.DynamoDBv2.Model; {{~ end ~}} }; {{~ end ~}} + {{~ end ~}} {{~ for assignment in from_assignments ~}} {{ assignment }} {{~ end ~}} diff --git a/src/LayeredCraft.DynamoMapper.Generators/WellKnownTypes/WellKnownTypeData.cs b/src/LayeredCraft.DynamoMapper.Generators/WellKnownTypes/WellKnownTypeData.cs index 3a7848e..e16553a 100644 --- a/src/LayeredCraft.DynamoMapper.Generators/WellKnownTypes/WellKnownTypeData.cs +++ b/src/LayeredCraft.DynamoMapper.Generators/WellKnownTypes/WellKnownTypeData.cs @@ -52,6 +52,7 @@ public enum WellKnownType DynamoMapper_Runtime_DynamoMapperAttribute, DynamoMapper_Runtime_DynamoFieldAttribute, DynamoMapper_Runtime_DynamoIgnoreAttribute, + DynamoMapper_Runtime_DynamoMapperConstructorAttribute, } public static readonly string[] WellKnownTypeNames = @@ -94,5 +95,6 @@ public enum WellKnownType "DynamoMapper.Runtime.DynamoMapperAttribute", "DynamoMapper.Runtime.DynamoFieldAttribute", "DynamoMapper.Runtime.DynamoIgnoreAttribute", + "DynamoMapper.Runtime.DynamoMapperConstructorAttribute", ]; } diff --git a/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperConstructorAttribute.cs b/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperConstructorAttribute.cs new file mode 100644 index 0000000..1874342 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Runtime/DynamoMapperConstructorAttribute.cs @@ -0,0 +1,8 @@ +namespace DynamoMapper.Runtime; + +/// +/// Marks which constructor the DynamoMapper generator should use when deserializing entities +/// from DynamoDB AttributeValue dictionaries. +/// +[AttributeUsage(AttributeTargets.Constructor)] +public class DynamoMapperConstructorAttribute : Attribute; diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/ConstructorVerifyTests.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/ConstructorVerifyTests.cs new file mode 100644 index 0000000..e6b57dc --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/ConstructorVerifyTests.cs @@ -0,0 +1,435 @@ +namespace LayeredCraft.DynamoMapper.Generators.Tests; + +public class ConstructorVerifyTests +{ + [Fact] + public async Task Constructor_RecordWithPrimaryConstructor() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class PersonMapper + { + public static partial Dictionary ToItem(Person source); + public static partial Person FromItem(Dictionary item); + } + + public record Person(string FirstName, string LastName, int Age); + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ClassWithReadOnlyPropertiesAndConstructor() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using 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 Product(string name, decimal price, int quantity) + { + Name = name; + Price = price; + Quantity = quantity; + } + + public string Name { get; } + public decimal Price { get; } + public int Quantity { get; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ClassWithAttributedConstructor() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class UserMapper + { + public static partial Dictionary ToItem(User source); + public static partial User FromItem(Dictionary item); + } + + public class User + { + public User() + { + Id = string.Empty; + Name = string.Empty; + } + + [DynamoMapperConstructor] + public User(string id, string name) + { + Id = id; + Name = name; + } + + public string Id { get; set; } + public string Name { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ClassWithSettablePropertiesPreferredOverConstructor() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class CustomerMapper + { + public static partial Dictionary ToItem(Customer source); + public static partial Customer FromItem(Dictionary item); + } + + public class Customer + { + public Customer() + { + } + + public Customer(string name, int age) + { + Name = name; + Age = age; + } + + public string Name { get; set; } + public int Age { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ClassWithMultipleConstructorsPicksLargest() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class EntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public Entity(string id) + { + Id = id; + Name = string.Empty; + Count = 0; + } + + public Entity(string id, string name) + { + Id = id; + Name = name; + Count = 0; + } + + public Entity(string id, string name, int count) + { + Id = id; + Name = name; + Count = count; + } + + public string Id { get; } + public string Name { get; } + public int Count { get; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ClassWithInitOnlyProperties() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class ConfigMapper + { + public static partial Dictionary ToItem(Config source); + public static partial Config FromItem(Dictionary item); + } + + public class Config + { + public string Name { get; init; } + public int Value { get; init; } + public bool Enabled { get; init; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_RecordStructWithPrimaryConstructor() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class PointMapper + { + public static partial Dictionary ToItem(Point source); + public static partial Point FromItem(Dictionary item); + } + + public record struct Point(int X, int Y, int Z); + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_MixedReadOnlyAndSettableProperties() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class EntityMapper + { + public static partial Dictionary ToItem(Entity source); + public static partial Entity FromItem(Dictionary item); + } + + public class Entity + { + public Entity(string id) + { + Id = id; + } + + public string Id { get; } + public string Name { get; set; } + public int Count { get; set; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_NoParameterlessConstructorWithInitProperties() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class OrderMapper + { + public static partial Dictionary ToItem(Order source); + public static partial Order FromItem(Dictionary item); + } + + public class Order + { + public Order(string id) + { + Id = id; + } + + public string Id { get; } + public string CustomerName { get; init; } + public decimal Total { get; init; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_ComplexNameMatching() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class PersonMapper + { + public static partial Dictionary ToItem(Person source); + public static partial Person FromItem(Dictionary item); + } + + public class Person + { + public Person(string firstName, string lastName, int age, bool isActive) + { + FirstName = firstName; + LastName = lastName; + Age = age; + IsActive = isActive; + } + + public string FirstName { get; } + public string LastName { get; } + public int Age { get; } + public bool IsActive { get; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_RecordWithPrimaryConstructorAndAccessibleProperties() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using 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 record Product(string Id, string Name) + { + public decimal Price { get; set; } + public int Quantity { get; init; } + } + """, + }, + TestContext.Current.CancellationToken + ); + + [Fact] + public async Task Constructor_MultipleAttributedConstructors_ShouldFail() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using System.Collections.Generic; + using Amazon.DynamoDBv2.Model; + using DynamoMapper.Runtime; + + namespace MyNamespace; + + [DynamoMapper] + public static partial class PersonMapper + { + public static partial Dictionary ToItem(Person source); + public static partial Person FromItem(Dictionary item); + } + + public class Person + { + [DynamoMapperConstructor] + public Person(string name) + { + Name = name; + Age = 0; + } + + [DynamoMapperConstructor] + public Person(string name, int age) + { + Name = name; + Age = age; + } + + public string Name { get; } + public int Age { get; } + } + """, + ExpectedDiagnosticId = "DM0103", + }, + TestContext.Current.CancellationToken + ); +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_ComplexElementType_ShouldFail.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_ComplexElementType_ShouldFail.verified.txt index 3f0c605..763635e 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_ComplexElementType_ShouldFail.verified.txt +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_ComplexElementType_ShouldFail.verified.txt @@ -1,12 +1,12 @@ { Diagnostics: [ { - Message: The property 'Items' has element type 'MyNamespace.CustomClass' which is not supported. Only primitive types are supported as collection elements, + Message: The property 'Items' has element type 'MyNamespace.CustomClass' which is not supported. Only primitive types are supported as collection elements., Severity: Error, Descriptor: { Id: DM0003, Title: Collection element type not supported, - MessageFormat: The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements, + MessageFormat: The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements., Category: DynamoMapper.Usage, DefaultSeverity: Error, IsEnabledByDefault: true diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_NestedList_ShouldFail.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_NestedList_ShouldFail.verified.txt index 6db1b5a..c5c7ad6 100644 --- a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_NestedList_ShouldFail.verified.txt +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/CollectionVerifyTests.Collection_NestedList_ShouldFail.verified.txt @@ -1,12 +1,12 @@ { Diagnostics: [ { - Message: The property 'NestedList' has element type 'System.Collections.Generic.List' which is not supported. Only primitive types are supported as collection elements, + Message: The property 'NestedList' has element type 'System.Collections.Generic.List' which is not supported. Only primitive types are supported as collection elements., Severity: Error, Descriptor: { Id: DM0003, Title: Collection element type not supported, - MessageFormat: The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements, + MessageFormat: The property '{0}' has element type '{1}' which is not supported. Only primitive types are supported as collection elements., Category: DynamoMapper.Usage, DefaultSeverity: Error, IsEnabledByDefault: true diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithAttributedConstructor#UserMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithAttributedConstructor#UserMapper.g.verified.cs new file mode 100644 index 0000000..2049617 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithAttributedConstructor#UserMapper.g.verified.cs @@ -0,0 +1,36 @@ +//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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class UserMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.User source) => + new Dictionary(2) + .SetString("id", source.Id, false, true) + .SetString("name", source.Name, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.User FromItem(global::System.Collections.Generic.Dictionary item) + { + var user = new global::MyNamespace.User( + id: item.GetString("id", Requiredness.InferFromNullability), + name: item.GetString("name", Requiredness.InferFromNullability) + ); + return user; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithInitOnlyProperties#ConfigMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithInitOnlyProperties#ConfigMapper.g.verified.cs new file mode 100644 index 0000000..fb196cc --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithInitOnlyProperties#ConfigMapper.g.verified.cs @@ -0,0 +1,39 @@ +//HintName: ConfigMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ConfigMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Config source) => + new Dictionary(3) + .SetString("name", source.Name, false, true) + .SetInt("value", source.Value, false, true) + .SetBool("enabled", source.Enabled, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Config FromItem(global::System.Collections.Generic.Dictionary item) + { + var config = new global::MyNamespace.Config + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Value = item.GetInt("value", Requiredness.InferFromNullability), + Enabled = item.GetBool("enabled", Requiredness.InferFromNullability), + }; + return config; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithMultipleConstructorsPicksLargest#EntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithMultipleConstructorsPicksLargest#EntityMapper.g.verified.cs new file mode 100644 index 0000000..64c7946 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithMultipleConstructorsPicksLargest#EntityMapper.g.verified.cs @@ -0,0 +1,38 @@ +//HintName: EntityMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class EntityMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Entity source) => + new Dictionary(3) + .SetString("id", source.Id, false, true) + .SetString("name", source.Name, false, true) + .SetInt("count", source.Count, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Entity FromItem(global::System.Collections.Generic.Dictionary item) + { + var entity = new global::MyNamespace.Entity( + id: item.GetString("id", Requiredness.InferFromNullability), + name: item.GetString("name", Requiredness.InferFromNullability), + count: item.GetInt("count", Requiredness.InferFromNullability) + ); + return entity; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithReadOnlyPropertiesAndConstructor#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithReadOnlyPropertiesAndConstructor#ProductMapper.g.verified.cs new file mode 100644 index 0000000..033171d --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithReadOnlyPropertiesAndConstructor#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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(3) + .SetString("name", source.Name, false, true) + .SetDecimal("price", source.Price, false, true) + .SetInt("quantity", source.Quantity, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("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), + quantity: item.GetInt("quantity", Requiredness.InferFromNullability) + ); + return product; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithSettablePropertiesPreferredOverConstructor#CustomerMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithSettablePropertiesPreferredOverConstructor#CustomerMapper.g.verified.cs new file mode 100644 index 0000000..cc612df --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ClassWithSettablePropertiesPreferredOverConstructor#CustomerMapper.g.verified.cs @@ -0,0 +1,37 @@ +//HintName: CustomerMapper.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +using DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class CustomerMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Customer source) => + new Dictionary(2) + .SetString("name", source.Name, false, true) + .SetInt("age", source.Age, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Customer FromItem(global::System.Collections.Generic.Dictionary item) + { + var customer = new global::MyNamespace.Customer + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Age = item.GetInt("age", Requiredness.InferFromNullability), + }; + return customer; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ComplexNameMatching#PersonMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ComplexNameMatching#PersonMapper.g.verified.cs new file mode 100644 index 0000000..a8b9341 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_ComplexNameMatching#PersonMapper.g.verified.cs @@ -0,0 +1,40 @@ +//HintName: PersonMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class PersonMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Person source) => + new Dictionary(4) + .SetString("firstName", source.FirstName, false, true) + .SetString("lastName", source.LastName, false, true) + .SetInt("age", source.Age, false, true) + .SetBool("isActive", source.IsActive, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Person FromItem(global::System.Collections.Generic.Dictionary item) + { + var person = new global::MyNamespace.Person( + firstName: item.GetString("firstName", Requiredness.InferFromNullability), + lastName: item.GetString("lastName", Requiredness.InferFromNullability), + age: item.GetInt("age", Requiredness.InferFromNullability), + isActive: item.GetBool("isActive", Requiredness.InferFromNullability) + ); + return person; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MixedReadOnlyAndSettableProperties#EntityMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MixedReadOnlyAndSettableProperties#EntityMapper.g.verified.cs new file mode 100644 index 0000000..ef0bf2e --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MixedReadOnlyAndSettableProperties#EntityMapper.g.verified.cs @@ -0,0 +1,40 @@ +//HintName: EntityMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class EntityMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Entity source) => + new Dictionary(3) + .SetString("id", source.Id, false, true) + .SetString("name", source.Name, false, true) + .SetInt("count", source.Count, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Entity FromItem(global::System.Collections.Generic.Dictionary item) + { + var entity = new global::MyNamespace.Entity( + id: item.GetString("id", Requiredness.InferFromNullability) + ) + { + Name = item.GetString("name", Requiredness.InferFromNullability), + Count = item.GetInt("count", Requiredness.InferFromNullability), + }; + return entity; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MultipleAttributedConstructors_ShouldFail.verified.txt b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MultipleAttributedConstructors_ShouldFail.verified.txt new file mode 100644 index 0000000..b32290f --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_MultipleAttributedConstructors_ShouldFail.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (23,11)-(23,17), + Message: The type 'MyNamespace.Person' has multiple constructors marked with [DynamoMapperConstructor]. Only one constructor can be marked with this attribute., + Severity: Error, + Descriptor: { + Id: DM0103, + Title: Multiple constructors marked with [DynamoMapperConstructor], + MessageFormat: The type '{0}' has multiple constructors marked with [DynamoMapperConstructor]. Only one constructor can be marked with this attribute., + Category: DynamoMapper.Usage, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_NoParameterlessConstructorWithInitProperties#OrderMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_NoParameterlessConstructorWithInitProperties#OrderMapper.g.verified.cs new file mode 100644 index 0000000..3bd85d6 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_NoParameterlessConstructorWithInitProperties#OrderMapper.g.verified.cs @@ -0,0 +1,40 @@ +//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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class OrderMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Order source) => + new Dictionary(3) + .SetString("id", source.Id, false, true) + .SetString("customerName", source.CustomerName, false, true) + .SetDecimal("total", source.Total, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Order FromItem(global::System.Collections.Generic.Dictionary item) + { + var order = new global::MyNamespace.Order( + id: item.GetString("id", Requiredness.InferFromNullability) + ) + { + CustomerName = item.GetString("customerName", Requiredness.InferFromNullability), + Total = item.GetDecimal("total", Requiredness.InferFromNullability), + }; + return order; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordStructWithPrimaryConstructor#PointMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordStructWithPrimaryConstructor#PointMapper.g.verified.cs new file mode 100644 index 0000000..861e9fb --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordStructWithPrimaryConstructor#PointMapper.g.verified.cs @@ -0,0 +1,39 @@ +//HintName: PointMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class PointMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Point source) => + new Dictionary(3) + .SetInt("x", source.X, false, true) + .SetInt("y", source.Y, false, true) + .SetInt("z", source.Z, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Point FromItem(global::System.Collections.Generic.Dictionary item) + { + var point = new global::MyNamespace.Point + { + X = item.GetInt("x", Requiredness.InferFromNullability), + Y = item.GetInt("y", Requiredness.InferFromNullability), + Z = item.GetInt("z", Requiredness.InferFromNullability), + }; + return point; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructor#PersonMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructor#PersonMapper.g.verified.cs new file mode 100644 index 0000000..45a0bba --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructor#PersonMapper.g.verified.cs @@ -0,0 +1,38 @@ +//HintName: PersonMapper.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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class PersonMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Person source) => + new Dictionary(3) + .SetString("firstName", source.FirstName, false, true) + .SetString("lastName", source.LastName, false, true) + .SetInt("age", source.Age, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Person FromItem(global::System.Collections.Generic.Dictionary item) + { + var person = new global::MyNamespace.Person( + FirstName: item.GetString("firstName", Requiredness.InferFromNullability), + LastName: item.GetString("lastName", Requiredness.InferFromNullability), + Age: item.GetInt("age", Requiredness.InferFromNullability) + ); + return person; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructorAndAccessibleProperties#ProductMapper.g.verified.cs b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructorAndAccessibleProperties#ProductMapper.g.verified.cs new file mode 100644 index 0000000..5a1f61d --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Generators.Tests/Snapshots/ConstructorVerifyTests.Constructor_RecordWithPrimaryConstructorAndAccessibleProperties#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 DynamoMapper.Runtime; +using System.Collections.Generic; +using Amazon.DynamoDBv2.Model; + +namespace MyNamespace; + +public static partial class ProductMapper +{ + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::System.Collections.Generic.Dictionary ToItem(global::MyNamespace.Product source) => + new Dictionary(4) + .SetString("id", source.Id, false, true) + .SetString("name", source.Name, false, true) + .SetDecimal("price", source.Price, false, true) + .SetInt("quantity", source.Quantity, false, true); + + [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")] + public static partial global::MyNamespace.Product FromItem(global::System.Collections.Generic.Dictionary item) + { + var product = new global::MyNamespace.Product( + Id: item.GetString("id", Requiredness.InferFromNullability), + Name: item.GetString("name", Requiredness.InferFromNullability) + ) + { + Price = item.GetDecimal("price", Requiredness.InferFromNullability), + Quantity = item.GetInt("quantity", Requiredness.InferFromNullability), + }; + return product; + } +}