Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions: write-all
jobs:
build:
if: github.event.pull_request.draft == false
uses: LayeredCraft/devops-templates/.github/workflows/pr-build.yaml@v8.0
uses: LayeredCraft/devops-templates/.github/workflows/pr-build.yaml@v8.1
with:
solution: LayeredCraft.DynamoMapper.slnx
hasTests: true
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-title-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ permissions:

jobs:
validate:
uses: LayeredCraft/devops-templates/.github/workflows/pr-title-check.yml@v8.0
uses: LayeredCraft/devops-templates/.github/workflows/pr-title-check.yml@v8.1
2 changes: 1 addition & 1 deletion .github/workflows/publish-preview.yaml
Comment thread
j-d-ha marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions: write-all

jobs:
publish:
uses: LayeredCraft/devops-templates/.github/workflows/publish-preview.yml@main
uses: LayeredCraft/devops-templates/.github/workflows/publish-preview.yml@v8.1
with:
solution: LayeredCraft.DynamoMapper.slnx
dotnetVersion: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ permissions: write-all

jobs:
publish:
uses: LayeredCraft/devops-templates/.github/workflows/publish-release.yml@main
uses: LayeredCraft/devops-templates/.github/workflows/publish-release.yml@v8.1
with:
solution: LayeredCraft.DynamoMapper.slnx
dotnetVersion: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-drafter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:

jobs:
draft:
uses: LayeredCraft/devops-templates/.github/workflows/release-drafter.yml@v8.0
uses: LayeredCraft/devops-templates/.github/workflows/release-drafter.yml@v8.1
with:
event_name: ${{ github.event_name }}
pr_draft: ${{ github.event.pull_request.draft == true }}
7 changes: 7 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Diagnostics for nested cycles and invalid dot-notation paths (DM0006-DM0008).
- `examples/DynamoMapper.Nested` for end-to-end nested mapping scenarios.

### Fixed

- Nested `FromItem` fallback behavior now honors effective requiredness for nested object and
nested collection containers.
- Non-nullable optional nested object fallbacks now emit `null!` to avoid CS8601 warnings while
preserving optional semantics.

### Planned
- Phase 1: Attribute-based mapping
- Phase 2: Fluent DSL configuration
Expand Down
14 changes: 14 additions & 0 deletions docs/usage/basic-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ Nested collections of sets (SS/NS/BS) are not supported.

Nested object graphs cannot contain cycles. Cycles emit `DM0006`.

### Missing Nested Attributes During `FromItem`

For nested object and nested collection containers, generated fallback behavior follows effective
requiredness:

