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;
+ }
+}