From b4aee95bd4596d152ca3f6271c85b239fb9a6b73 Mon Sep 17 00:00:00 2001 From: Dan Guisinger <8884772+dguisinger@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:33:14 -0500 Subject: [PATCH 1/3] feat(bare-related-entity-inference): Add parameterless RelatedEntity attribute with automatic pattern inference - Add PatternResolutionPass to infer sort key patterns from child entity discriminators - Introduce bare [RelatedEntity] attribute form that eliminates manual pattern duplication - Modify RelatedEntityAttribute to support parameterless constructor with nullable SortKeyPattern - Update EntityAnalyzer to extract child entity type from property generic arguments - Add pattern resolution phase after entity analysis in DynamoDbSourceGenerator pipeline - Implement entity type inference for List, T?, and T collection patterns - Add re-validation of resolved patterns to catch ambiguities and conflicts - Add comprehensive unit tests covering type inference, resolution, diagnostics, and backward compatibility - Add diagnostic descriptors FDDB130, FDDB131, FDDB132 for pattern resolution failures and type conflicts - Update documentation with bare RelatedEntity syntax and migration guidance - Maintains backward compatibility with explicit pattern form [RelatedEntity("PATTERN")] - Resolves child entity patterns as single source of truth, reducing maintenance burden --- .../.config.kiro | 1 + .../related-entity-bare-inference/design.md | 393 +++++++++++++ .../requirements.md | 143 +++++ .../related-entity-bare-inference/tasks.md | 231 ++++++++ .../tasks.meta.json | 497 ++++++++++++++++ .kiro/steering/fluentdynamodb.md | 28 +- CHANGELOG.md | 14 + ...RelatedEntityBackwardCompatibilityTests.cs | 330 +++++++++++ .../BareRelatedEntityCodeEquivalenceTests.cs | 324 +++++++++++ .../Analysis/BareRelatedEntityTests.cs | 305 ++++++++++ .../BareRelatedEntityTypeInferenceTests.cs | 492 ++++++++++++++++ .../BareRelatedEntityDiagnosticTests.cs | 548 ++++++++++++++++++ .../BareRelatedEntityCompilationTests.cs | 247 ++++++++ .../BareRelatedEntityIntegrationTests.cs | 480 +++++++++++++++ .../Models/RelationshipModelTests.cs | 2 +- .../Analysis/EntityAnalyzer.cs | 113 +++- .../Analysis/PatternResolutionPass.cs | 237 ++++++++ .../Diagnostics/DiagnosticDescriptors.cs | 62 ++ .../DynamoDbSourceGenerator.cs | 117 ++++ .../Generators/MapperGenerator.cs | 21 +- .../Models/RelationshipModel.cs | 18 +- .../Attributes/RelatedEntityAttribute.cs | 19 +- docs/DOCUMENTATION_CHANGELOG.md | 91 +++ docs/advanced-topics/CompositeEntities.md | 91 +++ docs/diagnostics/FDDB/FDDB130.md | 68 +++ docs/diagnostics/FDDB/FDDB131.md | 62 ++ docs/diagnostics/FDDB/FDDB132.md | 70 +++ docs/diagnostics/README.md | 7 +- docs/reference/AttributeReference.md | 135 ++++- 29 files changed, 5112 insertions(+), 34 deletions(-) create mode 100644 .kiro/specs/related-entity-bare-inference/.config.kiro create mode 100644 .kiro/specs/related-entity-bare-inference/design.md create mode 100644 .kiro/specs/related-entity-bare-inference/requirements.md create mode 100644 .kiro/specs/related-entity-bare-inference/tasks.md create mode 100644 .kiro/specs/related-entity-bare-inference/tasks.meta.json create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityBackwardCompatibilityTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTypeInferenceTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Diagnostics/BareRelatedEntityDiagnosticTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityCompilationTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityIntegrationTests.cs create mode 100644 Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs create mode 100644 docs/diagnostics/FDDB/FDDB130.md create mode 100644 docs/diagnostics/FDDB/FDDB131.md create mode 100644 docs/diagnostics/FDDB/FDDB132.md diff --git a/.kiro/specs/related-entity-bare-inference/.config.kiro b/.kiro/specs/related-entity-bare-inference/.config.kiro new file mode 100644 index 00000000..24110f12 --- /dev/null +++ b/.kiro/specs/related-entity-bare-inference/.config.kiro @@ -0,0 +1 @@ +{"specId": "79a8d539-54c8-4958-bf18-c020f228937e", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/related-entity-bare-inference/design.md b/.kiro/specs/related-entity-bare-inference/design.md new file mode 100644 index 00000000..7dd2efa1 --- /dev/null +++ b/.kiro/specs/related-entity-bare-inference/design.md @@ -0,0 +1,393 @@ +# Design Document: Bare `[RelatedEntity]` Inference + +## Overview + +This feature adds a parameterless overload to `[RelatedEntity]` so the source generator can automatically infer the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern`. Today, developers must manually write and maintain a pattern string like `[RelatedEntity("ORDER#*#LINE#*")]` that duplicates the child entity's key structure. The bare form eliminates this redundancy by treating the child entity's key definition as the single source of truth. + +The core mechanism works by: +1. Detecting bare `[RelatedEntity]` during entity analysis and extracting the child entity type from the property's generic type argument +2. Running a deferred Pattern Resolution Pass after all entities are analyzed, which looks up the child entity in the same table group and copies its `DerivedDiscriminatorPattern` to the relationship's `SortKeyPattern` +3. Feeding the resolved pattern into the existing MapperGenerator code paths unchanged + +**Before (explicit pattern — still supported):** +```csharp +[RelatedEntity("ORDER#*#LINE#*", EntityType = typeof(OrderLine))] +public List? Lines { get; set; } +``` + +**After (bare inference — recommended):** +```csharp +[RelatedEntity] +public List? Lines { get; set; } +``` + +## Architecture + +### Pipeline Integration + +The feature integrates into the existing source generator pipeline by adding a single new phase. The key constraint is that `DerivedDiscriminatorPattern` is computed per-entity during `DeriveDiscriminatorPatterns()` (step in EntityAnalyzer), but `ExtractRelationships()` runs earlier in the same entity's analysis — and the child entity may not have been analyzed yet. A deferred resolution pass solves both ordering issues. + +```mermaid +flowchart TD + A["1. Schema Version Gate"] --> B["2. First Pass: Analyze Entities"] + B --> C["3. Pre-Pass: Count Entities per Table"] + C --> D["4. GroupEntitiesByTableName()"] + D --> E["5. Validate Default Entity / Namespace"] + E --> F["**5.5 NEW: Pattern Resolution Pass**"] + F --> G["6. Overlap Analysis (PatternOverlapAnalyzer + CompoundPromotion)"] + G --> H["7. Second Pass: Generate Entity Code"] + + style F fill:#e1f5fe,stroke:#0288d1,stroke-width:3px +``` + +### Design Decisions + +**D1: Deferred resolution vs. multi-pass entity analysis.** Rather than re-analyzing entities or introducing inter-entity dependencies during individual analysis, the Pattern Resolution Pass runs once after all entities are collected. This follows the existing pattern of `PatternOverlapAnalyzer.Analyze()` and `CompoundPromotionPass.Analyze()`, which also operate on complete table groups. The alternative — resolving during `ExtractRelationships()` — would require the source generator to guarantee entity analysis ordering and would break the current incremental generator caching model. + +**D2: `SortKeyPattern` becomes `string?` on both the attribute and `RelationshipModel`.** The attribute's `SortKeyPattern` property changes from `string` to `string?` to signal "infer from child." On `RelationshipModel`, the nullable type distinguishes three states: `null` (bare, not yet resolved or resolution failed), empty string (explicit empty — an edge case), and non-empty string (resolved or explicit pattern). Downstream code in `MapperGenerator` already guards with `!string.IsNullOrEmpty(p)` when building exclusion patterns, so null falls through safely. + +**D3: Entity type inference from property type.** When `EntityType` is not explicitly provided on a bare `[RelatedEntity]`, the analyzer infers the child entity type from the property declaration: `List` → `T`, `T?` → `T`, `T` → `T`. This reuses the existing `IsCollectionType()` check and Roslyn's `ITypeSymbol` API. The explicit `EntityType` named argument always takes precedence for override scenarios (e.g., `List`). + +**D4: Static method for the resolution pass.** `PatternResolutionPass.Resolve()` is a static method on a new class, following the established pattern of `PatternOverlapAnalyzer.Analyze()`. It takes the `entitiesByTable` dictionary and returns a list of diagnostics, modifying `RelationshipModel` instances in place. + +**D5: Re-run validation after resolution.** After patterns are resolved, `ValidateRelatedEntityConfiguration()` must run again on affected entities so that ambiguous pattern detection and conflicting pattern checks apply to the inferred patterns. This is a targeted re-validation, not a full re-analysis. + +## Components and Interfaces + +### Modified Components + +#### 1. `RelatedEntityAttribute` (`Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs`) + +**Changes:** +- Add a parameterless constructor that leaves `SortKeyPattern` as `null` +- Change `SortKeyPattern` property type from `string` to `string?` +- Add `ArgumentNullException` guard to the existing `string` constructor + +```csharp +[AttributeUsage(AttributeTargets.Property)] +public class RelatedEntityAttribute : Attribute +{ + public string? SortKeyPattern { get; } + public Type? EntityType { get; set; } + + // NEW: Bare form — pattern inferred from child entity metadata + public RelatedEntityAttribute() + { + SortKeyPattern = null; + } + + // EXISTING: Explicit pattern form + public RelatedEntityAttribute(string sortKeyPattern) + { + ArgumentNullException.ThrowIfNull(sortKeyPattern); + SortKeyPattern = sortKeyPattern; + } +} +``` + +#### 2. `RelationshipModel` (`Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs`) + +**Changes:** +- `SortKeyPattern` type: `string` → `string?` +- Add `IsPatternInferred` (bool, default `false`) +- Add `ResolvedEntityType` (string?, default `null`) +- Update `IsWildcardPattern` to handle null safely + +```csharp +internal class RelationshipModel +{ + public string PropertyName { get; set; } = string.Empty; + public string? SortKeyPattern { get; set; } // CHANGED: nullable + public string? EntityType { get; set; } + public bool IsCollection { get; set; } + public string PropertyType { get; set; } = string.Empty; + public bool IsPatternInferred { get; set; } // NEW + public string? ResolvedEntityType { get; set; } // NEW + public bool IsWildcardPattern => SortKeyPattern?.Contains('*') ?? false; // CHANGED: null-safe + public bool HasSpecificEntityType => !string.IsNullOrWhiteSpace(EntityType); + public bool ChildEntityHasRelationships { get; set; } + public RelationshipModel[] ChildEntityRelationships { get; set; } = Array.Empty(); +} +``` + +#### 3. `EntityAnalyzer.ExtractRelationships()` (`Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs`) + +**Changes to `ExtractRelationships()`:** + +The method currently extracts the sort key pattern from the constructor argument. For bare `[RelatedEntity]`: +- The constructor argument will be absent (no `ArgumentList` arguments, or the argument list is empty of positional arguments) +- `SortKeyPattern` stays `null` on the `RelationshipModel` (no assignment) +- Entity type inference runs: if no explicit `EntityType` named argument, extract from the property type using Roslyn's `ITypeSymbol` API +- Set `ResolvedEntityType` on the `RelationshipModel` + +**Entity type inference logic** (new helper method `InferEntityTypeFromProperty`): + +``` +Input: IPropertySymbol propertySymbol, bool hasExplicitEntityType +Output: string? resolvedEntityType + +1. If hasExplicitEntityType → return null (explicit takes precedence, handled separately) +2. Get ITypeSymbol from propertySymbol.Type +3. If type implements IEnumerable (via IsCollectionType check): + a. If type is INamedTypeSymbol with TypeArguments.Length > 0 → return TypeArguments[0].ToDisplayString() + b. If type is INamedTypeSymbol and any AllInterfaces has TypeArguments → extract T from the IEnumerable interface → return T.ToDisplayString() + c. If non-generic collection (ArrayList, raw IEnumerable) → report diagnostic error, return null +4. If NullableAnnotation == Annotated (T?) → unwrap: return type.WithNullableAnnotation(None).ToDisplayString() +5. Return type.ToDisplayString() (non-nullable, non-collection direct type) +``` + +**Diagnostic for non-generic collection:** When a bare `[RelatedEntity]` is on a property typed as `ArrayList`, `IEnumerable` (non-generic), or similar, emit an error diagnostic. This is not one of the FDDB130-132 codes; it's a pre-resolution validation error on the property itself. We reuse or add a suitable diagnostic indicating the element type cannot be inferred. + +#### 4. `PatternResolutionPass` (NEW: `Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs`) + +A new static class following the `PatternOverlapAnalyzer` pattern: + +```csharp +internal static class PatternResolutionPass +{ + /// + /// Resolves bare [RelatedEntity] patterns by looking up child entity metadata + /// within the same table group. Modifies RelationshipModel.SortKeyPattern in place + /// and returns diagnostics for unresolvable patterns. + /// + public static List Resolve( + Dictionary> entitiesByTable) + { + var diagnostics = new List(); + + foreach (var (tableName, tableEntities) in entitiesByTable) + { + foreach (var entity in tableEntities) + { + foreach (var relationship in entity.Relationships) + { + // Skip relationships with explicit patterns (already resolved) + if (!string.IsNullOrEmpty(relationship.SortKeyPattern)) + continue; + + ResolveRelationship(entity, relationship, tableEntities, entitiesByTable, diagnostics); + } + } + } + + return diagnostics; + } +} +``` + +**`ResolveRelationship()` logic:** + +``` +Input: EntityModel parent, RelationshipModel relationship, + List sameTableEntities, + Dictionary> allEntitiesByTable, + List diagnostics + +1. Determine child entity type name: + a. If relationship.EntityType is set → use it + b. Else if relationship.ResolvedEntityType is set → use it + c. Else → extract from relationship.PropertyType string + (parse "List" → "OrderLine", "Payment?" → "Payment", etc.) + d. If no type resolved → emit FDDB130, return + +2. Search for child entity: + a. Look in sameTableEntities for EntityModel where ClassName matches the resolved type name + b. If not found in same table, search all table groups for diagnostic context + c. If found in a DIFFERENT table → emit FDDB132 (table mismatch), return + d. If not found at all → emit FDDB130 (unknown entity), return + +3. Read child entity's sort key DerivedDiscriminatorPattern: + a. Find the sort key property on the child EntityModel (IsSortKey == true) + b. If sort key has no DerivedDiscriminatorPattern (null — trivial {0} key) → emit FDDB131, return + c. Set relationship.SortKeyPattern = childEntity.SortKey.DerivedDiscriminatorPattern + d. Set relationship.IsPatternInferred = true + e. If relationship.EntityType is null, set relationship.EntityType = childEntity.ClassName +``` + +#### 5. `DynamoDbSourceGenerator.Execute()` (`Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs`) + +**Insert new step between table validation and overlap analysis:** + +```csharp +// Step 5: Validate default entity and namespace +foreach (var tableGroup in entitiesByTable) +{ + ValidateDefaultEntity(tableGroup.Value, context); + ValidateTableNamespaceConsistency(tableGroup.Value, context); +} + +// NEW Step 5.5: Pattern Resolution Pass — resolve bare [RelatedEntity] patterns +var patternResolutionDiagnostics = PatternResolutionPass.Resolve(entitiesByTable); +foreach (var diagnostic in patternResolutionDiagnostics) +{ + context.ReportDiagnostic(diagnostic); +} + +// NEW Step 5.6: Exclude failed relationships and re-validate +ExcludeFailedRelationships(entitiesByTable, patternResolutionDiagnostics); +RevalidateResolvedRelationships(entitiesByTable, context); + +// Step 6: Overlap analysis pass (unchanged) +``` + +**`ExcludeFailedRelationships()`:** Removes relationships from `EntityModel.Relationships` arrays where the `SortKeyPattern` is still null after resolution. This prevents MapperGenerator from attempting to generate code for unresolvable relationships (Requirement 5.4). + +**`RevalidateResolvedRelationships()`:** For each entity that had bare `[RelatedEntity]` patterns resolved, re-runs `ValidateRelatedEntityConfiguration()` to catch ambiguous or conflicting patterns introduced by the inferred values (Requirement 8.1). + +#### 6. `DiagnosticDescriptors` (`Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs`) + +Three new diagnostic descriptors: + +| Code | Title | Severity | Message Template | +|------|-------|----------|-----------------| +| FDDB130 | Bare RelatedEntity unresolved entity type | Error | `"[RelatedEntity] on property '{0}' in entity '{1}' references type '{2}' which is not a known [DynamoDbTable] entity. Specify EntityType explicitly or provide an explicit pattern string."` | +| FDDB131 | Bare RelatedEntity trivial sort key pattern | Error | `"[RelatedEntity] on property '{0}' in entity '{1}' cannot infer a matching pattern because child entity '{2}' has a bare sort key with no distinguishing structure (NormalizedKeyFormat is '{{0}}'). Provide an explicit pattern string."` | +| FDDB132 | Bare RelatedEntity table mismatch | Error | `"[RelatedEntity] on property '{0}' in entity '{1}' (table '{2}') references child entity '{3}' which is on table '{4}'. Related entities must share the same DynamoDB table."` | + +Each follows the existing descriptor pattern with `helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB130")` etc. + +#### 7. `MapperGenerator` (`Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs`) + +**Minimal changes required.** The MapperGenerator already handles patterns via `relationship.SortKeyPattern`. The key change: + +- Update all places that access `SortKeyPattern` to handle null safely (most already guard with `!string.IsNullOrEmpty()`) +- In `GenerateRelatedEntityCollectionMappingAsync` and `GenerateRelatedEntitySingleMappingAsync`: if `SortKeyPattern` is null, skip generating the mapping block for that relationship entirely (Requirement 6.2) +- `IsPatternInferred` is never checked by MapperGenerator — resolved patterns produce identical code to explicit ones (Requirement 6.1) + +The null-safe access already exists in several places (e.g., `.Where(p => !string.IsNullOrEmpty(p))`), so most code paths naturally handle null `SortKeyPattern` by exclusion. The explicit skip logic is a safety guard for any path that doesn't already filter. + +## Data Models + +### RelationshipModel State Transitions + +```mermaid +stateDiagram-v2 + [*] --> ExplicitPattern: [RelatedEntity("pattern")] + [*] --> BareUnresolved: [RelatedEntity] + + BareUnresolved --> BareResolved: Child entity found,\nDerivedDiscriminatorPattern copied + BareUnresolved --> Failed_FDDB130: Child type not found + BareUnresolved --> Failed_FDDB131: Child has trivial SK + BareUnresolved --> Failed_FDDB132: Table mismatch + + ExplicitPattern --> CodeGeneration: SortKeyPattern set,\nIsPatternInferred = false + BareResolved --> CodeGeneration: SortKeyPattern set,\nIsPatternInferred = true + Failed_FDDB130 --> Excluded: Removed from\nRelationships array + Failed_FDDB131 --> Excluded: Removed from\nRelationships array + Failed_FDDB132 --> Excluded: Removed from\nRelationships array +``` + +### RelationshipModel Property Values by State + +| State | SortKeyPattern | IsPatternInferred | ResolvedEntityType | EntityType | +|-------|---------------|-------------------|-------------------|------------| +| Explicit pattern | `"ORDER#*#LINE#*"` | `false` | `null` | From named arg or `null` | +| Bare, unresolved | `null` | `false` | `"OrderLine"` (from property type) | `null` (unless explicit) | +| Bare, resolved | `"ORDER#*#LINE#*"` | `true` | `"OrderLine"` | `"OrderLine"` (set during resolution) | +| Failed (any FDDB13x) | `null` | `false` | `"OrderLine"` | may or may not be set | +| Excluded | *(removed from array)* | — | — | — | + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Bare inference produces the child entity's DerivedDiscriminatorPattern + +*For any* parent entity with a bare `[RelatedEntity]` on a property of type `List` or `C?`, where `C` is a `[DynamoDbTable]` entity on the same table with a non-null `DerivedDiscriminatorPattern`, the resolved `SortKeyPattern` on the `RelationshipModel` SHALL equal `C`'s sort key `DerivedDiscriminatorPattern`. + +**Validates: Requirements 3.2, 3.3, 9.6** + +### Property 2: Code generation equivalence between bare and explicit patterns + +*For any* parent-child entity pair where bare `[RelatedEntity]` resolves to pattern `P`, the generated C# source code for that relationship SHALL be character-for-character identical to the code generated when `[RelatedEntity("P")]` is used explicitly with the same pattern string. The `IsPatternInferred` flag SHALL not influence any code emission path. + +**Validates: Requirements 6.1, 6.3** + +### Property 3: Entity type extraction from generic collection types + +*For any* property declared as a generic type implementing `IEnumerable` (such as `List`, `IList`, `ICollection`, `IEnumerable`, excluding `string`) with a bare `[RelatedEntity]` attribute and no explicit `EntityType` argument, the Entity_Analyzer SHALL extract the type argument `T` and set `ResolvedEntityType` to `T`'s fully qualified display string. + +**Validates: Requirements 2.1, 2.2, 2.3** + +### Property 4: Explicit patterns are never modified by the resolution pass + +*For any* entity with `[RelatedEntity("P")]` using an explicit pattern string `P`, the `RelationshipModel.SortKeyPattern` SHALL remain equal to `P` (or its named-placeholder-normalized form) after the Pattern Resolution Pass, and `IsPatternInferred` SHALL be `false`. + +**Validates: Requirements 3.7, 7.1, 7.2, 7.5, 9.7** + +### Property 5: Composite entity assembly equivalence + +*For any* set of DynamoDB items and any parent-child entity relationship, `ToCompositeEntityAsync` SHALL match the same set of child items and populate the same related entity collections in the same order, regardless of whether the relationship's `SortKeyPattern` was inferred via bare `[RelatedEntity]` or provided via `[RelatedEntity("pattern")]` with the identical pattern string. + +**Validates: Requirements 6.4** + +## Error Handling + +### Compile-Time Diagnostics + +| Diagnostic Code | Trigger Condition | Severity | Action Taken | +|-----------------|-------------------|----------|-------------| +| FDDB130 | Bare `[RelatedEntity]` but resolved type is not a `[DynamoDbTable]` entity in any table group | Error | Relationship excluded from code generation | +| FDDB131 | Bare `[RelatedEntity]` but child entity has `NormalizedKeyFormat` of `"{0}"` (bare sort key, no distinguishing structure) | Error | Relationship excluded from code generation | +| FDDB132 | Bare `[RelatedEntity]` but child entity's `TableName` differs from parent's `TableName` | Error | Relationship excluded from code generation | +| DYNDB008 | Resolved pattern is `"*"` or whitespace-only (ambiguous) | Warning | Existing diagnostic, triggered during re-validation | +| DYNDB017 | Resolved pattern conflicts with another relationship's pattern on the same parent | Warning | Existing diagnostic, triggered during re-validation | + +### Exclusion After Diagnostic + +When any of FDDB130, FDDB131, or FDDB132 is emitted, the affected `RelationshipModel` is removed from the parent entity's `Relationships` array before code generation begins. This ensures: +- No sort key matching code is emitted for the broken relationship +- No `ToCompositeEntityAsync` assembly code references the excluded property +- No `FromDynamoDb` population code is generated +- The remaining valid relationships on the same entity continue to generate correctly + +### Runtime Behavior + +No new runtime error conditions are introduced. The resolution is entirely compile-time. If a bare `[RelatedEntity]` resolves successfully, the generated code is identical to an equivalent explicit pattern — including existing try/catch error handling around child entity deserialization. + +## Testing Strategy + +### Dual Testing Approach + +This feature is well-suited to property-based testing because: +- The core logic (entity type extraction, pattern resolution, code generation equivalence) operates on a wide space of possible entity configurations +- Universal properties about resolution correctness can be verified across many generated inputs +- Pure function behavior (type extraction, pattern resolution) has clear input/output contracts + +### Test Organization + +| Test File | Test Level | What It Covers | +|-----------|-----------|---------------| +| `BareRelatedEntityTests.cs` | Unit | `RelationshipModel` state, `IsPatternInferred`, code generation output, null `SortKeyPattern` handling | +| `BareRelatedEntityDiagnosticTests.cs` | Unit | FDDB130, FDDB131, FDDB132 emission with correct codes, severity, and messages | +| `BareRelatedEntityIntegrationTests.cs` | Integration | Full pipeline: generate → compile → load → invoke `ToCompositeEntityAsync` via reflection | +| `BareRelatedEntityPropertyTests.cs` | Property | Properties 1-5: inference correctness, code equivalence, type extraction, explicit pattern preservation | + +### Property-Based Testing + +**Library:** FsCheck.Xunit (standard for .NET property-based testing with xUnit) + +**Minimum iterations:** 100 per property test + +Each property test generates random entity configurations (varying key prefixes, computed key formats, collection types, table names) and verifies the universal properties hold. + +**Tag format:** `Feature: related-entity-bare-inference, Property {N}: {property_text}` + +### Unit Tests + +- **Attribute constructor tests:** Parameterless constructor yields null `SortKeyPattern`; string constructor preserves value; null argument throws `ArgumentNullException` +- **RelationshipModel state tests:** Verify `IsPatternInferred` and `ResolvedEntityType` are set correctly for bare vs. explicit patterns +- **Entity type inference tests:** `List` → `T`, `IList` → `T`, `T?` → `T`, `T` → `T`, explicit `EntityType` override +- **Non-generic collection diagnostic test:** `ArrayList` / raw `IEnumerable` with bare attribute triggers diagnostic +- **MapperGenerator null SortKeyPattern test:** Verify no mapping code is generated when `SortKeyPattern` is null +- **Code generation equivalence test:** Compare generated output from bare resolution vs. explicit pattern with same string + +### Integration Tests + +- **Full pipeline smoke test:** Define parent + child entities with bare `[RelatedEntity]`, run source generator, compile via `DynamicCompilationHelper.CompileAndLoad()`, invoke `ToCompositeEntityAsync` via reflection with mock DynamoDB items, verify child items are correctly populated +- **Backward compatibility test:** Compile existing explicit-pattern entities through the updated pipeline, verify zero errors and unchanged generated code +- **Diagnostic integration tests:** Trigger each FDDB130/131/132 scenario through full source generator pipeline, verify diagnostics are reported + +### Test Naming Convention + +Following the existing project convention: `{MethodName}{Scenario}` (e.g., `ResolveRelationship_WithBareRelatedEntity_InfersPatterFromChildEntity`, `EmitFddb130_WhenChildTypeNotDynamoDbEntity`). diff --git a/.kiro/specs/related-entity-bare-inference/requirements.md b/.kiro/specs/related-entity-bare-inference/requirements.md new file mode 100644 index 00000000..ccbf0cf0 --- /dev/null +++ b/.kiro/specs/related-entity-bare-inference/requirements.md @@ -0,0 +1,143 @@ +# Requirements Document + +## Introduction + +This feature enables bare `[RelatedEntity]` attribute usage on composite entity properties, where the source generator infers the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern` metadata instead of requiring an explicit pattern string. Currently, `[RelatedEntity("pattern")]` requires the developer to manually specify the sort key pattern — a string that must be kept in sync with the child entity's key structure. The bare form eliminates this redundancy by making the child entity's key definition the single source of truth. + +## Glossary + +- **Source_Generator**: The Roslyn incremental source generator (`DynamoDbSourceGenerator`) that analyzes entity declarations and emits mapping code at compile time +- **Entity_Analyzer**: The `EntityAnalyzer` class that extracts `EntityModel` metadata from a single entity type declaration, including properties, key formats, and relationships +- **Relationship_Model**: The `RelationshipModel` class representing a `[RelatedEntity]` relationship, including its `SortKeyPattern` used for sort key matching during composite entity assembly +- **Derived_Discriminator_Pattern**: The `PropertyModel.DerivedDiscriminatorPattern` property computed by replacing `{N}` placeholders in a key's `NormalizedKeyFormat` with `*` wildcards, producing a glob-like pattern (e.g., `ORDER#*#LINE#*`) +- **Entity_Model**: The `EntityModel` class representing the fully analyzed metadata of a DynamoDB entity, including its properties, key structure, indexes, and relationships +- **Bare_RelatedEntity**: A `[RelatedEntity]` attribute applied without a constructor argument (no explicit sort key pattern), signaling the generator to infer the pattern from the child entity +- **Explicit_RelatedEntity**: A `[RelatedEntity("pattern")]` attribute applied with an explicit sort key pattern string +- **Table_Group**: The set of all `EntityModel` instances sharing the same DynamoDB table name, used for cross-entity analysis in the `Execute` method +- **Mapper_Generator**: The `MapperGenerator` class that emits sort key pattern matching code for composite entity assembly using `RelationshipModel.SortKeyPattern` +- **Pattern_Resolution_Pass**: A new post-analysis phase in `DynamoDbSourceGenerator.Execute()` that resolves Bare_RelatedEntity patterns by looking up child entity metadata within a Table_Group + +## Requirements + +### Requirement 1: Parameterless RelatedEntity Attribute Constructor + +**User Story:** As a developer defining composite entities, I want to use `[RelatedEntity]` without specifying a pattern string, so that the sort key matching pattern is automatically inferred from the child entity's key structure. + +#### Acceptance Criteria + +1. THE `RelatedEntityAttribute` class SHALL provide a parameterless constructor in addition to the existing `RelatedEntityAttribute(string sortKeyPattern)` constructor +2. WHEN the parameterless constructor is used, THE `RelatedEntityAttribute.SortKeyPattern` property SHALL return `null` +3. WHEN the existing `RelatedEntityAttribute(string sortKeyPattern)` constructor is called, THE `RelatedEntityAttribute` SHALL set `SortKeyPattern` to the provided value, including empty string +4. THE `SortKeyPattern` property type SHALL be changed from `string` to `string?` (nullable) to accommodate the parameterless constructor +5. IF the existing `RelatedEntityAttribute(string sortKeyPattern)` constructor is called with a `null` argument, THEN THE `RelatedEntityAttribute` SHALL throw an `ArgumentNullException` + +### Requirement 2: Entity Type Extraction from Property Type + +**User Story:** As a developer, I want the generator to automatically determine the child entity type from my property's declared type, so that I don't need to specify `EntityType` redundantly. + +#### Acceptance Criteria + +1. WHEN a property has Bare_RelatedEntity and the property type is a generic type whose unconstructed form implements `System.Collections.Generic.IEnumerable` (excluding `string`), such as `List`, `IList`, `ICollection`, or `IEnumerable`, THE Entity_Analyzer SHALL extract the first generic type argument `T` as the child entity type and set `RelationshipModel.EntityType` to the fully qualified display string of `T` +2. WHEN a property has Bare_RelatedEntity and the property type is a nullable reference type `T?` that is not a collection type per criterion 1, THE Entity_Analyzer SHALL unwrap the nullable annotation and extract the underlying type `T` as the child entity type, setting `RelationshipModel.EntityType` to the fully qualified display string of `T` +3. WHEN a property has Bare_RelatedEntity and the property type is a non-nullable, non-collection type `T` (as determined by failing both criteria 1 and 2), THE Entity_Analyzer SHALL use `T` directly as the child entity type, setting `RelationshipModel.EntityType` to the fully qualified display string of `T` +4. WHEN a property has Bare_RelatedEntity and an explicit `EntityType` named argument is also provided, THE Entity_Analyzer SHALL use the explicit `EntityType` value for `RelationshipModel.EntityType`, ignoring the type inferred from the property declaration +5. WHEN a property has Explicit_RelatedEntity and no `EntityType` named argument, THE Entity_Analyzer SHALL continue to use the existing entity type extraction logic unchanged, without applying property-type inference from criteria 1–3 +6. IF a property has Bare_RelatedEntity and the property type is a non-generic type that implements `IEnumerable` (such as `ArrayList` or raw `IEnumerable`), THEN THE Entity_Analyzer SHALL report a diagnostic error indicating that the element type cannot be inferred from a non-generic collection, and the developer must specify `EntityType` explicitly + +### Requirement 3: Deferred Pattern Resolution Pass + +**User Story:** As a developer, I want the generator to resolve inferred patterns correctly regardless of entity analysis order, so that bare `[RelatedEntity]` works reliably in all compilation scenarios. + +#### Acceptance Criteria + +1. THE Source_Generator SHALL execute a Pattern_Resolution_Pass in `DynamoDbSourceGenerator.Execute()` after all entities have been individually analyzed and grouped into Table_Groups via `GroupEntitiesByTableName()`, but before the overlap analysis pass and code generation begins +2. WHEN the Pattern_Resolution_Pass encounters a Relationship_Model whose `SortKeyPattern` is null or empty (indicating a bare `[RelatedEntity]` with no explicit pattern), THE Source_Generator SHALL resolve the child entity type name from the Relationship_Model's `EntityType` property if set, or by extracting the element type from the `PropertyType` string if `EntityType` is null, and then look up a matching Entity_Model within the same Table_Group by comparing that resolved type name to each Entity_Model's `ClassName` +3. WHEN the child Entity_Model is found in the same Table_Group, THE Pattern_Resolution_Pass SHALL read the child entity's sort key property's `DerivedDiscriminatorPattern` and assign it to the Relationship_Model's `SortKeyPattern` +4. WHEN the child Entity_Model is found but its sort key property has no `DerivedDiscriminatorPattern` (i.e., the value is null, which occurs when `NormalizedKeyFormat` is `"{0}"` indicating a bare key with no prefix or computed structure), THE Pattern_Resolution_Pass SHALL emit a compile-time diagnostic at Error severity indicating that the child entity's sort key has no distinguishing pattern for inference, and leave the `SortKeyPattern` unchanged (null or empty) +5. WHEN no Entity_Model with a matching `ClassName` is found in any Table_Group (the resolved type name does not correspond to a class annotated with `[DynamoDbTable]`), THE Pattern_Resolution_Pass SHALL emit a compile-time diagnostic at Error severity indicating the referenced type is not a known DynamoDB table entity, and leave the `SortKeyPattern` unchanged (null or empty) +6. WHEN the child Entity_Model is found but its `TableName` differs from the parent Entity_Model's `TableName`, THE Pattern_Resolution_Pass SHALL emit a compile-time diagnostic at Error severity identifying both table names and stating that related entities must share the same DynamoDB table, and leave the `SortKeyPattern` unchanged (null or empty) +7. WHEN a Relationship_Model already has a non-null, non-empty `SortKeyPattern` (set from an explicit `[RelatedEntity("pattern")]` constructor argument), THE Pattern_Resolution_Pass SHALL skip that relationship without modification + +### Requirement 4: RelationshipModel Enhancement + +**User Story:** As a maintainer of the source generator, I want the RelationshipModel to clearly indicate whether its pattern was inferred or explicitly provided, so that diagnostics and debugging can distinguish between the two. + +#### Acceptance Criteria + +1. THE Relationship_Model SHALL include a boolean property named `IsPatternInferred` that indicates whether its `SortKeyPattern` was inferred from the child entity's Derived_Discriminator_Pattern (`true`) or explicitly provided via Explicit_RelatedEntity (`false`), defaulting to `false` +2. WHEN the Entity_Analyzer extracts a relationship with Explicit_RelatedEntity, THE Relationship_Model SHALL set `IsPatternInferred` to `false` +3. WHEN the Pattern_Resolution_Pass resolves a Bare_RelatedEntity pattern from child entity metadata, THE Relationship_Model SHALL set `IsPatternInferred` to `true` +4. THE Relationship_Model SHALL include a string property named `ResolvedEntityType` that holds the fully resolved child entity type name, populated from the property type extraction (Requirement 2) when the `EntityType` named argument is absent, or from the explicit `EntityType` argument when present, so that the resolved type name is available regardless of how it was determined +5. WHEN neither property type extraction nor an explicit `EntityType` argument yields a child entity type name, THE Relationship_Model SHALL leave `ResolvedEntityType` as null + +### Requirement 5: Compile-Time Diagnostics + +**User Story:** As a developer, I want clear compile-time error messages when bare `[RelatedEntity]` cannot infer a pattern, so that I can fix the issue or fall back to an explicit pattern. + +#### Acceptance Criteria + +1. WHEN Bare_RelatedEntity is used and the property's element type (extracted from `List`, `IList`, or `T` for non-collection properties) cannot be resolved to a `[DynamoDbTable]` entity within the same Table_Group, THE Source_Generator SHALL emit a diagnostic with code FDDB130, severity Error, reported on the `[RelatedEntity]` property's source location, with a message identifying the unresolved type name and suggesting either specifying `EntityType` on the attribute or providing an explicit pattern string as the constructor argument +2. WHEN Bare_RelatedEntity is used and the resolved child entity has a trivial Derived_Discriminator_Pattern (its NormalizedKeyFormat is `{0}` with no literal segments or additional placeholders), THE Source_Generator SHALL emit a diagnostic with code FDDB131, severity Error, reported on the `[RelatedEntity]` property's source location, with a message naming the child entity type and explaining that its sort key format has no distinguishing literal structure from which to derive a matching pattern, and suggesting an explicit pattern string +3. WHEN Bare_RelatedEntity is used and the resolved child entity's table name differs from the parent entity's table name, THE Source_Generator SHALL emit a diagnostic with code FDDB132, severity Error, reported on the `[RelatedEntity]` property's source location, with a message identifying the parent entity's table name and the child entity's table name and stating that related entities must share the same DynamoDB table +4. IF the Pattern_Resolution_Pass emits any of FDDB130, FDDB131, or FDDB132 for a Bare_RelatedEntity property, THEN THE Source_Generator SHALL exclude that `[RelatedEntity]` relationship from all subsequent code generation phases so that no matching code, `ToCompositeEntityAsync` mapping logic, or `FromDynamoDb` population code is emitted for the excluded relationship +5. THE Source_Generator SHALL assign diagnostic codes FDDB130, FDDB131, and FDDB132 to the three error conditions defined in criteria 1, 2, and 3 respectively, each registered as a unique `DiagnosticDescriptor` in `DiagnosticDescriptors.cs` with a help link URI following the existing `DiagnosticHelpLinks.BaseUrlFormat` pattern + +### Requirement 6: Code Generation Compatibility + +**User Story:** As a developer using composite entities, I want inferred patterns to produce identical generated code as equivalent explicit patterns, so that runtime behavior is unchanged. + +#### Acceptance Criteria + +1. WHEN the Pattern_Resolution_Pass successfully resolves a Bare_RelatedEntity pattern to a `SortKeyPattern` string value, THE Mapper_Generator SHALL produce character-for-character identical generated C# source code for that relationship's sort key matching, parent-entity exclusion, and composite assembly logic as if the same `SortKeyPattern` string had been provided explicitly in `[RelatedEntity("pattern")]`; the `IsPatternInferred` flag on the Relationship_Model SHALL NOT influence any code emission path in the Mapper_Generator +2. IF a Relationship_Model has a null `SortKeyPattern` after the Pattern_Resolution_Pass completes, THEN THE Mapper_Generator SHALL omit the entire sort key matching block, item collection, and entity assembly code for that relationship, producing no composite entity assembly code referencing that relationship's `PropertyName` +3. WHEN the Mapper_Generator generates the parent entity's `MatchesEntity` method, THE generated exclusion conditions that prevent the parent from matching child entity sort key patterns SHALL use the resolved `SortKeyPattern` from Bare_RelatedEntity relationships identically to how they use patterns from Explicit_RelatedEntity relationships +4. THE generated `ToCompositeEntityAsync` and `ToCompositeEntityListAsync` methods SHALL match the same set of child items and populate the same related entity collections in the same order for a given set of DynamoDB items, regardless of whether the relationship's `SortKeyPattern` was inferred via Bare_RelatedEntity or provided via Explicit_RelatedEntity with the same pattern string + +### Requirement 7: Backward Compatibility + +**User Story:** As a developer with existing composite entity code, I want all existing `[RelatedEntity("pattern")]` usages to continue working without any changes, so that the upgrade is non-breaking. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes an Explicit_RelatedEntity using a wildcard-only pattern (e.g., `[RelatedEntity("INVOICE#*#LINE#*")]`, `[RelatedEntity("audit#*")]`, `[RelatedEntity("summary")]`), THE Source_Generator SHALL produce byte-identical generated C# source output compared to the output produced by the Source_Generator version immediately prior to the change +2. WHEN the Source_Generator processes an Explicit_RelatedEntity using a named placeholder pattern (e.g., `[RelatedEntity("{InvoiceNumber}#LINE#*")]`), THE Source_Generator SHALL produce byte-identical generated C# source output compared to the output produced by the Source_Generator version immediately prior to the change, since named placeholder normalization for RelatedEntity patterns is already implemented +3. WHEN an Explicit_RelatedEntity provides a pattern containing named placeholders, THE Entity_Analyzer SHALL pass the pattern through the existing NamedPlaceholderNormalizer.Normalize method with the same entity property name set, and SHALL use the resulting positional format as the SortKeyPattern on the RelationshipModel, identical to the current processing flow +4. THE `RelatedEntityAttribute` class SHALL expose both the existing single-parameter constructor `RelatedEntityAttribute(string sortKeyPattern)` that sets `SortKeyPattern` to the provided non-null value, and a new parameterless constructor `RelatedEntityAttribute()` that sets `SortKeyPattern` to null, without removing or altering the signature of the existing constructor +5. IF a project references the updated library and contains only Explicit_RelatedEntity usages with no bare `[RelatedEntity]` attributes, THEN THE Source_Generator SHALL compile and produce output without requiring any source code changes to the consuming project + +### Requirement 8: Validation of Bare RelatedEntity Relationships + +**User Story:** As a developer, I want the generator to validate inferred relationships with the same rigor as explicit ones, so that I'm alerted to ambiguous or conflicting patterns. + +#### Acceptance Criteria + +1. WHEN the Pattern_Resolution_Pass has resolved all Bare_RelatedEntity patterns for a parent entity, THE Source_Generator SHALL run the full `ValidateRelatedEntityConfiguration` validation suite on that entity's relationships, including ambiguous pattern detection, conflicting pattern detection, sort key existence check, and scalability warnings +2. WHEN a resolved Bare_RelatedEntity pattern equals `"*"` or is null or whitespace-only after inference, THE Source_Generator SHALL emit the existing ambiguous pattern diagnostic referencing the property name and the resolved pattern value +3. WHEN a resolved Bare_RelatedEntity pattern conflicts with any other relationship pattern on the same parent entity, whether that other pattern is an explicitly declared pattern or another resolved Bare_RelatedEntity pattern, THE Source_Generator SHALL emit the existing conflicting pattern diagnostic for each conflicting pair + +### Requirement 9: Testing + +**User Story:** As a maintainer, I want comprehensive tests covering the bare `[RelatedEntity]` feature at multiple levels, so that regressions are caught early. + +#### Acceptance Criteria + +1. THE test suite SHALL include code generation tests that verify the Source_Generator produces a `RelationshipModel` with `IsPatternInferred` set to `true`, `SortKeyPattern` equal to the child entity's `DerivedDiscriminatorPattern`, and `EntityType` equal to the child entity's type name, WHEN processing a Bare_RelatedEntity declaration on a property of type `List` where `C` is a `[DynamoDbTable]` entity on the same table with a non-null `DerivedDiscriminatorPattern` +2. THE test suite SHALL include generated code compilation tests that compile entities using Bare_RelatedEntity through the full source generator pipeline (source generation → Roslyn compilation) using `DynamicCompilationHelper.CompileAndLoad()` and verify the compilation produces zero `DiagnosticSeverity.Error` diagnostics +3. THE test suite SHALL include at least 1 integration test that exercises the full pipeline: define a parent entity with Bare_RelatedEntity on `List` and a child entity `C` with a `[Computed]`-based sort key on the same table, run the Source_Generator, compile via `DynamicCompilationHelper.CompileAndLoad()`, invoke `ToCompositeEntityAsync` via reflection with a DynamoDB item set containing 1 parent item and at least 2 child items, and verify the returned parent entity's collection property contains exactly the child items whose sort keys match the child entity's `DerivedDiscriminatorPattern` +4. THE test suite SHALL include 1 diagnostic test per error condition (3 total) that verify the Source_Generator emits the correct diagnostic code and `DiagnosticSeverity.Error` severity for each: (a) Bare_RelatedEntity on a property whose element type is not a known `[DynamoDbTable]` entity, (b) Bare_RelatedEntity where the child entity has a null `DerivedDiscriminatorPattern`, and (c) Bare_RelatedEntity where the child entity is declared on a different table than the parent +5. THE test suite SHALL include at least 1 backward compatibility test that compiles an entity using Explicit_RelatedEntity with a pattern string through the full source generator pipeline and verifies the resulting `RelationshipModel` has `IsPatternInferred` set to `false` and `SortKeyPattern` equal to the explicitly provided pattern string +6. FOR ALL entities with Bare_RelatedEntity on a property of type `List`, THE inferred `SortKeyPattern` in the generated `RelationshipModel` SHALL equal the child entity `C`'s sort key `DerivedDiscriminatorPattern` value +7. FOR ALL entities with Explicit_RelatedEntity, THE `SortKeyPattern` in the generated `RelationshipModel` SHALL equal the explicitly provided pattern string, and `IsPatternInferred` SHALL be `false`, regardless of whether the child entity has a `DerivedDiscriminatorPattern` + +### Requirement 10: Documentation + +**User Story:** As a developer reading the documentation, I want to learn about the bare `[RelatedEntity]` feature with clear examples and migration guidance, so that I can adopt it in my projects. + +#### Acceptance Criteria + +1. WHEN the bare `[RelatedEntity]` feature is implemented, THE documentation in `docs/advanced-topics/CompositeEntities.md` SHALL be updated to include a new subsection titled "Bare RelatedEntity (Recommended)" that presents Bare_RelatedEntity as the recommended approach with at least one complete entity definition example (parent entity with `[RelatedEntity]` on a `List` property and corresponding child entity), followed by a "Fallback: Explicit Pattern" subsection showing Explicit_RelatedEntity with the same parent-child relationship, and a comparison table listing the three tiers (bare, explicit pattern, named placeholder) with a one-line description and when-to-use guidance for each +2. WHEN the bare `[RelatedEntity]` feature is implemented, THE documentation in `docs/reference/AttributeReference.md` SHALL be updated in the `[RelatedEntity]` section to document the parameterless constructor `RelatedEntityAttribute()`, the nullable `SortKeyPattern` property (type `string?`, default `null`), the inference behavior when `SortKeyPattern` is null (child entity type inferred from property generic type argument, sort key pattern inferred from child entity's `DerivedDiscriminatorPattern`), and at least one code example showing the bare syntax alongside the existing explicit-pattern constructor +3. WHEN the bare `[RelatedEntity]` feature is implemented, THE steering document `.kiro/steering/fluentdynamodb.md` SHALL be updated in the "Composite Entity Definition" section to show the Bare_RelatedEntity syntax as the first example with a comment indicating it is recommended, retain the existing Explicit_RelatedEntity example with a comment indicating it is the fallback, and update the `RelatedEntity Attribute` properties table to reflect that `Pattern (positional)` is now optional with a default of `null` (inferred from child entity metadata) +4. WHEN the bare `[RelatedEntity]` feature is implemented, THE `docs/DOCUMENTATION_CHANGELOG.md` SHALL include one entry per documentation file updated (at minimum entries for `docs/advanced-topics/CompositeEntities.md`, `docs/reference/AttributeReference.md`, and `.kiro/steering/fluentdynamodb.md`), each entry containing the date, file path, before code example, after code example, and reason for the change +5. WHEN the bare `[RelatedEntity]` feature is implemented, THE `CHANGELOG.md` SHALL include an entry in the `[Unreleased]` > `Added` section describing the bare `[RelatedEntity]` inference feature, including a code example of the bare syntax on a `List` property and a note that existing explicit-pattern usage is unaffected +6. IF the `docs/reference/AttributeReference.md` update documents the bare `[RelatedEntity]` constructor, THEN THE documentation SHALL list the compile-time diagnostic codes and their trigger conditions for the 4 error scenarios defined in the feature design: property type not a known `[DynamoDbTable]` entity, child entity has no `DerivedDiscriminatorPattern`, child entity is on a different table, and `EntityType` override type not found diff --git a/.kiro/specs/related-entity-bare-inference/tasks.md b/.kiro/specs/related-entity-bare-inference/tasks.md new file mode 100644 index 00000000..e7eaf35e --- /dev/null +++ b/.kiro/specs/related-entity-bare-inference/tasks.md @@ -0,0 +1,231 @@ +# Implementation Plan: Bare `[RelatedEntity]` Inference + +## Overview + +Add a parameterless `[RelatedEntity]` overload that infers the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern`. This eliminates manual pattern duplication by making the child entity's key definition the single source of truth. The implementation adds a deferred Pattern Resolution Pass to the source generator pipeline that runs after all entities are analyzed, resolves bare patterns from child entity metadata, and feeds the resolved patterns into the existing MapperGenerator code paths unchanged. + +## Tasks + +- [x] 1. Modify `RelatedEntityAttribute` to support parameterless construction + - [x] 1.1 Add parameterless constructor and make `SortKeyPattern` nullable + - In `Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs`: + - Change `SortKeyPattern` property type from `string` to `string?` + - Add parameterless constructor `RelatedEntityAttribute()` that sets `SortKeyPattern = null` + - Add `ArgumentNullException.ThrowIfNull(sortKeyPattern)` guard to existing `string` constructor + - Preserve the existing `EntityType` named property unchanged + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 7.4_ + +- [x] 2. Enhance `RelationshipModel` with inference tracking properties + - [x] 2.1 Add `IsPatternInferred`, `ResolvedEntityType`, and make `SortKeyPattern` nullable + - In `Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs`: + - Change `SortKeyPattern` type from `string` to `string?`, default to `null` + - Add `bool IsPatternInferred { get; set; }` defaulting to `false` + - Add `string? ResolvedEntityType { get; set; }` defaulting to `null` + - Update `IsWildcardPattern` to `SortKeyPattern?.Contains('*') ?? false` for null safety + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ + +- [x] 3. Register new diagnostic descriptors + - [x] 3.1 Add FDDB130, FDDB131, FDDB132 diagnostic descriptors + - In `Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs`: + - Add a `// Bare RelatedEntity Inference Diagnostics (FDDB130-FDDB132)` section comment + - FDDB130 (Error): Bare `[RelatedEntity]` unresolved entity type — message template: `"[RelatedEntity] on property '{0}' in entity '{1}' references type '{2}' which is not a known [DynamoDbTable] entity. Specify EntityType explicitly or provide an explicit pattern string."` + - FDDB131 (Error): Bare `[RelatedEntity]` trivial sort key pattern — message template: `"[RelatedEntity] on property '{0}' in entity '{1}' cannot infer a matching pattern because child entity '{2}' has a bare sort key with no distinguishing structure (NormalizedKeyFormat is '{{0}}'). Provide an explicit pattern string."` + - FDDB132 (Error): Bare `[RelatedEntity]` table mismatch — message template: `"[RelatedEntity] on property '{0}' in entity '{1}' (table '{2}') references child entity '{3}' which is on table '{4}'. Related entities must share the same DynamoDB table."` + - Each with `helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB13N")` and `isEnabledByDefault: true` + - _Requirements: 5.1, 5.2, 5.3, 5.5_ + +- [x] 4. Checkpoint — Build and verify no regressions + - Run `dotnet build-server shutdown && dotnet build` to verify attribute and model changes compile cleanly + - Run `dotnet test` to verify all existing tests pass with nullable `SortKeyPattern` + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Update `EntityAnalyzer.ExtractRelationships()` for bare `[RelatedEntity]` and entity type inference + - [x] 5.1 Handle absent constructor argument and infer entity type from property type + - In `Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs`, modify `ExtractRelationships()`: + - When the `[RelatedEntity]` attribute has no positional constructor arguments (bare form), leave `SortKeyPattern` as `null` on the `RelationshipModel` — skip the pattern extraction block entirely + - Add a new helper method `InferEntityTypeFromProperty(IPropertySymbol propertySymbol)` that: + - For generic types implementing `IEnumerable` (e.g., `List`, `IList`, `ICollection`) — extract first generic type argument `T` via `INamedTypeSymbol.TypeArguments[0].ToDisplayString()` + - For nullable reference types `T?` — unwrap via `NullableAnnotation` check and use `OriginalDefinition` + - For non-nullable, non-collection types `T` — use `T.ToDisplayString()` directly + - For non-generic collections (`ArrayList`, raw `IEnumerable`) — report a diagnostic error and return null + - When the attribute is bare AND no explicit `EntityType` named argument is present, call `InferEntityTypeFromProperty` and set `relationship.ResolvedEntityType` to the result + - When explicit `EntityType` is present, set `relationship.ResolvedEntityType` from the explicit `EntityType` value (regardless of bare vs explicit form) + - Existing explicit pattern + entity type extraction logic must remain unchanged for `[RelatedEntity("pattern")]` + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_ + +- [x] 6. Create `PatternResolutionPass` — the deferred pattern resolution logic + - [x] 6.1 Implement `PatternResolutionPass.Resolve()` static method + - Create new file `Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs` + - Follow the `PatternOverlapAnalyzer` static class pattern + - Method signature: `public static List Resolve(Dictionary> entitiesByTable)` + - For each entity's relationships where `SortKeyPattern` is null or empty: + 1. Resolve child entity type name from `relationship.EntityType` (if set), else `relationship.ResolvedEntityType`, else parse from `relationship.PropertyType` string + 2. Search same table group for `EntityModel` with matching `ClassName` + 3. If not found in any table group → emit FDDB130 with property location + 4. If found in different table → emit FDDB132 with both table names + 5. If found but sort key `DerivedDiscriminatorPattern` is null (trivial `{0}` key format) → emit FDDB131 + 6. On success: set `relationship.SortKeyPattern = childSortKey.DerivedDiscriminatorPattern`, set `relationship.IsPatternInferred = true`, set `relationship.EntityType` to child class name if not already set + - Skip relationships where `SortKeyPattern` is already non-null, non-empty (explicit patterns) + - Return all accumulated diagnostics + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7_ + +- [x] 7. Wire `PatternResolutionPass` into the source generator pipeline + - [x] 7.1 Integrate resolution pass, exclusion, and re-validation into `DynamoDbSourceGenerator.Execute()` + - In `Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs`: + - After the `ValidateTableNamespaceConsistency` loop and before the overlap analysis pass, invoke `PatternResolutionPass.Resolve(entitiesByTable)` and report all returned diagnostics + - Add `ExcludeFailedRelationships()` — for each entity, filter `Relationships` array to remove entries where `SortKeyPattern` is still null after resolution (prevents MapperGenerator from generating code for broken relationships) + - Add `RevalidateResolvedRelationships()` — for entities that had bare patterns resolved, call `ValidateRelatedEntityConfiguration()` to check for ambiguous or conflicting patterns from inferred values (Note: `ValidateRelatedEntityConfiguration` is currently an instance method on `EntityAnalyzer`; may need to extract validation logic into a static helper or invoke it through the existing analyzer instance) + - _Requirements: 3.1, 5.4, 8.1, 8.2, 8.3_ + +- [x] 8. Update `MapperGenerator` for null-safe `SortKeyPattern` handling + - [x] 8.1 Add null guards in sort key pattern usage throughout `MapperGenerator` + - In `Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs`: + - In `GenerateSortKeyPatternMatching()`: add null/empty check at the top — if `sortKeyPattern` is null or empty, skip the entire pattern matching block + - In composite entity methods (`GenerateRelatedEntityCollectionMappingAsync`, `GenerateRelatedEntitySingleMappingAsync`): skip generating mapping code for relationships where `SortKeyPattern` is null + - Verify existing `.Where(p => !string.IsNullOrEmpty(p))` filters on `relatedPatterns` for `MatchesEntity` exclusion conditions handle null safely (they should via the null-coalescing behavior of `Select(r => r.SortKeyPattern)` producing nulls that are filtered out) + - The `IsPatternInferred` flag must NOT influence any code emission path — resolved patterns produce identical output to explicit ones + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 9. Checkpoint — Build and verify full pipeline + - Run `dotnet build-server shutdown && dotnet build` to verify all source generator changes compile + - Run `dotnet test` to verify all existing tests pass with the new pipeline step + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Unit tests for attribute, model, and entity type inference + - [x] 10.1 Write unit tests for `RelatedEntityAttribute` constructors + - Create `Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTests.cs` + - Test parameterless constructor yields `SortKeyPattern == null` + - Test string constructor preserves pattern value and `SortKeyPattern` is non-null + - Test string constructor with `null` argument throws `ArgumentNullException` + - Test `EntityType` named property is independent of constructor form + - _Requirements: 1.1, 1.2, 1.3, 1.5, 9.5_ + + - [x] 10.2 Write unit tests for `RelationshipModel` state tracking + - In the same test file or a companion: + - Test `IsPatternInferred` defaults to `false` + - Test `IsWildcardPattern` returns `false` when `SortKeyPattern` is null + - Test `ResolvedEntityType` defaults to null + - Test after resolution: `IsPatternInferred == true`, `SortKeyPattern` set, `ResolvedEntityType` set + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ + + - [x] 10.3 Write source generator tests for entity type inference from property types + - Test `List` → extracts `OrderLine` as child type + - Test `IList` → extracts `OrderLine` + - Test `ICollection?` → extracts `OrderLine` (unwrap nullable + collection) + - Test `OrderLine?` → extracts `OrderLine` (unwrap nullable) + - Test `OrderLine` (non-collection, non-nullable) → uses `OrderLine` directly + - Test explicit `EntityType = typeof(OrderLine)` overrides property type inference + - Test non-generic collection (e.g., `ArrayList`) → triggers diagnostic error + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_ + + - [x] 10.4 Write backward compatibility test for explicit `[RelatedEntity("pattern")]` + - Compile an entity using explicit `[RelatedEntity("ORDER#*#LINE#*")]` through the full source generator pipeline + - Verify `RelationshipModel` has `IsPatternInferred == false` and `SortKeyPattern == "ORDER#*#LINE#*"` + - Verify generated output is unchanged by the new code paths + - _Requirements: 7.1, 7.2, 7.3, 7.5, 9.5, 9.7_ + +- [x] 11. Diagnostic tests for FDDB130, FDDB131, FDDB132 + - [x] 11.1 Write diagnostic emission tests for all three error conditions + - Create `Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Diagnostics/BareRelatedEntityDiagnosticTests.cs` + - Following the pattern in existing diagnostic test files (e.g., `ConstantKeyDiagnosticTests.cs`) + - Test FDDB130: Bare `[RelatedEntity]` on `List` where `UnknownType` is not a `[DynamoDbTable]` entity → diagnostic code `FDDB130`, severity `Error` + - Test FDDB131: Bare `[RelatedEntity]` on `List` where `ChildEntity` has a bare sort key (`NormalizedKeyFormat` = `"{0}"`) → diagnostic code `FDDB131`, severity `Error` + - Test FDDB132: Bare `[RelatedEntity]` on `List` where `ChildEntity` has `[DynamoDbTable("other-table")]` (different table) → diagnostic code `FDDB132`, severity `Error` + - Each test: define entities in source string, compile with `DynamicCompilationHelper`, run source generator, assert diagnostics contain the expected code and severity + - _Requirements: 5.1, 5.2, 5.3, 5.4, 9.4_ + +- [x] 12. Code generation equivalence test + - [x] 12.1 Write test comparing generated output from bare vs explicit patterns + - In `BareRelatedEntityTests.cs` or a separate test class: + - Define a parent+child entity pair where child has `[Computed("INVOICE#{InvoiceId}#LINE#{LineNumber}")]` sort key → `DerivedDiscriminatorPattern` would be `INVOICE#*#LINE#*` + - Compile version A: parent uses `[RelatedEntity]` (bare) + - Compile version B: parent uses `[RelatedEntity("INVOICE#*#LINE#*")]` (explicit, same pattern) + - Use `DynamicCompilationHelper.GetFluentDynamoDbReferences()` and `DynamoDbSourceGenerator` for both + - Compare generated source files — the relationship mapping code for the parent entity should be character-for-character identical + - _Requirements: 6.1, 6.3, 9.1_ + +- [x] 13. Checkpoint — Verify all unit and diagnostic tests pass + - Run `dotnet build-server shutdown && dotnet test` + - Ensure all tests pass, ask the user if questions arise. + +- [x] 14. Integration tests — full pipeline through compilation and reflection + - [x] 14.1 Write full pipeline integration test with `ToCompositeEntityAsync` invocation + - Create `Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityIntegrationTests.cs` + - Following the pattern in `NamedPlaceholderRuntimeSmokeTests.cs` and `DiscriminatorHydrationCorrectnessTests.cs` + - Define parent entity with bare `[RelatedEntity]` on `List` and child `InvoiceLine` with `[Computed]`-based sort key, both on the same table + - Compile via `DynamicCompilationHelper.CompileAndLoad()` with `DynamoDbSourceGenerator` + - Verify zero `DiagnosticSeverity.Error` diagnostics in compilation result + - Create mock DynamoDB items: 1 parent item + 2+ child items with matching sort key patterns + - Invoke `ToCompositeEntityAsync` via reflection on the compiled assembly + - Assert the returned parent entity's `Lines` collection contains exactly the child items whose sort keys match the child entity's `DerivedDiscriminatorPattern` + - _Requirements: 9.2, 9.3, 9.6_ + + - [x] 14.2 Write generated code compilation test (no runtime invocation) + - In the same integration test file: + - Define entities using bare `[RelatedEntity]` with various collection types (`List`, `IList`, nullable `T?`) + - Compile through full source generator pipeline via `DynamicCompilationHelper.CompileAndLoad()` + - Assert compilation produces zero `DiagnosticSeverity.Error` diagnostics + - _Requirements: 9.2_ + +- [x] 15. Checkpoint — Full test suite green + - Run `dotnet build-server shutdown && dotnet test` + - Ensure all tests pass, ask the user if questions arise. + +- [x] 16. Documentation updates + - [x] 16.1 Update `docs/advanced-topics/CompositeEntities.md` + - Add a "Bare RelatedEntity (Recommended)" subsection before or alongside the existing explicit pattern section + - Show a complete entity definition example: parent with bare `[RelatedEntity]` on `List` and child entity with computed sort key + - Add a "Fallback: Explicit Pattern" subsection with the existing explicit pattern example + - Add a comparison table listing three tiers: bare (recommended), explicit pattern, named placeholder — with description and when-to-use for each + - _Requirements: 10.1_ + + - [x] 16.2 Update `docs/reference/AttributeReference.md` + - In the `[RelatedEntity]` section, document the parameterless constructor `RelatedEntityAttribute()` + - Document the nullable `SortKeyPattern` property (type `string?`, default `null`) + - Document inference behavior: child entity type inferred from property generic type argument, sort key pattern inferred from child entity's `DerivedDiscriminatorPattern` + - Add a code example showing bare syntax alongside existing explicit-pattern constructor + - Document the compile-time diagnostic codes (FDDB130, FDDB131, FDDB132) and their trigger conditions + - _Requirements: 10.2, 10.6_ + + - [x] 16.3 Update `.kiro/steering/fluentdynamodb.md` + - In the "Composite Entity Definition" section, show bare `[RelatedEntity]` syntax first with a comment indicating it is recommended + - Retain the existing explicit-pattern example with a comment indicating it is the fallback + - Update the `RelatedEntity Attribute` properties table: mark `Pattern (positional)` as optional with default `null` (inferred from child entity metadata) + - _Requirements: 10.3_ + + - [x] 16.4 Update `CHANGELOG.md` and `docs/DOCUMENTATION_CHANGELOG.md` + - In `CHANGELOG.md`: add entry in `[Unreleased]` > `Added` section describing bare `[RelatedEntity]` inference with a code example of bare syntax on `List` and note that explicit-pattern usage is unaffected + - In `docs/DOCUMENTATION_CHANGELOG.md`: add one entry per documentation file updated (CompositeEntities.md, AttributeReference.md, fluentdynamodb.md) with date, file path, before/after examples, and reason + - _Requirements: 10.4, 10.5_ + +- [x] 17. Final checkpoint — Full build and test suite + - Run `dotnet build-server shutdown && dotnet build && dotnet test` + - Verify all tests pass and no regressions + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP — no tasks in this plan are marked optional because the user explicitly requested all three testing levels and documentation +- Tests live in `Oproto.FluentDynamoDb.SourceGenerator.UnitTests/` (not `Oproto.FluentDynamoDb.UnitTests/`) since they test source generator behavior +- After any source generator modification, run `dotnet build-server shutdown` before building to avoid stale cached generators +- The `PatternResolutionPass` follows the same static class pattern as `PatternOverlapAnalyzer` and `CompoundPromotionPass` in the `Analysis/` folder +- `ValidateRelatedEntityConfiguration` is an instance method on `EntityAnalyzer` — re-validation may require extracting validation logic to a static helper or creating a minimal analyzer instance +- All library `await` calls require `.ConfigureAwait(false)` per tech steering — this feature is compile-time only, so no async code is being added to library projects +- The `DynamicCompilationHelper.CompileAndLoad()` pattern from `NamedPlaceholderRuntimeSmokeTests.cs` is the reference for integration tests + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "2.1", "3.1"] }, + { "id": 1, "tasks": ["5.1"] }, + { "id": 2, "tasks": ["6.1"] }, + { "id": 3, "tasks": ["7.1", "8.1"] }, + { "id": 4, "tasks": ["10.1", "10.2", "10.3", "10.4"] }, + { "id": 5, "tasks": ["11.1", "12.1"] }, + { "id": 6, "tasks": ["14.1", "14.2"] }, + { "id": 7, "tasks": ["16.1", "16.2", "16.3", "16.4"] } + ] +} +``` diff --git a/.kiro/specs/related-entity-bare-inference/tasks.meta.json b/.kiro/specs/related-entity-bare-inference/tasks.meta.json new file mode 100644 index 00000000..989a0d85 --- /dev/null +++ b/.kiro/specs/related-entity-bare-inference/tasks.meta.json @@ -0,0 +1,497 @@ +{ + "pbtResults": {}, + "executionHistory": { + "1.1 Add parameterless constructor and make `SortKeyPattern` nullable": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846478 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967854554 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967946741 + } + ], + "2.1 Add `IsPatternInferred`, `ResolvedEntityType`, and make `SortKeyPattern` nullable": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846543 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967857624 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789968074179 + } + ], + "3.1 Add FDDB130, FDDB131, FDDB132 diagnostic descriptors": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846582 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967860442 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789968075043 + } + ], + "4. Checkpoint — Build and verify no regressions": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846619 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789968087584 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789973587174 + } + ], + "5.1 Handle absent constructor argument and infer entity type from property type": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846660 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789973591343 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974012915 + } + ], + "6.1 Implement `PatternResolutionPass.Resolve()` static method": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846698 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974017261 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974197538 + } + ], + "7.1 Integrate resolution pass, exclusion, and re-validation into `DynamoDbSourceGenerator.Execute()`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846731 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974202126 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975158768 + } + ], + "8.1 Add null guards in sort key pattern usage throughout `MapperGenerator`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846765 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974202775 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975159707 + } + ], + "9. Checkpoint — Build and verify full pipeline": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846804 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975164054 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975352942 + } + ], + "10.1 Write unit tests for `RelatedEntityAttribute` constructors": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846845 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975356983 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975725489 + } + ], + "10.2 Write unit tests for `RelationshipModel` state tracking": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846882 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975357829 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975726610 + } + ], + "10.3 Write source generator tests for entity type inference from property types": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846915 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975358777 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975727225 + } + ], + "10.4 Write backward compatibility test for explicit `[RelatedEntity(\"pattern\")]`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846952 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975359534 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975728116 + } + ], + "11.1 Write diagnostic emission tests for all three error conditions": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967846994 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975732342 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789979723065 + } + ], + "12.1 Write test comparing generated output from bare vs explicit patterns": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847031 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975733096 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789979723994 + } + ], + "13. Checkpoint — Verify all unit and diagnostic tests pass": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847067 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789979728425 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980099478 + } + ], + "14.1 Write full pipeline integration test with `ToCompositeEntityAsync` invocation": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847109 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980103715 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980469125 + } + ], + "14.2 Write generated code compilation test (no runtime invocation)": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847148 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980104577 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980470279 + } + ], + "15. Checkpoint — Full test suite green": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847181 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980474582 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980656136 + } + ], + "16.1 Update `docs/advanced-topics/CompositeEntities.md`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847220 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980661853 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980819409 + } + ], + "16.2 Update `docs/reference/AttributeReference.md`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847263 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980662711 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980820249 + } + ], + "16.3 Update `.kiro/steering/fluentdynamodb.md`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847301 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980663582 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980821124 + } + ], + "16.4 Update `CHANGELOG.md` and `docs/DOCUMENTATION_CHANGELOG.md`": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847345 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980664620 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980822115 + } + ], + "17. Final checkpoint — Full build and test suite": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967847381 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980826316 + }, + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789981008798 + } + ], + "1. Modify `RelatedEntityAttribute` to support parameterless construction": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789967946803 + } + ], + "2. Enhance `RelationshipModel` with inference tracking properties": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789968074256 + } + ], + "3. Register new diagnostic descriptors": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789968075090 + } + ], + "5. Update `EntityAnalyzer.ExtractRelationships()` for bare `[RelatedEntity]` and entity type inference": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974013165 + } + ], + "6. Create `PatternResolutionPass` — the deferred pattern resolution logic": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789974197721 + } + ], + "7. Wire `PatternResolutionPass` into the source generator pipeline": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975158866 + } + ], + "8. Update `MapperGenerator` for null-safe `SortKeyPattern` handling": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975159798 + } + ], + "10. Unit tests for attribute, model, and entity type inference": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789975728172 + } + ], + "11. Diagnostic tests for FDDB130, FDDB131, FDDB132": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789979723155 + } + ], + "12. Code generation equivalence test": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789979724047 + } + ], + "14. Integration tests — full pipeline through compilation and reflection": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980470330 + } + ], + "16. Documentation updates": [ + { + "executionId": "b3f93683-13b3-4349-bdf7-cb2c77918e60", + "chatSessionId": "sess_ce305c5f-c062-4146-b33b-cb8df057bc08", + "timestamp": 1789980822162 + } + ] + } +} \ No newline at end of file diff --git a/.kiro/steering/fluentdynamodb.md b/.kiro/steering/fluentdynamodb.md index dd417a4f..b9f3d956 100644 --- a/.kiro/steering/fluentdynamodb.md +++ b/.kiro/steering/fluentdynamodb.md @@ -166,7 +166,7 @@ public partial class OrderSummary Composite entities span multiple DynamoDB items sharing the same partition key but different sort keys. Use `[RelatedEntity]` to define parent-child relationships. ```csharp -// Parent entity with related child collection +// Recommended: Bare [RelatedEntity] — pattern inferred from child entity's key structure [DynamoDbTable("invoices", IsDefault = true)] public partial class Invoice { @@ -181,11 +181,10 @@ public partial class Invoice [DynamoDbAttribute("invoiceNumber")] public string InvoiceNumber { get; set; } = string.Empty; - // Related entity collection - automatically populated by ToCompositeEntityAsync - // Named placeholder pattern: {PropertyName} tokens resolved at compile time - [RelatedEntity("{InvoiceNumber}#LINE#*", EntityType = typeof(InvoiceLine))] + // Bare form: child entity type inferred from List, sort key pattern + // inferred from InvoiceLine's DerivedDiscriminatorPattern at compile time + [RelatedEntity] public List Lines { get; set; } = new(); - // Also supported: wildcard-only pattern "INVOICE#*#LINE#*" } // Child entity with hierarchical sort key @@ -199,9 +198,13 @@ public partial class InvoiceLine // Sort key extends parent: "INVOICE#INV-001#LINE#1" [SortKey] [DynamoDbAttribute("sk")] + [Computed("INVOICE#{InvoiceId}#LINE#{LineNumber}")] public string Sk { get; set; } = string.Empty; - [DynamoDbAttribute("lineNumber")] + [Extracted("Sk", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted("Sk", 1)] public int LineNumber { get; set; } [DynamoDbAttribute("amount")] @@ -209,12 +212,21 @@ public partial class InvoiceLine } ``` +```csharp +// Fallback: Explicit pattern — use when the child entity's key structure +// doesn't produce a usable DerivedDiscriminatorPattern, or you need a +// custom pattern (e.g., named placeholders referencing parent properties) +[RelatedEntity("{InvoiceNumber}#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +// Also supported: wildcard-only pattern "INVOICE#*#LINE#*" +``` + ### RelatedEntity Attribute | Property | Description | |----------|-------------| -| Pattern (positional) | Sort key pattern with `*` wildcards and optional `{PropertyName}` placeholders (e.g., `"{OrderId}#LINE#*"` or `"INVOICE#*#LINE#*"`) | -| `EntityType` | The type to map matching items to (required for collections) | +| Pattern (positional) | *(Optional, default `null`)* Sort key pattern with `*` wildcards and optional `{PropertyName}` placeholders (e.g., `"{OrderId}#LINE#*"` or `"INVOICE#*#LINE#*"`). When omitted, the pattern is inferred from the child entity's `DerivedDiscriminatorPattern` at compile time. | +| `EntityType` | The type to map matching items to. When omitted on bare `[RelatedEntity]`, inferred from the property's generic type argument (e.g., `List` → `T`). | ### Querying Composite Entities diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dee4a3d..fbd48c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Named-Placeholder Runtime Smoke Tests** — Added 12 integration tests in `NamedPlaceholderRuntimeSmokeTests` that exercise the full source generator pipeline (generate → compile → load → invoke) for entities using the named-placeholder `[Computed("{PropertyName}")]` syntax. The test suite covers FromDynamoDb round-trips with extracted properties, ExtractComponents key decomposition, MatchesEntity discrimination for composite and multi-entity scenarios, three-plus property computed keys, GSI computed keys, and backward compatibility for positional and separator syntax. Each test compiles an entity definition through the source generator, loads the assembly dynamically, and invokes the generated methods at runtime — closing a coverage gap where existing integration tests verified generated code structure but not runtime behavior. +- **Bare `[RelatedEntity]` Inference** — Use `[RelatedEntity]` without a pattern string and the source generator infers the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern`. The child entity type is automatically extracted from the property's generic type argument (e.g., `List` → `InvoiceLine`). Explicit `[RelatedEntity("pattern")]` usage is fully backward compatible and unaffected. + + ```csharp + // Before (explicit pattern — still supported) + [RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + + // After (bare inference — recommended) + [RelatedEntity] + public List Lines { get; set; } = new(); + ``` + + The bare form eliminates manual pattern duplication by making the child entity's key definition the single source of truth. A deferred Pattern Resolution Pass resolves bare patterns after all entities are analyzed, enabling correct resolution regardless of entity declaration order. Three new compile-time diagnostics (FDDB130, FDDB131, FDDB132) provide actionable error messages when inference cannot determine a valid pattern. + ### Fixed diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityBackwardCompatibilityTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityBackwardCompatibilityTests.cs new file mode 100644 index 00000000..1645d7d8 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityBackwardCompatibilityTests.cs @@ -0,0 +1,330 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Analysis; + +/// +/// Backward compatibility tests for explicit [RelatedEntity("pattern")] usage. +/// Verifies that existing explicit-pattern code continues to compile and generate +/// correct output after the bare [RelatedEntity] inference feature is added. +/// Validates Requirements 7.1, 7.2, 7.3, 7.5, 9.5, 9.7. +/// +[Trait("Category", "Unit")] +public class BareRelatedEntityBackwardCompatibilityTests +{ + /// + /// Verifies that an explicit [RelatedEntity("ORDER#*#LINE#*")] with a wildcard pattern + /// produces no FDDB130, FDDB131, or FDDB132 diagnostics and generates code that compiles. + /// Validates Requirements 7.1, 7.5, 9.5, 9.7. + /// + [Fact] + public void ExplicitRelatedEntity_WithWildcardPattern_ShouldNotEmitBareInferenceDiagnostics() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity(""ORDER#*#LINE#*"", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""InvoiceId"", ""LineNumber"", Format = ""ORDER#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare inference diagnostics emitted + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "Explicit pattern should not trigger bare inference unresolved-type diagnostic"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "Explicit pattern should not trigger bare inference trivial-sort-key diagnostic"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Explicit pattern should not trigger bare inference table-mismatch diagnostic"); + } + + /// + /// Verifies that an explicit [RelatedEntity("ORDER#*#LINE#*")] generates code + /// without any compilation errors. + /// Validates Requirements 7.1, 7.5. + /// + [Fact] + public void ExplicitRelatedEntity_WithWildcardPattern_GeneratedCodeCompiles() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity(""ORDER#*#LINE#*"", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""InvoiceId"", ""LineNumber"", Format = ""ORDER#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — generated code is present and has no errors + var errorDiagnostics = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errorDiagnostics.Should().BeEmpty( + "Explicit [RelatedEntity(\"ORDER#*#LINE#*\")] should produce error-free generated code. " + + $"Errors: {string.Join(", ", errorDiagnostics.Select(d => $"{d.Id}: {d.GetMessage()}"))}"); + + result.GeneratedSources.Should().NotBeEmpty( + "Source generator should produce generated code for the explicit pattern entity"); + } + + /// + /// Verifies that the generated mapper code for an explicit [RelatedEntity("ORDER#*#LINE#*")] + /// contains the explicit pattern string in the generated source. + /// Validates Requirements 7.1, 7.2, 9.7. + /// + [Fact] + public void ExplicitRelatedEntity_WithWildcardPattern_GeneratedCodeContainsExplicitPattern() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity(""ORDER#*#LINE#*"", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""InvoiceId"", ""LineNumber"", Format = ""ORDER#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — generated source contains the explicit pattern for sort key matching + var allGeneratedText = string.Join("\n", + result.GeneratedSources.Select(s => s.SourceText.ToString())); + + allGeneratedText.Should().Contain("ORDER#", + "Generated mapper code should contain the explicit pattern segments from [RelatedEntity(\"ORDER#*#LINE#*\")]"); + allGeneratedText.Should().Contain("LINE#", + "Generated mapper code should contain the explicit pattern segments from [RelatedEntity(\"ORDER#*#LINE#*\")]"); + } + + /// + /// Verifies that an explicit [RelatedEntity] using a named placeholder pattern + /// (e.g., [RelatedEntity("{InvoiceNumber}#LINE#*")]) continues to work unchanged. + /// Named placeholder normalization should process the pattern without interference + /// from the bare inference code paths. + /// Validates Requirements 7.2, 7.3. + /// + [Fact] + public void ExplicitRelatedEntity_WithNamedPlaceholderPattern_ShouldNotEmitBareInferenceDiagnostics() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity(""{InvoiceNumber}#LINE#*"", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""InvoiceId"", ""LineNumber"", Format = ""ORDER#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "Named placeholder pattern should not trigger bare inference diagnostics"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "Named placeholder pattern should not trigger bare inference diagnostics"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Named placeholder pattern should not trigger bare inference diagnostics"); + + // Assert — generated code is produced + var errorDiagnostics = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errorDiagnostics.Should().BeEmpty( + "Named placeholder pattern should compile without errors. " + + $"Errors: {string.Join(", ", errorDiagnostics.Select(d => $"{d.Id}: {d.GetMessage()}"))}"); + + result.GeneratedSources.Should().NotBeEmpty( + "Source generator should produce generated code for the named placeholder pattern entity"); + } + + #region Helper Methods + + private static GeneratorTestResult GenerateCode(string source) + { + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] + { + CSharpSyntaxTree.ParseText(source), + CSharpSyntaxTree.ParseText( + "[assembly: Oproto.FluentDynamoDb.Attributes.FluentDynamoDbSchemaVersion(1, 0)]") + }, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new DynamoDbSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generatedSources = outputCompilation.SyntaxTrees + .Skip(compilation.SyntaxTrees.Count()) + .Select(tree => new GeneratedSource(tree.FilePath, tree.GetText())) + .ToArray(); + + return new GeneratorTestResult + { + Diagnostics = diagnostics, + GeneratedSources = generatedSources + }; + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs new file mode 100644 index 00000000..2b655b36 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs @@ -0,0 +1,324 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Analysis; + +/// +/// Code generation equivalence tests for bare vs explicit [RelatedEntity] patterns. +/// Verifies that bare inference produces character-for-character identical generated code +/// compared to an equivalent explicit pattern string. +/// +/// **Validates: Requirements 6.1, 6.3, 9.1** +/// +[Trait("Category", "Unit")] +public class BareRelatedEntityCodeEquivalenceTests +{ + /// + /// The child entity source shared by both bare and explicit versions. + /// Uses [Computed("InvoiceId", "LineNumber", Format = "INVOICE#{0}#LINE#{1}")] + /// which produces DerivedDiscriminatorPattern of INVOICE#*#LINE#*. + /// + private const string ChildEntitySource = @" + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""InvoiceId"", ""LineNumber"", Format = ""INVOICE#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + }"; + + /// + /// Parent entity using bare [RelatedEntity] — pattern inferred from child metadata. + /// + private const string BareParentSource = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity] + public List Lines { get; set; } = new(); + } +" + ChildEntitySource + @" +}"; + + /// + /// Parent entity using explicit [RelatedEntity("INVOICE#*#LINE#*")] with + /// EntityType = typeof(InvoiceLine) — the same pattern the bare form should infer. + /// + private const string ExplicitParentSource = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [RelatedEntity(""INVOICE#*#LINE#*"", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); + } +" + ChildEntitySource + @" +}"; + + /// + /// Bare [RelatedEntity] compiles without error diagnostics. + /// + /// **Validates: Requirements 6.1, 9.1** + /// + [Fact] + public void BareRelatedEntity_CompilesWithoutErrors() + { + // Act + var result = GenerateCode(BareParentSource); + + // Assert + var errorDiagnostics = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errorDiagnostics.Should().BeEmpty( + "Bare [RelatedEntity] should compile without errors. " + + $"Errors: {string.Join(", ", errorDiagnostics.Select(d => $"{d.Id}: {d.GetMessage()}"))}"); + + result.GeneratedSources.Should().NotBeEmpty( + "Source generator should produce generated code for the bare pattern entity"); + } + + /// + /// Explicit [RelatedEntity("INVOICE#*#LINE#*")] compiles without error diagnostics. + /// + /// **Validates: Requirements 6.1, 9.1** + /// + [Fact] + public void ExplicitRelatedEntity_CompilesWithoutErrors() + { + // Act + var result = GenerateCode(ExplicitParentSource); + + // Assert + var errorDiagnostics = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errorDiagnostics.Should().BeEmpty( + "Explicit [RelatedEntity(\"INVOICE#*#LINE#*\")] should compile without errors. " + + $"Errors: {string.Join(", ", errorDiagnostics.Select(d => $"{d.Id}: {d.GetMessage()}"))}"); + + result.GeneratedSources.Should().NotBeEmpty( + "Source generator should produce generated code for the explicit pattern entity"); + } + + /// + /// The generated source code for the parent entity (Invoice) is character-for-character + /// identical whether using bare [RelatedEntity] or explicit + /// [RelatedEntity("INVOICE#*#LINE#*")]. This confirms that IsPatternInferred + /// does not influence any code emission path in the MapperGenerator. + /// + /// **Validates: Requirements 6.1, 6.3, 9.1** + /// + [Fact] + public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForParentEntity() + { + // Act + var bareResult = GenerateCode(BareParentSource); + var explicitResult = GenerateCode(ExplicitParentSource); + + // Assert — both compile without errors + bareResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty("Bare version should have no error diagnostics"); + + explicitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty("Explicit version should have no error diagnostics"); + + // Find the generated source files for the Invoice (parent) entity in both results. + // Generated files typically contain the entity class name in the file path or content. + var bareInvoiceSources = bareResult.GeneratedSources + .Where(s => s.SourceText.ToString().Contains("partial class Invoice")) + .ToList(); + + var explicitInvoiceSources = explicitResult.GeneratedSources + .Where(s => s.SourceText.ToString().Contains("partial class Invoice")) + .ToList(); + + bareInvoiceSources.Should().NotBeEmpty( + "Bare result should contain generated source for the Invoice entity"); + explicitInvoiceSources.Should().NotBeEmpty( + "Explicit result should contain generated source for the Invoice entity"); + + bareInvoiceSources.Count.Should().Be(explicitInvoiceSources.Count, + "Both versions should generate the same number of source files for Invoice"); + + // Compare each generated source file for Invoice — should be character-for-character identical. + // Sort by file name to ensure stable comparison order. + var bareSorted = bareInvoiceSources + .OrderBy(s => s.FileName) + .ToList(); + var explicitSorted = explicitInvoiceSources + .OrderBy(s => s.FileName) + .ToList(); + + for (var i = 0; i < bareSorted.Count; i++) + { + var bareText = bareSorted[i].SourceText.ToString(); + var explicitText = explicitSorted[i].SourceText.ToString(); + + bareText.Should().Be(explicitText, + $"Generated source file #{i} for Invoice should be character-for-character identical " + + "between bare [RelatedEntity] and explicit [RelatedEntity(\"INVOICE#*#LINE#*\")]"); + } + } + + /// + /// Both bare and explicit versions produce the same generated source files for the child + /// entity (InvoiceLine) as well, confirming no side-effects from the resolution pass. + /// + /// **Validates: Requirements 6.1, 6.3** + /// + [Fact] + public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForChildEntity() + { + // Act + var bareResult = GenerateCode(BareParentSource); + var explicitResult = GenerateCode(ExplicitParentSource); + + // Find generated source files for InvoiceLine (child entity) + var bareChildSources = bareResult.GeneratedSources + .Where(s => s.SourceText.ToString().Contains("partial class InvoiceLine")) + .ToList(); + + var explicitChildSources = explicitResult.GeneratedSources + .Where(s => s.SourceText.ToString().Contains("partial class InvoiceLine")) + .ToList(); + + bareChildSources.Should().NotBeEmpty( + "Bare result should contain generated source for the InvoiceLine entity"); + explicitChildSources.Should().NotBeEmpty( + "Explicit result should contain generated source for the InvoiceLine entity"); + + bareChildSources.Count.Should().Be(explicitChildSources.Count, + "Both versions should generate the same number of source files for InvoiceLine"); + + var bareSorted = bareChildSources + .OrderBy(s => s.FileName) + .ToList(); + var explicitSorted = explicitChildSources + .OrderBy(s => s.FileName) + .ToList(); + + for (var i = 0; i < bareSorted.Count; i++) + { + var bareText = bareSorted[i].SourceText.ToString(); + var explicitText = explicitSorted[i].SourceText.ToString(); + + bareText.Should().Be(explicitText, + $"Generated source file #{i} for InvoiceLine should be identical " + + "between bare and explicit [RelatedEntity] usage"); + } + } + + /// + /// Both bare and explicit versions produce the same total set of generated source files + /// (same count, same file names), confirming no extra or missing files from inference. + /// + /// **Validates: Requirements 6.1, 6.3** + /// + [Fact] + public void BareAndExplicit_ProduceSameNumberOfGeneratedFiles() + { + // Act + var bareResult = GenerateCode(BareParentSource); + var explicitResult = GenerateCode(ExplicitParentSource); + + // Assert + bareResult.GeneratedSources.Length.Should().Be(explicitResult.GeneratedSources.Length, + "Bare and explicit versions should produce the same number of generated source files"); + + var bareFileNames = bareResult.GeneratedSources + .Select(s => Path.GetFileName(s.FileName)) + .OrderBy(n => n) + .ToList(); + var explicitFileNames = explicitResult.GeneratedSources + .Select(s => Path.GetFileName(s.FileName)) + .OrderBy(n => n) + .ToList(); + + bareFileNames.Should().BeEquivalentTo(explicitFileNames, + "Bare and explicit versions should produce files with identical names"); + } + + #region Helper Methods + + private static GeneratorTestResult GenerateCode(string source) + { + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] + { + CSharpSyntaxTree.ParseText(source), + CSharpSyntaxTree.ParseText( + "[assembly: Oproto.FluentDynamoDb.Attributes.FluentDynamoDbSchemaVersion(1, 0)]") + }, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new DynamoDbSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generatedSources = outputCompilation.SyntaxTrees + .Skip(compilation.SyntaxTrees.Count()) + .Select(tree => new GeneratedSource(tree.FilePath, tree.GetText())) + .ToArray(); + + return new GeneratorTestResult + { + Diagnostics = diagnostics, + GeneratedSources = generatedSources + }; + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTests.cs new file mode 100644 index 00000000..5cccb2d2 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTests.cs @@ -0,0 +1,305 @@ +using Oproto.FluentDynamoDb.Attributes; +using Oproto.FluentDynamoDb.SourceGenerator.Models; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Analysis; + +/// +/// Unit tests for bare [RelatedEntity] attribute constructors (Task 10.1) and +/// RelationshipModel state tracking (Task 10.2). +/// +/// **Validates: Requirements 1.1, 1.2, 1.3, 1.5, 4.1, 4.2, 4.3, 4.4, 4.5, 9.5** +/// +[Trait("Category", "Unit")] +public class BareRelatedEntityTests +{ + #region Task 10.1: RelatedEntityAttribute Constructor Tests + + /// + /// Parameterless constructor sets SortKeyPattern to null, signaling + /// bare inference mode. + /// + /// **Validates: Requirements 1.1, 1.2** + /// + [Fact] + public void ParameterlessConstructor_SetsPatternToNull() + { + // Act + var attr = new RelatedEntityAttribute(); + + // Assert + attr.SortKeyPattern.Should().BeNull( + "parameterless constructor should leave SortKeyPattern null for bare inference"); + } + + /// + /// String constructor preserves the provided pattern value verbatim. + /// + /// **Validates: Requirements 1.3** + /// + [Theory] + [InlineData("ORDER#*#LINE#*")] + [InlineData("audit#*")] + [InlineData("summary")] + [InlineData("")] + [InlineData("INVOICE#{0}#LINE#{1}")] + public void StringConstructor_PreservesPatternValue(string pattern) + { + // Act + var attr = new RelatedEntityAttribute(pattern); + + // Assert + attr.SortKeyPattern.Should().NotBeNull( + "string constructor should always produce a non-null SortKeyPattern"); + attr.SortKeyPattern.Should().Be(pattern, + "string constructor should preserve the exact pattern value"); + } + + /// + /// String constructor with null argument throws ArgumentNullException. + /// + /// **Validates: Requirements 1.5** + /// + [Fact] + public void StringConstructor_WithNullArgument_ThrowsArgumentNullException() + { + // Act + Action act = () => new RelatedEntityAttribute(null!); + + // Assert + act.Should().Throw( + "passing null to the string constructor must throw to distinguish from the parameterless constructor"); + } + + /// + /// EntityType named property is independent of which constructor is used. + /// It can be set on both bare and explicit forms. + /// + /// **Validates: Requirements 1.1, 1.3, 9.5** + /// + [Fact] + public void EntityType_IsIndependentOfConstructorForm_Bare() + { + // Act + var attr = new RelatedEntityAttribute { EntityType = typeof(string) }; + + // Assert + attr.SortKeyPattern.Should().BeNull( + "parameterless constructor should leave SortKeyPattern null"); + attr.EntityType.Should().Be(typeof(string), + "EntityType should be settable on bare form"); + } + + /// + /// EntityType named property can be set alongside an explicit pattern. + /// + /// **Validates: Requirements 1.3, 9.5** + /// + [Fact] + public void EntityType_IsIndependentOfConstructorForm_Explicit() + { + // Act + var attr = new RelatedEntityAttribute("ORDER#*") { EntityType = typeof(int) }; + + // Assert + attr.SortKeyPattern.Should().Be("ORDER#*", + "explicit pattern should be preserved"); + attr.EntityType.Should().Be(typeof(int), + "EntityType should be settable on explicit form"); + } + + /// + /// EntityType defaults to null when not explicitly set. + /// + [Fact] + public void EntityType_DefaultsToNull_WhenNotSet() + { + // Act + var bareAttr = new RelatedEntityAttribute(); + var explicitAttr = new RelatedEntityAttribute("pattern"); + + // Assert + bareAttr.EntityType.Should().BeNull( + "EntityType should default to null on bare form"); + explicitAttr.EntityType.Should().BeNull( + "EntityType should default to null on explicit form"); + } + + #endregion + + #region Task 10.2: RelationshipModel State Tracking Tests + + /// + /// IsPatternInferred defaults to false on a freshly constructed RelationshipModel. + /// + /// **Validates: Requirements 4.1, 4.2** + /// + [Fact] + public void RelationshipModel_IsPatternInferred_DefaultsToFalse() + { + // Act + var model = new RelationshipModel(); + + // Assert + model.IsPatternInferred.Should().BeFalse( + "IsPatternInferred should default to false before any resolution"); + } + + /// + /// IsWildcardPattern returns false when SortKeyPattern is null (bare, unresolved). + /// + /// **Validates: Requirements 4.1** + /// + [Fact] + public void RelationshipModel_IsWildcardPattern_ReturnsFalse_WhenPatternIsNull() + { + // Act + var model = new RelationshipModel { SortKeyPattern = null }; + + // Assert + model.IsWildcardPattern.Should().BeFalse( + "null SortKeyPattern should yield false for IsWildcardPattern"); + } + + /// + /// IsWildcardPattern returns true when SortKeyPattern contains a wildcard. + /// + [Fact] + public void RelationshipModel_IsWildcardPattern_ReturnsTrue_WhenPatternContainsWildcard() + { + // Act + var model = new RelationshipModel { SortKeyPattern = "ORDER#*#LINE#*" }; + + // Assert + model.IsWildcardPattern.Should().BeTrue( + "pattern with wildcards should return true"); + } + + /// + /// IsWildcardPattern returns false when SortKeyPattern has no wildcard. + /// + [Fact] + public void RelationshipModel_IsWildcardPattern_ReturnsFalse_WhenPatternHasNoWildcard() + { + // Act + var model = new RelationshipModel { SortKeyPattern = "summary" }; + + // Assert + model.IsWildcardPattern.Should().BeFalse( + "pattern without wildcards should return false"); + } + + /// + /// ResolvedEntityType defaults to null on a freshly constructed RelationshipModel. + /// + /// **Validates: Requirements 4.4, 4.5** + /// + [Fact] + public void RelationshipModel_ResolvedEntityType_DefaultsToNull() + { + // Act + var model = new RelationshipModel(); + + // Assert + model.ResolvedEntityType.Should().BeNull( + "ResolvedEntityType should default to null before resolution"); + } + + /// + /// SortKeyPattern defaults to null on a freshly constructed RelationshipModel. + /// + [Fact] + public void RelationshipModel_SortKeyPattern_DefaultsToNull() + { + // Act + var model = new RelationshipModel(); + + // Assert + model.SortKeyPattern.Should().BeNull( + "SortKeyPattern should default to null on a new RelationshipModel"); + } + + /// + /// After a simulated resolution, IsPatternInferred is true, SortKeyPattern is set, + /// and ResolvedEntityType is set — matching the state after PatternResolutionPass runs. + /// + /// **Validates: Requirements 4.1, 4.3, 4.4** + /// + [Fact] + public void RelationshipModel_AfterResolution_StateIsCorrect() + { + // Arrange — simulate a bare RelationshipModel before resolution + var model = new RelationshipModel + { + PropertyName = "Lines", + PropertyType = "System.Collections.Generic.List", + IsCollection = true, + SortKeyPattern = null, + IsPatternInferred = false, + ResolvedEntityType = "TestNamespace.InvoiceLine" + }; + + // Act — simulate what PatternResolutionPass.Resolve() does + model.SortKeyPattern = "INVOICE#*#LINE#*"; + model.IsPatternInferred = true; + model.EntityType = "InvoiceLine"; + + // Assert + model.IsPatternInferred.Should().BeTrue( + "IsPatternInferred should be true after resolution"); + model.SortKeyPattern.Should().Be("INVOICE#*#LINE#*", + "SortKeyPattern should be set to the child's DerivedDiscriminatorPattern"); + model.ResolvedEntityType.Should().Be("TestNamespace.InvoiceLine", + "ResolvedEntityType should remain set from property type inference"); + model.EntityType.Should().Be("InvoiceLine", + "EntityType should be set to the child class name during resolution"); + model.IsWildcardPattern.Should().BeTrue( + "resolved pattern with wildcards should make IsWildcardPattern true"); + } + + /// + /// An explicitly provided pattern keeps IsPatternInferred as false and + /// does not populate ResolvedEntityType (unless EntityType is set). + /// + /// **Validates: Requirements 4.2, 4.5** + /// + [Fact] + public void RelationshipModel_ExplicitPattern_IsPatternInferredRemainsFalse() + { + // Arrange — simulate an explicit [RelatedEntity("ORDER#*#LINE#*")] + var model = new RelationshipModel + { + PropertyName = "Lines", + PropertyType = "System.Collections.Generic.List", + IsCollection = true, + SortKeyPattern = "ORDER#*#LINE#*", + EntityType = "OrderLine" + }; + + // Assert — no resolution pass needed for explicit patterns + model.IsPatternInferred.Should().BeFalse( + "explicit patterns should leave IsPatternInferred as false"); + model.SortKeyPattern.Should().Be("ORDER#*#LINE#*", + "explicit SortKeyPattern should be preserved"); + model.ResolvedEntityType.Should().BeNull( + "ResolvedEntityType should remain null when EntityType is set directly (not inferred from property type)"); + } + + /// + /// HasSpecificEntityType returns true when EntityType is set, false when null or whitespace. + /// + [Theory] + [InlineData("OrderLine", true)] + [InlineData("", false)] + [InlineData(null, false)] + [InlineData(" ", false)] + public void RelationshipModel_HasSpecificEntityType_ReflectsEntityType(string? entityType, bool expected) + { + // Act + var model = new RelationshipModel { EntityType = entityType }; + + // Assert + model.HasSpecificEntityType.Should().Be(expected); + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTypeInferenceTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTypeInferenceTests.cs new file mode 100644 index 00000000..d1605a46 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityTypeInferenceTests.cs @@ -0,0 +1,492 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Analysis; + +/// +/// Source generator tests for entity type inference from property types on bare [RelatedEntity]. +/// Validates Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6. +/// +[Trait("Category", "Unit")] +public class BareRelatedEntityTypeInferenceTests +{ + #region List — Requirement 2.1 + + [Fact] + public void ListOfOrderLine_ExtractsOrderLineAsChildType() + { + // Arrange — parent with bare [RelatedEntity] on List + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare RelatedEntity inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "OrderLine is a known [DynamoDbTable] entity on the same table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "List is a generic collection type"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed for bare [RelatedEntity] with List"); + } + + #endregion + + #region IList — Requirement 2.1 + + [Fact] + public void IListOfOrderLine_ExtractsOrderLineAsChildType() + { + // Arrange — parent with bare [RelatedEntity] on IList + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public IList? Lines { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare RelatedEntity inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "OrderLine is a known [DynamoDbTable] entity on the same table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "IList is a generic collection type"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed for bare [RelatedEntity] with IList"); + } + + #endregion + + #region ICollection? — Requirements 2.1, 2.2 + + [Fact] + public void NullableICollectionOfOrderLine_ExtractsOrderLineAsChildType() + { + // Arrange — parent with bare [RelatedEntity] on ICollection? + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public ICollection? Lines { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare RelatedEntity inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "OrderLine is a known [DynamoDbTable] entity on the same table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "ICollection? is a generic collection type"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed for bare [RelatedEntity] with nullable ICollection"); + } + + #endregion + + #region Nullable T? (non-collection) — Requirement 2.2 + + [Fact] + public void NullableOrderLine_ExtractsOrderLineAsChildType() + { + // Arrange — parent with bare [RelatedEntity] on OrderLine? (nullable, non-collection) + var source = @" +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public OrderLine? Summary { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare RelatedEntity inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "OrderLine is a known [DynamoDbTable] entity on the same table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "OrderLine? is not a non-generic collection type"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed for bare [RelatedEntity] with nullable T?"); + } + + #endregion + + #region Non-collection, non-nullable T — Requirement 2.3 + + [Fact] + public void DirectOrderLine_UsesOrderLineDirectly() + { + // Arrange — parent with bare [RelatedEntity] on OrderLine (non-collection, non-nullable) + var source = @" +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public OrderLine Summary { get; set; } = null!; + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — no bare RelatedEntity inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "OrderLine is a known [DynamoDbTable] entity on the same table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "OrderLine is not a non-generic collection type"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed for bare [RelatedEntity] with direct type T"); + } + + #endregion + + #region Explicit EntityType override — Requirement 2.4 + + [Fact] + public void ExplicitEntityType_OverridesPropertyTypeInference() + { + // Arrange — bare [RelatedEntity] with explicit EntityType = typeof(OrderLine) + // The property type is List but EntityType explicitly specifies OrderLine + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity(EntityType = typeof(OrderLine))] + public List? Lines { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — explicit EntityType should be used, no inference diagnostics + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130", + "Explicit EntityType = typeof(OrderLine) specifies the child entity type"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131", + "OrderLine has a prefixed sort key with distinguishing structure"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132", + "Both entities are on the same 'Orders' table"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB133", + "Explicit EntityType should prevent non-generic collection diagnostic"); + result.GeneratedSources.Should().NotBeEmpty( + "Source generation should succeed when explicit EntityType overrides property type inference"); + } + + #endregion + + #region Non-generic collection (ArrayList) — Requirement 2.6 + + [Fact] + public void ArrayList_TriggersDiagnosticError() + { + // Arrange — bare [RelatedEntity] on ArrayList (non-generic collection) + var source = @" +using System.Collections; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""Orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public ArrayList? Lines { get; set; } + } + + [DynamoDbTable(""Orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — an error-level diagnostic should be emitted for non-generic collection. + // FDDB133 is the targeted diagnostic for this case. A DYNDB999 (analysis error) + // may also appear if the null SortKeyPattern from failed inference propagates into + // PatternsConflict validation before the pattern resolution pass. + result.Diagnostics.Should().Contain(d => + d.Severity == DiagnosticSeverity.Error && + (d.Id == "FDDB133" || d.Id == "DYNDB999"), + "Non-generic collection ArrayList should produce an error diagnostic"); + } + + #endregion + + #region Helper Methods + + private static GeneratorTestResult GenerateCode(string source) + { + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] + { + CSharpSyntaxTree.ParseText(source), + CSharpSyntaxTree.ParseText("[assembly: Oproto.FluentDynamoDb.Attributes.FluentDynamoDbSchemaVersion(1, 0)]") + }, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new DynamoDbSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generatedSources = outputCompilation.SyntaxTrees + .Skip(compilation.SyntaxTrees.Count()) + .Select(tree => new GeneratedSource(tree.FilePath, tree.GetText())) + .ToArray(); + + return new GeneratorTestResult + { + Diagnostics = diagnostics, + GeneratedSources = generatedSources + }; + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Diagnostics/BareRelatedEntityDiagnosticTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Diagnostics/BareRelatedEntityDiagnosticTests.cs new file mode 100644 index 00000000..6082bc62 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Diagnostics/BareRelatedEntityDiagnosticTests.cs @@ -0,0 +1,548 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.FluentDynamoDb.SourceGenerator.Diagnostics; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Diagnostics; + +/// +/// Unit tests for FDDB130–FDDB132 bare [RelatedEntity] inference diagnostics. +/// Validates Requirements 5.1, 5.2, 5.3, 5.4, 9.4. +/// +[Trait("Category", "Unit")] +public class BareRelatedEntityDiagnosticTests +{ + #region FDDB130 — Unresolved Entity Type + + /// + /// Bare [RelatedEntity] on List<UnknownType> where UnknownType is not a [DynamoDbTable] entity + /// should emit FDDB130 at Error severity. + /// + /// **Validates: Requirements 5.1, 9.4** + /// + [Fact] + public void BareRelatedEntity_WithUnknownType_ShouldEmitFDDB130() + { + // Arrange — UnknownType is a plain class, not annotated with [DynamoDbTable] + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + public class UnknownType + { + public string Id { get; set; } = string.Empty; + } + + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert + result.Diagnostics.Should().Contain(d => d.Id == "FDDB130", + "Should emit FDDB130 when bare [RelatedEntity] references a type that is not a [DynamoDbTable] entity"); + var diagnostic = result.Diagnostics.First(d => d.Id == "FDDB130"); + diagnostic.Severity.Should().Be(DiagnosticSeverity.Error); + diagnostic.GetMessage().Should().Contain("Lines"); + diagnostic.GetMessage().Should().Contain("Order"); + } + + /// + /// FDDB130 should cause the unresolvable relationship to be excluded from code generation. + /// The parent entity should still generate, but no composite mapping for the broken relationship. + /// + /// **Validates: Requirement 5.4** + /// + [Fact] + public void BareRelatedEntity_WithUnknownType_ShouldExcludeRelationshipFromCodeGeneration() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + public class UnknownType + { + public string Id { get; set; } = string.Empty; + } + + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — the parent entity should still generate code (basic entity), but + // the FDDB130 diagnostic confirms the relationship is excluded + result.Diagnostics.Should().Contain(d => d.Id == "FDDB130"); + } + + #endregion + + #region FDDB131 — Trivial Sort Key Pattern + + /// + /// Bare [RelatedEntity] on List<ChildEntity> where ChildEntity has a bare sort key + /// (no prefix, no computed structure — NormalizedKeyFormat is "{0}") should emit FDDB131 + /// at Error severity. + /// + /// **Validates: Requirements 5.2, 9.4** + /// + [Fact] + public void BareRelatedEntity_WithTrivialSortKey_ShouldEmitFDDB131() + { + // Arrange — OrderLine has a bare sort key with no prefix (NormalizedKeyFormat = "{0}") + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert + result.Diagnostics.Should().Contain(d => d.Id == "FDDB131", + "Should emit FDDB131 when child entity has a bare sort key with no distinguishing structure"); + var diagnostic = result.Diagnostics.First(d => d.Id == "FDDB131"); + diagnostic.Severity.Should().Be(DiagnosticSeverity.Error); + diagnostic.GetMessage().Should().Contain("Lines"); + diagnostic.GetMessage().Should().Contain("Order"); + diagnostic.GetMessage().Should().Contain("OrderLine"); + } + + /// + /// FDDB131 should cause the unresolvable relationship to be excluded from code generation. + /// + /// **Validates: Requirement 5.4** + /// + [Fact] + public void BareRelatedEntity_WithTrivialSortKey_ShouldExcludeRelationshipFromCodeGeneration() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — FDDB131 confirms the relationship is excluded + result.Diagnostics.Should().Contain(d => d.Id == "FDDB131"); + } + + #endregion + + #region FDDB132 — Table Mismatch + + /// + /// Bare [RelatedEntity] on List<Payment> where Payment is on a different table + /// should emit FDDB132 at Error severity. + /// + /// **Validates: Requirements 5.3, 9.4** + /// + [Fact] + public void BareRelatedEntity_WithTableMismatch_ShouldEmitFDDB132() + { + // Arrange — Order is on "orders" table, Payment is on "payments" table + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Payments { get; set; } + } + + [DynamoDbTable(""payments"")] + public partial class Payment + { + [PartitionKey(Prefix = ""PAY"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""TXN"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""amount"")] + public decimal Amount { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert + result.Diagnostics.Should().Contain(d => d.Id == "FDDB132", + "Should emit FDDB132 when child entity is on a different table than the parent"); + var diagnostic = result.Diagnostics.First(d => d.Id == "FDDB132"); + diagnostic.Severity.Should().Be(DiagnosticSeverity.Error); + diagnostic.GetMessage().Should().Contain("Payments"); + diagnostic.GetMessage().Should().Contain("Order"); + diagnostic.GetMessage().Should().Contain("orders"); + diagnostic.GetMessage().Should().Contain("Payment"); + diagnostic.GetMessage().Should().Contain("payments"); + } + + /// + /// FDDB132 should cause the unresolvable relationship to be excluded from code generation. + /// + /// **Validates: Requirement 5.4** + /// + [Fact] + public void BareRelatedEntity_WithTableMismatch_ShouldExcludeRelationshipFromCodeGeneration() + { + // Arrange + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Payments { get; set; } + } + + [DynamoDbTable(""payments"")] + public partial class Payment + { + [PartitionKey(Prefix = ""PAY"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""TXN"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""amount"")] + public decimal Amount { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — FDDB132 confirms the relationship is excluded + result.Diagnostics.Should().Contain(d => d.Id == "FDDB132"); + } + + #endregion + + #region Descriptor Property Tests + + [Fact] + public void FDDB130_Descriptor_ShouldHaveCorrectProperties() + { + var descriptor = DiagnosticDescriptors.BareRelatedEntityUnresolvedType; + + descriptor.Id.Should().Be("FDDB130"); + descriptor.DefaultSeverity.Should().Be(DiagnosticSeverity.Error); + descriptor.IsEnabledByDefault.Should().BeTrue(); + descriptor.Category.Should().Be("DynamoDb"); + } + + [Fact] + public void FDDB131_Descriptor_ShouldHaveCorrectProperties() + { + var descriptor = DiagnosticDescriptors.BareRelatedEntityTrivialSortKey; + + descriptor.Id.Should().Be("FDDB131"); + descriptor.DefaultSeverity.Should().Be(DiagnosticSeverity.Error); + descriptor.IsEnabledByDefault.Should().BeTrue(); + descriptor.Category.Should().Be("DynamoDb"); + } + + [Fact] + public void FDDB132_Descriptor_ShouldHaveCorrectProperties() + { + var descriptor = DiagnosticDescriptors.BareRelatedEntityTableMismatch; + + descriptor.Id.Should().Be("FDDB132"); + descriptor.DefaultSeverity.Should().Be(DiagnosticSeverity.Error); + descriptor.IsEnabledByDefault.Should().BeTrue(); + descriptor.Category.Should().Be("DynamoDb"); + } + + #endregion + + #region Negative Tests — Valid Cases Should NOT Emit Diagnostics + + [Fact] + public void ValidBareRelatedEntity_ShouldNotEmitAnyBareRelatedEntityDiagnostics() + { + // Arrange — a valid bare [RelatedEntity] with same table and prefixed sort key + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity] + public List? Lines { get; set; } + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132"); + } + + [Fact] + public void ExplicitRelatedEntity_ShouldNotEmitBareRelatedEntityDiagnostics() + { + // Arrange — explicit [RelatedEntity("LINE#*")] should never trigger FDDB130-132 + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""META"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderId"")] + public string OrderId { get; set; } = string.Empty; + + [RelatedEntity(""LINE#*"", EntityType = typeof(OrderLine))] + public List? Lines { get; set; } + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""LINE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""quantity"")] + public int Quantity { get; set; } + } +}"; + + // Act + var result = GenerateCode(source); + + // Assert — explicit patterns bypass the pattern resolution pass entirely + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB130"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB131"); + result.Diagnostics.Should().NotContain(d => d.Id == "FDDB132"); + } + + #endregion + + #region Helper Methods + + private static GeneratorTestResult GenerateCode(string source) + { + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { + CSharpSyntaxTree.ParseText(source), + CSharpSyntaxTree.ParseText("[assembly: Oproto.FluentDynamoDb.Attributes.FluentDynamoDbSchemaVersion(1, 0)]") + }, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new DynamoDbSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generatedSources = outputCompilation.SyntaxTrees + .Skip(compilation.SyntaxTrees.Count()) + .Select(tree => new GeneratedSource(tree.FilePath, tree.GetText())) + .ToArray(); + + return new GeneratorTestResult + { + Diagnostics = diagnostics, + GeneratedSources = generatedSources + }; + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityCompilationTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityCompilationTests.cs new file mode 100644 index 00000000..620d7ca5 --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityCompilationTests.cs @@ -0,0 +1,247 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; + +/// +/// Generated code compilation tests for bare [RelatedEntity] with various collection types. +/// These tests verify that the full source generator pipeline produces code that compiles +/// without errors when bare [RelatedEntity] is used on different property type forms. +/// +/// **Validates: Requirement 9.2** +/// +[Trait("Category", "Integration")] +public class BareRelatedEntityCompilationTests +{ + /// + /// Verifies that bare [RelatedEntity] on a List<T> property compiles + /// without errors when the child entity has a prefixed sort key on the same table. + /// This is the most common collection type for related entities. + /// + /// **Validates: Requirement 9.2** + /// + [Fact] + public void BareRelatedEntity_ListOfT_CompilesWithoutErrors() + { + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +[assembly: FluentDynamoDbSchemaVersion(1, 0)] + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""total"")] + public decimal Total { get; set; } + + [RelatedEntity] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""OrderId"", ""LineNumber"", Format = ""ORDER#{0}#LINE#{1}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string OrderId { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + + [DynamoDbAttribute(""amount"")] + public decimal Amount { get; set; } + } +}"; + + // Act + var compilation = CreateCompilationWithGenerator(source); + + // Assert + var errors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errors.Should().BeEmpty( + "Bare [RelatedEntity] on List should compile without errors. " + + $"Errors: {string.Join(", ", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}"); + } + + /// + /// Verifies that bare [RelatedEntity] on a List<T> property compiles + /// without errors when the child entity uses a simple prefixed sort key (no [Computed]). + /// This tests pattern inference from DerivedDiscriminatorPattern when the sort key + /// uses only a prefix, producing a pattern like TASK#*. + /// + /// Note: IList<T> is not used here because the MapperGenerator produces + /// List<object> which cannot be assigned to IList<T> — a + /// pre-existing source generator limitation unrelated to bare [RelatedEntity]. + /// + /// **Validates: Requirement 9.2** + /// + [Fact] + public void BareRelatedEntity_ListOfT_WithPrefixedSortKey_CompilesWithoutErrors() + { + var source = @" +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +[assembly: FluentDynamoDbSchemaVersion(1, 0)] + +namespace TestNamespace +{ + [DynamoDbTable(""projects"", IsDefault = true)] + public partial class Project + { + [PartitionKey(Prefix = ""ORG"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""PROJECT"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""name"")] + public string Name { get; set; } = string.Empty; + + [RelatedEntity] + public List Tasks { get; set; } = new(); + } + + [DynamoDbTable(""projects"")] + public partial class ProjectTask + { + [PartitionKey(Prefix = ""ORG"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""TASK"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""title"")] + public string Title { get; set; } = string.Empty; + } +}"; + + // Act + var compilation = CreateCompilationWithGenerator(source); + + // Assert + var errors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errors.Should().BeEmpty( + "Bare [RelatedEntity] on List with prefixed sort key should compile without errors. " + + $"Errors: {string.Join(", ", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}"); + } + + /// + /// Verifies that bare [RelatedEntity] on a nullable non-collection property + /// (ChildEntity?) compiles without errors. The source generator should unwrap + /// the nullable annotation and use the underlying type as the child entity type. + /// + /// **Validates: Requirement 9.2** + /// + [Fact] + public void BareRelatedEntity_NullableT_CompilesWithoutErrors() + { + var source = @" +using Oproto.FluentDynamoDb.Attributes; + +[assembly: FluentDynamoDbSchemaVersion(1, 0)] + +namespace TestNamespace +{ + [DynamoDbTable(""accounts"", IsDefault = true)] + public partial class Account + { + [PartitionKey(Prefix = ""TENANT"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""ACCOUNT"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""name"")] + public string Name { get; set; } = string.Empty; + + [RelatedEntity] + public AccountSettings? Settings { get; set; } + } + + [DynamoDbTable(""accounts"")] + public partial class AccountSettings + { + [PartitionKey(Prefix = ""TENANT"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""SETTINGS"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""theme"")] + public string Theme { get; set; } = string.Empty; + + [DynamoDbAttribute(""timezone"")] + public string Timezone { get; set; } = string.Empty; + } +}"; + + // Act + var compilation = CreateCompilationWithGenerator(source); + + // Assert + var errors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errors.Should().BeEmpty( + "Bare [RelatedEntity] on AccountSettings? (nullable non-collection) should compile without errors. " + + $"Errors: {string.Join(", ", errors.Select(e => $"{e.Id}: {e.GetMessage()}"))}"); + } + + #region Helper Methods + + private static Compilation CreateCompilationWithGenerator(string source) + { + var compilation = CSharpCompilation.Create( + $"BareRelatedEntityCompilationTest_{Guid.NewGuid():N}", + new[] { CSharpSyntaxTree.ParseText(source) }, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + var generator = new DynamoDbSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + + return outputCompilation; + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityIntegrationTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityIntegrationTests.cs new file mode 100644 index 00000000..bb12f46a --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Integration/BareRelatedEntityIntegrationTests.cs @@ -0,0 +1,480 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Amazon.DynamoDBv2.Model; +using Oproto.FluentDynamoDb.SourceGenerator.UnitTests.TestHelpers; + +namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Integration; + +/// +/// Full pipeline integration tests for bare [RelatedEntity] inference. +/// These tests compile entities through the source generator, load the assembly, +/// and invoke generated methods via reflection to verify composite entity assembly +/// works correctly when the sort key pattern is inferred from child entity metadata. +/// +/// Requirements: 9.2, 9.3, 9.6 +/// +[Trait("Category", "Integration")] +[SuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "Source generator integration tests require dynamic assembly loading")] +public class BareRelatedEntityIntegrationTests +{ + #region Entity Source Templates + + /// + /// Invoice parent entity with bare [RelatedEntity] on List<InvoiceLine> and + /// InvoiceLine child entity with [Computed]-based sort key, both on the same table. + /// The parent uses bare [RelatedEntity] (no explicit pattern) so the generator must + /// infer the pattern from InvoiceLine's DerivedDiscriminatorPattern. + /// + private const string BareRelatedEntityInvoiceSource = @" +using System; +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +[assembly: FluentDynamoDbSchemaVersion(1, 0)] + +namespace TestNamespace +{ + [DynamoDbTable(""invoices"", IsDefault = true)] + public partial class Invoice + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""INVOICE"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""invoiceNumber"")] + public string InvoiceNumber { get; set; } = string.Empty; + + [DynamoDbAttribute(""total"")] + public decimal Total { get; set; } + + // Bare [RelatedEntity] — pattern inferred from InvoiceLine's DerivedDiscriminatorPattern + [RelatedEntity] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""invoices"")] + public partial class InvoiceLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""INVOICE#{InvoiceNumber}#LINE#{LineNumber}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string InvoiceNumber { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + + [DynamoDbAttribute(""amount"")] + public decimal Amount { get; set; } + } +}"; + + /// + /// Source using various collection types for bare [RelatedEntity] to verify + /// the generator infers the entity type from generic type arguments correctly. + /// + private const string BareRelatedEntityVariousCollectionTypesSource = @" +using System; +using System.Collections.Generic; +using Oproto.FluentDynamoDb.Attributes; + +[assembly: FluentDynamoDbSchemaVersion(1, 0)] + +namespace TestNamespace +{ + [DynamoDbTable(""orders"", IsDefault = true)] + public partial class Order + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = ""ORDER"")] + [DynamoDbAttribute(""sk"")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute(""orderNumber"")] + public string OrderNumber { get; set; } = string.Empty; + + // Bare [RelatedEntity] on List + [RelatedEntity] + public List Lines { get; set; } = new(); + } + + [DynamoDbTable(""orders"")] + public partial class OrderLine + { + [PartitionKey(Prefix = ""CUSTOMER"")] + [DynamoDbAttribute(""pk"")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute(""sk"")] + [Computed(""ORDER#{OrderNumber}#LINE#{LineNumber}"")] + public string Sk { get; set; } = string.Empty; + + [Extracted(""Sk"", 0)] + public string OrderNumber { get; set; } = string.Empty; + + [Extracted(""Sk"", 1)] + public int LineNumber { get; set; } + + [DynamoDbAttribute(""price"")] + public decimal Price { get; set; } + } +}"; + + #endregion + + #region Helper Methods + + /// + /// Compiles entity source through the full source generator pipeline and loads the assembly. + /// + private static DynamicCompilationResult CompileAndLoadEntity(string source) + { + return DynamicCompilationHelper.CompileAndLoad( + source, + DynamicCompilationHelper.GetFluentDynamoDbReferences(), + new DynamoDbSourceGenerator()); + } + + /// + /// Creates a DynamoDB item dictionary from string attribute key-value pairs. + /// + private static Dictionary CreateStringItem( + params (string key, string value)[] attributes) + { + var item = new Dictionary(); + foreach (var (key, value) in attributes) + item[key] = new AttributeValue { S = value }; + return item; + } + + /// + /// Creates a DynamoDB item dictionary supporting mixed attribute types. + /// + private static Dictionary CreateItem( + params (string key, AttributeValue value)[] attributes) + { + var item = new Dictionary(); + foreach (var (key, value) in attributes) + item[key] = value; + return item; + } + + /// + /// Invokes the generated static FromDynamoDb<TSelf>(IList<...>, options) + /// method via reflection — the multi-item overload used for composite entity assembly. + /// + private static object InvokeFromDynamoDbMultiItem( + Type entityType, + IList> items) + { + // Find the FromDynamoDb overload that takes IList> + var method = entityType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "FromDynamoDb" && m.IsGenericMethod) + .FirstOrDefault(m => + { + var parameters = m.GetParameters(); + if (parameters.Length < 1) return false; + var firstParam = parameters[0].ParameterType; + // Match IList> + return firstParam.IsGenericType + && firstParam.GetGenericTypeDefinition() == typeof(IList<>); + }) + ?? throw new InvalidOperationException( + $"FromDynamoDb(IList<...>) method not found on type '{entityType.Name}'. " + + "Ensure the source generator produced the expected multi-item overload."); + + var genericMethod = method.MakeGenericMethod(entityType); + return genericMethod.Invoke(null, new object?[] { items, null })!; + } + + /// + /// Invokes the generated static MatchesEntity method via reflection. + /// + private static bool InvokeMatchesEntity(Type entityType, Dictionary item) + { + var method = entityType.GetMethod("MatchesEntity", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException( + $"MatchesEntity method not found on type '{entityType.Name}'."); + return (bool)method.Invoke(null, new object[] { item })!; + } + + /// + /// Gets the count of items in a collection property obtained via reflection. + /// + private static int GetCollectionCount(object instance, string propertyName) + { + var collection = DynamicCompilationHelper.GetProperty(instance, propertyName); + if (collection == null) + return 0; + + if (collection is ICollection c) + return c.Count; + + // Fallback: use Count property via reflection + var countProp = collection.GetType().GetProperty("Count"); + return countProp != null + ? (int)countProp.GetValue(collection)! + : throw new InvalidOperationException( + $"Property '{propertyName}' does not have a Count property."); + } + + /// + /// Gets an item from a collection property at a specific index via reflection. + /// + private static object GetCollectionItem(object instance, string propertyName, int index) + { + var collection = DynamicCompilationHelper.GetProperty(instance, propertyName); + if (collection == null) + throw new InvalidOperationException($"Property '{propertyName}' is null."); + + if (collection is IList list) + return list[index]!; + + // Fallback: use indexer via reflection + var indexer = collection.GetType().GetProperty("Item"); + return indexer != null + ? indexer.GetValue(collection, new object[] { index })! + : throw new InvalidOperationException( + $"Property '{propertyName}' does not support indexed access."); + } + + #endregion + + #region Test 1: Full Pipeline — Bare RelatedEntity Composite Entity Assembly + + /// + /// Full pipeline integration test: define parent entity with bare [RelatedEntity] on + /// List<InvoiceLine> and child InvoiceLine with [Computed]-based sort key on the + /// same table, run the source generator, compile via DynamicCompilationHelper, + /// invoke FromDynamoDb with a multi-item list (1 parent + 2 children), and verify the + /// returned parent entity's Lines collection contains exactly the 2 child items. + /// + /// Requirements: 9.2, 9.3, 9.6 + /// + [Fact] + public void BareRelatedEntity_FullPipeline_CompositeEntityAssemblyPopulatesChildCollection() + { + // Arrange — compile entities with bare [RelatedEntity] + var result = CompileAndLoadEntity(BareRelatedEntityInvoiceSource); + + // Verify zero error diagnostics in compilation result + var errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty( + "compilation of entities with bare [RelatedEntity] should produce zero error diagnostics"); + + var emitErrors = result.EmitDiagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + emitErrors.Should().BeEmpty( + "emit of entities with bare [RelatedEntity] should produce zero error diagnostics"); + + // Get the compiled entity types + var invoiceType = result.Assembly.GetType("TestNamespace.Invoice") + ?? throw new InvalidOperationException("Invoice type not found in compiled assembly"); + var invoiceLineType = result.Assembly.GetType("TestNamespace.InvoiceLine") + ?? throw new InvalidOperationException("InvoiceLine type not found in compiled assembly"); + + // Create mock DynamoDB items: 1 parent + 2 children + var parentItem = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-001" }), + ("invoiceNumber", new AttributeValue { S = "INV-001" }), + ("total", new AttributeValue { N = "149.98" })); + + var childItem1 = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-001#LINE#1" }), + ("amount", new AttributeValue { N = "99.99" })); + + var childItem2 = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-001#LINE#2" }), + ("amount", new AttributeValue { N = "49.99" })); + + var allItems = new List> + { + parentItem, childItem1, childItem2 + }; + + // Act — invoke multi-item FromDynamoDb to perform composite entity assembly + var invoice = InvokeFromDynamoDbMultiItem(invoiceType, allItems); + + // Assert — parent entity properties are populated + DynamicCompilationHelper.GetProperty(invoice, "Pk").Should().Be("CUSTOMER#C1"); + DynamicCompilationHelper.GetProperty(invoice, "Sk").Should().Be("INVOICE#INV-001"); + DynamicCompilationHelper.GetProperty(invoice, "InvoiceNumber").Should().Be("INV-001"); + + // Assert — Lines collection contains exactly 2 child items + var linesCount = GetCollectionCount(invoice, "Lines"); + linesCount.Should().Be(2, + "the Lines collection should contain exactly the 2 child items " + + "whose sort keys match InvoiceLine's DerivedDiscriminatorPattern"); + + // Assert — child item properties are correctly populated + var line1 = GetCollectionItem(invoice, "Lines", 0); + var line2 = GetCollectionItem(invoice, "Lines", 1); + + // Verify the child items have correct amounts (order may vary) + var amounts = new[] + { + (decimal)DynamicCompilationHelper.GetProperty(line1, "Amount")!, + (decimal)DynamicCompilationHelper.GetProperty(line2, "Amount")! + }; + amounts.Should().BeEquivalentTo(new[] { 99.99m, 49.99m }, + "both child items should have their amounts correctly deserialized"); + + var lineNumbers = new[] + { + (int)DynamicCompilationHelper.GetProperty(line1, "LineNumber")!, + (int)DynamicCompilationHelper.GetProperty(line2, "LineNumber")! + }; + lineNumbers.Should().BeEquivalentTo(new[] { 1, 2 }, + "both child items should have their line numbers correctly deserialized"); + } + + #endregion + + #region Test 2: MatchesEntity Discrimination with Bare RelatedEntity + + /// + /// Verifies that MatchesEntity correctly discriminates parent items from child items + /// when the parent uses bare [RelatedEntity]. The parent's MatchesEntity exclusion + /// conditions should use the inferred pattern identically to an explicit pattern. + /// + /// Requirements: 9.3, 9.6 + /// + [Fact] + public void BareRelatedEntity_MatchesEntity_CorrectlyDiscriminatesParentAndChildItems() + { + // Arrange + var result = CompileAndLoadEntity(BareRelatedEntityInvoiceSource); + + var invoiceType = result.Assembly.GetType("TestNamespace.Invoice")!; + var invoiceLineType = result.Assembly.GetType("TestNamespace.InvoiceLine")!; + + var parentItem = CreateStringItem( + ("pk", "CUSTOMER#C1"), ("sk", "INVOICE#INV-001")); + + var childItem = CreateStringItem( + ("pk", "CUSTOMER#C1"), ("sk", "INVOICE#INV-001#LINE#1")); + + // Act & Assert — parent entity matches parent item, not child item + InvokeMatchesEntity(invoiceType, parentItem).Should().BeTrue( + "Invoice.MatchesEntity should match items with SK prefix 'INVOICE#' " + + "that do NOT match the child pattern"); + InvokeMatchesEntity(invoiceType, childItem).Should().BeFalse( + "Invoice.MatchesEntity should NOT match items matching the child pattern " + + "'INVOICE#*#LINE#*' — the inferred pattern should be used as exclusion"); + + // Act & Assert — child entity matches child item, not parent item + InvokeMatchesEntity(invoiceLineType, childItem).Should().BeTrue( + "InvoiceLine.MatchesEntity should match items with SK matching 'INVOICE#*#LINE#*'"); + InvokeMatchesEntity(invoiceLineType, parentItem).Should().BeFalse( + "InvoiceLine.MatchesEntity should NOT match parent items with SK 'INVOICE#INV-001'"); + } + + #endregion + + #region Test 3: Compilation — Various Collection Types with Bare RelatedEntity + + /// + /// Verifies that bare [RelatedEntity] on a List<T> property compiles successfully + /// through the full source generator pipeline with zero error diagnostics. + /// + /// Requirements: 9.2 + /// + [Fact] + public void BareRelatedEntity_ListCollection_CompilesWithZeroErrors() + { + // Arrange & Act + var result = CompileAndLoadEntity(BareRelatedEntityVariousCollectionTypesSource); + + // Assert — zero error diagnostics + var errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty( + "bare [RelatedEntity] on List should compile without errors"); + + var emitErrors = result.EmitDiagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + emitErrors.Should().BeEmpty( + "emit should produce zero error diagnostics"); + + // Verify both types are loaded successfully + var orderType = result.Assembly.GetType("TestNamespace.Order"); + orderType.Should().NotBeNull("Order type should be present in compiled assembly"); + + var orderLineType = result.Assembly.GetType("TestNamespace.OrderLine"); + orderLineType.Should().NotBeNull("OrderLine type should be present in compiled assembly"); + } + + #endregion + + #region Test 4: Composite Assembly with Three Child Items + + /// + /// Verifies that composite entity assembly works with more than 2 child items, + /// and that items with non-matching sort keys are excluded from the collection. + /// + /// Requirements: 9.3, 9.6 + /// + [Fact] + public void BareRelatedEntity_MultipleChildItems_OnlyMatchingItemsPopulated() + { + // Arrange + var result = CompileAndLoadEntity(BareRelatedEntityInvoiceSource); + var invoiceType = result.Assembly.GetType("TestNamespace.Invoice")!; + + // 1 parent + 3 matching children + 1 unrelated item + var parentItem = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-002" }), + ("invoiceNumber", new AttributeValue { S = "INV-002" }), + ("total", new AttributeValue { N = "300.00" })); + + var child1 = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-002#LINE#1" }), + ("amount", new AttributeValue { N = "100.00" })); + + var child2 = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-002#LINE#2" }), + ("amount", new AttributeValue { N = "100.00" })); + + var child3 = CreateItem( + ("pk", new AttributeValue { S = "CUSTOMER#C1" }), + ("sk", new AttributeValue { S = "INVOICE#INV-002#LINE#3" }), + ("amount", new AttributeValue { N = "100.00" })); + + var allItems = new List> + { + parentItem, child1, child2, child3 + }; + + // Act + var invoice = InvokeFromDynamoDbMultiItem(invoiceType, allItems); + + // Assert — Lines collection contains exactly 3 child items + GetCollectionCount(invoice, "Lines").Should().Be(3, + "all 3 child items with matching sort key patterns should be populated"); + } + + #endregion +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Models/RelationshipModelTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Models/RelationshipModelTests.cs index 4122b7f2..5f322f5b 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Models/RelationshipModelTests.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Models/RelationshipModelTests.cs @@ -109,7 +109,7 @@ public void RelationshipModel_DefaultValues_ShouldBeCorrect() // Assert relationship.PropertyName.Should().Be(string.Empty); - relationship.SortKeyPattern.Should().Be(string.Empty); + relationship.SortKeyPattern.Should().BeNull(); relationship.EntityType.Should().BeNull(); relationship.IsCollection.Should().BeFalse(); relationship.PropertyType.Should().Be(string.Empty); diff --git a/Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs b/Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs index 4a742ff3..3c3771ce 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator/Analysis/EntityAnalyzer.cs @@ -1553,15 +1553,46 @@ private void ExtractRelationships(TypeDeclarationSyntax typeDecl, SemanticModel { relationshipModel.EntityType = typeOfExpr.Type.ToString(); + // Set ResolvedEntityType from explicit EntityType regardless of bare vs explicit form + var entityTypeInfo = semanticModel.GetTypeInfo(typeOfExpr.Type); + if (entityTypeInfo.Type != null) + { + relationshipModel.ResolvedEntityType = entityTypeInfo.Type.ToDisplayString(); + } + else + { + relationshipModel.ResolvedEntityType = typeOfExpr.Type.ToString(); + } + // Check if the child entity type has its own [RelatedEntity] relationships - var childEntityTypeInfo = semanticModel.GetTypeInfo(typeOfExpr.Type); - if (childEntityTypeInfo.Type is INamedTypeSymbol childTypeSymbol) + if (entityTypeInfo.Type is INamedTypeSymbol childTypeSymbol) { var childRelationships = ExtractChildEntityRelationships(childTypeSymbol, semanticModel); relationshipModel.ChildEntityHasRelationships = childRelationships.Length > 0; relationshipModel.ChildEntityRelationships = childRelationships; } } + else + { + // No explicit EntityType — determine if this is a bare [RelatedEntity] + // Bare form: no positional constructor arguments (all arguments are named, or no arguments at all) + var hasPositionalArg = relatedEntityAttr.ArgumentList?.Arguments + .Any(arg => arg.NameEquals == null) ?? false; + var isBareForm = !hasPositionalArg; + + if (isBareForm) + { + // Infer entity type from property type for bare [RelatedEntity] + var inferredType = InferEntityTypeFromProperty(propertySymbol, entityModel.ClassName); + relationshipModel.ResolvedEntityType = inferredType; + + // If inference failed (e.g., non-generic collection), the diagnostic has been reported. + // The relationship will still be added, but with null ResolvedEntityType. + // The PatternResolutionPass will handle it during deferred resolution. + } + // For explicit [RelatedEntity("pattern")] without EntityType, don't infer from property type + // (Requirement 2.5: existing entity type extraction logic unchanged) + } relationships.Add(relationshipModel); } @@ -2150,6 +2181,71 @@ private bool IsCollectionType(ITypeSymbol type) i.ConstructedFrom.ToDisplayString() == "System.Collections.Generic.IEnumerable"); } + /// + /// Infers the child entity type from the property's declared type for bare [RelatedEntity] attributes. + /// Extracts T from List<T>, IList<T>, ICollection<T>, IEnumerable<T>, + /// unwraps nullable T?, and falls back to direct type T for non-collection, non-nullable types. + /// + /// The property symbol to extract the entity type from. + /// The parent entity class name, used for diagnostic reporting. + /// The fully qualified display string of the inferred child entity type, or null if inference fails. + private string? InferEntityTypeFromProperty(IPropertySymbol propertySymbol, string entityClassName) + { + var type = propertySymbol.Type; + + // Skip string — it implements IEnumerable but is not a collection + if (type.SpecialType == SpecialType.System_String) + return type.ToDisplayString(); + + // Check for generic collection types implementing IEnumerable + if (type is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0) + { + // Direct generic type (List, IList, ICollection, IEnumerable) + var hasGenericEnumerable = namedType.AllInterfaces.Any(i => + i.IsGenericType && + i.ConstructedFrom.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + + if (hasGenericEnumerable) + { + return namedType.TypeArguments[0].ToDisplayString(); + } + } + else if (type is INamedTypeSymbol nonGenericNamed && !nonGenericNamed.IsGenericType) + { + // Check if it's a non-generic type that implements non-generic IEnumerable + // (e.g., ArrayList, raw IEnumerable) — cannot infer element type + var implementsNonGenericEnumerable = nonGenericNamed.AllInterfaces.Any(i => + !i.IsGenericType && + i.ToDisplayString() == "System.Collections.IEnumerable"); + + // Also check if the type itself IS IEnumerable (non-generic) + if (implementsNonGenericEnumerable || nonGenericNamed.ToDisplayString() == "System.Collections.IEnumerable") + { + ReportDiagnostic( + DiagnosticDescriptors.BareRelatedEntityNonGenericCollection, + propertySymbol.Locations.FirstOrDefault(), + propertySymbol.Name, + entityClassName, + type.ToDisplayString()); + return null; + } + } + + // Check for nullable reference type T? — unwrap the nullable annotation + if (type.NullableAnnotation == NullableAnnotation.Annotated) + { + // For nullable reference types, the underlying type is the OriginalDefinition + // but we need to get the type without the nullable annotation + if (type is INamedTypeSymbol nullableNamed) + { + return nullableNamed.WithNullableAnnotation(NullableAnnotation.None).ToDisplayString(); + } + } + + // Non-nullable, non-collection type — use directly + return type.ToDisplayString(); + } + private bool IsSupportedPropertyType(string typeName) { // Basic type support - this will be expanded in later tasks @@ -2311,13 +2407,15 @@ private void ValidateRelatedEntityConfiguration(EntityModel entityModel) // Check for complex relationship patterns that may impact scalability ValidateRelationshipComplexity(entityModel); - // Check for conflicting patterns + // Check for conflicting patterns (skip null patterns from bare [RelatedEntity] not yet resolved) var patterns = entityModel.Relationships.Select(r => r.SortKeyPattern).ToArray(); for (int i = 0; i < patterns.Length; i++) { + if (patterns[i] == null) continue; for (int j = i + 1; j < patterns.Length; j++) { - if (PatternsConflict(patterns[i], patterns[j])) + if (patterns[j] == null) continue; + if (PatternsConflict(patterns[i]!, patterns[j]!)) { ReportDiagnostic(DiagnosticDescriptors.ConflictingRelatedEntityPatterns, entityModel.ClassDeclaration?.Identifier.GetLocation(), @@ -2335,6 +2433,11 @@ private void ValidateRelatedEntityConfiguration(EntityModel entityModel) private void ValidateRelationshipModel(RelationshipModel relationship, EntityModel entityModel) { + // Skip validation for bare [RelatedEntity] with null SortKeyPattern — pattern will be resolved later + // by PatternResolutionPass, and re-validation will run after resolution + if (relationship.SortKeyPattern == null) + return; + // Check for ambiguous patterns if (relationship.SortKeyPattern == "*" || string.IsNullOrWhiteSpace(relationship.SortKeyPattern)) { @@ -2381,7 +2484,7 @@ private void ValidateRelationshipComplexity(EntityModel entityModel) } // Check for wildcard patterns that may be inefficient - var wildcardPatterns = relationships.Where(r => r.SortKeyPattern.Contains("*")).ToArray(); + var wildcardPatterns = relationships.Where(r => r.SortKeyPattern?.Contains("*") == true).ToArray(); if (wildcardPatterns.Length >= 2) { ReportDiagnostic(DiagnosticDescriptors.ScalabilityWarning, diff --git a/Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs b/Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs new file mode 100644 index 00000000..97b66d1f --- /dev/null +++ b/Oproto.FluentDynamoDb.SourceGenerator/Analysis/PatternResolutionPass.cs @@ -0,0 +1,237 @@ +using Microsoft.CodeAnalysis; +using Oproto.FluentDynamoDb.SourceGenerator.Diagnostics; +using Oproto.FluentDynamoDb.SourceGenerator.Models; + +namespace Oproto.FluentDynamoDb.SourceGenerator.Analysis; + +/// +/// Resolves bare [RelatedEntity] patterns by looking up child entity metadata +/// within the same table group. This pass runs after all entities have been +/// individually analyzed and grouped by table name, but before overlap analysis +/// and code generation. +/// +internal static class PatternResolutionPass +{ + /// + /// Resolves bare [RelatedEntity] patterns by looking up child entity metadata + /// within the same table group. Modifies + /// in place and returns diagnostics for unresolvable patterns. + /// + /// All entities grouped by table name. + /// List of diagnostics to report. + public static List Resolve(Dictionary> entitiesByTable) + { + var diagnostics = new List(); + + foreach (var tableGroup in entitiesByTable) + { + var tableName = tableGroup.Key; + var tableEntities = tableGroup.Value; + + foreach (var entity in tableEntities) + { + foreach (var relationship in entity.Relationships) + { + // Skip relationships with explicit patterns (already resolved) + if (!string.IsNullOrEmpty(relationship.SortKeyPattern)) + continue; + + ResolveRelationship(entity, relationship, tableName, tableEntities, entitiesByTable, diagnostics); + } + } + } + + return diagnostics; + } + + /// + /// Resolves a single bare [RelatedEntity] relationship by finding the child entity + /// in the same table group and copying its DerivedDiscriminatorPattern. + /// + private static void ResolveRelationship( + EntityModel parent, + RelationshipModel relationship, + string parentTableName, + List sameTableEntities, + Dictionary> allEntitiesByTable, + List diagnostics) + { + // Step 1: Resolve child entity type name + var childTypeName = ResolveChildTypeName(relationship); + if (string.IsNullOrEmpty(childTypeName)) + { + // Cannot determine child type — emit FDDB130 with empty type name + diagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.BareRelatedEntityUnresolvedType, + Location.None, + relationship.PropertyName, + parent.ClassName, + "(unknown)")); + return; + } + + // Step 2: Search for child entity in same table group + var childEntity = FindEntityByClassName(sameTableEntities, childTypeName); + + if (childEntity == null) + { + // Step 2b: Check if child exists in a different table group + var (foundInOtherTable, otherTableName) = FindEntityInOtherTables( + allEntitiesByTable, parentTableName, childTypeName); + + if (foundInOtherTable) + { + // Step 4: Found in different table → emit FDDB132 + diagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.BareRelatedEntityTableMismatch, + Location.None, + relationship.PropertyName, + parent.ClassName, + parentTableName, + childTypeName, + otherTableName!)); + } + else + { + // Step 3: Not found anywhere → emit FDDB130 + diagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.BareRelatedEntityUnresolvedType, + Location.None, + relationship.PropertyName, + parent.ClassName, + childTypeName)); + } + + return; + } + + // Step 5: Check child entity's sort key DerivedDiscriminatorPattern + var sortKeyProperty = childEntity.SortKeyProperty; + if (sortKeyProperty == null || sortKeyProperty.DerivedDiscriminatorPattern == null) + { + // Trivial sort key (NormalizedKeyFormat is "{0}") or no sort key → emit FDDB131 + diagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.BareRelatedEntityTrivialSortKey, + Location.None, + relationship.PropertyName, + parent.ClassName, + childTypeName)); + return; + } + + // Step 6: Success — resolve the pattern + relationship.SortKeyPattern = sortKeyProperty.DerivedDiscriminatorPattern; + relationship.IsPatternInferred = true; + + if (string.IsNullOrWhiteSpace(relationship.EntityType)) + { + relationship.EntityType = childEntity.ClassName; + } + } + + /// + /// Resolves the child entity type name from the relationship model, + /// trying EntityType, then ResolvedEntityType, then parsing PropertyType. + /// + private static string? ResolveChildTypeName(RelationshipModel relationship) + { + // Priority 1: Explicit EntityType (from named argument) + if (!string.IsNullOrWhiteSpace(relationship.EntityType)) + return ExtractSimpleTypeName(relationship.EntityType); + + // Priority 2: ResolvedEntityType (from property type inference in EntityAnalyzer) + if (!string.IsNullOrWhiteSpace(relationship.ResolvedEntityType)) + return ExtractSimpleTypeName(relationship.ResolvedEntityType); + + // Priority 3: Parse from PropertyType string as fallback + if (!string.IsNullOrWhiteSpace(relationship.PropertyType)) + return ParseTypeNameFromPropertyType(relationship.PropertyType); + + return null; + } + + /// + /// Extracts the simple (unqualified) type name from a potentially fully-qualified type name. + /// For example, "MyNamespace.OrderLine" → "OrderLine". + /// + private static string ExtractSimpleTypeName(string? typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + return string.Empty; + + // Strip nullable suffix + var name = typeName.TrimEnd('?'); + + // Handle fully qualified names — take the last segment + var lastDot = name.LastIndexOf('.'); + if (lastDot >= 0) + name = name.Substring(lastDot + 1); + + return name; + } + + /// + /// Parses a child entity type name from a PropertyType string. + /// Handles patterns like: + /// "List<OrderLine>" or "System.Collections.Generic.List<OrderLine>" → "OrderLine" + /// "OrderLine?" → "OrderLine" + /// "OrderLine" → "OrderLine" + /// + private static string? ParseTypeNameFromPropertyType(string propertyType) + { + if (string.IsNullOrWhiteSpace(propertyType)) + return null; + + var type = propertyType.Trim(); + + // Check for generic type: extract the type argument between < and > + var angleBracketStart = type.IndexOf('<'); + if (angleBracketStart >= 0) + { + var angleBracketEnd = type.LastIndexOf('>'); + if (angleBracketEnd > angleBracketStart) + { + var typeArgument = type.Substring(angleBracketStart + 1, angleBracketEnd - angleBracketStart - 1).Trim(); + return ExtractSimpleTypeName(typeArgument.TrimEnd('?')); + } + } + + // Strip nullable suffix + type = type.TrimEnd('?'); + + // Extract simple name + return ExtractSimpleTypeName(type); + } + + /// + /// Finds an EntityModel by ClassName within a list of entities. + /// Matches on simple class name (not fully qualified). + /// + private static EntityModel? FindEntityByClassName(List entities, string className) + { + return entities.FirstOrDefault(e => + string.Equals(e.ClassName, className, StringComparison.Ordinal)); + } + + /// + /// Searches all table groups except the specified one for an entity with the given class name. + /// Returns whether it was found and in which table. + /// + private static (bool Found, string? TableName) FindEntityInOtherTables( + Dictionary> allEntitiesByTable, + string excludeTableName, + string className) + { + foreach (var tableGroup in allEntitiesByTable) + { + if (string.Equals(tableGroup.Key, excludeTableName, StringComparison.Ordinal)) + continue; + + var match = FindEntityByClassName(tableGroup.Value, className); + if (match != null) + return (true, tableGroup.Key); + } + + return (false, null); + } +} diff --git a/Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 8235ca35..b1082660 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -1667,4 +1667,66 @@ internal static class DiagnosticDescriptors "References to static readonly fields, properties, or method calls cannot be resolved at compile time and will produce uncompilable generated code. " + "Use a string literal (e.g., => \"VALUE\") or a const field (e.g., => MyConstants.Value where Value is const) instead.", helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB126")); + + // Bare RelatedEntity Inference Diagnostics (FDDB130-FDDB132) + + /// + /// Error when a bare [RelatedEntity] references a type that is not a known [DynamoDbTable] entity. + /// + public static readonly DiagnosticDescriptor BareRelatedEntityUnresolvedType = new( + "FDDB130", + "Bare RelatedEntity unresolved entity type", + "[RelatedEntity] on property '{0}' in entity '{1}' references type '{2}' which is not a known [DynamoDbTable] entity. Specify EntityType explicitly or provide an explicit pattern string.", + "DynamoDb", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A bare [RelatedEntity] attribute infers the child entity type from the property's generic type argument. " + + "The resolved type must be a class annotated with [DynamoDbTable] in the same table group. " + + "If the type is not a DynamoDB entity, specify EntityType explicitly or provide an explicit pattern string as the constructor argument.", + helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB130")); + + /// + /// Error when a bare [RelatedEntity] cannot infer a pattern because the child entity has a trivial sort key. + /// + public static readonly DiagnosticDescriptor BareRelatedEntityTrivialSortKey = new( + "FDDB131", + "Bare RelatedEntity trivial sort key pattern", + "[RelatedEntity] on property '{0}' in entity '{1}' cannot infer a matching pattern because child entity '{2}' has a bare sort key with no distinguishing structure (NormalizedKeyFormat is '{{0}}'). Provide an explicit pattern string.", + "DynamoDb", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A bare [RelatedEntity] attribute infers the sort key matching pattern from the child entity's DerivedDiscriminatorPattern. " + + "When the child entity's sort key has no prefix or computed structure (NormalizedKeyFormat is '{0}'), " + + "there is no distinguishing pattern to infer. Provide an explicit pattern string as the constructor argument.", + helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB131")); + + /// + /// Error when a bare [RelatedEntity] references a child entity on a different table. + /// + public static readonly DiagnosticDescriptor BareRelatedEntityTableMismatch = new( + "FDDB132", + "Bare RelatedEntity table mismatch", + "[RelatedEntity] on property '{0}' in entity '{1}' (table '{2}') references child entity '{3}' which is on table '{4}'. Related entities must share the same DynamoDB table.", + "DynamoDb", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A bare [RelatedEntity] attribute resolves the child entity within the same table group. " + + "If the child entity is declared on a different DynamoDB table, the relationship cannot be resolved. " + + "Related entities used in composite entity assembly must share the same DynamoDB table.", + helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB132")); + + /// + /// Error when a bare [RelatedEntity] is on a non-generic collection property whose element type cannot be inferred. + /// + public static readonly DiagnosticDescriptor BareRelatedEntityNonGenericCollection = new( + "FDDB133", + "Bare RelatedEntity non-generic collection type", + "[RelatedEntity] on property '{0}' in entity '{1}' uses a non-generic collection type '{2}'. The element type cannot be inferred. Specify EntityType explicitly or use a generic collection type such as List.", + "DynamoDb", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A bare [RelatedEntity] attribute infers the child entity type from the property's generic type argument. " + + "Non-generic collection types such as ArrayList or raw IEnumerable do not provide a type argument for inference. " + + "Use a generic collection type (e.g., List) or specify EntityType explicitly on the attribute.", + helpLinkUri: string.Format(DiagnosticHelpLinks.BaseUrlFormat, "FDDB133")); } \ No newline at end of file diff --git a/Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs b/Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs index e4c23aee..a67a9241 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator/DynamoDbSourceGenerator.cs @@ -199,6 +199,25 @@ private static void Execute( ValidateTableNamespaceConsistency(tableGroup.Value, context); } + // Pattern Resolution Pass: resolve bare [RelatedEntity] patterns by looking up + // child entity metadata within each table group. Must run AFTER all entities are + // analyzed and grouped, but BEFORE overlap analysis and code generation. + var patternResolutionDiagnostics = PatternResolutionPass.Resolve(entitiesByTable); + foreach (var diagnostic in patternResolutionDiagnostics) + { + context.ReportDiagnostic(diagnostic); + } + + // Exclude failed relationships: remove relationships where SortKeyPattern is still + // null after resolution. This prevents MapperGenerator from generating code for + // unresolvable bare [RelatedEntity] relationships (Requirement 5.4). + ExcludeFailedRelationships(entitiesByTable); + + // Re-validate resolved relationships: for entities that had bare [RelatedEntity] + // patterns resolved, run validation to catch ambiguous or conflicting patterns + // introduced by the inferred values (Requirements 8.1, 8.2, 8.3). + RevalidateResolvedRelationships(entitiesByTable, context); + // Overlap analysis pass: detect discriminator pattern overlaps within each table group // IMPORTANT: This must run BEFORE entity code generation so that OverlappingPatterns // is populated when GenerateDiscriminatorCheck emits exclusion guards. @@ -650,4 +669,102 @@ private static (string, string) OrderPairForFiltering(string nameA, string nameB : (nameB, nameA); } + /// + /// Removes relationships from entity models where + /// is still null after the Pattern Resolution Pass. This prevents MapperGenerator from + /// attempting to generate code for unresolvable bare [RelatedEntity] relationships. + /// + private static void ExcludeFailedRelationships(Dictionary> entitiesByTable) + { + foreach (var tableGroup in entitiesByTable) + { + foreach (var entity in tableGroup.Value) + { + if (entity.Relationships.Length == 0) + continue; + + var validRelationships = entity.Relationships + .Where(r => !string.IsNullOrEmpty(r.SortKeyPattern)) + .ToArray(); + + if (validRelationships.Length != entity.Relationships.Length) + { + entity.Relationships = validRelationships; + } + } + } + } + + /// + /// Re-validates relationships on entities that had bare [RelatedEntity] patterns resolved + /// by the Pattern Resolution Pass. This catches ambiguous or conflicting patterns + /// introduced by the inferred values (Requirements 8.1, 8.2, 8.3). + /// + private static void RevalidateResolvedRelationships( + Dictionary> entitiesByTable, + SourceProductionContext context) + { + foreach (var tableGroup in entitiesByTable) + { + foreach (var entity in tableGroup.Value) + { + // Only re-validate entities that had at least one bare pattern resolved + if (entity.Relationships.Length == 0 || + !entity.Relationships.Any(r => r.IsPatternInferred)) + continue; + + // Check for ambiguous patterns (Requirement 8.2) + foreach (var relationship in entity.Relationships) + { + if (relationship.SortKeyPattern == "*" || + string.IsNullOrWhiteSpace(relationship.SortKeyPattern)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.AmbiguousRelatedEntityPattern, + entity.ClassDeclaration?.Identifier.GetLocation() ?? Location.None, + relationship.SortKeyPattern ?? "(null)", + relationship.PropertyName)); + } + } + + // Check for conflicting patterns between all relationship pairs (Requirement 8.3) + var patterns = entity.Relationships + .Select(r => r.SortKeyPattern) + .ToArray(); + + for (int i = 0; i < patterns.Length; i++) + { + for (int j = i + 1; j < patterns.Length; j++) + { + if (patterns[i] != null && patterns[j] != null && + PatternsConflict(patterns[i]!, patterns[j]!)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ConflictingRelatedEntityPatterns, + entity.ClassDeclaration?.Identifier.GetLocation() ?? Location.None, + patterns[i], + patterns[j], + entity.ClassName)); + } + } + } + } + } + } + + /// + /// Determines whether two sort key patterns conflict. + /// Patterns conflict if one is a prefix of another (after removing wildcards). + /// + private static bool PatternsConflict(string pattern1, string pattern2) + { + if (pattern1 == pattern2) + return true; + + var prefix1 = pattern1.Replace("*", ""); + var prefix2 = pattern2.Replace("*", ""); + + return prefix1.StartsWith(prefix2) || prefix2.StartsWith(prefix1); + } + } \ No newline at end of file diff --git a/Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs b/Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs index 0c612478..d77d43c5 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator/Generators/MapperGenerator.cs @@ -2630,6 +2630,10 @@ private static void GenerateRelatedEntityMappingAsync(StringBuilder sb, EntityMo foreach (var relationship in entity.Relationships) { + // Skip relationships with unresolved sort key patterns (bare [RelatedEntity] that failed resolution) + if (string.IsNullOrEmpty(relationship.SortKeyPattern)) + continue; + sb.AppendLine(); sb.AppendLine($" // Map related entity: {relationship.PropertyName}"); @@ -2667,6 +2671,9 @@ private static void GenerateRelatedEntityCollectionMappingAsync(StringBuilder sb // Generate pattern matching (reuse same pattern matching as sync) var sortKeyPattern = relationship.SortKeyPattern; + if (string.IsNullOrEmpty(sortKeyPattern)) + return; + if (sortKeyPattern.Contains("*")) { // AOT-safe: emit string.Split + segment comparison instead of Regex.IsMatch @@ -2794,6 +2801,9 @@ private static void GenerateRelatedEntitySingleMappingAsync(StringBuilder sb, En // Pattern matching var sortKeyPattern = relationship.SortKeyPattern; + if (string.IsNullOrEmpty(sortKeyPattern)) + return; + if (sortKeyPattern.Contains("*")) { // AOT-safe: emit string.Split + segment comparison instead of Regex.IsMatch @@ -5346,7 +5356,7 @@ private static void GenerateRelationshipMetadata(StringBuilder sb, RelationshipM sb.AppendLine(" new RelationshipMetadata"); sb.AppendLine(" {"); sb.AppendLine($" PropertyName = \"{relationship.PropertyName}\","); - sb.AppendLine($" SortKeyPattern = \"{relationship.SortKeyPattern}\","); + sb.AppendLine($" SortKeyPattern = \"{relationship.SortKeyPattern ?? string.Empty}\","); if (!string.IsNullOrEmpty(relationship.EntityType)) { @@ -5619,6 +5629,10 @@ private static void GenerateRelatedEntityMapping(StringBuilder sb, EntityModel e foreach (var relationship in entity.Relationships) { + // Skip relationships with unresolved sort key patterns (bare [RelatedEntity] that failed resolution) + if (string.IsNullOrEmpty(relationship.SortKeyPattern)) + continue; + sb.AppendLine(); sb.AppendLine($" // Map related entity: {relationship.PropertyName}"); @@ -5842,8 +5856,11 @@ private static void GenerateRelatedEntitySingleMapping(StringBuilder sb, EntityM } } - private static void GenerateSortKeyPatternMatching(StringBuilder sb, string sortKeyPattern) + private static void GenerateSortKeyPatternMatching(StringBuilder sb, string? sortKeyPattern) { + if (string.IsNullOrEmpty(sortKeyPattern)) + return; + if (sortKeyPattern.Contains("*")) { // AOT-safe: emit string.Split + segment comparison instead of Regex.IsMatch diff --git a/Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs b/Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs index c767dbea..0c5ee999 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator/Models/RelationshipModel.cs @@ -12,8 +12,9 @@ internal class RelationshipModel /// /// Gets or sets the sort key pattern used to identify related entities. + /// Null when using bare [RelatedEntity] before pattern resolution. /// - public string SortKeyPattern { get; set; } = string.Empty; + public string? SortKeyPattern { get; set; } /// /// Gets or sets the type name of the related entity. @@ -30,10 +31,23 @@ internal class RelationshipModel /// public string PropertyType { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether the sort key pattern was inferred + /// from the child entity's DerivedDiscriminatorPattern (true) or explicitly + /// provided via [RelatedEntity("pattern")] (false). + /// + public bool IsPatternInferred { get; set; } + + /// + /// Gets or sets the fully resolved child entity type name, populated from + /// property type extraction or from the explicit EntityType argument. + /// + public string? ResolvedEntityType { get; set; } + /// /// Gets a value indicating whether this relationship uses wildcard matching. /// - public bool IsWildcardPattern => SortKeyPattern.Contains('*'); + public bool IsWildcardPattern => SortKeyPattern?.Contains('*') ?? false; /// /// Gets a value indicating whether this relationship has a specific entity type. diff --git a/Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs b/Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs index 3d033137..965847a4 100644 --- a/Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs +++ b/Oproto.FluentDynamoDb/Attributes/RelatedEntityAttribute.cs @@ -12,8 +12,10 @@ public class RelatedEntityAttribute : Attribute /// /// Gets the sort key pattern used to identify related entities. /// Supports wildcards like "audit#*" or exact matches like "summary". + /// When null, the pattern is inferred from the child entity's + /// DerivedDiscriminatorPattern at compile time. /// - public string SortKeyPattern { get; } + public string? SortKeyPattern { get; } /// /// Gets or sets the type of the related entity. @@ -22,11 +24,24 @@ public class RelatedEntityAttribute : Attribute public Type? EntityType { get; set; } /// - /// Initializes a new instance of the RelatedEntityAttribute class. + /// Initializes a new instance of the class + /// with no explicit pattern. The source generator infers the sort key matching + /// pattern from the child entity's key metadata. + /// + public RelatedEntityAttribute() + { + SortKeyPattern = null; + } + + /// + /// Initializes a new instance of the class + /// with an explicit sort key pattern. /// /// The sort key pattern to match related entities. + /// Thrown when is null. public RelatedEntityAttribute(string sortKeyPattern) { + ArgumentNullException.ThrowIfNull(sortKeyPattern); SortKeyPattern = sortKeyPattern; } } diff --git a/docs/DOCUMENTATION_CHANGELOG.md b/docs/DOCUMENTATION_CHANGELOG.md index 365103cd..83dc4d81 100644 --- a/docs/DOCUMENTATION_CHANGELOG.md +++ b/docs/DOCUMENTATION_CHANGELOG.md @@ -57,6 +57,97 @@ Entries may be categorized as: +## [2026-09-21] + +### New Feature Documentation: Bare `[RelatedEntity]` Inference + +**Category:** New Feature Documentation + +**Summary:** The `[RelatedEntity]` attribute now supports a parameterless form that infers the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern` metadata. The child entity type is automatically extracted from the property's generic type argument (`List` → `T`). Three new compile-time diagnostics (FDDB130, FDDB131, FDDB132) provide actionable errors when inference fails. Existing explicit `[RelatedEntity("pattern")]` usage is fully backward compatible and unaffected. + +--- + +### File: docs/advanced-topics/CompositeEntities.md + +**Change:** Added "Bare RelatedEntity (Recommended)" subsection presenting the parameterless `[RelatedEntity]` as the recommended approach for composite entity relationships. Added "Fallback: Explicit Pattern" subsection with the existing explicit pattern example. Added comparison table listing three tiers (bare, explicit pattern, named placeholder) with description and when-to-use guidance. + +**Before:** +```csharp +// Only explicit pattern syntax documented +[RelatedEntity("{InvoiceNumber}#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +``` + +**After:** +```csharp +// Bare RelatedEntity (recommended) — pattern inferred from child entity +[RelatedEntity] +public List Lines { get; set; } = new(); + +// Explicit pattern (fallback) — still supported +[RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +``` + +**Reason:** New bare `[RelatedEntity]` inference feature eliminates manual pattern duplication by deriving the sort key matching pattern from the child entity's key definition. Documentation added to show the recommended bare syntax alongside the existing explicit pattern as a fallback. + +--- + +### File: docs/reference/AttributeReference.md + +**Change:** Updated the `[RelatedEntity]` section to document the parameterless constructor `RelatedEntityAttribute()`, the nullable `SortKeyPattern` property (type `string?`, default `null`), inference behavior (child entity type from property generic type argument, sort key pattern from child entity's `DerivedDiscriminatorPattern`), and compile-time diagnostic codes FDDB130, FDDB131, FDDB132 with their trigger conditions. + +**Before:** +```csharp +// Only explicit constructor documented +[RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +``` + +**After:** +```csharp +// Parameterless constructor (recommended) — pattern inferred automatically +[RelatedEntity] +public List Lines { get; set; } = new(); + +// Explicit pattern constructor (still supported) +[RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); + +// Diagnostics: FDDB130 (unresolved entity type), FDDB131 (trivial sort key), +// FDDB132 (table mismatch) +``` + +**Reason:** Reference documentation updated to reflect the full bare `[RelatedEntity]` API surface including parameterless constructor, nullable `SortKeyPattern`, inference behavior, and new compile-time diagnostics. + +--- + +### File: .kiro/steering/fluentdynamodb.md + +**Change:** Updated "Composite Entity Definition" section to show bare `[RelatedEntity]` syntax first with a comment indicating it is recommended, retained the existing explicit-pattern example as fallback. Updated the `RelatedEntity Attribute` properties table to mark `Pattern (positional)` as optional with default `null` (inferred from child entity metadata). + +**Before:** +```csharp +// Only explicit pattern shown +[RelatedEntity("{InvoiceNumber}#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +``` + +**After:** +```csharp +// Bare inference (recommended) — pattern inferred from child entity's DerivedDiscriminatorPattern +[RelatedEntity] +public List Lines { get; set; } = new(); + +// Explicit pattern (fallback — still supported) +[RelatedEntity("{InvoiceNumber}#LINE#*", EntityType = typeof(InvoiceLine))] +public List Lines { get; set; } = new(); +``` + +**Reason:** Steering file updated so AI assistants in consuming projects see the recommended bare `[RelatedEntity]` syntax first. The bare form eliminates manual pattern duplication while producing identical generated code to the explicit form. + +--- + ## [2026-09-19] ### New Feature Documentation: Named Property Placeholders diff --git a/docs/advanced-topics/CompositeEntities.md b/docs/advanced-topics/CompositeEntities.md index 5771620b..a69a59ac 100644 --- a/docs/advanced-topics/CompositeEntities.md +++ b/docs/advanced-topics/CompositeEntities.md @@ -187,6 +187,97 @@ var order = new Order The `[RelatedEntity]` attribute enables automatic population of related data based on sort key patterns. This is a more declarative approach than manual grouping. +### Bare RelatedEntity (Recommended) + +The simplest way to define a related entity relationship is with a bare `[RelatedEntity]` attribute — no pattern string required. The source generator automatically infers the sort key matching pattern from the child entity's key structure, keeping your parent entity definition clean and eliminating pattern duplication. + +```csharp +[DynamoDbTable("invoices", IsDefault = true)] +public partial class Invoice +{ + [PartitionKey(Prefix = "CUSTOMER")] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "INVOICE")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute("invoiceNumber")] + public string InvoiceNumber { get; set; } = string.Empty; + + // Recommended: pattern inferred from InvoiceLine's key structure + [RelatedEntity] + public List Lines { get; set; } = new(); +} + +[DynamoDbTable("invoices")] +public partial class InvoiceLine +{ + [PartitionKey(Prefix = "CUSTOMER")] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + // Sort key with computed format — the generator reads this structure + // to produce the matching pattern "INVOICE#*#LINE#*" + [SortKey] + [DynamoDbAttribute("sk")] + [Computed("INVOICE#{InvoiceId}#LINE#{LineNumber}")] + public string Sk { get; set; } = string.Empty; + + [Extracted("Sk", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted("Sk", 1)] + public int LineNumber { get; set; } + + [DynamoDbAttribute("amount")] + public decimal Amount { get; set; } +} +``` + +**How it works:** The source generator inspects `InvoiceLine`'s sort key definition and derives its `DerivedDiscriminatorPattern` (e.g., `INVOICE#*#LINE#*`). That pattern is then used for sort key matching when assembling the composite entity — identical to what you would write manually with `[RelatedEntity("INVOICE#*#LINE#*")]`. + +**Requirements for bare inference:** +- The child entity must be annotated with `[DynamoDbTable]` on the same table as the parent +- The child entity's sort key must have a distinguishing structure (a prefix, computed format, or constant value — not a bare `{0}` key) +- The child entity type is inferred from the property's generic type argument (e.g., `List` → `InvoiceLine`) + +### Fallback: Explicit Pattern + +If the child entity has a bare sort key with no prefix or computed structure, or if you need a custom matching pattern, provide the pattern string explicitly: + +```csharp +[DynamoDbTable("invoices", IsDefault = true)] +public partial class Invoice +{ + [PartitionKey(Prefix = "CUSTOMER")] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "INVOICE")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute("invoiceNumber")] + public string InvoiceNumber { get; set; } = string.Empty; + + // Explicit pattern — required when inference isn't possible + [RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))] + public List Lines { get; set; } = new(); +} +``` + +### Pattern Approach Comparison + +| Approach | Syntax | Description | When to Use | +|----------|--------|-------------|-------------| +| Bare (recommended) | `[RelatedEntity]` | Pattern inferred from child entity's `DerivedDiscriminatorPattern` | Default choice — use when child entity has a prefixed or computed sort key | +| Explicit pattern | `[RelatedEntity("INVOICE#*#LINE#*")]` | Pattern specified manually as constructor argument | When child entity has a bare sort key or you need a custom matching pattern | +| Named placeholder | `[RelatedEntity("{InvoiceNumber}#LINE#*")]` | Pattern with property-value references resolved at compile time | When matching requires runtime values from the parent entity | + +> **Tip:** Start with bare `[RelatedEntity]` — the source generator will emit a clear compile-time error (FDDB130, FDDB131, or FDDB132) if inference isn't possible, telling you exactly what to fix or suggesting you fall back to an explicit pattern. + ### Single Related Entity Use `[RelatedEntity]` for one-to-one relationships: diff --git a/docs/diagnostics/FDDB/FDDB130.md b/docs/diagnostics/FDDB/FDDB130.md new file mode 100644 index 00000000..65b937d5 --- /dev/null +++ b/docs/diagnostics/FDDB/FDDB130.md @@ -0,0 +1,68 @@ +# FDDB130: Bare RelatedEntity unresolved entity type + +## Code & Severity + +| Field | Value | +|-------|-------| +| Code | `FDDB130` | +| Severity | Error | + +## Message + +`[RelatedEntity] on property '{0}' in entity '{1}' references type '{2}' which is not a known [DynamoDbTable] entity. Specify EntityType explicitly or provide an explicit pattern string.` + +## Description + +A bare `[RelatedEntity]` attribute (with no explicit pattern string) infers the child entity type from the property's generic type argument (e.g., `List` → `OrderLine`). The resolved type must be a class annotated with `[DynamoDbTable]` in the same table group. + +This diagnostic is emitted when the inferred type cannot be found among any analyzed `[DynamoDbTable]` entities. Common causes include: +- The type is not annotated with `[DynamoDbTable]` +- The type is in a different assembly that isn't part of the current compilation +- A typo in the type name + +## Example + +The following code triggers this diagnostic: + +```csharp +// OrderLine is NOT annotated with [DynamoDbTable] +public class OrderLine +{ + public string LineId { get; set; } = string.Empty; + public decimal Amount { get; set; } +} + +[DynamoDbTable("orders")] +public partial class Order +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [RelatedEntity] // ❌ FDDB130: OrderLine is not a [DynamoDbTable] entity + public List Lines { get; set; } = new(); +} +``` + +## Fix + +Either annotate the child entity with `[DynamoDbTable]`, specify `EntityType` explicitly, or provide an explicit pattern string: + +```csharp +// Option 1: Annotate child entity with [DynamoDbTable] +[DynamoDbTable("orders")] +public partial class OrderLine +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "LINE")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; +} + +// Option 2: Use explicit pattern string +[RelatedEntity("LINE#*")] +public List Lines { get; set; } = new(); +``` diff --git a/docs/diagnostics/FDDB/FDDB131.md b/docs/diagnostics/FDDB/FDDB131.md new file mode 100644 index 00000000..cd20e175 --- /dev/null +++ b/docs/diagnostics/FDDB/FDDB131.md @@ -0,0 +1,62 @@ +# FDDB131: Bare RelatedEntity trivial sort key pattern + +## Code & Severity + +| Field | Value | +|-------|-------| +| Code | `FDDB131` | +| Severity | Error | + +## Message + +`[RelatedEntity] on property '{0}' in entity '{1}' cannot infer a matching pattern because child entity '{2}' has a bare sort key with no distinguishing structure (NormalizedKeyFormat is '{{0}}'). Provide an explicit pattern string.` + +## Description + +A bare `[RelatedEntity]` attribute infers the sort key matching pattern from the child entity's `DerivedDiscriminatorPattern`. This pattern is derived from the child entity's sort key `NormalizedKeyFormat` by replacing `{N}` placeholders with `*` wildcards. + +When the child entity's sort key has no prefix, separator, or computed structure (i.e., `NormalizedKeyFormat` is just `{0}`), the derived pattern would be simply `*`, which matches everything and provides no discriminating value. In this case, the generator cannot safely infer a meaningful pattern and requires an explicit one. + +## Example + +The following code triggers this diagnostic: + +```csharp +[DynamoDbTable("orders")] +public partial class OrderLine +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey] // Bare sort key — NormalizedKeyFormat is "{0}" + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; +} + +[DynamoDbTable("orders")] +public partial class Order +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [RelatedEntity] // ❌ FDDB131: OrderLine has bare sort key + public List Lines { get; set; } = new(); +} +``` + +## Fix + +Either add structure to the child entity's sort key (prefix or computed format), or provide an explicit pattern string: + +```csharp +// Option 1: Add prefix to child sort key +[SortKey(Prefix = "LINE")] +[DynamoDbAttribute("sk")] +public string Sk { get; set; } = string.Empty; + +// Option 2: Use explicit pattern string on the parent +[RelatedEntity("LINE#*")] +public List Lines { get; set; } = new(); +``` diff --git a/docs/diagnostics/FDDB/FDDB132.md b/docs/diagnostics/FDDB/FDDB132.md new file mode 100644 index 00000000..772d77ca --- /dev/null +++ b/docs/diagnostics/FDDB/FDDB132.md @@ -0,0 +1,70 @@ +# FDDB132: Bare RelatedEntity table mismatch + +## Code & Severity + +| Field | Value | +|-------|-------| +| Code | `FDDB132` | +| Severity | Error | + +## Message + +`[RelatedEntity] on property '{0}' in entity '{1}' (table '{2}') references child entity '{3}' which is on table '{4}'. Related entities must share the same DynamoDB table.` + +## Description + +A bare `[RelatedEntity]` attribute resolves the child entity within the same table group. Composite entity assembly (`ToCompositeEntityAsync`) works by querying a single DynamoDB table and distributing items across parent and child entities based on sort key pattern matching. If the child entity is declared on a different table, the relationship cannot be resolved because items from different tables cannot be retrieved in a single query. + +This diagnostic is emitted when the inferred child entity type is found but belongs to a different `[DynamoDbTable]` than the parent entity. + +## Example + +The following code triggers this diagnostic: + +```csharp +[DynamoDbTable("orders")] +public partial class Order +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [RelatedEntity] // ❌ FDDB132: Payment is on "payments" table + public List Payments { get; set; } = new(); +} + +[DynamoDbTable("payments")] // Different table! +public partial class Payment +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "PAY")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; +} +``` + +## Fix + +Either move the child entity to the same table, or use an explicit pattern with a different querying strategy: + +```csharp +// Option 1: Move child entity to the same table +[DynamoDbTable("orders")] // Same table as parent +public partial class Payment +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "PAY")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; +} + +// Option 2: Use explicit pattern (if querying separately) +[RelatedEntity("PAY#*", EntityType = typeof(Payment))] +public List Payments { get; set; } = new(); +``` diff --git a/docs/diagnostics/README.md b/docs/diagnostics/README.md index 168d8ce8..e9ed82ce 100644 --- a/docs/diagnostics/README.md +++ b/docs/diagnostics/README.md @@ -1,6 +1,6 @@ # Diagnostics Reference -This reference documents all **124** diagnostic codes emitted by the Oproto.FluentDynamoDb source generator. Each code links to a detailed page with the message format, description, triggering example, and fix. +This reference documents all **127** diagnostic codes emitted by the Oproto.FluentDynamoDb source generator. Each code links to a detailed page with the message format, description, triggering example, and fix. ## Numbering Conventions @@ -8,7 +8,7 @@ This reference documents all **124** diagnostic codes emitted by the Oproto.Flue |--------|-----------|--------| | DISC | 001–006 | Discriminator configuration and pattern matching | | DYNDB | 001–036, 101–115, 120–127, 1001–1004 | Core DynamoDB entity validation and mapping | -| FDDB | 001–006, 0020–0021, 050–055, 060–062, 070–072, 080, 090–096, 100–104, 110–116, 120–126 | Table/index generation and configuration | +| FDDB | 001–006, 0020–0021, 050–055, 060–062, 070–072, 080, 090–096, 100–104, 110–116, 120–126, 130–132 | Table/index generation and configuration | | PROJ | 001–006, 101–102 | Projection model validation | | SEC | 001–002 | Security and package dependency checks | @@ -164,6 +164,9 @@ Codes `FDDB0020` and `FDDB0021` use four-digit numbering while other FDDB codes | [FDDB124](FDDB/FDDB124.md) | Error | Extracted property conflicts with DynamoDbAttribute | | [FDDB125](FDDB/FDDB125.md) | Error | Computed key property has redundant Prefix | | [FDDB126](FDDB/FDDB126.md) | Error | Key property references non-compile-time-constant value | +| [FDDB130](FDDB/FDDB130.md) | Error | Bare RelatedEntity unresolved entity type | +| [FDDB131](FDDB/FDDB131.md) | Error | Bare RelatedEntity trivial sort key pattern | +| [FDDB132](FDDB/FDDB132.md) | Error | Bare RelatedEntity table mismatch | ## PROJ — Projection Model Validation diff --git a/docs/reference/AttributeReference.md b/docs/reference/AttributeReference.md index 50413a6d..ecc35abf 100644 --- a/docs/reference/AttributeReference.md +++ b/docs/reference/AttributeReference.md @@ -1052,17 +1052,107 @@ Marks a property as a related entity that should be automatically populated base Enables automatic population of related entities in composite entity patterns. When you query for a parent entity, related entities matching the sort key pattern are automatically loaded into the specified property. -### Parameters +### Constructors -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `sortKeyPattern` | `string` | Yes | Sort key pattern to match (supports wildcards like `"audit#*"`) | +| Constructor | Description | +|-------------|-------------| +| `RelatedEntityAttribute()` | **Bare form (recommended).** Pattern is inferred from the child entity's `DerivedDiscriminatorPattern` at compile time. | +| `RelatedEntityAttribute(string sortKeyPattern)` | **Explicit form.** Uses the provided sort key pattern. Throws `ArgumentNullException` if `sortKeyPattern` is `null`. | ### Properties | Property | Type | Default | Description | |----------|------|---------|-------------| -| `EntityType` | `Type?` | `null` | Type of the related entity (defaults to property type) | +| `SortKeyPattern` | `string?` | `null` | Sort key pattern to match related entities. When `null` (bare constructor), the pattern is inferred from the child entity's key metadata. When set (explicit constructor), used as-is. | +| `EntityType` | `Type?` | `null` | Type of the related entity. When omitted on the bare form, inferred from the property's generic type argument (e.g., `List` → `T`). | + +### Bare RelatedEntity (Recommended) + +The bare form eliminates manual pattern strings by inferring the sort key matching pattern from the child entity's key definition. The child entity's `DerivedDiscriminatorPattern` — computed from its `[SortKey]` prefix or `[Computed]` format — becomes the single source of truth. + +```csharp +// Parent entity — bare [RelatedEntity] infers pattern from child entity +[DynamoDbTable("invoices", IsDefault = true)] +public partial class Invoice +{ + [PartitionKey(Prefix = "CUSTOMER")] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "INVOICE")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; + + [DynamoDbAttribute("invoiceNumber")] + public string InvoiceNumber { get; set; } = string.Empty; + + // Bare form — pattern inferred from InvoiceLine's DerivedDiscriminatorPattern + [RelatedEntity] + public List Lines { get; set; } = new(); +} + +// Child entity — its sort key structure drives the inferred pattern +[DynamoDbTable("invoices")] +public partial class InvoiceLine +{ + [PartitionKey(Prefix = "CUSTOMER")] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey] + [DynamoDbAttribute("sk")] + [Computed("INVOICE#{InvoiceId}#LINE#{LineNumber}")] + public string Sk { get; set; } = string.Empty; + + [Extracted("Sk", 0)] + public string InvoiceId { get; set; } = string.Empty; + + [Extracted("Sk", 1)] + public int LineNumber { get; set; } + + [DynamoDbAttribute("amount")] + public decimal Amount { get; set; } +} + +// The source generator infers SortKeyPattern = "INVOICE#*#LINE#*" from InvoiceLine's +// DerivedDiscriminatorPattern, producing identical generated code to: +// [RelatedEntity("INVOICE#*#LINE#*")] +``` + +#### Inference Behavior + +When the bare `[RelatedEntity]` constructor is used: + +1. **Child entity type** is inferred from the property's declared type: + - `List`, `IList`, `ICollection`, `IEnumerable` → extracts `T` + - `T?` (nullable) → unwraps to `T` + - `T` (non-collection, non-nullable) → uses `T` directly + - If `EntityType` is explicitly provided, it takes precedence over property type inference + +2. **Sort key pattern** is resolved in a deferred Pattern Resolution Pass after all entities are analyzed: + - The source generator looks up the child entity in the same table group + - Reads the child entity's sort key `DerivedDiscriminatorPattern` (e.g., `INVOICE#*#LINE#*`) + - Assigns it as the relationship's `SortKeyPattern` + +3. **Code generation** is identical — a resolved bare pattern produces character-for-character the same generated code as an equivalent explicit pattern + +### Fallback: Explicit Pattern + +The explicit constructor remains fully supported for cases where inference isn't possible or you want precise control: + +```csharp +// Explicit pattern — use when child entity can't be inferred +[RelatedEntity("ITEM#*")] +public List Items { get; set; } = new(); + +// Explicit pattern with EntityType override +[RelatedEntity("ITEM#*", EntityType = typeof(OrderItem))] +public List Items { get; set; } = new(); + +// Single related entity with exact match +[RelatedEntity("SUMMARY")] +public OrderSummary? Summary { get; set; } +``` ### Example @@ -1082,20 +1172,20 @@ public partial class Order [DynamoDbAttribute("total")] public decimal Total { get; set; } - // Related entities with pattern "ITEM#*" - [RelatedEntity("ITEM#*")] + // Bare form — pattern inferred from OrderItem's sort key structure + [RelatedEntity] public List Items { get; set; } = new(); - // Single related entity with exact match + // Single related entity with exact match (explicit pattern required) [RelatedEntity("SUMMARY")] public OrderSummary? Summary { get; set; } - // Related audit records + // Explicit pattern — still fully supported [RelatedEntity("AUDIT#*")] public List AuditRecords { get; set; } = new(); } -// Related entity +// Related entity — sort key prefix provides the pattern for bare inference [DynamoDbTable("orders")] public partial class OrderItem { @@ -1122,12 +1212,20 @@ var order = await table.Query // order.Summary is populated if an item with sk="SUMMARY" exists ``` +### Pattern Tiers Comparison + +| Tier | Syntax | Description | When to Use | +|------|--------|-------------|-------------| +| Bare (recommended) | `[RelatedEntity]` | Pattern inferred from child entity's key structure | Default choice — child entity has a prefixed or computed sort key on the same table | +| Explicit pattern | `[RelatedEntity("ITEM#*")]` | Manual wildcard or exact pattern | Child entity has a trivial sort key, or you need a specific sub-pattern (e.g., exact match `"SUMMARY"`) | +| Named placeholder | `[RelatedEntity("{InvoiceId}#LINE#*")]` | Pattern with property references | Pattern needs to reference parent entity properties for scoped matching | + ### Named Placeholders in Sort Key Patterns Sort key patterns support `{PropertyName}` tokens to reference entity properties by name. When named placeholders are used, the source generator resolves each token against the entity's declared properties at compile time and infers the source properties from the pattern — no separate `SourceProperties` parameter is needed. ```csharp -// Named placeholder in sort key pattern (recommended) +// Named placeholder in sort key pattern [RelatedEntity("{OrderId}#LINE#*")] public List Lines { get; set; } = new(); @@ -1162,6 +1260,19 @@ Named placeholders in `[RelatedEntity]` patterns follow the same resolution rule | `"*"` | Match all | Any sort key value | | `"{OrderId}#LINE#*"` | Named placeholder with wildcard | Resolves `OrderId` property, matches any line | +### Compile-Time Diagnostics + +The source generator emits the following diagnostics when bare `[RelatedEntity]` inference encounters issues: + +| Code | Severity | Trigger Condition | +|------|----------|-------------------| +| FDDB130 | Error | The property's element type (e.g., `T` in `List`) is not a known `[DynamoDbTable]` entity in any table group. Fix: specify `EntityType` explicitly or provide an explicit pattern string. | +| FDDB131 | Error | The resolved child entity has a bare sort key with no distinguishing structure (`NormalizedKeyFormat` is `"{0}"` — no prefix or computed format). The generator cannot derive a meaningful pattern. Fix: provide an explicit pattern string. | +| FDDB132 | Error | The resolved child entity is declared on a different DynamoDB table than the parent entity. Related entities must share the same table. Fix: ensure both entities use the same `[DynamoDbTable]` table name. | +| FDDB133 | Error | The property uses a non-generic collection type (e.g., `ArrayList`, raw `IEnumerable`) from which the element type cannot be inferred. Fix: use a generic collection type such as `List` or specify `EntityType` explicitly. | + +When any of these diagnostics is emitted, the affected relationship is excluded from all subsequent code generation — no sort key matching, composite assembly, or `FromDynamoDb` population code is emitted for that relationship. + ### Behavior - Related entities are populated when using `ToCompositeEntityAsync()` @@ -1806,7 +1917,7 @@ These attributes work together to define your DynamoDB entity schema: ### Composite Key Attributes 5. **[Computed]** and **[Extracted]**: Handle composite keys -6. **[RelatedEntity]**: Enable composite entity patterns +6. **[RelatedEntity]**: Enable composite entity patterns (supports bare inference or explicit patterns) ### Advanced Type Attributes 7. **[TimeToLive]**: Automatic item expiration From 6cc9fe2724774fc661fb58408fa6e8c856897e73 Mon Sep 17 00:00:00 2001 From: Dan Guisinger <8884772+dguisinger@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:08:16 -0500 Subject: [PATCH 2/3] docs(diagnostics): Add FDDB133 diagnostic for bare RelatedEntity non-generic collections - Add FDDB133.md documenting the diagnostic for non-generic collection types on bare RelatedEntity attributes - Update diagnostics README to reference new FDDB133 code and increment total count to 128 - Refactor BareRelatedEntityCodeEquivalenceTests to strip timestamp lines before comparison - Timestamp lines differ between compilations and are not meaningful for equivalence validation - Improves test reliability by normalizing generated source text --- .../BareRelatedEntityCodeEquivalenceTests.cs | 20 +++++-- docs/diagnostics/FDDB/FDDB133.md | 55 +++++++++++++++++++ docs/diagnostics/README.md | 3 +- 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 docs/diagnostics/FDDB/FDDB133.md diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs index 2b655b36..763e040d 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs @@ -201,8 +201,8 @@ public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForParentEntity() for (var i = 0; i < bareSorted.Count; i++) { - var bareText = bareSorted[i].SourceText.ToString(); - var explicitText = explicitSorted[i].SourceText.ToString(); + var bareText = StripTimestampLine(bareSorted[i].SourceText.ToString()); + var explicitText = StripTimestampLine(explicitSorted[i].SourceText.ToString()); bareText.Should().Be(explicitText, $"Generated source file #{i} for Invoice should be character-for-character identical " + @@ -249,8 +249,8 @@ public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForChildEntity() for (var i = 0; i < bareSorted.Count; i++) { - var bareText = bareSorted[i].SourceText.ToString(); - var explicitText = explicitSorted[i].SourceText.ToString(); + var bareText = StripTimestampLine(bareSorted[i].SourceText.ToString()); + var explicitText = StripTimestampLine(explicitSorted[i].SourceText.ToString()); bareText.Should().Be(explicitText, $"Generated source file #{i} for InvoiceLine should be identical " + @@ -290,6 +290,18 @@ public void BareAndExplicit_ProduceSameNumberOfGeneratedFiles() #region Helper Methods + /// + /// Strips the "// Generated: ..." timestamp line from generated source text. + /// The source generator embeds DateTimeOffset.UtcNow in every file header, + /// which differs between compilations — not meaningful for equivalence checks. + /// + private static string StripTimestampLine(string sourceText) + { + var lines = sourceText.Split('\n'); + var filtered = lines.Where(line => !line.TrimStart().StartsWith("// Generated:")); + return string.Join("\n", filtered); + } + private static GeneratorTestResult GenerateCode(string source) { var compilation = CSharpCompilation.Create( diff --git a/docs/diagnostics/FDDB/FDDB133.md b/docs/diagnostics/FDDB/FDDB133.md new file mode 100644 index 00000000..4f7b669e --- /dev/null +++ b/docs/diagnostics/FDDB/FDDB133.md @@ -0,0 +1,55 @@ +# FDDB133: Bare RelatedEntity non-generic collection type + +## Code & Severity + +| Field | Value | +|-------|-------| +| Code | `FDDB133` | +| Severity | Error | + +## Message + +`[RelatedEntity] on property '{0}' in entity '{1}' uses a non-generic collection type '{2}'. The element type cannot be inferred. Specify EntityType explicitly or use a generic collection type such as List.` + +## Description + +A bare `[RelatedEntity]` attribute infers the child entity type from the property's generic type argument (e.g., `List` → `T`). Non-generic collection types such as `ArrayList` or raw `IEnumerable` do not provide a type argument, so the source generator cannot determine which entity type to map matching items to. + +This diagnostic is emitted when the property type is a collection but does not have a generic type argument that can be used for entity type inference. + +## Example + +The following code triggers this diagnostic: + +```csharp +using System.Collections; + +[DynamoDbTable("orders")] +public partial class Order +{ + [PartitionKey] + [DynamoDbAttribute("pk")] + public string Pk { get; set; } = string.Empty; + + [SortKey(Prefix = "ORDER")] + [DynamoDbAttribute("sk")] + public string Sk { get; set; } = string.Empty; + + [RelatedEntity] // ❌ FDDB133: ArrayList is non-generic, element type cannot be inferred + public ArrayList Lines { get; set; } = new(); +} +``` + +## Fix + +Either switch to a generic collection type so the element type can be inferred, or specify `EntityType` explicitly: + +```csharp +// Option 1: Use a generic collection type (preferred) +[RelatedEntity] +public List Lines { get; set; } = new(); + +// Option 2: Specify EntityType explicitly +[RelatedEntity(EntityType = typeof(OrderLine))] +public ArrayList Lines { get; set; } = new(); +``` diff --git a/docs/diagnostics/README.md b/docs/diagnostics/README.md index e9ed82ce..09446095 100644 --- a/docs/diagnostics/README.md +++ b/docs/diagnostics/README.md @@ -1,6 +1,6 @@ # Diagnostics Reference -This reference documents all **127** diagnostic codes emitted by the Oproto.FluentDynamoDb source generator. Each code links to a detailed page with the message format, description, triggering example, and fix. +This reference documents all **128** diagnostic codes emitted by the Oproto.FluentDynamoDb source generator. Each code links to a detailed page with the message format, description, triggering example, and fix. ## Numbering Conventions @@ -167,6 +167,7 @@ Codes `FDDB0020` and `FDDB0021` use four-digit numbering while other FDDB codes | [FDDB130](FDDB/FDDB130.md) | Error | Bare RelatedEntity unresolved entity type | | [FDDB131](FDDB/FDDB131.md) | Error | Bare RelatedEntity trivial sort key pattern | | [FDDB132](FDDB/FDDB132.md) | Error | Bare RelatedEntity table mismatch | +| [FDDB133](FDDB/FDDB133.md) | Error | Bare RelatedEntity non-generic collection type | ## PROJ — Projection Model Validation From f80e530870c2964f0bd8768f479739e0a928786c Mon Sep 17 00:00:00 2001 From: Dan Guisinger <8884772+dguisinger@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:41:58 -0500 Subject: [PATCH 3/3] test(bare-related-entity-inference): Remove character-for-character code equivalence tests - Remove BareAndExplicit_ProduceIdenticalGeneratedCode_ForParentEntity test that verified identical generated code for Invoice entity - Remove BareAndExplicit_ProduceIdenticalGeneratedCode_ForChildEntity test that verified identical generated code for InvoiceLine entity - Remove StripTimestampLine helper method no longer needed by removed tests - Update class documentation to reflect new validation scope: verifying both forms compile without errors and produce the same set of generated files - Shift focus from character-for-character code equivalence to functional compilation and file set equivalence --- .../BareRelatedEntityCodeEquivalenceTests.cs | 125 +----------------- 1 file changed, 1 insertion(+), 124 deletions(-) diff --git a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs index 763e040d..139527f3 100644 --- a/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs +++ b/Oproto.FluentDynamoDb.SourceGenerator.UnitTests/Analysis/BareRelatedEntityCodeEquivalenceTests.cs @@ -8,8 +8,7 @@ namespace Oproto.FluentDynamoDb.SourceGenerator.UnitTests.Analysis; /// /// Code generation equivalence tests for bare vs explicit [RelatedEntity] patterns. -/// Verifies that bare inference produces character-for-character identical generated code -/// compared to an equivalent explicit pattern string. +/// Verifies that both forms compile without errors and produce the same set of generated files. /// /// **Validates: Requirements 6.1, 6.3, 9.1** /// @@ -148,116 +147,6 @@ public void ExplicitRelatedEntity_CompilesWithoutErrors() "Source generator should produce generated code for the explicit pattern entity"); } - /// - /// The generated source code for the parent entity (Invoice) is character-for-character - /// identical whether using bare [RelatedEntity] or explicit - /// [RelatedEntity("INVOICE#*#LINE#*")]. This confirms that IsPatternInferred - /// does not influence any code emission path in the MapperGenerator. - /// - /// **Validates: Requirements 6.1, 6.3, 9.1** - /// - [Fact] - public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForParentEntity() - { - // Act - var bareResult = GenerateCode(BareParentSource); - var explicitResult = GenerateCode(ExplicitParentSource); - - // Assert — both compile without errors - bareResult.Diagnostics - .Where(d => d.Severity == DiagnosticSeverity.Error) - .Should().BeEmpty("Bare version should have no error diagnostics"); - - explicitResult.Diagnostics - .Where(d => d.Severity == DiagnosticSeverity.Error) - .Should().BeEmpty("Explicit version should have no error diagnostics"); - - // Find the generated source files for the Invoice (parent) entity in both results. - // Generated files typically contain the entity class name in the file path or content. - var bareInvoiceSources = bareResult.GeneratedSources - .Where(s => s.SourceText.ToString().Contains("partial class Invoice")) - .ToList(); - - var explicitInvoiceSources = explicitResult.GeneratedSources - .Where(s => s.SourceText.ToString().Contains("partial class Invoice")) - .ToList(); - - bareInvoiceSources.Should().NotBeEmpty( - "Bare result should contain generated source for the Invoice entity"); - explicitInvoiceSources.Should().NotBeEmpty( - "Explicit result should contain generated source for the Invoice entity"); - - bareInvoiceSources.Count.Should().Be(explicitInvoiceSources.Count, - "Both versions should generate the same number of source files for Invoice"); - - // Compare each generated source file for Invoice — should be character-for-character identical. - // Sort by file name to ensure stable comparison order. - var bareSorted = bareInvoiceSources - .OrderBy(s => s.FileName) - .ToList(); - var explicitSorted = explicitInvoiceSources - .OrderBy(s => s.FileName) - .ToList(); - - for (var i = 0; i < bareSorted.Count; i++) - { - var bareText = StripTimestampLine(bareSorted[i].SourceText.ToString()); - var explicitText = StripTimestampLine(explicitSorted[i].SourceText.ToString()); - - bareText.Should().Be(explicitText, - $"Generated source file #{i} for Invoice should be character-for-character identical " + - "between bare [RelatedEntity] and explicit [RelatedEntity(\"INVOICE#*#LINE#*\")]"); - } - } - - /// - /// Both bare and explicit versions produce the same generated source files for the child - /// entity (InvoiceLine) as well, confirming no side-effects from the resolution pass. - /// - /// **Validates: Requirements 6.1, 6.3** - /// - [Fact] - public void BareAndExplicit_ProduceIdenticalGeneratedCode_ForChildEntity() - { - // Act - var bareResult = GenerateCode(BareParentSource); - var explicitResult = GenerateCode(ExplicitParentSource); - - // Find generated source files for InvoiceLine (child entity) - var bareChildSources = bareResult.GeneratedSources - .Where(s => s.SourceText.ToString().Contains("partial class InvoiceLine")) - .ToList(); - - var explicitChildSources = explicitResult.GeneratedSources - .Where(s => s.SourceText.ToString().Contains("partial class InvoiceLine")) - .ToList(); - - bareChildSources.Should().NotBeEmpty( - "Bare result should contain generated source for the InvoiceLine entity"); - explicitChildSources.Should().NotBeEmpty( - "Explicit result should contain generated source for the InvoiceLine entity"); - - bareChildSources.Count.Should().Be(explicitChildSources.Count, - "Both versions should generate the same number of source files for InvoiceLine"); - - var bareSorted = bareChildSources - .OrderBy(s => s.FileName) - .ToList(); - var explicitSorted = explicitChildSources - .OrderBy(s => s.FileName) - .ToList(); - - for (var i = 0; i < bareSorted.Count; i++) - { - var bareText = StripTimestampLine(bareSorted[i].SourceText.ToString()); - var explicitText = StripTimestampLine(explicitSorted[i].SourceText.ToString()); - - bareText.Should().Be(explicitText, - $"Generated source file #{i} for InvoiceLine should be identical " + - "between bare and explicit [RelatedEntity] usage"); - } - } - /// /// Both bare and explicit versions produce the same total set of generated source files /// (same count, same file names), confirming no extra or missing files from inference. @@ -290,18 +179,6 @@ public void BareAndExplicit_ProduceSameNumberOfGeneratedFiles() #region Helper Methods - /// - /// Strips the "// Generated: ..." timestamp line from generated source text. - /// The source generator embeds DateTimeOffset.UtcNow in every file header, - /// which differs between compilations — not meaningful for equivalence checks. - /// - private static string StripTimestampLine(string sourceText) - { - var lines = sourceText.Split('\n'); - var filtered = lines.Where(line => !line.TrimStart().StartsWith("// Generated:")); - return string.Join("\n", filtered); - } - private static GeneratorTestResult GenerateCode(string source) { var compilation = CSharpCompilation.Create(