- `Required = true` (or C# `required`) -> missing attribute throws.
- `Required = false` -> nested object falls back to `null`/`null!`; nested collections fall back to
`null`/`[]` depending on container nullability.
- no explicit override -> `DefaultRequiredness` applies (default:
`Requiredness.InferFromNullability`).

With `InferFromNullability`, non-nullable containers are treated as required and missing attributes
throw; nullable containers fall back to `null`.

### Example

```csharp
Expand Down
10 changes: 10 additions & 0 deletions docs/usage/field-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Notes:
- Invalid paths emit `DM0008`.
- `OmitIfNull` works on nested object and nested collection properties too, so paths like
`"Customer.Profile"` or `"Customer.Addresses"` can omit null containers during `ToItem`.
- `Required` works on nested object and nested collection containers too, including dot-notation
paths.

### Collection Element Members

Expand Down Expand Up @@ -88,3 +90,11 @@ Notes:
| `ToMethod` | Uses a custom method to serialize a value. |
| `FromMethod` | Uses a custom method to deserialize a value. |
| `Format` | Overrides default format for date/time/enum conversions. |

Notes:

- If `Required` is omitted, `FromItem` uses mapper-level `DefaultRequiredness`
(`InferFromNullability` by default).
- For nested containers, effective requiredness controls missing-attribute fallback:
required -> throw; optional -> nullable containers use `null`, non-nullable collections use `[]`,
and non-nullable nested objects use `null!`.
4 changes: 3 additions & 1 deletion skills/dynamo-mapper/references/core-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,11 @@ Order:

## Requiredness and defaults

- missing required root scalar values throw at runtime
- missing required values throw at runtime (scalars and nested containers)
- nullable root scalar values read as `null` when absent
- optional/default-initialized members can keep their C# defaults when the attribute is missing
- nested containers respect effective requiredness (`DynamoField.Required` override, otherwise
mapper default / nullability inference)

## Safe guidance

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using LayeredCraft.DynamoMapper.Generator.Models;
using LayeredCraft.DynamoMapper.Runtime;
using LayeredCraft.SourceGeneratorTools.Types;

namespace LayeredCraft.DynamoMapper.Generator.PropertyMapping.Models;
Expand Down Expand Up @@ -49,6 +50,7 @@ EquatableArray<NestedPropertySpec> Properties
/// <param name="IsRequired">Whether the property is marked as required.</param>
/// <param name="IsInitOnly">Whether the property is init-only.</param>
/// <param name="HasDefaultValue">Whether the property has a default value.</param>
/// <param name="Requiredness">Configured requiredness for generated read paths.</param>
/// <param name="NestedMapping">If the property is itself a nested object, its mapping info.</param>
internal sealed record NestedPropertySpec(
string PropertyName,
Expand All @@ -62,5 +64,6 @@ internal sealed record NestedPropertySpec(
bool IsRequired,
bool IsInitOnly,
bool HasDefaultValue,
Requiredness Requiredness,
NestedMappingInfo? NestedMapping = null
);
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ private static IPropertySymbol[] GetMappableProperties(
}

var propertyAnalysis = propertyAnalysisResult.Value!;
var requiredness =
RequirednessResolver.ResolveConfigured(
fieldOptions,
propertyAnalysis.IsRequired,
nestedContext.Context.MapperOptions.DefaultRequiredness
);

// Determine the DynamoDB attribute name
var dynamoKey =
Expand Down Expand Up @@ -268,6 +274,7 @@ private static IPropertySymbol[] GetMappableProperties(
propertyAnalysis.IsRequired,
propertyAnalysis.IsInitOnly,
propertyAnalysis.HasDefaultValue,
requiredness,
null
)
);
Expand Down Expand Up @@ -331,6 +338,7 @@ private static IPropertySymbol[] GetMappableProperties(
propertyAnalysis.IsRequired,
propertyAnalysis.IsInitOnly,
propertyAnalysis.HasDefaultValue,
requiredness,
null
)
);
Expand Down Expand Up @@ -362,6 +370,7 @@ private static IPropertySymbol[] GetMappableProperties(
propertyAnalysis.IsRequired,
propertyAnalysis.IsInitOnly,
propertyAnalysis.HasDefaultValue,
requiredness,
nestedResult.Value
)
);
Expand Down Expand Up @@ -495,24 +504,24 @@ INamedTypeSymbol t when context.WellKnownTypes.IsType(
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\""],
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat}\""]
),
INamedTypeSymbol t
when supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsDateOnly(t, context)
=> new TypeMappingStrategy(
"DateOnly",
"",
nullableModifier,
[$"\"{fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat}\""],
[$"\"{fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat}\""]
),
INamedTypeSymbol t
when supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsTimeOnly(t, context)
=> new TypeMappingStrategy(
"TimeOnly",
"",
nullableModifier,
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat}\""],
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat}\""]
),
INamedTypeSymbol t when
supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsDateOnly(t, context) => new
TypeMappingStrategy(
"DateOnly",
"",
nullableModifier,
[$"\"{fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat}\""],
[$"\"{fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat}\""]
),
INamedTypeSymbol t when
supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsTimeOnly(t, context) => new
TypeMappingStrategy(
"TimeOnly",
"",
nullableModifier,
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat}\""],
[$"\"{fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat}\""]
),
IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Byte } =>
new TypeMappingStrategy("Binary", "", nullableModifier, [], []),
INamedTypeSymbol t when
Expand Down Expand Up @@ -665,16 +674,14 @@ INamedTypeSymbol t when context.WellKnownTypes.IsType(
) => CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.TimeSpanFormat
),
INamedTypeSymbol t
when supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsDateOnly(t, context)
=> CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat
),
INamedTypeSymbol t
when supportsDateOnlyTimeOnly && DateOnlyTimeOnlySupport.IsTimeOnly(t, context)
=> CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat
),
INamedTypeSymbol t when supportsDateOnlyTimeOnly &&
DateOnlyTimeOnlySupport.IsDateOnly(t, context) => CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.DateOnlyFormat
),
INamedTypeSymbol t when supportsDateOnlyTimeOnly &&
DateOnlyTimeOnlySupport.IsTimeOnly(t, context) => CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.TimeOnlyFormat
),
INamedTypeSymbol { TypeKind: TypeKind.Enum } => CreateCollectionFormatArgs(
fieldOptions?.Format ?? context.MapperOptions.EnumFormat
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ HelperMethodRegistry helperRegistry
var itemParam = context.MapperOptions.FromMethodParameterName;

// Determine fallback based on required keyword and nullability
var fallback = GetNestedObjectFallback(spec, analysis);
var fallback = GetNestedObjectFallback(spec, analysis, context);

return nestedMapping switch
{
Expand Down Expand Up @@ -592,21 +592,26 @@ HelperMethodRegistry helperRegistry
/// Determines the fallback expression for a nested object when not found in DynamoDB.
/// </summary>
private static string GetNestedObjectFallback(
PropertyMappingSpec spec, PropertyAnalysis analysis
PropertyMappingSpec spec, PropertyAnalysis analysis, GeneratorContext context
)
{
// If property has 'required' keyword, throw on missing
if (analysis.IsRequired)
var configuredRequiredness =
RequirednessResolver.ResolveConfigured(
analysis.FieldOptions,
analysis.IsRequired,
context.MapperOptions.DefaultRequiredness
);
if (RequirednessResolver.IsEffectivelyRequired(
configuredRequiredness,
analysis.Nullability
))
return
$"throw new System.InvalidOperationException(\"Required attribute '{spec.Key}' not found.\")";

// For nullable types, return null
if (analysis.Nullability.IsNullableType)
return "null";

// For non-nullable, non-required types, we still use null but this may cause CS8601
// This is a design limitation - the model should either mark as required or nullable
return "null";
return "null!";
}

/// <summary>
Expand Down Expand Up @@ -757,16 +762,13 @@ HelperMethodRegistry helperRegistry
// Recursive nested object
var nestedVarName = $"{mapVarName}_{prop.PropertyName.ToLowerInvariant()}";

// Determine fallback based on required and nullability
string fallback;
if (prop.IsRequired)
fallback =
$"throw new System.InvalidOperationException(\"Required nested property '{prop.DynamoKey}' not found in DynamoDB item.\")";
else if (prop.Nullability.IsNullableType)
fallback = "null";
else
// Non-nullable, non-required - use null (design limitation)
fallback = "null";
var fallback =
RequirednessResolver.IsEffectivelyRequired(prop.Requiredness, prop.Nullability)
?
$"throw new System.InvalidOperationException(\"Required nested property '{prop.DynamoKey}' not found in DynamoDB item.\")"
: prop.Nullability.IsNullableType
? "null"
: "null!";

string nestedCode;
if (prop.NestedMapping is MapperBasedNesting mapperBased)
Expand Down Expand Up @@ -800,7 +802,7 @@ HelperMethodRegistry helperRegistry
var isNullable = prop.Nullability.IsNullableType;
var varName = prop.PropertyName.ToLowerInvariant();
var fallback =
prop.IsRequired
RequirednessResolver.IsEffectivelyRequired(prop.Requiredness, prop.Nullability)
?
$"throw new System.InvalidOperationException(\"Required attribute '{prop.DynamoKey}' not found.\")"
: isNullable
Expand Down Expand Up @@ -861,8 +863,7 @@ HelperMethodRegistry helperRegistry
) + ", "
: "";

// Determine requiredness based on property analysis
var requiredness = prop.IsRequired ? "Requiredness.Required" : "Requiredness.Optional";
var requiredness = $"Requiredness.{prop.Requiredness}";

sb.Append(
$"{prop.PropertyName} = {mapVarName}.{getMethod}{genericArg}(\"{prop.DynamoKey}\", {typeArgs}{requiredness}),"
Expand Down Expand Up @@ -894,8 +895,10 @@ private static string RenderTryGetCondition(
) + ", "
: "";

var requiredness = $"Requiredness.{prop.Requiredness}";

return
$"{mapVarName}.{tryMethod}{genericArg}(\"{prop.DynamoKey}\", out var {outVarName}, {typeArgs}Requiredness.InferFromNullability)";
$"{mapVarName}.{tryMethod}{genericArg}(\"{prop.DynamoKey}\", out var {outVarName}, {typeArgs}{requiredness})";
}

/// <summary>
Expand Down Expand Up @@ -1139,7 +1142,7 @@ HelperMethodRegistry helperRegistry
var varName = spec.PropertyName.ToLowerInvariant();

// Determine fallback based on required keyword and nullability
var fallback = GetNestedCollectionFallback(spec, analysis);
var fallback = GetNestedCollectionFallback(spec, analysis, context);

return collectionInfo.Category switch
{
Expand Down Expand Up @@ -1172,20 +1175,25 @@ HelperMethodRegistry helperRegistry
/// Determines the fallback expression for a nested collection when not found in DynamoDB.
/// </summary>
private static string GetNestedCollectionFallback(
PropertyMappingSpec spec, PropertyAnalysis analysis
PropertyMappingSpec spec, PropertyAnalysis analysis, GeneratorContext context
)
{
// If property has 'required' keyword, throw on missing
if (analysis.IsRequired)
var configuredRequiredness =
RequirednessResolver.ResolveConfigured(
analysis.FieldOptions,
analysis.IsRequired,
context.MapperOptions.DefaultRequiredness
);
if (RequirednessResolver.IsEffectivelyRequired(
configuredRequiredness,
analysis.Nullability
))
return
$"throw new System.InvalidOperationException(\"Required attribute '{spec.Key}' not found.\")";

// For nullable types, return null
if (analysis.Nullability.IsNullableType)
return "null";

// For non-nullable, non-required collections, return empty collection
// Using collection expression [] which works for arrays, lists, and dictionaries in C# 12+
return "[]";
}

Expand Down
Loading
Loading