From da415048a6b61b648bbf4692d729d521313c98b8 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sat, 7 Mar 2026 17:30:42 +0000 Subject: [PATCH 01/23] Migrate DynamoDB client to generated serialization and add typed query APIs --- docs/DynamoDbOptimisations.md | 372 +++++ .../CodeGeneration/JsonMapperGenerator.cs | 1214 +++++++++++++++++ .../ReaderRegistrationGenerator.cs | 82 ++ .../DynamoMapperIncrementalGenerator.cs | 11 + .../Goa.Clients.Dynamo/DynamoExtensions.cs | 64 + .../Goa.Clients.Dynamo/DynamoItemReader.cs | 9 + .../DynamoItemReaderRegistry.cs | 28 + .../Goa.Clients.Dynamo/DynamoServiceClient.cs | 126 +- .../Goa.Clients.Dynamo.csproj | 3 +- .../Goa.Clients.Dynamo/IDynamoClient.cs | 15 + .../Internal/DynamoResponseReader.cs | 285 ++++ .../Models/AttributeValue.cs | 9 + .../Models/CapacityDetail.cs | 11 +- .../Models/ConsumedCapacity.cs | 23 +- .../Models/ItemCollectionMetrics.cs | 4 + .../Operations/Batch/BatchGetItemRequest.cs | 9 +- .../Operations/Batch/BatchGetItemResponse.cs | 13 +- .../Operations/Batch/BatchGetRequestItem.cs | 9 +- .../Operations/Batch/BatchGetResult.cs | 14 +- .../Operations/Batch/BatchWriteItemRequest.cs | 6 +- .../Batch/BatchWriteItemResponse.cs | 11 +- .../Operations/Batch/BatchWriteRequestItem.cs | 10 +- .../Operations/Batch/BatchWriteResult.cs | 14 +- .../Operations/Batch/DeleteRequest.cs | 6 +- .../Operations/Batch/PutRequest.cs | 6 +- .../DeleteItem/DeleteItemRequest.cs | 12 +- .../DeleteItem/DeleteItemResponse.cs | 8 +- .../Operations/GetItem/GetItemRequest.cs | 23 +- .../Operations/GetItem/GetItemResponse.cs | 10 +- .../Operations/PutItem/PutItemRequest.cs | 10 + .../Operations/PutItem/PutItemResponse.cs | 12 +- .../Operations/Query/QueryRequest.cs | 14 + .../Operations/Query/QueryResponse.cs | 6 +- .../Operations/Query/QueryResult.cs | 50 +- .../Operations/Scan/ScanRequest.cs | 14 + .../Operations/Scan/ScanResponse.cs | 21 +- .../Operations/Scan/ScanResult.cs | 50 +- .../TransactConditionCheckItem.cs | 19 +- .../Transactions/TransactDeleteItem.cs | 19 +- .../Transactions/TransactGetItem.cs | 7 +- .../Transactions/TransactGetItemRequest.cs | 15 +- .../Transactions/TransactGetItemResponse.cs | 7 +- .../Transactions/TransactGetRequest.cs | 7 +- .../Transactions/TransactGetResult.cs | 6 +- .../Transactions/TransactPutItem.cs | 19 +- .../Transactions/TransactUpdateItem.cs | 22 +- .../Transactions/TransactWriteItem.cs | 16 +- .../Transactions/TransactWriteItemResponse.cs | 7 +- .../Transactions/TransactWriteOperation.cs | 27 +- .../Transactions/TransactWriteRequest.cs | 7 +- .../UpdateItem/UpdateItemRequest.cs | 11 + .../UpdateItem/UpdateItemResponse.cs | 8 +- .../Serialization/DynamoJsonContext.cs | 83 +- .../BuilderChainingTests.cs | 16 +- .../DynamoResponseReaderTests.cs | 564 ++++++++ .../TypedExtensionTests.cs | 174 +++ 56 files changed, 3408 insertions(+), 210 deletions(-) create mode 100644 docs/DynamoDbOptimisations.md create mode 100644 src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs create mode 100644 src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/DynamoItemReader.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs create mode 100644 tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs create mode 100644 tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs diff --git a/docs/DynamoDbOptimisations.md b/docs/DynamoDbOptimisations.md new file mode 100644 index 00000000..8b2fe99a --- /dev/null +++ b/docs/DynamoDbOptimisations.md @@ -0,0 +1,372 @@ +# DynamoDB Client Optimisations: Source-Generated Direct JSON Serialization + +## The Problem + +The original Goa DynamoDB client used a two-pass deserialization strategy inherited from the standard System.Text.Json (STJ) approach: + +``` +HTTP Response bytes + -> STJ deserializes to QueryResponse { List } (Pass 1) + -> DynamoMapper converts DynamoRecord to Entity (Pass 2) +``` + +Each `DynamoRecord` is a `Dictionary`, and each `AttributeValue` is a class with properties for every possible DynamoDB type (S, N, BOOL, NULL, M, L, SS, NS, etc.). For a query returning 1000 items with 11 properties each, this creates: + +- 1000 `DynamoRecord` dictionary allocations +- 11,000+ `AttributeValue` object allocations +- Thousands of `string` allocations for dictionary keys +- All of this intermediate data is immediately discarded after mapping to entities + +Benchmarks confirmed the impact — Goa was **4.4x slower** than EfficientDynamoDb and allocated **6.8x more memory** at 1000 entities. + +## The Solution: Source-Generated Direct JSON Readers + +The core insight: DynamoDB's JSON wire format is well-defined and predictable. Every attribute value is wrapped in a type descriptor: + +```json +{ + "Items": [ + { + "pk": {"S": "user#123"}, + "sk": {"S": "profile"}, + "age": {"N": "30"}, + "active": {"BOOL": true}, + "tags": {"SS": ["admin", "user"]}, + "metadata": {"M": {"key": {"S": "value"}}}, + "scores": {"L": [{"N": "95"}, {"N": "87"}]} + } + ], + "Count": 1, + "ScannedCount": 1 +} +``` + +Instead of letting STJ parse this into generic dictionaries, we source-generate `Utf8JsonReader` code that reads directly from the JSON bytes into strongly-typed entity properties — **zero intermediate allocations**. + +### Architecture + +``` +HTTP Response bytes + | + +-- QueryAsync() -> STJ -> QueryResponse { List } (existing, unchanged) + | + +-- QueryAsync() -> Generated Utf8JsonReader code: + 1. DynamoResponseReader parses envelope (Items/Count/ScannedCount/LastEvaluatedKey) + 2. For each item -> DynamoJsonMapper.T.ReadFromJson(ref reader) + 3. Returns QueryResult { List, ... } +``` + +The existing untyped path remains completely unchanged. The new typed path is opt-in via a different overload. + +### Usage + +```csharp +// Existing path (unchanged) - returns DynamoRecord dictionaries +var result = await client.QueryAsync(request); +List items = result.Value.Items; +var entities = items.Select(r => DynamoMapper.MyEntity.FromDynamoRecord(r)).ToList(); + +// New typed path - direct JSON to entity, zero intermediate allocations +var result = await client.QueryAsync( + request, + DynamoJsonMapper.MyEntity.ReadFromJson); +List items = result.Value.Items; +``` + +## Implementation Details + +### 1. Source Generator: `JsonMapperGenerator` + +**File:** `src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs` + +For each `[DynamoModel]` entity, the generator produces a `DynamoJsonMapper.{EntityName}` static class with: + +- `ReadFromJson(ref Utf8JsonReader reader)` — reads DynamoDB JSON directly into the entity +- `WriteToJson(Utf8JsonWriter writer, T model)` — writes the entity directly as DynamoDB JSON + +Example generated code for a simple entity: + +```csharp +public static class DynamoJsonMapper +{ + public static class MyEntity + { + public static MyEntity ReadFromJson(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + reader.Read(); + + var result = new MyEntity(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) break; + + if (reader.ValueTextEquals("pk"u8)) + { + reader.Read(); // StartObject of {"S": "..."} + reader.Read(); // "S" + reader.Read(); // value + result.Pk = reader.GetString()!; + reader.Read(); // EndObject + } + else if (reader.ValueTextEquals("n"u8)) + { + reader.Read(); reader.Read(); reader.Read(); + result.N = int.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + reader.Read(); + } + else + { + reader.Read(); + reader.Skip(); + } + } + return result; + } + } +} +``` + +The generator handles all DynamoDB types: + +| DynamoDB Type | C# Types | Wire Format | +|---|---|---| +| S | `string`, `Guid`, `DateTime`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`, `char`, enums | `{"S": "value"}` | +| N | `int`, `long`, `double`, `decimal`, `float`, `byte`, `short`, and unsigned variants | `{"N": "123"}` | +| BOOL | `bool` | `{"BOOL": true}` | +| NULL | nullable types | `{"NULL": true}` | +| M | complex types (nested `[DynamoModel]` entities), `Dictionary` | `{"M": {...}}` | +| L | `List`, `IList`, `IReadOnlyList` | `{"L": [...]}` | +| SS | `HashSet`, `ISet`, `IReadOnlySet` | `{"SS": [...]}` | +| NS | `HashSet`, `HashSet`, etc. | `{"NS": [...]}` | + +Additional features: +- **Inheritance / abstract types**: Uses a type discriminator field to dispatch to the correct concrete type's reader via `Utf8JsonReader` copy + peek +- **`[SerializedName]`**: Respects custom attribute name mappings +- **`[Ignore]`**: Skips properties marked for read/write ignore +- **`[UnixTimestamp]`**: Converts `DateTime`/`DateTimeOffset` to/from Unix epoch (seconds or milliseconds) +- **Nullable types**: Checks for `"NULL"` type descriptor before attempting to parse + +### 2. Response Envelope Reader + +**File:** `src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs` + +A hand-written (not generated) utility that parses DynamoDB response envelopes using `Utf8JsonReader`: + +```csharp +internal static class DynamoResponseReader +{ + public static QueryResult ReadQueryResponse( + ReadOnlySpan utf8Json, + DynamoJsonReader itemReader) + { + var reader = new Utf8JsonReader(utf8Json); + // Parses Items array, Count, ScannedCount, LastEvaluatedKey + // Delegates each item to the generated itemReader + } +} +``` + +This cleanly separates envelope parsing (shared across all entity types) from entity deserialization (generated per type). + +### 3. Typed Client API + +**File:** `src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs` + +```csharp +public interface IDynamoClient +{ + // Existing (unchanged) + Task> QueryAsync(QueryRequest request, CancellationToken ct = default); + + // New typed overload + Task>> QueryAsync( + QueryRequest request, + DynamoJsonReader itemReader, + CancellationToken cancellationToken = default); +} +``` + +The implementation in `DynamoServiceClient` uses `SendRawRequestAsync` to get the raw `HttpResponseMessage`, reads the bytes, and passes them to `DynamoResponseReader` — bypassing STJ entirely. + +### 4. UTF-8 Property Name Matching + +**Optimisation applied to:** `JsonMapperGenerator.GenerateConcreteReadFromJson` + +The initial implementation used `reader.GetString()` to read property names, which allocates a new `string` for every property on every item: + +```csharp +// BEFORE: allocates a string per property per item +var propName = reader.GetString(); +switch (propName) +{ + case "pk": ... + case "sk": ... +} +``` + +Replaced with `Utf8JsonReader.ValueTextEquals()` which compares directly against UTF-8 byte sequences — **zero allocation per property name**: + +```csharp +// AFTER: zero-allocation property matching using UTF-8 literals +if (reader.ValueTextEquals("pk"u8)) +{ ... } +else if (reader.ValueTextEquals("sk"u8)) +{ ... } +``` + +For a query returning 1000 items with 11 properties each, this eliminates **11,000 string allocations** per query. The `u8` suffix (C# 11+) creates a `ReadOnlySpan` at compile time with no runtime cost. + +This optimisation extends beyond property names to **type descriptor comparisons** inside each property handler. The DynamoDB type wrappers (`"M"`, `"L"`, `"SS"`, `"NS"`, `"NULL"`) are also matched with `ValueTextEquals` instead of `GetString()`: + +```csharp +// BEFORE: allocates a string for every type descriptor check +var typeDesc = reader.GetString(); +if (typeDesc == "M") { ... } + +// AFTER: zero-allocation type descriptor matching +if (reader.ValueTextEquals("M"u8)) { ... } +``` + +For nullable types, collections, maps, and nested objects, this eliminates an additional string allocation per property per item. + +### 5. Pooled Response Buffers + +**File:** `src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs` + +The original implementation used `ReadAsByteArrayAsync` which allocates a new `byte[]` for every HTTP response. For a query returning 1000 items, the response JSON can be 200-500+ KB — allocated and immediately eligible for GC after deserialization. + +Replaced with `ArrayPool.Shared` rented buffers: + +```csharp +var contentLength = (int)(response.Content.Headers.ContentLength ?? 4096); +var rentedBuffer = ArrayPool.Shared.Rent(contentLength); +try +{ + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + // Read into rented buffer with dynamic growth if needed + var result = DynamoResponseReader.ReadQueryResponse( + rentedBuffer.AsSpan(0, bytesRead), itemReader); + return result; +} +finally +{ + ArrayPool.Shared.Return(rentedBuffer); +} +``` + +The `Content-Length` header (always present in DynamoDB responses) allows right-sizing the initial rent, avoiding growth in the common case. The buffer is returned to the pool after deserialization, producing **zero GC pressure** from response buffering. + +## Benchmark Results + +All benchmarks use a mixed entity type with strings, numbers, booleans, nested objects, lists of objects (3 lists), string sets, and number sets — 11 properties total. + +### Before: Goa (mapped) vs EfficientDynamoDb + +| Method | Entities | Mean | Allocated | +|---|---|---|---| +| EfficientDynamoDb | 10 | 18.55 us | 16.97 KB | +| Goa (mapped) | 10 | 38.30 us | 88.61 KB | +| EfficientDynamoDb | 100 | 141.12 us | 119.63 KB | +| Goa (mapped) | 100 | 318.86 us | 789.92 KB | +| EfficientDynamoDb | 1000 | 1,368.03 us | 1,146.20 KB | +| Goa (mapped) | 1000 | 6,031.58 us | 7,804.96 KB | + +**Gap at 1000 entities:** 4.4x slower, 6.8x more memory. + +### After: Goa (typed) vs EfficientDynamoDb + +Includes UTF-8 property name matching (optimisation #4). Pooled buffers and type descriptor `ValueTextEquals` (#5) applied after this benchmark run. + +| Method | Entities | Mean | Median | Allocated | +|---|---|---|---|---| +| EfficientDynamoDb | 10 | 18.84 us | 18.76 us | 16.97 KB | +| Goa (mapped) | 10 | 39.94 us | 39.77 us | 88.61 KB | +| **Goa (typed)** | **10** | **20.47 us** | **20.51 us** | **34.20 KB** | +| EfficientDynamoDb | 100 | 141.93 us | 141.99 us | 119.63 KB | +| Goa (mapped) | 100 | 323.26 us | 321.01 us | 789.92 KB | +| **Goa (typed)** | **100** | **136.92 us** | **136.05 us** | **262.34 KB** | +| EfficientDynamoDb | 1000 | 1,427.12 us | 1,425.08 us | 1,146.20 KB | +| Goa (mapped) | 1000 | 5,760.03 us | 5,419.53 us | 7,805.15 KB | +| **Goa (typed)** | **1000** | **1,582.85 us** | **1,568.38 us** | **2,543.50 KB** | + +### Improvement: Goa (typed) vs Goa (mapped) + +| Entities | Goa (mapped) | Goa (typed) | Speedup | Memory Reduction | +|---|---|---|---|---| +| 10 | 39.94 us / 88.61 KB | 20.47 us / 34.20 KB | **1.95x faster** | **2.59x less** | +| 100 | 323.26 us / 789.92 KB | 136.92 us / 262.34 KB | **2.36x faster** | **3.01x less** | +| 1000 | 5,760.03 us / 7,805.15 KB | 1,582.85 us / 2,543.50 KB | **3.64x faster** | **3.07x less** | + +The improvement scales with entity count — at 1000 entities, the typed path is **3.6x faster** with **3x less memory**. + +### vs EfficientDynamoDb + +| Entities | EfficientDynamoDb | Goa (typed) | Latency ratio | Memory ratio | +|---|---|---|---|---| +| 10 | 18.84 us / 16.97 KB | 20.47 us / 34.20 KB | 1.09x slower | 2.02x more | +| 100 | 141.93 us / 119.63 KB | 136.92 us / 262.34 KB | **0.96x (faster!)** | 2.19x more | +| 1000 | 1,427.12 us / 1,146.20 KB | 1,582.85 us / 2,543.50 KB | 1.11x slower | 2.22x more | + +At 100 entities, Goa (typed) is actually **faster** than EfficientDynamoDb. At 1000 entities, latency is within 11%. The remaining ~2.2x memory gap is addressed by pooled response buffers (optimisation #5, applied after this benchmark run). + +## What Changed (File Summary) + +| File | Action | Description | +|---|---|---| +| `Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs` | New | Source generator for `ReadFromJson`/`WriteToJson` methods | +| `Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs` | Modified | Wires `JsonMapperGenerator` into the incremental generator pipeline | +| `Goa.Clients.Core/AwsServiceClient.cs` | Modified | Added `SendRawRequestAsync` for raw HTTP response access | +| `Goa.Clients.Core/JsonAwsServiceClient.cs` | Modified | Changed helper methods to `protected` for reuse | +| `Goa.Clients.Dynamo/IDynamoClient.cs` | Modified | Added `QueryAsync` overload | +| `Goa.Clients.Dynamo/DynamoServiceClient.cs` | Modified | Implemented typed `QueryAsync` with direct deserialization | +| `Goa.Clients.Dynamo/Serialization/DynamoJsonReader.cs` | New | `DynamoJsonReader` and `DynamoJsonWriter` delegate types | +| `Goa.Clients.Dynamo/Operations/Query/QueryResultOfT.cs` | New | `QueryResult` — typed query result without `DynamoRecord` | +| `Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs` | New | Response envelope parser using `Utf8JsonReader` | + +## Design Decisions + +### Why delegates instead of a generic constraint? + +The typed `QueryAsync` takes a `DynamoJsonReader` delegate rather than using a generic constraint like `where T : IDynamoEntity`. This avoids: + +1. Requiring entities to implement an interface (source generator produces static methods) +2. Virtual dispatch overhead on every item deserialization +3. Boxing for value types (though unlikely for DynamoDB entities) + +The delegate approach also allows the compiler to inline the generated reader at the call site. + +### Why not replace the existing path? + +The untyped `QueryAsync()` returning `DynamoRecord` is still valuable for: + +- Dynamic queries where the schema isn't known at compile time +- Debugging and inspection of raw DynamoDB responses +- Backwards compatibility with existing code + +Both paths coexist with zero interference. + +### Why `ref Utf8JsonReader` instead of `ReadOnlySpan`? + +The `DynamoJsonReader` delegate takes `ref Utf8JsonReader` because: + +1. The reader maintains position state as it traverses nested objects +2. The caller (`DynamoResponseReader`) positions the reader at each item's `StartObject` and the reader continues from where the entity reader leaves off +3. This enables single-pass parsing of the entire response with no backtracking + +## Future Optimisations + +### Remaining Memory Gap + +After pooled response buffers, the remaining memory allocations come from: + +1. **`List` growth** — The items list starts empty and grows via array doubling. Pre-allocating based on `Count` from the response would eliminate resize copies, but DynamoDB doesn't guarantee field ordering (Count may appear after Items). +2. **String value allocations** — `reader.GetString()` for property *values* (not names or type descriptors — those are already optimised) still allocates. This is inherent for string properties but could be reduced for numeric parsing by using `reader.GetInt32()` directly on the N value where the number fits. +3. **Collection allocations** — Each `List`, `HashSet` per entity property allocates. Could use pooled collections or array-backed storage. + +### Planned Features + +- **Typed `PutItemAsync`** — Use generated `WriteToJson` for zero-allocation request serialization +- **Typed `ScanAsync`, `GetItemAsync`** — Same pattern as `QueryAsync`, minimal additional code +- **Generated request serialization** — Bypass STJ for `QueryRequest` serialization too +- **`Utf8JsonReader.GetInt32()` for N type** — Skip the string allocation for numeric DynamoDB values when the target type matches directly diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs new file mode 100644 index 00000000..bfb12a1f --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -0,0 +1,1214 @@ +using Microsoft.CodeAnalysis; +using Goa.Clients.Dynamo.Generator.Models; +using Goa.Clients.Dynamo.Generator.TypeHandlers; + +namespace Goa.Clients.Dynamo.Generator.CodeGeneration; + +/// +/// Generates DynamoJsonMapper classes for directly reading/writing DynamoDB JSON wire format +/// to/from entities, bypassing intermediate DynamoRecord allocations. +/// +public class JsonMapperGenerator : ICodeGenerator +{ + private readonly TypeHandlerRegistry _typeHandlerRegistry; + private int _variableCounter; + + public JsonMapperGenerator(TypeHandlerRegistry typeHandlerRegistry) + { + _typeHandlerRegistry = typeHandlerRegistry; + } + + public string GenerateCode(IEnumerable types, GenerationContext context) + { + var typesByNamespace = types.GroupBy(t => t.Namespace).Where(x => x.Any()).ToList(); + + if (!typesByNamespace.Any()) + return string.Empty; + + var builder = new CodeBuilder(); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using System;"); + builder.AppendLine("using System.Buffers.Text;"); + builder.AppendLine("using System.Collections.Generic;"); + builder.AppendLine("using System.Globalization;"); + builder.AppendLine("using System.Text.Json;"); + + foreach (var ns in typesByNamespace) + { + var targetNamespace = string.IsNullOrEmpty(ns.Key) ? "Generated" : ns.Key; + builder.AppendLine(); + builder.AppendLine($"namespace {targetNamespace}"); + builder.OpenBrace(); + { + builder.OpenBraceWithLine("public static class DynamoJsonMapper"); + + foreach (var type in ns) + { + builder.AppendLine($"// {type.FullName}"); + GenerateTypeMapper(builder, type, context); + } + + builder.CloseBrace(); + } + builder.CloseBrace(); + } + + return builder.ToString(); + } + + private void GenerateTypeMapper(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + var dynamoModelAttr = type.Attributes.OfType().FirstOrDefault(); + + // Skip abstract types that don't have concrete subtypes and don't have [DynamoModel] directly + if (type.IsAbstract && !HasConcreteSubtypes(type, context) && dynamoModelAttr == null) + return; + + var normalizedTypeName = NamingHelpers.NormalizeTypeName(type.Name); + + builder.AppendLine(); + builder.OpenBraceWithLine($"public static class {normalizedTypeName}"); + + GenerateWriteToJson(builder, type, context); + GenerateReadFromJson(builder, type, context); + + builder.CloseBrace(); + } + + // ─── WriteToJson ──────────────────────────────────────────────────── + + private void GenerateWriteToJson(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + builder.AppendLine(); + builder.OpenBraceWithLine($"public static void WriteToJson(System.Text.Json.Utf8JsonWriter writer, {type.FullName} model)"); + + if (type.IsAbstract && HasConcreteSubtypes(type, context)) + { + GenerateAbstractWriteDispatch(builder, type, context); + } + else + { + GenerateConcreteWriteToJson(builder, type, context); + } + + builder.CloseBrace(); + } + + private void GenerateAbstractWriteDispatch(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + builder.OpenBraceWithLine("switch (model)"); + + if (context.TypeRegistry.TryGetValue(type.FullName, out var concreteTypes)) + { + foreach (var concreteType in concreteTypes) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(concreteType.Name); + builder.AppendLine($"case {concreteType.FullName} concrete:"); + builder.Indent(); + builder.AppendLine($"DynamoJsonMapper.{normalizedTypeName}.WriteToJson(writer, concrete);"); + builder.AppendLine("return;"); + builder.Unindent(); + } + } + + builder.AppendLine("default:"); + builder.Indent(); + builder.AppendLine($"throw new InvalidOperationException($\"Unknown concrete type: {{model.GetType().FullName}} for abstract type {type.FullName}\");"); + builder.Unindent(); + builder.CloseBrace(); + } + + private void GenerateConcreteWriteToJson(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + builder.AppendLine("writer.WriteStartObject();"); + + // Add type discriminator for inheritance + var needsDiscriminator = type.IsAbstract || HasConcreteSubtypes(type, context) || InheritsFromAbstractType(type); + if (needsDiscriminator) + { + var dynamoModelAttr = type.Attributes.OfType().FirstOrDefault() + ?? GetInheritedDynamoModelAttribute(type); + var typeNameField = (dynamoModelAttr?.TypeName != "Type" ? dynamoModelAttr?.TypeName : null) + ?? GetInheritedDynamoModelAttribute(type)?.TypeName + ?? "Type"; + + builder.AppendLine($"writer.WritePropertyName(\"{typeNameField}\");"); + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", \"{type.FullName}\"); writer.WriteEndObject();"); + } + + var allProperties = GetAllProperties(type); + var supportedProperties = allProperties + .Where(p => _typeHandlerRegistry.CanHandle(p) && !p.IsIgnored(IgnoreDirection.WhenWriting)); + + foreach (var property in supportedProperties) + { + var attrName = property.GetDynamoAttributeName(); + GenerateWriteProperty(builder, property, attrName, "model"); + } + + builder.AppendLine("writer.WriteEndObject();"); + } + + private void GenerateWriteProperty(CodeBuilder builder, PropertyInfo property, string attrName, string modelVar) + { + var accessExpr = $"{modelVar}.{property.Name}"; + var underlyingType = property.UnderlyingType; + var isNullable = property.IsNullable; + + // Check for [UnixTimestamp] attribute + var hasUnixTimestamp = property.Attributes.Any(a => a is UnixTimestampAttributeInfo); + + // Dictionary handling + if (property.IsDictionary && property.DictionaryTypes.HasValue) + { + GenerateWriteDictionary(builder, property, attrName, modelVar); + return; + } + + // Collection handling + if (property.IsCollection && property.ElementType != null) + { + GenerateWriteCollection(builder, property, attrName, modelVar); + return; + } + + // Complex type handling (non-primitive, non-collection) + if (IsComplexType(underlyingType)) + { + GenerateWriteComplexType(builder, property, attrName, modelVar); + return; + } + + // Primitive handling + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + + if (isNullable) + { + var isString = underlyingType.SpecialType == SpecialType.System_String; + var nullCheck = isString + ? $"!string.IsNullOrEmpty({accessExpr})" + : $"{accessExpr} != null"; + + builder.OpenBraceWithLine($"if ({nullCheck})"); + EmitPrimitiveTypeWrapper(builder, property, isNullable, accessExpr, hasUnixTimestamp); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + } + else + { + // Non-nullable strings are reference types that could still be null at runtime + if (underlyingType.SpecialType == SpecialType.System_String) + { + builder.OpenBraceWithLine($"if (!string.IsNullOrEmpty({accessExpr}))"); + EmitPrimitiveTypeWrapper(builder, property, isNullable, accessExpr, hasUnixTimestamp); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + } + else + { + EmitPrimitiveTypeWrapper(builder, property, isNullable, accessExpr, hasUnixTimestamp); + } + } + } + + private void EmitPrimitiveTypeWrapper(CodeBuilder builder, PropertyInfo property, bool isNullable, string accessExpr, bool hasUnixTimestamp) + { + var underlyingType = property.UnderlyingType; + var valueAccess = isNullable && underlyingType.SpecialType != SpecialType.System_String ? $"{accessExpr}.Value" : accessExpr; + + // UnixTimestamp DateTime/DateTimeOffset -> N + if (hasUnixTimestamp) + { + var unixAttr = property.Attributes.OfType().First(); + var isMilliseconds = unixAttr.Format == UnixTimestampFormat.Milliseconds; + var method = isMilliseconds ? "ToUnixTimeMilliseconds" : "ToUnixTimeSeconds"; + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"N\", ((DateTimeOffset){valueAccess}).{method}().ToString()); writer.WriteEndObject();"); + return; + } + + switch (underlyingType.SpecialType) + { + case SpecialType.System_String: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}); writer.WriteEndObject();"); + break; + + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"N\", {valueAccess}.ToString(CultureInfo.InvariantCulture)); writer.WriteEndObject();"); + break; + + case SpecialType.System_Boolean: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteBoolean(\"BOOL\", {valueAccess}); writer.WriteEndObject();"); + break; + + case SpecialType.System_Char: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString()); writer.WriteEndObject();"); + break; + + case SpecialType.System_DateTime: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString(\"o\")); writer.WriteEndObject();"); + break; + + default: + if (underlyingType.Name == "DateTimeOffset") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString(\"o\")); writer.WriteEndObject();"); + else if (underlyingType.Name == "Guid") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString()); writer.WriteEndObject();"); + else if (underlyingType.Name == "TimeSpan") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString()); writer.WriteEndObject();"); + else if (underlyingType.Name == "DateOnly") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString(\"yyyy-MM-dd\")); writer.WriteEndObject();"); + else if (underlyingType.Name == "TimeOnly") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString(\"HH:mm:ss.fffffff\")); writer.WriteEndObject();"); + else if (underlyingType.TypeKind == TypeKind.Enum) + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString()); writer.WriteEndObject();"); + else + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {valueAccess}.ToString()); writer.WriteEndObject();"); + break; + } + } + + private void GenerateWriteComplexType(CodeBuilder builder, PropertyInfo property, string attrName, string modelVar) + { + var accessExpr = $"{modelVar}.{property.Name}"; + var normalizedTypeName = NamingHelpers.NormalizeTypeName(property.UnderlyingType.Name); + + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); + builder.AppendLine("writer.WriteStartObject(); writer.WritePropertyName(\"M\");"); + builder.AppendLine($"DynamoJsonMapper.{normalizedTypeName}.WriteToJson(writer, {accessExpr});"); + builder.AppendLine("writer.WriteEndObject();"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + } + + private void GenerateWriteCollection(CodeBuilder builder, PropertyInfo property, string attrName, string modelVar) + { + var accessExpr = $"{modelVar}.{property.Name}"; + var elementType = property.ElementType!; + + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + + // Determine if this is a set type (HashSet, ISet, IReadOnlySet) + var isSetType = IsSetType(property.Type); + + // String set -> SS + if (isSetType && elementType.SpecialType == SpecialType.System_String) + { + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); + builder.AppendLine("writer.WriteStartObject(); writer.WritePropertyName(\"SS\"); writer.WriteStartArray();"); + builder.AppendLine($"foreach (var item in {accessExpr}) writer.WriteStringValue(item);"); + builder.AppendLine("writer.WriteEndArray(); writer.WriteEndObject();"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + return; + } + + // Number set -> NS + if (isSetType && IsNumericType(elementType)) + { + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); + builder.AppendLine("writer.WriteStartObject(); writer.WritePropertyName(\"NS\"); writer.WriteStartArray();"); + builder.AppendLine($"foreach (var item in {accessExpr}) writer.WriteStringValue(item.ToString(CultureInfo.InvariantCulture));"); + builder.AppendLine("writer.WriteEndArray(); writer.WriteEndObject();"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + return; + } + + // General list -> L + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); + builder.AppendLine("writer.WriteStartObject(); writer.WritePropertyName(\"L\"); writer.WriteStartArray();"); + builder.OpenBraceWithLine($"foreach (var item in {accessExpr})"); + EmitWriteElementValue(builder, elementType, "item"); + builder.CloseBrace(); + builder.AppendLine("writer.WriteEndArray(); writer.WriteEndObject();"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + } + + /// + /// Emits a single type-wrapped value for an element inside a List (L type). + /// + private void EmitWriteElementValue(CodeBuilder builder, ITypeSymbol elementType, string varName) + { + if (IsComplexType(elementType)) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(elementType.Name); + builder.AppendLine($"writer.WriteStartObject(); writer.WritePropertyName(\"M\");"); + builder.AppendLine($"DynamoJsonMapper.{normalizedTypeName}.WriteToJson(writer, {varName});"); + builder.AppendLine("writer.WriteEndObject();"); + return; + } + + switch (elementType.SpecialType) + { + case SpecialType.System_String: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}); writer.WriteEndObject();"); + break; + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"N\", {varName}.ToString(CultureInfo.InvariantCulture)); writer.WriteEndObject();"); + break; + case SpecialType.System_Boolean: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteBoolean(\"BOOL\", {varName}); writer.WriteEndObject();"); + break; + case SpecialType.System_Char: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString()); writer.WriteEndObject();"); + break; + case SpecialType.System_DateTime: + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString(\"o\")); writer.WriteEndObject();"); + break; + default: + if (elementType.Name == "DateTimeOffset") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString(\"o\")); writer.WriteEndObject();"); + else if (elementType.Name == "Guid") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString()); writer.WriteEndObject();"); + else if (elementType.Name == "TimeSpan") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString()); writer.WriteEndObject();"); + else if (elementType.Name == "DateOnly") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString(\"yyyy-MM-dd\")); writer.WriteEndObject();"); + else if (elementType.Name == "TimeOnly") + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString(\"HH:mm:ss.fffffff\")); writer.WriteEndObject();"); + else if (elementType.TypeKind == TypeKind.Enum) + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString()); writer.WriteEndObject();"); + else + builder.AppendLine($"writer.WriteStartObject(); writer.WriteString(\"S\", {varName}.ToString()); writer.WriteEndObject();"); + break; + } + } + + private void GenerateWriteDictionary(CodeBuilder builder, PropertyInfo property, string attrName, string modelVar) + { + var accessExpr = $"{modelVar}.{property.Name}"; + var dictionaryTypes = property.DictionaryTypes!.Value; + var keyType = dictionaryTypes.KeyType; + var valueType = dictionaryTypes.ValueType; + + // Only string-keyed dictionaries are supported (DynamoDB limitation) + if (keyType.SpecialType != SpecialType.System_String) + { + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + return; + } + + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); + builder.AppendLine("writer.WriteStartObject(); writer.WritePropertyName(\"M\"); writer.WriteStartObject();"); + builder.OpenBraceWithLine($"foreach (var kvp in {accessExpr})"); + builder.AppendLine("writer.WritePropertyName(kvp.Key);"); + EmitWriteTypeWrappedValue(builder, valueType, "kvp.Value"); + builder.CloseBrace(); + builder.AppendLine("writer.WriteEndObject(); writer.WriteEndObject();"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("writer.WriteStartObject(); writer.WriteBoolean(\"NULL\", true); writer.WriteEndObject();"); + builder.CloseBrace(); + } + + /// + /// Emits a type-wrapped value (the {"S": ...} / {"N": ...} etc. wrapper) for a given value expression. + /// Used for dictionary values and similar contexts. + /// + private void EmitWriteTypeWrappedValue(CodeBuilder builder, ITypeSymbol valueType, string valueExpr) + { + if (IsComplexType(valueType)) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(valueType.Name); + builder.AppendLine($"writer.WriteStartObject(); writer.WritePropertyName(\"M\");"); + builder.AppendLine($"DynamoJsonMapper.{normalizedTypeName}.WriteToJson(writer, {valueExpr});"); + builder.AppendLine("writer.WriteEndObject();"); + return; + } + + // Reuse the element write logic (same type-wrapper patterns) + EmitWriteElementValue(builder, valueType, valueExpr); + } + + // ─── ReadFromJson ─────────────────────────────────────────────────── + + private void GenerateReadFromJson(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + var dynamoModelAttr = type.Attributes.OfType().FirstOrDefault(); + if (dynamoModelAttr == null) + dynamoModelAttr = GetInheritedDynamoModelAttribute(type); + + var inheritsDynamoModel = HasInheritedDynamoModel(type); + var shouldGenerate = dynamoModelAttr != null || + (type.IsAbstract && HasConcreteSubtypes(type, context)) || + (!type.IsAbstract && dynamoModelAttr == null && !inheritsDynamoModel) || + inheritsDynamoModel; + + if (!shouldGenerate) + return; + + builder.AppendLine(); + builder.OpenBraceWithLine($"public static {type.FullName} ReadFromJson(ref System.Text.Json.Utf8JsonReader reader)"); + + // Reset variable counter for each method + _variableCounter = 0; + + if (type.IsAbstract && HasConcreteSubtypes(type, context)) + { + GenerateAbstractReadDispatch(builder, type, context); + } + else + { + GenerateConcreteReadFromJson(builder, type); + } + + builder.CloseBrace(); + } + + private void GenerateAbstractReadDispatch(CodeBuilder builder, DynamoTypeInfo type, GenerationContext context) + { + var dynamoModelAttr = type.Attributes.OfType().FirstOrDefault() + ?? GetInheritedDynamoModelAttribute(type); + var typeNameField = dynamoModelAttr?.TypeName ?? "Type"; + + // For abstract types we need to peek at the type discriminator. + // We copy the reader, scan for the discriminator field, then dispatch. + builder.AppendLine("// Copy reader to peek at type discriminator"); + builder.AppendLine("var readerCopy = reader;"); + builder.AppendLine("string? typeDiscriminator = null;"); + builder.AppendLine("if (readerCopy.TokenType != JsonTokenType.StartObject)"); + builder.Indent().AppendLine("readerCopy.Read();").Unindent(); + builder.OpenBraceWithLine("while (readerCopy.Read())"); + builder.AppendLine("if (readerCopy.TokenType == JsonTokenType.EndObject) break;"); + builder.AppendLine("if (readerCopy.TokenType != JsonTokenType.PropertyName) continue;"); + builder.AppendLine("var peekPropName = readerCopy.GetString();"); + builder.OpenBraceWithLine($"if (peekPropName == \"{typeNameField}\")"); + builder.AppendLine("readerCopy.Read(); // StartObject of {\"S\": ...}"); + builder.AppendLine("readerCopy.Read(); // \"S\""); + builder.AppendLine("readerCopy.Read(); // value"); + builder.AppendLine("typeDiscriminator = readerCopy.GetString();"); + builder.AppendLine("break;"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("readerCopy.Read(); // move to value"); + builder.AppendLine("readerCopy.Skip(); // skip the value"); + builder.CloseBrace(); + builder.CloseBrace(); + builder.AppendLine(); + builder.AppendLine($"if (typeDiscriminator == null)"); + builder.Indent().AppendLine($"throw new InvalidOperationException(\"Missing {typeNameField} discriminator for abstract type {type.FullName}\");").Unindent(); + builder.AppendLine(); + builder.OpenBraceWithLine("return typeDiscriminator switch"); + + if (context.TypeRegistry.TryGetValue(type.FullName, out var concreteTypes)) + { + foreach (var concreteType in concreteTypes) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(concreteType.Name); + builder.AppendLine($"\"{concreteType.FullName}\" => DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader),"); + } + } + + builder.AppendLine($"_ => throw new InvalidOperationException($\"Unknown type: {{typeDiscriminator}} for abstract type {type.FullName}\")"); + builder.CloseBrace().Append(";"); + builder.AppendLine(); + } + + private void GenerateConcreteReadFromJson(CodeBuilder builder, DynamoTypeInfo type) + { + builder.AppendLine("if (reader.TokenType != JsonTokenType.StartObject)"); + builder.Indent().AppendLine("reader.Read();").Unindent(); + builder.AppendLine(); + builder.AppendLine($"var result = new {type.FullName}();"); + builder.OpenBraceWithLine("while (reader.Read())"); + builder.AppendLine("if (reader.TokenType == JsonTokenType.EndObject) break;"); + builder.AppendLine(); + + var allProperties = GetAllProperties(type); + var readableProperties = allProperties + .Where(p => _typeHandlerRegistry.CanHandle(p) && !p.IsIgnored(IgnoreDirection.WhenReading) && p.Symbol?.SetMethod != null) + .ToList(); + + var isFirst = true; + foreach (var property in readableProperties) + { + var attrName = property.GetDynamoAttributeName(); + var keyword = isFirst ? "if" : "else if"; + builder.OpenBraceWithLine($"{keyword} (reader.ValueTextEquals(\"{attrName}\"u8))"); + GenerateReadProperty(builder, property, "result"); + builder.CloseBrace(); + isFirst = false; + } + + if (readableProperties.Count > 0) + { + builder.OpenBraceWithLine("else"); + } + builder.AppendLine("reader.Read(); // Move past property name to value"); + builder.AppendLine("reader.Skip(); // Skip the type wrapper object"); + if (readableProperties.Count > 0) + { + builder.CloseBrace(); + } + + builder.CloseBrace(); // while + builder.AppendLine("return result;"); + } + + private void GenerateReadProperty(CodeBuilder builder, PropertyInfo property, string resultVar) + { + var underlyingType = property.UnderlyingType; + var isNullable = property.IsNullable; + var hasUnixTimestamp = property.Attributes.Any(a => a is UnixTimestampAttributeInfo); + + // Dictionary handling + if (property.IsDictionary && property.DictionaryTypes.HasValue) + { + GenerateReadDictionary(builder, property, resultVar); + return; + } + + // Collection handling + if (property.IsCollection && property.ElementType != null) + { + GenerateReadCollection(builder, property, resultVar); + return; + } + + // Complex type handling + if (IsComplexType(underlyingType)) + { + GenerateReadComplexType(builder, property, resultVar); + return; + } + + // Primitive handling: read the type wrapper object + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (S, N, BOOL, NULL, etc.)"); + + if (isNullable) + { + // Check if NULL descriptor using zero-allocation UTF-8 comparison + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"NULL\"u8))"); + builder.AppendLine("reader.Read(); // value (true)"); + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value"); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp); + builder.CloseBrace(); + } + else + { + builder.AppendLine("reader.Read(); // value"); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp); + } + + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void EmitReadPrimitiveAssignment(CodeBuilder builder, PropertyInfo property, string resultVar, bool hasUnixTimestamp) + { + var underlyingType = property.UnderlyingType; + var propAccess = $"{resultVar}.{property.Name}"; + + // UnixTimestamp DateTime/DateTimeOffset -> read N as number + if (hasUnixTimestamp) + { + var unixAttr = property.Attributes.OfType().First(); + var isMilliseconds = unixAttr.Format == UnixTimestampFormat.Milliseconds; + if (underlyingType.Name == "DateTimeOffset") + { + var method = isMilliseconds ? "FromUnixTimeMilliseconds" : "FromUnixTimeSeconds"; + builder.AppendLine($"{propAccess} = DateTimeOffset.{method}(long.Parse(reader.GetString()!, CultureInfo.InvariantCulture));"); + } + else + { + var method = isMilliseconds ? "FromUnixTimeMilliseconds" : "FromUnixTimeSeconds"; + builder.AppendLine($"{propAccess} = DateTimeOffset.{method}(long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)).UtcDateTime;"); + } + return; + } + + // Try Utf8Parser for numeric types to avoid string allocation + var utf8Expr = GetUtf8NumericParseExpression(underlyingType, property.Name); + if (utf8Expr != null) + { + builder.AppendLine($"{utf8Expr};"); + builder.AppendLine($"{propAccess} = {property.Name}Parsed;"); + return; + } + + switch (underlyingType.SpecialType) + { + case SpecialType.System_String: + builder.AppendLine($"{propAccess} = reader.GetString()!;"); + break; + case SpecialType.System_Boolean: + builder.AppendLine($"{propAccess} = reader.GetBoolean();"); + break; + case SpecialType.System_Char: + builder.AppendLine($"{propAccess} = reader.GetString()![0];"); + break; + case SpecialType.System_DateTime: + builder.AppendLine($"{propAccess} = DateTime.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);"); + break; + default: + if (underlyingType.Name == "DateTimeOffset") + builder.AppendLine($"{propAccess} = DateTimeOffset.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);"); + else if (underlyingType.Name == "Guid") + builder.AppendLine($"{propAccess} = Guid.Parse(reader.GetString()!);"); + else if (underlyingType.Name == "TimeSpan") + builder.AppendLine($"{propAccess} = TimeSpan.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (underlyingType.Name == "DateOnly") + builder.AppendLine($"{propAccess} = DateOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (underlyingType.Name == "TimeOnly") + builder.AppendLine($"{propAccess} = TimeOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (underlyingType.TypeKind == TypeKind.Enum) + builder.AppendLine($"{propAccess} = Enum.Parse<{underlyingType.ToDisplayString()}>(reader.GetString()!);"); + else + builder.AppendLine($"{propAccess} = reader.GetString()!;"); + break; + } + } + + private void GenerateReadComplexType(CodeBuilder builder, PropertyInfo property, string resultVar) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(property.UnderlyingType.Name); + + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (M or NULL)"); + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"M\"u8))"); + builder.AppendLine("reader.Read(); // StartObject of the nested entity"); + builder.AppendLine($"{resultVar}.{property.Name} = DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader);"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void GenerateReadCollection(CodeBuilder builder, PropertyInfo property, string resultVar) + { + var elementType = property.ElementType!; + var isSetType = IsSetType(property.Type); + var elementTypeName = elementType.ToDisplayString(); + + // Determine the collection type and DynamoDB wire type + if (isSetType && elementType.SpecialType == SpecialType.System_String) + { + // SS type + GenerateReadStringSet(builder, property, resultVar); + return; + } + + if (isSetType && IsNumericType(elementType)) + { + // NS type + GenerateReadNumberSet(builder, property, resultVar, elementType); + return; + } + + // L type (general list) + GenerateReadList(builder, property, resultVar, elementType); + } + + private void GenerateReadStringSet(CodeBuilder builder, PropertyInfo property, string resultVar) + { + var collectionTypeName = GetCollectionTypeName(property.Type); + var varName = GetUniqueVarName("ss"); + + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (SS or NULL)"); + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"SS\"u8))"); + builder.AppendLine("reader.Read(); // StartArray"); + builder.AppendLine($"var {varName}Set = new HashSet();"); + builder.OpenBraceWithLine($"while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)"); + builder.AppendLine($"{varName}Set.Add(reader.GetString()!);"); + builder.CloseBrace(); + builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, property.ElementType!, $"{varName}Set")};"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void GenerateReadNumberSet(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType) + { + var elementTypeName = elementType.ToDisplayString(); + var varName = GetUniqueVarName("ns"); + + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (NS or NULL)"); + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"NS\"u8))"); + builder.AppendLine("reader.Read(); // StartArray"); + builder.AppendLine($"var {varName}Set = new HashSet<{elementTypeName}>();"); + builder.OpenBraceWithLine($"while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)"); + + var utf8NsExpr = GetUtf8NumericParseExpression(elementType, $"{varName}Elem"); + if (utf8NsExpr != null) + { + builder.AppendLine($"{utf8NsExpr};"); + builder.AppendLine($"{varName}Set.Add({varName}ElemParsed);"); + } + else + { + var parseExpr = GetNumericParseExpression(elementType, "reader.GetString()!"); + builder.AppendLine($"{varName}Set.Add({parseExpr});"); + } + builder.CloseBrace(); + builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}Set")};"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void GenerateReadList(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType) + { + var elementTypeName = elementType.ToDisplayString(); + var varName = GetUniqueVarName("l"); + + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (L or NULL)"); + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"L\"u8))"); + builder.AppendLine("reader.Read(); // StartArray"); + builder.AppendLine($"var {varName}List = new List<{elementTypeName}>();"); + builder.OpenBraceWithLine($"while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)"); + + // Each element in the array is a type-wrapped value + EmitReadElementValue(builder, elementType, $"{varName}List"); + + builder.CloseBrace(); // while + builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}List")};"); + builder.CloseBrace(); // if L + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + /// + /// Emits code to read a single type-wrapped element from a list. + /// The reader is positioned at the StartObject of the element's type wrapper. + /// + private void EmitReadElementValue(CodeBuilder builder, ITypeSymbol elementType, string listVar) + { + if (IsComplexType(elementType)) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(elementType.Name); + builder.AppendLine("// Element is {\"M\": {...}}"); + builder.AppendLine("reader.Read(); // \"M\""); + builder.AppendLine("reader.Read(); // StartObject of nested entity"); + builder.AppendLine($"{listVar}.Add(DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader));"); + builder.AppendLine("reader.Read(); // EndObject of element wrapper"); + return; + } + + // Primitive element: {"S": "..."} or {"N": "..."} or {"BOOL": ...} + builder.AppendLine("reader.Read(); // type descriptor"); + builder.AppendLine("reader.Read(); // value"); + + switch (elementType.SpecialType) + { + case SpecialType.System_String: + builder.AppendLine($"{listVar}.Add(reader.GetString()!);"); + break; + case SpecialType.System_Boolean: + builder.AppendLine($"{listVar}.Add(reader.GetBoolean());"); + break; + case SpecialType.System_Char: + builder.AppendLine($"{listVar}.Add(reader.GetString()![0]);"); + break; + case SpecialType.System_DateTime: + builder.AppendLine($"{listVar}.Add(DateTime.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));"); + break; + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + { + var utf8Expr = GetUtf8NumericParseExpression(elementType, $"{listVar}Elem"); + if (utf8Expr != null) + { + builder.AppendLine($"{utf8Expr};"); + builder.AppendLine($"{listVar}.Add({listVar}ElemParsed);"); + } + else + { + builder.AppendLine($"{listVar}.Add({GetNumericParseExpression(elementType, "reader.GetString()!")});"); + } + break; + } + default: + if (elementType.Name == "DateTimeOffset") + builder.AppendLine($"{listVar}.Add(DateTimeOffset.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));"); + else if (elementType.Name == "Guid") + builder.AppendLine($"{listVar}.Add(Guid.Parse(reader.GetString()!));"); + else if (elementType.Name == "TimeSpan") + builder.AppendLine($"{listVar}.Add(TimeSpan.Parse(reader.GetString()!, CultureInfo.InvariantCulture));"); + else if (elementType.Name == "DateOnly") + builder.AppendLine($"{listVar}.Add(DateOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture));"); + else if (elementType.Name == "TimeOnly") + builder.AppendLine($"{listVar}.Add(TimeOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture));"); + else if (elementType.TypeKind == TypeKind.Enum) + builder.AppendLine($"{listVar}.Add(Enum.Parse<{elementType.ToDisplayString()}>(reader.GetString()!));"); + else + builder.AppendLine($"{listVar}.Add(reader.GetString()!);"); + break; + } + + builder.AppendLine("reader.Read(); // EndObject of element wrapper"); + } + + private void GenerateReadDictionary(CodeBuilder builder, PropertyInfo property, string resultVar) + { + var dictionaryTypes = property.DictionaryTypes!.Value; + var keyType = dictionaryTypes.KeyType; + var valueType = dictionaryTypes.ValueType; + var keyTypeName = keyType.ToDisplayString(); + var valueTypeName = valueType.ToDisplayString(); + var varName = GetUniqueVarName("dict"); + + builder.AppendLine("reader.Read(); // StartObject of type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor (M or NULL)"); + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"M\"u8))"); + builder.AppendLine("reader.Read(); // StartObject of the map"); + builder.AppendLine($"var {varName}Map = new Dictionary<{keyTypeName}, {valueTypeName}>();"); + builder.OpenBraceWithLine($"while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)"); + builder.AppendLine($"var {varName}Key = reader.GetString()!;"); + + // Read the type-wrapped value for this dictionary entry + EmitReadDictionaryValue(builder, valueType, $"{varName}Map", $"{varName}Key"); + + builder.CloseBrace(); // while + builder.AppendLine($"{resultVar}.{property.Name} = {varName}Map;"); + builder.CloseBrace(); // if M + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void EmitReadDictionaryValue(CodeBuilder builder, ITypeSymbol valueType, string mapVar, string keyVar) + { + if (IsComplexType(valueType)) + { + var normalizedTypeName = NamingHelpers.NormalizeTypeName(valueType.Name); + builder.AppendLine("reader.Read(); // StartObject of value type wrapper"); + builder.AppendLine("reader.Read(); // \"M\""); + builder.AppendLine("reader.Read(); // StartObject of nested entity"); + builder.AppendLine($"{mapVar}[{keyVar}] = DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader);"); + builder.AppendLine("reader.Read(); // EndObject of value type wrapper"); + return; + } + + // Primitive value: read type wrapper + builder.AppendLine("reader.Read(); // StartObject of value type wrapper"); + builder.AppendLine("reader.Read(); // type descriptor"); + builder.AppendLine("reader.Read(); // value"); + + // Try Utf8Parser for numeric dictionary values + var dictUtf8Expr = GetUtf8NumericParseExpression(valueType, $"{mapVar}Val"); + if (dictUtf8Expr != null) + { + builder.AppendLine($"{dictUtf8Expr};"); + builder.AppendLine($"{mapVar}[{keyVar}] = {mapVar}ValParsed;"); + builder.AppendLine("reader.Read(); // EndObject of value type wrapper"); + return; + } + + switch (valueType.SpecialType) + { + case SpecialType.System_String: + builder.AppendLine($"{mapVar}[{keyVar}] = reader.GetString()!;"); + break; + case SpecialType.System_Boolean: + builder.AppendLine($"{mapVar}[{keyVar}] = reader.GetBoolean();"); + break; + case SpecialType.System_DateTime: + builder.AppendLine($"{mapVar}[{keyVar}] = DateTime.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);"); + break; + case SpecialType.System_Char: + builder.AppendLine($"{mapVar}[{keyVar}] = reader.GetString()![0];"); + break; + default: + if (valueType.Name == "DateTimeOffset") + builder.AppendLine($"{mapVar}[{keyVar}] = DateTimeOffset.ParseExact(reader.GetString()!, \"o\", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);"); + else if (valueType.Name == "Guid") + builder.AppendLine($"{mapVar}[{keyVar}] = Guid.Parse(reader.GetString()!);"); + else if (valueType.Name == "TimeSpan") + builder.AppendLine($"{mapVar}[{keyVar}] = TimeSpan.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (valueType.Name == "DateOnly") + builder.AppendLine($"{mapVar}[{keyVar}] = DateOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (valueType.Name == "TimeOnly") + builder.AppendLine($"{mapVar}[{keyVar}] = TimeOnly.Parse(reader.GetString()!, CultureInfo.InvariantCulture);"); + else if (valueType.TypeKind == TypeKind.Enum) + builder.AppendLine($"{mapVar}[{keyVar}] = Enum.Parse<{valueType.ToDisplayString()}>(reader.GetString()!);"); + else + builder.AppendLine($"{mapVar}[{keyVar}] = reader.GetString()!;"); + break; + } + + builder.AppendLine("reader.Read(); // EndObject of value type wrapper"); + } + + // ─── Helper methods ───────────────────────────────────────────────── + + private List GetAllProperties(DynamoTypeInfo type) + { + var properties = new List(); + var current = type; + + while (current != null) + { + properties.AddRange(current.Properties); + current = current.BaseType; + } + + // Remove duplicates - keep most derived version + var uniqueProperties = new Dictionary(); + foreach (var prop in properties) + { + if (!uniqueProperties.ContainsKey(prop.Name)) + { + uniqueProperties[prop.Name] = prop; + } + } + + return uniqueProperties.Values.ToList(); + } + + private static bool HasConcreteSubtypes(DynamoTypeInfo type, GenerationContext context) + { + return context.TypeRegistry.ContainsKey(type.FullName); + } + + private static bool HasInheritedDynamoModel(DynamoTypeInfo type) + { + var current = type.BaseType; + while (current != null) + { + if (current.Attributes.Any(a => a is DynamoModelAttributeInfo)) + return true; + current = current.BaseType; + } + return false; + } + + private static DynamoModelAttributeInfo? GetInheritedDynamoModelAttribute(DynamoTypeInfo type) + { + var current = type.BaseType; + while (current != null) + { + var attr = current.Attributes.OfType().FirstOrDefault(); + if (attr != null) + return attr; + current = current.BaseType; + } + return null; + } + + private static bool InheritsFromAbstractType(DynamoTypeInfo type) + { + var current = type.BaseType; + while (current != null) + { + if (current.IsAbstract) + return true; + current = current.BaseType; + } + return false; + } + + private static bool IsComplexType(ITypeSymbol type) + { + if (type.SpecialType != SpecialType.None) + return false; + + if (type.Name == "Guid" || type.Name == "TimeSpan" || type.Name == "DateTimeOffset" || + type.Name == "DateOnly" || type.Name == "TimeOnly") + return false; + + if (type.TypeKind == TypeKind.Enum) + return false; + + // Exclude collections + if (type is IArrayTypeSymbol) + return false; + if (type is INamedTypeSymbol namedType && namedType.IsGenericType) + { + var name = namedType.Name; + if (name == "List" || name == "IList" || name == "ICollection" || name == "IEnumerable" || + name == "HashSet" || name == "ISet" || name == "IReadOnlyCollection" || + name == "IReadOnlyList" || name == "IReadOnlySet" || name == "Collection" || + name == "Dictionary" || name == "IDictionary" || name == "IReadOnlyDictionary" || + name == "Nullable") + return false; + } + + return type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct || type.IsRecord; + } + + private static bool IsNumericType(ITypeSymbol type) + { + return type.SpecialType switch + { + SpecialType.System_Byte or SpecialType.System_SByte or + SpecialType.System_Int16 or SpecialType.System_UInt16 or + SpecialType.System_Int32 or SpecialType.System_UInt32 or + SpecialType.System_Int64 or SpecialType.System_UInt64 or + SpecialType.System_Decimal or SpecialType.System_Single or SpecialType.System_Double => true, + _ => false + }; + } + + private static bool IsSetType(ITypeSymbol type) + { + if (type is INamedTypeSymbol namedType && namedType.IsGenericType) + { + var name = namedType.Name; + return name == "HashSet" || name == "ISet" || name == "IReadOnlySet"; + } + return false; + } + + private static string GetNumericParseExpression(ITypeSymbol elementType, string readerExpr) + { + return elementType.SpecialType switch + { + SpecialType.System_Byte => $"byte.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_SByte => $"sbyte.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Int16 => $"short.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_UInt16 => $"ushort.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Int32 => $"int.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_UInt32 => $"uint.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Int64 => $"long.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_UInt64 => $"ulong.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Decimal => $"decimal.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Single => $"float.Parse({readerExpr}, CultureInfo.InvariantCulture)", + SpecialType.System_Double => $"double.Parse({readerExpr}, CultureInfo.InvariantCulture)", + _ => $"int.Parse({readerExpr}, CultureInfo.InvariantCulture)" + }; + } + + /// + /// Returns a Utf8Parser-based expression that parses a numeric value directly from reader.ValueSpan, + /// avoiding the string allocation from reader.GetString(). + /// Returns null if the type is not supported by Utf8Parser (e.g. decimal, float, double with DynamoDB's string encoding). + /// + private static string? GetUtf8NumericParseExpression(ITypeSymbol type, string targetExpr) + { + // Utf8Parser supports: byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal + // All parse from UTF-8 byte spans directly, avoiding string allocation. + // DynamoDB N values are JSON strings like "123", so reader.ValueSpan gives the raw UTF-8 bytes. + return type.SpecialType switch + { + SpecialType.System_Byte => $"Utf8Parser.TryParse(reader.ValueSpan, out byte {targetExpr}Parsed, out _)", + SpecialType.System_SByte => $"Utf8Parser.TryParse(reader.ValueSpan, out sbyte {targetExpr}Parsed, out _)", + SpecialType.System_Int16 => $"Utf8Parser.TryParse(reader.ValueSpan, out short {targetExpr}Parsed, out _)", + SpecialType.System_UInt16 => $"Utf8Parser.TryParse(reader.ValueSpan, out ushort {targetExpr}Parsed, out _)", + SpecialType.System_Int32 => $"Utf8Parser.TryParse(reader.ValueSpan, out int {targetExpr}Parsed, out _)", + SpecialType.System_UInt32 => $"Utf8Parser.TryParse(reader.ValueSpan, out uint {targetExpr}Parsed, out _)", + SpecialType.System_Int64 => $"Utf8Parser.TryParse(reader.ValueSpan, out long {targetExpr}Parsed, out _)", + SpecialType.System_UInt64 => $"Utf8Parser.TryParse(reader.ValueSpan, out ulong {targetExpr}Parsed, out _)", + SpecialType.System_Single => $"Utf8Parser.TryParse(reader.ValueSpan, out float {targetExpr}Parsed, out _)", + SpecialType.System_Double => $"Utf8Parser.TryParse(reader.ValueSpan, out double {targetExpr}Parsed, out _)", + SpecialType.System_Decimal => $"Utf8Parser.TryParse(reader.ValueSpan, out decimal {targetExpr}Parsed, out _)", + _ => null + }; + } + + private string GetUniqueVarName(string prefix) + { + return $"{prefix}{_variableCounter++}"; + } + + private static string GetCollectionTypeName(ITypeSymbol type) + { + if (type is INamedTypeSymbol namedType) + return namedType.Name; + if (type is IArrayTypeSymbol) + return "Array"; + return "List"; + } + + /// + /// Converts a source collection expression to the target collection type. + /// The source is always a List<T> (for lists) or HashSet<T> (for sets), + /// so we return the source directly when the target is compatible to avoid redundant copies. + /// + private static string ConvertToTargetCollection(ITypeSymbol targetType, ITypeSymbol elementType, string sourceExpr) + { + var elementTypeName = elementType.ToDisplayString(); + + if (targetType is IArrayTypeSymbol) + return $"{sourceExpr}.ToArray()"; + + if (targetType is INamedTypeSymbol namedType) + { + return namedType.Name switch + { + // Concrete List — assign directly, no copy needed + "List" => sourceExpr, + // Interface types — use T[] which is smaller than List and implements IList, IReadOnlyList, etc. + "IList" or "ICollection" or "IReadOnlyCollection" or "IReadOnlyList" => $"{sourceExpr}.ToArray()", + // IEnumerable — assign List directly, no need to copy + "IEnumerable" => sourceExpr, + // Concrete HashSet — assign directly + "HashSet" => sourceExpr, + // Interface set types — assign HashSet directly (implements ISet, IReadOnlySet) + "ISet" or "IReadOnlySet" => sourceExpr, + "Collection" => $"new System.Collections.ObjectModel.Collection<{elementTypeName}>({sourceExpr})", + _ => sourceExpr + }; + } + + return sourceExpr; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs new file mode 100644 index 00000000..c0fa72aa --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs @@ -0,0 +1,82 @@ +using Goa.Clients.Dynamo.Generator.Models; + +namespace Goa.Clients.Dynamo.Generator.CodeGeneration; + +/// +/// Generates a static registration class for all concrete DynamoDB model types. +/// Consumers call DynamoReaderRegistration.Initialize() once at startup +/// to trigger the static constructor, which registers all types with DynamoItemReaderRegistry. +/// +public class ReaderRegistrationGenerator : ICodeGenerator +{ + public string GenerateCode(IEnumerable types, GenerationContext context) + { + var registrableTypes = types.Where(ShouldRegister).ToList(); + + if (!registrableTypes.Any()) + return string.Empty; + + var typesByNamespace = registrableTypes.GroupBy(t => t.Namespace).Where(x => x.Any()).ToList(); + + if (!typesByNamespace.Any()) + return string.Empty; + + // Use the first namespace as the containing namespace for the registration class + var targetNamespace = typesByNamespace.First().Key; + if (string.IsNullOrEmpty(targetNamespace)) + targetNamespace = "Generated"; + + var builder = new CodeBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("using Goa.Clients.Dynamo;"); + builder.AppendLine(); + builder.AppendLine($"namespace {targetNamespace}"); + builder.OpenBrace(); + { + builder.OpenBraceWithLine("internal static class DynamoReaderRegistration"); + { + builder.OpenBraceWithLine("static DynamoReaderRegistration()"); + { + foreach (var type in registrableTypes) + { + var normalizedName = NamingHelpers.NormalizeTypeName(type.Name); + builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.ReadFromJson);"); + } + } + builder.CloseBrace(); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// Ensures all DynamoDB model readers are registered. Call once during application startup."); + builder.AppendLine("/// "); + builder.AppendLine("public static void Initialize() { }"); + } + builder.CloseBrace(); + } + builder.CloseBrace(); + + return builder.ToString(); + } + + private bool ShouldRegister(DynamoTypeInfo type) + { + if (type.IsAbstract) + return false; + + return type.Attributes.Any(a => a is DynamoModelAttributeInfo) || + HasInheritedDynamoModel(type); + } + + private bool HasInheritedDynamoModel(DynamoTypeInfo type) + { + var current = type.BaseType; + while (current != null) + { + if (current.Attributes.Any(a => a is DynamoModelAttributeInfo)) + return true; + + current = current.BaseType; + } + return false; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs index e5532cb2..1b33432a 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs @@ -111,6 +111,8 @@ private static void Execute(Compilation compilation, ImmutableArray ScanAllAsync(this IDynamoClie while (true); } + /// + /// Executes a typed DynamoDB Query operation with automatic pagination using an async iterator. + /// + public static async IAsyncEnumerable QueryAllAsync(this IDynamoClient client, string tableName, DynamoItemReader itemReader, Action builder, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var _builder = new QueryBuilder(tableName); + builder(_builder); + var request = _builder.Build(); + + do + { + var result = await client.QueryAsync(request, itemReader, cancellationToken); + if (result.IsError) + { + yield break; + } + + foreach (var item in result.Value.Items) + { + yield return item; + } + + if (!result.Value.HasMoreResults) + { + break; + } + + request.ExclusiveStartKey = result.Value.LastEvaluatedKey; + } + while (true); + } + + /// + /// Executes a typed DynamoDB Scan operation with automatic pagination using an async iterator. + /// + public static async IAsyncEnumerable ScanAllAsync(this IDynamoClient client, string tableName, DynamoItemReader itemReader, Action builder, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var _builder = new ScanBuilder(tableName); + builder(_builder); + var request = _builder.Build(); + + do + { + var result = await client.ScanAsync(request, itemReader, cancellationToken); + if (result.IsError) + { + yield break; + } + + foreach (var item in result.Value.Items) + { + yield return item; + } + + if (!result.Value.HasMoreResults) + { + break; + } + + request.ExclusiveStartKey = result.Value.LastEvaluatedKey; + } + while (true); + } + /// /// Executes a DynamoDB BatchGetItem operation with automatic retry of unprocessed keys using an async iterator. /// diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemReader.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemReader.cs new file mode 100644 index 00000000..c92961b7 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemReader.cs @@ -0,0 +1,9 @@ +namespace Goa.Clients.Dynamo; + +/// +/// Delegate that reads a single DynamoDB item from a UTF-8 JSON reader. +/// +/// The type to deserialize the item into. +/// The UTF-8 JSON reader positioned at the start of the item object. +/// The deserialized item. +public delegate T DynamoItemReader(ref System.Text.Json.Utf8JsonReader reader); diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs new file mode 100644 index 00000000..490fe5ab --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs @@ -0,0 +1,28 @@ +namespace Goa.Clients.Dynamo; + +/// +/// Registry for DynamoItemReader delegates, populated by source generators. +/// +public static class DynamoItemReaderRegistry +{ + /// + /// Registers a reader delegate for the specified type. + /// + /// The type the reader deserializes. + /// The reader delegate. + public static void Register(DynamoItemReader reader) => Cache.Reader = reader; + + /// + /// Gets the registered reader delegate for the specified type. + /// + /// The type to get the reader for. + /// The registered reader delegate. + /// Thrown when no reader is registered for the type. + public static DynamoItemReader Get() => Cache.Reader + ?? throw new InvalidOperationException($"No DynamoItemReader registered for {typeof(T).Name}. Ensure the type has [DynamoModel] attribute and the source generator has run."); + + private static class Cache + { + public static DynamoItemReader? Reader; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index b4d39675..c61c7e2f 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -2,6 +2,7 @@ using Goa.Clients.Core; using Goa.Clients.Core.Http; using Goa.Clients.Dynamo.Errors; +using Goa.Clients.Dynamo.Internal; using Goa.Clients.Dynamo.Operations.Batch; using Goa.Clients.Dynamo.Operations.DeleteItem; using Goa.Clients.Dynamo.Operations.GetItem; @@ -12,6 +13,7 @@ using Goa.Clients.Dynamo.Operations.UpdateItem; using Goa.Clients.Dynamo.Serialization; using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; namespace Goa.Clients.Dynamo; @@ -28,16 +30,46 @@ public class DynamoServiceClient : JsonAwsServiceClientLogger instance for logging operations. /// Configuration for the DynamoDB service. public DynamoServiceClient(IHttpClientFactory httpClientFactory, ILogger logger, DynamoServiceClientConfiguration configuration) - : base(httpClientFactory, logger, configuration) + : base(httpClientFactory, logger, configuration, Goa.Clients.Core.AwsServiceClientSerializationContext.Default) { } /// - protected override System.Text.Json.Serialization.Metadata.JsonTypeInfo ResolveJsonTypeInfo() + protected override byte[] SerializeToUtf8Bytes(TRequest request) { - return DynamoJsonContext.Default.GetTypeInfo(typeof(TValue)) - as System.Text.Json.Serialization.Metadata.JsonTypeInfo - ?? throw new InvalidOperationException($"Cannot find type {typeof(TValue).Name} in serialization context"); + var writer = DynamoJsonContext.GetWriter(); + using var buffer = new System.IO.MemoryStream(); + using var jsonWriter = new System.Text.Json.Utf8JsonWriter(buffer); + writer(jsonWriter, request); + jsonWriter.Flush(); + return buffer.ToArray(); + } + + /// + protected override async Task> ProcessJsonResponseAsync(HttpResponseMessage response) + { + if (typeof(TResponse) == typeof(string)) + return await base.ProcessJsonResponseAsync(response); + + if (!response.IsSuccessStatusCode) + return await base.ProcessJsonResponseAsync(response); + + try + { + var reader = DynamoJsonContext.GetReader(); + using var pooledBuffer = await ReadResponseBytesAsync(response, default); + if (pooledBuffer.Length == 0) + return new Core.Http.ApiResponse(default(TResponse)); + + var jsonReader = new System.Text.Json.Utf8JsonReader(pooledBuffer.Span); + var result = reader(ref jsonReader); + var headers = Core.Http.ResponseHeaders.FromHttpResponse(response.Headers); + return new Core.Http.ApiResponse(result, headers); + } + catch (InvalidOperationException) + { + return await base.ProcessJsonResponseAsync(response); + } } /// @@ -220,6 +252,90 @@ public async Task> TransactGetItemsAsync(Transa return ConvertApiResponse(response); } + /// + public async Task>> QueryAsync(QueryRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = SerializeToUtf8Bytes(request); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.Query"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.Query", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var buffer = await ReadResponseBytesAsync(response, cancellationToken); + if (buffer.Length == 0) + return new QueryResult(); + + return DynamoResponseReader.ReadQueryResponse(buffer.Span, itemReader); + } + + /// + public async Task>> ScanAsync(ScanRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = SerializeToUtf8Bytes(request); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.Scan"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.Scan", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var buffer = await ReadResponseBytesAsync(response, cancellationToken); + if (buffer.Length == 0) + return new ScanResult(); + + return DynamoResponseReader.ReadScanResponse(buffer.Span, itemReader); + } + + /// + public async Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = SerializeToUtf8Bytes(request); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.GetItem"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.GetItem", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var buffer = await ReadResponseBytesAsync(response, cancellationToken); + if (buffer.Length == 0) + return default(T); + + return DynamoResponseReader.ReadGetItemResponse(buffer.Span, itemReader); + } + + private async Task HandleTypedErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var errorPayload = await response.Content.ReadAsStringAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(errorPayload)) + { + return Error.Failure("Goa.DynamoDb.Unknown", "Request not successful."); + } + + var error = DeserializeJsonError(errorPayload); + if (error is not null) + { + error = error with { Payload = errorPayload, StatusCode = response.StatusCode }; + error = ProcessAwsErrorHeaders(response, error); + } + + var errorType = error?.Type ?? error?.Code ?? "Unknown"; + return Error.Failure( + code: MapErrorCodeToDynamo(errorType), + description: error?.Message ?? "An error occurred while processing the request."); + } + /// /// Converts an ApiResponse to an ErrorOr result, mapping AWS-specific error codes to DynamoDB error codes. /// diff --git a/src/Clients/Goa.Clients.Dynamo/Goa.Clients.Dynamo.csproj b/src/Clients/Goa.Clients.Dynamo/Goa.Clients.Dynamo.csproj index a0fd458d..17a39420 100644 --- a/src/Clients/Goa.Clients.Dynamo/Goa.Clients.Dynamo.csproj +++ b/src/Clients/Goa.Clients.Dynamo/Goa.Clients.Dynamo.csproj @@ -14,6 +14,7 @@ - + + diff --git a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs index 1d4f61be..6458c4e7 100644 --- a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs @@ -95,4 +95,19 @@ public interface IDynamoClient /// A cancellation token to cancel the operation. /// The transact get response with items in the same order as the requests, or an error if the operation failed. Task> TransactGetItemsAsync(TransactGetRequest request, CancellationToken cancellationToken = default); + + /// + /// Queries items from a DynamoDB table with direct typed deserialization. + /// + Task>> QueryAsync(QueryRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); + + /// + /// Scans items from a DynamoDB table with direct typed deserialization. + /// + Task>> ScanAsync(ScanRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); + + /// + /// Gets an item from a DynamoDB table with direct typed deserialization. + /// + Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); } diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs new file mode 100644 index 00000000..ed994616 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -0,0 +1,285 @@ +using System.Text.Json; +using Goa.Clients.Dynamo.Models; +using Goa.Clients.Dynamo.Operations.Query; +using Goa.Clients.Dynamo.Operations.Scan; + +namespace Goa.Clients.Dynamo.Internal; + +internal static class DynamoResponseReader +{ + public static QueryResult ReadQueryResponse(ReadOnlySpan utf8Json, DynamoItemReader itemReader) + { + var result = new QueryResult(); + var reader = new Utf8JsonReader(utf8Json); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Items": + result.Items = ReadItems(ref reader, itemReader); + break; + case "Count": + // Count is derived from Items.Count, skip + reader.GetInt32(); + break; + case "ScannedCount": + result.ScannedCount = reader.GetInt32(); + break; + case "LastEvaluatedKey": + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + break; + case "ConsumedCapacity": + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + return result; + } + + public static ScanResult ReadScanResponse(ReadOnlySpan utf8Json, DynamoItemReader itemReader) + { + var result = new ScanResult(); + var reader = new Utf8JsonReader(utf8Json); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Items": + result.Items = ReadItems(ref reader, itemReader); + break; + case "Count": + reader.GetInt32(); + break; + case "ScannedCount": + result.ScannedCount = reader.GetInt32(); + break; + case "LastEvaluatedKey": + result.LastEvaluatedKey = ReadAttributeMap(ref reader); + break; + case "ConsumedCapacity": + result.ConsumedCapacity = ReadConsumedCapacity(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + return result; + } + + public static T? ReadGetItemResponse(ReadOnlySpan utf8Json, DynamoItemReader itemReader) + { + var reader = new Utf8JsonReader(utf8Json); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Item": + return itemReader(ref reader); + default: + reader.Skip(); + break; + } + } + + return default; + } + + private static List ReadItems(ref Utf8JsonReader reader, DynamoItemReader itemReader) + { + var items = new List(); + if (reader.TokenType == JsonTokenType.Null) + return items; + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + // reader is at StartObject for each item + items.Add(itemReader(ref reader)); + } + return items; + } + + private static Dictionary ReadAttributeMap(ref Utf8JsonReader reader) + { + var map = new Dictionary(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var key = reader.GetString()!; + reader.Read(); // StartObject (type wrapper) + map[key] = ReadAttributeValue(ref reader); + } + return map; + } + + private static AttributeValue ReadAttributeValue(ref Utf8JsonReader reader) + { + var attr = new AttributeValue(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var typeName = reader.GetString(); + reader.Read(); // value + + switch (typeName) + { + case "S": + attr.S = reader.GetString(); + break; + case "N": + attr.N = reader.GetString(); + break; + case "BOOL": + attr.BOOL = reader.GetBoolean(); + break; + case "NULL": + attr.NULL = reader.GetBoolean(); + break; + case "SS": + attr.SS = ReadStringArray(ref reader); + break; + case "NS": + attr.NS = ReadStringArray(ref reader); + break; + case "L": + attr.L = ReadAttributeValueList(ref reader); + break; + case "M": + attr.M = ReadAttributeMap(ref reader); + break; + case "B": + case "BS": + reader.Skip(); + break; + default: + reader.Skip(); + break; + } + } + return attr; + } + + private static List ReadStringArray(ref Utf8JsonReader reader) + { + var list = new List(); + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(reader.GetString()!); + } + return list; + } + + private static List ReadAttributeValueList(ref Utf8JsonReader reader) + { + var list = new List(); + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + // Each element is an AttributeValue object like {"S": "hello"} + list.Add(ReadAttributeValue(ref reader)); + } + return list; + } + + private static ConsumedCapacity ReadConsumedCapacity(ref Utf8JsonReader reader) + { + var capacity = new ConsumedCapacity(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "TableName": + capacity.TableName = reader.GetString(); + break; + case "CapacityUnits": + capacity.CapacityUnits = reader.GetDouble(); + break; + case "ReadCapacityUnits": + capacity.ReadCapacityUnits = reader.GetDouble(); + break; + case "WriteCapacityUnits": + capacity.WriteCapacityUnits = reader.GetDouble(); + break; + case "Table": + capacity.Table = ReadCapacityDetail(ref reader); + break; + case "GlobalSecondaryIndexes": + capacity.GlobalSecondaryIndexes = ReadCapacityDetailMap(ref reader); + break; + case "LocalSecondaryIndexes": + capacity.LocalSecondaryIndexes = ReadCapacityDetailMap(ref reader); + break; + default: + reader.Skip(); + break; + } + } + return capacity; + } + + private static CapacityDetail ReadCapacityDetail(ref Utf8JsonReader reader) + { + var detail = new CapacityDetail(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "CapacityUnits": + detail.CapacityUnits = reader.GetDouble(); + break; + case "ReadCapacityUnits": + detail.ReadCapacityUnits = reader.GetDouble(); + break; + case "WriteCapacityUnits": + detail.WriteCapacityUnits = reader.GetDouble(); + break; + default: + reader.Skip(); + break; + } + } + return detail; + } + + private static Dictionary ReadCapacityDetailMap(ref Utf8JsonReader reader) + { + var map = new Dictionary(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var key = reader.GetString()!; + reader.Read(); // StartObject + map[key] = ReadConsumedCapacity(ref reader); + } + return map; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index f61f58e6..0dfabb90 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json.Serialization; namespace Goa.Clients.Dynamo.Models; @@ -11,42 +12,50 @@ public class AttributeValue /// /// An attribute of type String. Strings are UTF-8 encoded and can be up to 400 KB in size. /// + [JsonPropertyName("S")] public string? S { get; set; } /// /// An attribute of type Number. Numbers are sent across the network to DynamoDB as strings, /// to maximize compatibility across platforms and languages. /// + [JsonPropertyName("N")] public string? N { get; set; } /// /// An attribute of type Boolean. For example: "BOOL": true /// + [JsonPropertyName("BOOL")] public bool? BOOL { get; set; } /// /// An attribute of type String Set. A set of Strings. /// + [JsonPropertyName("SS")] public List? SS { get; set; } /// /// An attribute of type Number Set. A set of Numbers. /// + [JsonPropertyName("NS")] public List? NS { get; set; } /// /// An attribute of type List. Lists are ordered collections of values. /// + [JsonPropertyName("L")] public List? L { get; set; } /// /// An attribute of type Map. Maps are unordered collections of key-value pairs. /// + [JsonPropertyName("M")] public Dictionary? M { get; set; } /// /// An attribute of type Null. When sending a null value, you must send the NULL attribute type. /// + [JsonPropertyName("NULL")] public bool? NULL { get; set; } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs index bf3c17d6..ec82cc43 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/CapacityDetail.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Goa.Clients.Dynamo.Models; /// @@ -8,15 +10,18 @@ public class CapacityDetail /// /// The number of read capacity units consumed by the operation. /// + [JsonPropertyName("ReadCapacityUnits")] public double? ReadCapacityUnits { get; set; } - + /// /// The number of write capacity units consumed by the operation. /// + [JsonPropertyName("WriteCapacityUnits")] public double? WriteCapacityUnits { get; set; } - + /// /// The total number of capacity units consumed by the operation. /// + [JsonPropertyName("CapacityUnits")] public double? CapacityUnits { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs index d92ae28c..43db7664 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Goa.Clients.Dynamo.Models; /// @@ -8,35 +10,42 @@ public class ConsumedCapacity /// /// The name of the table that was affected by the operation. /// + [JsonPropertyName("TableName")] public string? TableName { get; set; } - + /// /// The total number of capacity units consumed by the operation. /// + [JsonPropertyName("CapacityUnits")] public double? CapacityUnits { get; set; } - + /// /// The total number of read capacity units consumed by the operation. /// + [JsonPropertyName("ReadCapacityUnits")] public double? ReadCapacityUnits { get; set; } - + /// /// The total number of write capacity units consumed by the operation. /// + [JsonPropertyName("WriteCapacityUnits")] public double? WriteCapacityUnits { get; set; } - + /// /// The capacity consumed by the global secondary indexes affected by the operation. /// + [JsonPropertyName("GlobalSecondaryIndexes")] public Dictionary? GlobalSecondaryIndexes { get; set; } - + /// /// The capacity consumed by the local secondary indexes affected by the operation. /// + [JsonPropertyName("LocalSecondaryIndexes")] public Dictionary? LocalSecondaryIndexes { get; set; } - + /// /// The capacity consumed by the table itself. /// + [JsonPropertyName("Table")] public CapacityDetail? Table { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs index c3766732..bd3eb9c4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ItemCollectionMetrics.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Goa.Clients.Dynamo.Models; /// @@ -8,11 +10,13 @@ public class ItemCollectionMetrics /// /// The partition key value of the item collection. /// + [JsonPropertyName("ItemCollectionKey")] public Dictionary? ItemCollectionKey { get; set; } /// /// An estimate of item collection size, in gigabytes. This value is a two-element array /// containing a lower bound and an upper bound for the estimate. /// + [JsonPropertyName("SizeEstimateRangeGB")] public List? SizeEstimateRangeGB { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs index 79a611a7..20e38bf6 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,10 +11,12 @@ public class BatchGetItemRequest /// /// A map of one or more table names and, for each table, a map that describes one or more items to retrieve from that table. /// + [JsonPropertyName("RequestItems")] public Dictionary RequestItems { get; set; } = new(); - + /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs index d6ee53cf..61232af3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetItemResponse.cs @@ -1,5 +1,5 @@ -using Goa.Clients.Dynamo.Models; using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -11,21 +11,24 @@ public class BatchGetItemResponse /// /// A map of table name to a list of items. Each item is represented as a set of attributes. /// + [JsonPropertyName("Responses")] public Dictionary> Responses { get; set; } = new(); - + /// /// A map of tables and their respective keys that were not processed with the current response. /// + [JsonPropertyName("UnprocessedKeys")] public Dictionary? UnprocessedKeys { get; set; } - + /// /// Gets a value indicating whether there are unprocessed keys that need to be retried. /// [JsonIgnore] public bool HasUnprocessedKeys => UnprocessedKeys?.Count > 0; - + /// /// The read capacity units consumed by the BatchGetItem operation. /// + [JsonPropertyName("ConsumedCapacity")] public List? ConsumedCapacity { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs index 23855ede..6c1c8876 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetRequestItem.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,21 +11,25 @@ public class BatchGetRequestItem /// /// The primary keys of the items to retrieve. For each key, DynamoDB performs a GetItem operation and returns the entire item. /// + [JsonPropertyName("Keys")] public List> Keys { get; set; } = new(); /// /// A string that identifies one or more attributes to retrieve from the table. /// These attributes can include scalars, sets, or elements of a JSON document. /// + [JsonPropertyName("ProjectionExpression")] public string? ProjectionExpression { get; set; } /// /// The consistency of a read operation. If set to true, then a strongly consistent read is used; otherwise, an eventually consistent read is used. /// + [JsonPropertyName("ConsistentRead")] public bool ConsistentRead { get; set; } /// /// One or more substitution tokens for attribute names in the ProjectionExpression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs index 708a82d9..ca3deb41 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,20 +11,23 @@ public class BatchGetResult /// /// The items retrieved by the BatchGetItem operation. /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); - + /// /// Keys that were not processed during the BatchGetItem operation. /// + [JsonPropertyName("UnprocessedKeys")] public List> UnprocessedKeys { get; set; } = new(); - + /// /// Gets a value indicating whether there are unprocessed keys that require additional requests. /// public bool HasUnprocessedKeys => UnprocessedKeys.Count > 0; - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double ConsumedCapacityUnits { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs index 81715e18..97b26738 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,15 +11,18 @@ public class BatchWriteItemRequest /// /// A map of one or more table names and, for each table, a list of operations to be performed. /// + [JsonPropertyName("RequestItems")] public Dictionary> RequestItems { get; set; } = new(); /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; /// /// Determines whether item collection metrics are returned. /// + [JsonPropertyName("ReturnItemCollectionMetrics")] public ReturnItemCollectionMetrics ReturnItemCollectionMetrics { get; set; } = ReturnItemCollectionMetrics.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs index af4d2d12..af643513 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteItemResponse.cs @@ -1,5 +1,5 @@ -using Goa.Clients.Dynamo.Models; using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -11,21 +11,24 @@ public class BatchWriteItemResponse /// /// A map of tables and requests against those tables that were not processed. /// + [JsonPropertyName("UnprocessedItems")] public Dictionary>? UnprocessedItems { get; set; } - + /// /// Gets a value indicating whether there are unprocessed items that need to be retried. /// [JsonIgnore] public bool HasUnprocessedItems => UnprocessedItems?.Count > 0; - + /// /// The write capacity units consumed by the BatchWriteItem operation. /// + [JsonPropertyName("ConsumedCapacity")] public List? ConsumedCapacity { get; set; } /// /// A list of tables that were processed by BatchWriteItem and, for each table, information about any item collections that were affected by individual DeleteItem or PutItem operations. /// + [JsonPropertyName("ItemCollectionMetrics")] public Dictionary>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs index 79b131e7..12522fa6 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteRequestItem.cs @@ -1,4 +1,6 @@ -namespace Goa.Clients.Dynamo.Operations.Batch; +using System.Text.Json.Serialization; + +namespace Goa.Clients.Dynamo.Operations.Batch; /// /// Individual write request within a BatchWriteItem operation. @@ -8,10 +10,12 @@ public class BatchWriteRequestItem /// /// A request to perform a PutItem operation. /// + [JsonPropertyName("PutRequest")] public PutRequest? PutRequest { get; set; } - + /// /// A request to perform a DeleteItem operation. /// + [JsonPropertyName("DeleteRequest")] public DeleteRequest? DeleteRequest { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs index 5d31e0ac..8a69683b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchWriteResult.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,20 +11,23 @@ public class BatchWriteResult /// /// Put items that were not processed during the BatchWriteItem operation. /// + [JsonPropertyName("UnprocessedPutItems")] public List> UnprocessedPutItems { get; set; } = new(); - + /// /// Delete keys that were not processed during the BatchWriteItem operation. /// + [JsonPropertyName("UnprocessedDeleteKeys")] public List> UnprocessedDeleteKeys { get; set; } = new(); - + /// /// Gets a value indicating whether there are unprocessed items that require additional requests. /// public bool HasUnprocessedItems => UnprocessedPutItems.Count > 0 || UnprocessedDeleteKeys.Count > 0; - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double ConsumedCapacityUnits { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs index e79b15c5..dcaf8f65 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/DeleteRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,5 +11,6 @@ public class DeleteRequest /// /// The primary key of the item to be deleted. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs index b13f513c..c0d766c8 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/PutRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Batch; @@ -10,5 +11,6 @@ public class PutRequest /// /// A map of attribute names to AttributeValue objects representing the item to be put. /// + [JsonPropertyName("Item")] public Dictionary Item { get; set; } = new(); -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs index 548e5ca6..d3b30b93 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.DeleteItem; @@ -11,47 +12,56 @@ public class DeleteItemRequest /// /// The name of the table from which to delete the item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; /// /// A map of attribute names to AttributeValue objects representing the primary key of the item to delete. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); /// /// A condition that must be satisfied in order for a conditional DeleteItem to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Use ReturnValues if you want to get the item attributes as they appeared before they were deleted. /// For DeleteItem operations, only NONE and ALL_OLD values are supported. /// + [JsonPropertyName("ReturnValues")] public ReturnValues ReturnValues { get; set; } = ReturnValues.NONE; /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; /// /// Determines whether item collection metrics are returned. /// + [JsonPropertyName("ReturnItemCollectionMetrics")] public ReturnItemCollectionMetrics ReturnItemCollectionMetrics { get; set; } = ReturnItemCollectionMetrics.NONE; /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs index 072e3393..e0f3a8d2 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.DeleteItem; @@ -10,15 +11,18 @@ public class DeleteItemResponse /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was deleted. /// + [JsonPropertyName("Attributes")] public DynamoRecord? Attributes { get; set; } - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double? ConsumedCapacityUnits { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// + [JsonPropertyName("ItemCollectionMetrics")] public Dictionary>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs index 280fe580..d76357cd 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; @@ -11,32 +12,38 @@ public class GetItemRequest /// /// The name of the table containing the requested item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// A map of attribute names to AttributeValue objects representing the primary key of the item to retrieve. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); - + /// - /// Determines the read consistency model. If set to true, strongly consistent reads are used; + /// Determines the read consistency model. If set to true, strongly consistent reads are used; /// otherwise, eventually consistent reads are used. /// + [JsonPropertyName("ConsistentRead")] public bool ConsistentRead { get; set; } = false; - + /// - /// A string that identifies one or more attributes to retrieve from the table. + /// A string that identifies one or more attributes to retrieve from the table. /// These attributes can include scalars, sets, or elements of a JSON document. /// + [JsonPropertyName("ProjectionExpression")] public string? ProjectionExpression { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } - + /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs index 4b274bed..23ebfa8c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/GetItem/GetItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.GetItem; @@ -10,15 +11,18 @@ public class GetItemResponse /// /// A map of attribute names to AttributeValue objects, as specified by ProjectionExpression. /// + [JsonPropertyName("Item")] public DynamoRecord? Item { get; set; } - + /// /// The capacity units consumed by the GetItem operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double? ConsumedCapacityUnits { get; set; } - + /// /// The capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs index 2ee18891..0412ff60 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; @@ -11,46 +12,55 @@ public class PutItemRequest /// /// The name of the table to contain the item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; /// /// A map of attribute names to AttributeValue objects representing the item to put. /// + [JsonPropertyName("Item")] public Dictionary Item { get; set; } = new(); /// /// A condition that must be satisfied in order for a conditional PutItem operation to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Use ReturnValues if you want to get the item attributes as they appeared before they were deleted. /// + [JsonPropertyName("ReturnValues")] public ReturnValues ReturnValues { get; set; } = ReturnValues.NONE; /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; /// /// Determines whether item collection metrics are returned. /// + [JsonPropertyName("ReturnItemCollectionMetrics")] public ReturnItemCollectionMetrics ReturnItemCollectionMetrics { get; set; } = ReturnItemCollectionMetrics.NONE; /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs index cdca227e..b133ebb4 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.PutItem; @@ -8,18 +9,21 @@ namespace Goa.Clients.Dynamo.Operations.PutItem; public class PutItemResponse { /// - /// The attribute values as they appeared before the PutItem operation, + /// The attribute values as they appeared before the PutItem operation, /// but only if ReturnValues is specified as something other than NONE in the request. /// + [JsonPropertyName("Attributes")] public DynamoRecord? Attributes { get; set; } - + /// /// The capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } - + /// /// Information about item collections, if any, that were affected by the operation. /// + [JsonPropertyName("ItemCollectionMetrics")] public Dictionary>>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs index fff930d8..01d15151 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; @@ -11,66 +12,79 @@ public class QueryRequest /// /// The name of the table containing the requested items. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; /// /// The condition that specifies the key values for items to be retrieved by the Query action. /// + [JsonPropertyName("KeyConditionExpression")] public string KeyConditionExpression { get; set; } = string.Empty; /// /// A string that contains conditions that DynamoDB applies after the Query operation, but before the data is returned to you. /// + [JsonPropertyName("FilterExpression")] public string? FilterExpression { get; set; } /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// The maximum number of items to evaluate (not necessarily the number of matching items). /// + [JsonPropertyName("Limit")] public int? Limit { get; set; } /// /// The primary key of the first item that this operation will evaluate. /// + [JsonPropertyName("ExclusiveStartKey")] public Dictionary? ExclusiveStartKey { get; set; } /// /// Specifies the order for index traversal: If true (default), the traversal is performed in ascending order; /// if false, the traversal is performed in descending order. /// + [JsonPropertyName("ScanIndexForward")] public bool ScanIndexForward { get; set; } = true; /// /// The name of an index to query. This index can be any local secondary index or global secondary index on the table. /// + [JsonPropertyName("IndexName")] public string? IndexName { get; set; } /// /// A string that identifies one or more attributes to retrieve from the table. /// + [JsonPropertyName("ProjectionExpression")] public string? ProjectionExpression { get; set; } /// /// The attributes to be returned in the result. /// + [JsonPropertyName("Select")] public Select Select { get; set; } = Select.ALL_ATTRIBUTES; /// /// Determines the read consistency model. If set to true, strongly consistent reads are used. /// + [JsonPropertyName("ConsistentRead")] public bool ConsistentRead { get; set; } = false; /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs index c3331e0b..d6606f10 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResponse.cs @@ -1,5 +1,5 @@ -using Goa.Clients.Dynamo.Models; using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Query; @@ -11,12 +11,14 @@ public class QueryResponse /// /// An array of item attributes that match the query criteria. /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); /// /// The primary key of the item where the operation stopped, inclusive of the previous result set. /// Use this value to start a new operation, excluding this value in the new request. /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } /// @@ -34,10 +36,12 @@ public class QueryResponse /// /// The number of items evaluated, before any QueryFilter is applied. /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } /// /// The capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs index 91ae5b28..610a902a 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Query; @@ -10,31 +11,66 @@ public class QueryResult /// /// The items returned by the Query operation. /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); - + /// /// The primary key of the item where the operation stopped, inclusive of the previous result set. /// Use this value to start a new operation, excluding this value in the new request. /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } - + /// /// Gets the number of items in the response. /// public int Count => Items.Count; - + /// /// Gets a value indicating whether there are more results to retrieve. /// public bool HasMoreResults => LastEvaluatedKey?.Count > 0; - + /// /// The number of items evaluated, before applying any QueryFilter. /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double ConsumedCapacityUnits { get; set; } -} \ No newline at end of file +} + +/// +/// Typed result wrapper for Query operations with direct deserialization support. +/// +/// The type of the deserialized items. +public class QueryResult +{ + /// + [JsonPropertyName("Items")] + public List Items { get; set; } = new(); + + /// + [JsonPropertyName("LastEvaluatedKey")] + public Dictionary? LastEvaluatedKey { get; set; } + + /// + public int Count => Items.Count; + + /// + public bool HasMoreResults => LastEvaluatedKey?.Count > 0; + + /// + [JsonPropertyName("ScannedCount")] + public int ScannedCount { get; set; } + + /// + /// The capacity consumed by the operation. + /// + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs index e4f33fe2..8213917d 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; @@ -11,65 +12,78 @@ public class ScanRequest /// /// The name of the table containing the requested items. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; /// /// A string that contains conditions that DynamoDB applies after the Scan operation, but before the data is returned to you. /// + [JsonPropertyName("FilterExpression")] public string? FilterExpression { get; set; } /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// The maximum number of items to evaluate (not necessarily the number of matching items). /// + [JsonPropertyName("Limit")] public int? Limit { get; set; } /// /// The primary key of the first item that this operation will evaluate. /// + [JsonPropertyName("ExclusiveStartKey")] public Dictionary? ExclusiveStartKey { get; set; } /// /// The name of a secondary index to scan. This index can be any local secondary index or global secondary index. /// + [JsonPropertyName("IndexName")] public string? IndexName { get; set; } /// /// A string that identifies one or more attributes to retrieve from the specified table or index. /// + [JsonPropertyName("ProjectionExpression")] public string? ProjectionExpression { get; set; } /// /// The attributes to be returned in the result. /// + [JsonPropertyName("Select")] public Select Select { get; set; } = Select.ALL_ATTRIBUTES; /// /// The total number of segments for a parallel scan request. /// + [JsonPropertyName("TotalSegments")] public int? TotalSegments { get; set; } /// /// For a parallel scan request, Segment identifies an individual segment to be scanned by an application worker. /// + [JsonPropertyName("Segment")] public int? Segment { get; set; } /// /// Determines the read consistency model. If set to true, strongly consistent reads are used. /// + [JsonPropertyName("ConsistentRead")] public bool ConsistentRead { get; set; } = false; /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs index 6c4483c4..b9fb807e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResponse.cs @@ -1,5 +1,5 @@ -using Goa.Clients.Dynamo.Models; using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Scan; @@ -11,38 +11,43 @@ public class ScanResponse /// /// An array of item attributes that match the scan criteria. /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); - + /// /// The primary key of the item where the operation stopped, inclusive of the previous result set. /// Use this value to start a new operation, excluding this value in the new request. /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } - + /// /// The number of items in the response. /// [JsonIgnore] public int Count => Items.Count; - + /// /// Gets a value indicating whether there are more results available to retrieve. /// [JsonIgnore] public bool HasMoreResults => LastEvaluatedKey?.Count > 0; - + /// /// The number of items evaluated, before any ScanFilter is applied. /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } - + /// /// The capacity units consumed by the Scan operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double? ConsumedCapacityUnits { get; set; } - + /// /// The capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs index 2b8d9fca..4d51b173 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Scan; @@ -10,31 +11,66 @@ public class ScanResult /// /// The items returned by the Scan operation. /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); - + /// /// The primary key of the item where the operation stopped, inclusive of the previous result set. /// Use this value to start a new operation, excluding this value in the new request. /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } - + /// /// Gets the number of items in the response. /// public int Count => Items.Count; - + /// /// Gets a value indicating whether there are more results to retrieve. /// public bool HasMoreResults => LastEvaluatedKey?.Count > 0; - + /// /// The number of items evaluated, before applying any ScanFilter. /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double ConsumedCapacityUnits { get; set; } -} \ No newline at end of file +} + +/// +/// Typed result wrapper for Scan operations with direct deserialization support. +/// +/// The type of the deserialized items. +public class ScanResult +{ + /// + [JsonPropertyName("Items")] + public List Items { get; set; } = new(); + + /// + [JsonPropertyName("LastEvaluatedKey")] + public Dictionary? LastEvaluatedKey { get; set; } + + /// + public int Count => Items.Count; + + /// + public bool HasMoreResults => LastEvaluatedKey?.Count > 0; + + /// + [JsonPropertyName("ScannedCount")] + public int ScannedCount { get; set; } + + /// + /// The capacity consumed by the operation. + /// + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs index e7a31e52..a7e46638 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactConditionCheckItem.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -11,31 +12,37 @@ public class TransactConditionCheckItem /// /// The name of the table containing the requested item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// The primary key of the item to be checked. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); - + /// /// A condition that must be satisfied in order for the ConditionCheck to succeed. /// + [JsonPropertyName("ConditionExpression")] public string ConditionExpression { get; set; } = string.Empty; - + /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs index d93eca34..b19bc059 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactDeleteItem.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -11,31 +12,37 @@ public class TransactDeleteItem /// /// The name of the table from which to delete the item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// The primary key of the item to be deleted. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); - + /// /// A condition that must be satisfied in order for a conditional DeleteItem to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } - + /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs index 45b14bef..e41a7057 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItem.cs @@ -1,4 +1,6 @@ -namespace Goa.Clients.Dynamo.Operations.Transactions; +using System.Text.Json.Serialization; + +namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact get item. @@ -8,5 +10,6 @@ public class TransactGetItem /// /// Contains the primary key that identifies the item to get, together with the name of the table that contains the item. /// + [JsonPropertyName("Get")] public TransactGetItemRequest Get { get; set; } = new(); -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs index 80a371b8..3a37f75c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,20 +11,24 @@ public class TransactGetItemRequest /// /// The name of the table containing the requested item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// A map of attribute names to AttributeValue objects representing the primary key of the item to retrieve. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); - + /// /// A string that identifies one or more attributes to retrieve from the table. /// + [JsonPropertyName("ProjectionExpression")] public string? ProjectionExpression { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs index 81281a40..9ca8c401 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,10 +11,12 @@ public class TransactGetItemResponse /// /// An ordered array of up to 100 response items, each of which corresponds to the TransactGetItem request in the same position. /// + [JsonPropertyName("Responses")] public List Responses { get; set; } = new(); - + /// /// The capacity units consumed by the entire TransactGetItem operation. /// + [JsonPropertyName("ConsumedCapacity")] public List? ConsumedCapacity { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs index 29249342..692cf43e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,10 +11,12 @@ public class TransactGetRequest /// /// An ordered array of up to 100 TransactGetItem objects, each of which contains a Get operation. /// + [JsonPropertyName("TransactItems")] public List TransactItems { get; set; } = new(); /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs index 2d3129a2..c7960c32 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,5 +11,6 @@ public class TransactGetResult /// /// Map of attribute names to values for the requested item, or null if the item was not found. /// + [JsonPropertyName("Item")] public DynamoRecord? Item { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs index 0b9ecdda..5fd27526 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactPutItem.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -11,31 +12,37 @@ public class TransactPutItem /// /// The name of the table to contain the item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// A map of attribute names to AttributeValue objects representing the item. /// + [JsonPropertyName("Item")] public Dictionary Item { get; set; } = new(); - + /// /// A condition that must be satisfied in order for a conditional PutItem operation to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } - + /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs index 5b8b0d28..aa9f671f 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactUpdateItem.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -11,36 +12,43 @@ public class TransactUpdateItem /// /// The name of the table containing the item to be updated. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// The primary key of the item to be updated. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); - + /// /// An expression that defines one or more attributes to be updated. /// + [JsonPropertyName("UpdateExpression")] public string UpdateExpression { get; set; } = string.Empty; - + /// /// A condition that must be satisfied in order for a conditional UpdateItem to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } - + /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs index 566acaac..20ec5540 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItem.cs @@ -1,4 +1,6 @@ -namespace Goa.Clients.Dynamo.Operations.Transactions; +using System.Text.Json.Serialization; + +namespace Goa.Clients.Dynamo.Operations.Transactions; /// /// Individual transact write item. @@ -8,20 +10,24 @@ public class TransactWriteItem /// /// A request to perform a PutItem operation. /// + [JsonPropertyName("Put")] public TransactPutItem? Put { get; set; } - + /// /// A request to perform an UpdateItem operation. /// + [JsonPropertyName("Update")] public TransactUpdateItem? Update { get; set; } - + /// /// A request to perform a DeleteItem operation. /// + [JsonPropertyName("Delete")] public TransactDeleteItem? Delete { get; set; } - + /// /// A request to perform a condition check. /// + [JsonPropertyName("ConditionCheck")] public TransactConditionCheckItem? ConditionCheck { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs index ea2867c7..44f4524e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,10 +11,12 @@ public class TransactWriteItemResponse /// /// The capacity units consumed by the entire TransactWriteItem operation. /// + [JsonPropertyName("ConsumedCapacity")] public List? ConsumedCapacity { get; set; } - + /// /// A list of tables that were processed by TransactWriteItem and, for each table, information about any item collections that were affected by individual operations. /// + [JsonPropertyName("ItemCollectionMetrics")] public Dictionary? ItemCollectionMetrics { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs index 2f52344a..85f84da6 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteOperation.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Models; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,40 +11,48 @@ public class TransactWriteOperation /// /// The type of operation to perform (Put, Update, Delete, or ConditionCheck). /// + [JsonPropertyName("OperationType")] public TransactOperationType OperationType { get; set; } - + /// /// The name of the table containing the item. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; - + /// /// The primary key of the item to operate on. /// + [JsonPropertyName("Key")] public Dictionary? Key { get; set; } - + /// /// A map of attribute names to AttributeValue objects (for Put operations). /// + [JsonPropertyName("Item")] public Dictionary? Item { get; set; } - + /// /// A condition that must be satisfied in order for the operation to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } - + /// /// An expression that defines one or more attributes to be updated (for Update operations). /// + [JsonPropertyName("UpdateExpression")] public string? UpdateExpression { get; set; } - + /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } - + /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs index e752a36e..6a1cec6e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteRequest.cs @@ -1,4 +1,5 @@ -using Goa.Clients.Dynamo.Enums; +using System.Text.Json.Serialization; +using Goa.Clients.Dynamo.Enums; namespace Goa.Clients.Dynamo.Operations.Transactions; @@ -10,21 +11,25 @@ public class TransactWriteRequest /// /// An ordered array of up to 100 TransactWriteItem objects, each of which contains a ConditionCheck, Put, Update, or Delete operation. /// + [JsonPropertyName("TransactItems")] public List TransactItems { get; set; } = new(); /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; /// /// Determines whether item collection metrics are returned. /// + [JsonPropertyName("ReturnItemCollectionMetrics")] public ReturnItemCollectionMetrics ReturnItemCollectionMetrics { get; set; } = ReturnItemCollectionMetrics.NONE; /// /// Providing a ClientRequestToken makes the call to TransactWriteItems idempotent, /// meaning that multiple identical calls have the same effect as one single call. /// + [JsonPropertyName("ClientRequestToken")] public string? ClientRequestToken { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs index 2d009143..7094d476 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemRequest.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Enums; using Goa.Clients.Dynamo.Models; @@ -11,51 +12,61 @@ public class UpdateItemRequest /// /// The name of the table containing the item to update. /// + [JsonPropertyName("TableName")] public string TableName { get; set; } = string.Empty; /// /// The primary key of the item to be updated. /// + [JsonPropertyName("Key")] public Dictionary Key { get; set; } = new(); /// /// An expression that defines one or more attributes to be updated, the action to be performed on them, and new values for them. /// + [JsonPropertyName("UpdateExpression")] public string UpdateExpression { get; set; } = string.Empty; /// /// A condition that must be satisfied in order for a conditional update to succeed. /// + [JsonPropertyName("ConditionExpression")] public string? ConditionExpression { get; set; } /// /// One or more values that can be substituted in an expression. /// + [JsonPropertyName("ExpressionAttributeValues")] public Dictionary? ExpressionAttributeValues { get; set; } /// /// One or more substitution tokens for attribute names in an expression. /// + [JsonPropertyName("ExpressionAttributeNames")] public Dictionary? ExpressionAttributeNames { get; set; } /// /// Use ReturnValues if you want to get the item attributes as they appeared before they were updated. /// + [JsonPropertyName("ReturnValues")] public ReturnValues ReturnValues { get; set; } = ReturnValues.NONE; /// /// Determines the level of detail about provisioned throughput consumption that is returned in the response. /// + [JsonPropertyName("ReturnConsumedCapacity")] public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } = ReturnConsumedCapacity.NONE; /// /// Determines whether item collection metrics are returned. /// + [JsonPropertyName("ReturnItemCollectionMetrics")] public ReturnItemCollectionMetrics ReturnItemCollectionMetrics { get; set; } = ReturnItemCollectionMetrics.NONE; /// /// Specifies how to return attribute values when a conditional check fails. /// Use ALL_OLD to return all attributes of the item as they appeared before the operation. /// + [JsonPropertyName("ReturnValuesOnConditionCheckFailure")] public ReturnValuesOnConditionCheckFailure ReturnValuesOnConditionCheckFailure { get; set; } = ReturnValuesOnConditionCheckFailure.NONE; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs index d7ba2e10..ffd0e18d 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Goa.Clients.Dynamo.Models; namespace Goa.Clients.Dynamo.Operations.UpdateItem; @@ -10,15 +11,18 @@ public class UpdateItemResponse /// /// A map of attribute names to AttributeValue objects representing the item as it appeared before it was updated. /// + [JsonPropertyName("Attributes")] public DynamoRecord? Attributes { get; set; } - + /// /// The number of capacity units consumed by the operation. /// + [JsonPropertyName("ConsumedCapacityUnits")] public double? ConsumedCapacityUnits { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// + [JsonPropertyName("ItemCollectionMetrics")] public Dictionary>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file +} diff --git a/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs b/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs index ce125a46..a5d81dd3 100644 --- a/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs +++ b/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs @@ -1,6 +1,4 @@ -using Goa.Clients.Core.Http; -using Goa.Clients.Dynamo.Enums; -using Goa.Clients.Dynamo.Models; +using Goa.Json.Generator; using Goa.Clients.Dynamo.Operations.Batch; using Goa.Clients.Dynamo.Operations.DeleteItem; using Goa.Clients.Dynamo.Operations.GetItem; @@ -9,66 +7,29 @@ using Goa.Clients.Dynamo.Operations.Scan; using Goa.Clients.Dynamo.Operations.Transactions; using Goa.Clients.Dynamo.Operations.UpdateItem; -using System.Text.Json.Serialization; namespace Goa.Clients.Dynamo.Serialization; -/// -/// JSON source generator context for all DynamoDB types to enable AOT compilation and improved performance. -/// -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified, - GenerationMode = JsonSourceGenerationMode.Default, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -[JsonSerializable(typeof(AttributeValue))] -[JsonSerializable(typeof(DynamoRecord))] -[JsonSerializable(typeof(GetItemRequest))] -[JsonSerializable(typeof(GetItemResponse))] -[JsonSerializable(typeof(PutItemRequest))] -[JsonSerializable(typeof(PutItemResponse))] -[JsonSerializable(typeof(UpdateItemRequest))] -[JsonSerializable(typeof(UpdateItemResponse))] -[JsonSerializable(typeof(DeleteItemRequest))] -[JsonSerializable(typeof(DeleteItemResponse))] -[JsonSerializable(typeof(QueryRequest))] -[JsonSerializable(typeof(QueryResponse))] -[JsonSerializable(typeof(ScanRequest))] -[JsonSerializable(typeof(ScanResponse))] -[JsonSerializable(typeof(BatchGetItemRequest))] -[JsonSerializable(typeof(BatchGetItemResponse))] -[JsonSerializable(typeof(BatchGetRequestItem))] -[JsonSerializable(typeof(BatchWriteItemRequest))] -[JsonSerializable(typeof(BatchWriteItemResponse))] -[JsonSerializable(typeof(BatchWriteRequestItem))] -[JsonSerializable(typeof(PutRequest))] -[JsonSerializable(typeof(DeleteRequest))] -[JsonSerializable(typeof(TransactWriteRequest))] -[JsonSerializable(typeof(TransactWriteItemResponse))] -[JsonSerializable(typeof(TransactWriteItem))] -[JsonSerializable(typeof(TransactPutItem))] -[JsonSerializable(typeof(TransactUpdateItem))] -[JsonSerializable(typeof(TransactDeleteItem))] -[JsonSerializable(typeof(TransactConditionCheckItem))] -[JsonSerializable(typeof(TransactGetRequest))] -[JsonSerializable(typeof(TransactGetItemResponse))] -[JsonSerializable(typeof(TransactGetItem))] -[JsonSerializable(typeof(TransactGetItemRequest))] -[JsonSerializable(typeof(TransactGetResult))] -[JsonSerializable(typeof(ConsumedCapacity))] -[JsonSerializable(typeof(CapacityDetail))] -[JsonSerializable(typeof(ReturnConsumedCapacity))] -[JsonSerializable(typeof(ReturnValues))] -[JsonSerializable(typeof(ReturnValuesOnConditionCheckFailure))] -[JsonSerializable(typeof(ReturnItemCollectionMetrics))] -[JsonSerializable(typeof(ItemCollectionMetrics))] -[JsonSerializable(typeof(Select))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(ApiError))] -public partial class DynamoJsonContext : JsonSerializerContext +[JsonGenerator(typeof(GetItemRequest))] +[JsonGenerator(typeof(GetItemResponse))] +[JsonGenerator(typeof(PutItemRequest))] +[JsonGenerator(typeof(PutItemResponse))] +[JsonGenerator(typeof(UpdateItemRequest))] +[JsonGenerator(typeof(UpdateItemResponse))] +[JsonGenerator(typeof(DeleteItemRequest))] +[JsonGenerator(typeof(DeleteItemResponse))] +[JsonGenerator(typeof(QueryRequest))] +[JsonGenerator(typeof(QueryResponse))] +[JsonGenerator(typeof(ScanRequest))] +[JsonGenerator(typeof(ScanResponse))] +[JsonGenerator(typeof(BatchGetItemRequest))] +[JsonGenerator(typeof(BatchGetItemResponse))] +[JsonGenerator(typeof(BatchWriteItemRequest))] +[JsonGenerator(typeof(BatchWriteItemResponse))] +[JsonGenerator(typeof(TransactWriteRequest))] +[JsonGenerator(typeof(TransactWriteItemResponse))] +[JsonGenerator(typeof(TransactGetRequest))] +[JsonGenerator(typeof(TransactGetItemResponse))] +internal partial class DynamoJsonContext { } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs index 43fa2cf5..fce74fb9 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs @@ -304,7 +304,7 @@ public async Task DeleteItemBuilder_WithReturnValuesOnConditionCheckFailure_AllO .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); var request = builder.Build(); - var json = JsonSerializer.Serialize(request, DynamoJsonContext.Default.DeleteItemRequest); + var json = SerializeWithGeneratedWriter(request); await Assert.That(json).Contains("\"ReturnValuesOnConditionCheckFailure\":\"ALL_OLD\""); } @@ -317,7 +317,7 @@ public async Task PutItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_ .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); var request = builder.Build(); - var json = JsonSerializer.Serialize(request, DynamoJsonContext.Default.PutItemRequest); + var json = SerializeWithGeneratedWriter(request); await Assert.That(json).Contains("\"ReturnValuesOnConditionCheckFailure\":\"ALL_OLD\""); } @@ -331,8 +331,18 @@ public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_AllO .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); var request = builder.Build(); - var json = JsonSerializer.Serialize(request, DynamoJsonContext.Default.UpdateItemRequest); + var json = SerializeWithGeneratedWriter(request); await Assert.That(json).Contains("\"ReturnValuesOnConditionCheckFailure\":\"ALL_OLD\""); } + + private static string SerializeWithGeneratedWriter(T value) + { + var writer = DynamoJsonContext.GetWriter(); + using var buffer = new System.IO.MemoryStream(); + using var jsonWriter = new System.Text.Json.Utf8JsonWriter(buffer); + writer(jsonWriter, value); + jsonWriter.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs new file mode 100644 index 00000000..ab28e591 --- /dev/null +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs @@ -0,0 +1,564 @@ +using System.Text.Json; +using Goa.Clients.Dynamo.Internal; + +namespace Goa.Clients.Dynamo.Tests; + +public class DynamoResponseReaderTests +{ + /// + /// Simple test entity for deserialization. + /// + private sealed class TestEntity + { + public string? Pk { get; set; } + public string? Sk { get; set; } + public string? Data { get; set; } + } + + /// + /// Reads a TestEntity from a DynamoDB item JSON object. + /// Simulates what a source-generated DynamoJsonMapper would produce. + /// + private static TestEntity ReadTestEntity(ref Utf8JsonReader reader) + { + var entity = new TestEntity(); + + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propName = reader.GetString(); + reader.Read(); // StartObject (type wrapper) + reader.Read(); // PropertyName (type indicator like "S") + var typeName = reader.GetString(); + reader.Read(); // Value + + switch (propName) + { + case "pk" when typeName == "S": + entity.Pk = reader.GetString(); + break; + case "sk" when typeName == "S": + entity.Sk = reader.GetString(); + break; + case "data" when typeName == "S": + entity.Data = reader.GetString(); + break; + default: + // Skip unknown values + break; + } + + reader.Read(); // EndObject (type wrapper) + } + + return entity; + } + + [Test] + public async Task ReadQueryResponse_ShouldDeserializeItems() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "value1"}}, + {"pk": {"S": "pk2"}, "sk": {"S": "sk2"}, "data": {"S": "value2"}} + ], + "Count": 2, + "ScannedCount": 5 + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(2); + await Assert.That(result.Items[0].Pk).IsEqualTo("pk1"); + await Assert.That(result.Items[0].Sk).IsEqualTo("sk1"); + await Assert.That(result.Items[0].Data).IsEqualTo("value1"); + await Assert.That(result.Items[1].Pk).IsEqualTo("pk2"); + await Assert.That(result.Items[1].Sk).IsEqualTo("sk2"); + await Assert.That(result.Items[1].Data).IsEqualTo("value2"); + await Assert.That(result.ScannedCount).IsEqualTo(5); + await Assert.That(result.HasMoreResults).IsFalse(); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKey() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}} + ], + "Count": 1, + "ScannedCount": 1, + "LastEvaluatedKey": { + "pk": {"S": "pk1"}, + "sk": {"S": "sk1"} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(1); + await Assert.That(result.HasMoreResults).IsTrue(); + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["pk"].S).IsEqualTo("pk1"); + await Assert.That(result.LastEvaluatedKey!["sk"].S).IsEqualTo("sk1"); + } + + [Test] + public async Task ReadQueryResponse_ShouldHandleEmptyItems() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0 + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(0); + await Assert.That(result.ScannedCount).IsEqualTo(0); + await Assert.That(result.HasMoreResults).IsFalse(); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithNumberType() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "pk": {"S": "partition"}, + "sk": {"N": "42"} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["pk"].S).IsEqualTo("partition"); + await Assert.That(result.LastEvaluatedKey!["sk"].N).IsEqualTo("42"); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithBoolType() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "pk": {"S": "partition"}, + "flag": {"BOOL": true} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["flag"].BOOL).IsEqualTo(true); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithNullType() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "pk": {"S": "partition"}, + "empty": {"NULL": true} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["empty"].NULL).IsEqualTo(true); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithStringSet() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "tags": {"SS": ["a", "b", "c"]} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["tags"].SS).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["tags"].SS!.Count).IsEqualTo(3); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithNumberSet() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "scores": {"NS": ["1", "2", "3"]} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["scores"].NS).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["scores"].NS!.Count).IsEqualTo(3); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithListType() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "items": {"L": [{"S": "hello"}, {"N": "42"}]} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["items"].L).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["items"].L!.Count).IsEqualTo(2); + await Assert.That(result.LastEvaluatedKey!["items"].L![0].S).IsEqualTo("hello"); + await Assert.That(result.LastEvaluatedKey!["items"].L![1].N).IsEqualTo("42"); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseLastEvaluatedKeyWithMapType() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "nested": {"M": {"key1": {"S": "val1"}, "key2": {"N": "99"}}} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["nested"].M).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["nested"].M!["key1"].S).IsEqualTo("val1"); + await Assert.That(result.LastEvaluatedKey!["nested"].M!["key2"].N).IsEqualTo("99"); + } + + [Test] + public async Task ReadQueryResponse_ShouldSkipUnknownTopLevelProperties() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}} + ], + "Count": 1, + "ScannedCount": 1, + "SomeUnknownField": {}, + "ConsumedCapacity": {"TableName": "test", "CapacityUnits": 1.0} + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(1); + await Assert.That(result.Items[0].Pk).IsEqualTo("pk1"); + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.TableName).IsEqualTo("test"); + await Assert.That(result.ConsumedCapacity!.CapacityUnits).IsEqualTo(1.0); + } + + // === ScanResponse parsing === + + [Test] + public async Task ReadScanResponse_ShouldDeserializeItems() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "value1"}}, + {"pk": {"S": "pk2"}, "sk": {"S": "sk2"}, "data": {"S": "value2"}} + ], + "Count": 2, + "ScannedCount": 5 + } + """u8; + + var result = DynamoResponseReader.ReadScanResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(2); + await Assert.That(result.Items[0].Pk).IsEqualTo("pk1"); + await Assert.That(result.Items[0].Sk).IsEqualTo("sk1"); + await Assert.That(result.Items[0].Data).IsEqualTo("value1"); + await Assert.That(result.Items[1].Pk).IsEqualTo("pk2"); + await Assert.That(result.Items[1].Sk).IsEqualTo("sk2"); + await Assert.That(result.Items[1].Data).IsEqualTo("value2"); + await Assert.That(result.ScannedCount).IsEqualTo(5); + await Assert.That(result.HasMoreResults).IsFalse(); + } + + [Test] + public async Task ReadScanResponse_ShouldParseLastEvaluatedKey() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}} + ], + "Count": 1, + "ScannedCount": 1, + "LastEvaluatedKey": { + "pk": {"S": "pk1"}, + "sk": {"S": "sk1"} + } + } + """u8; + + var result = DynamoResponseReader.ReadScanResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(1); + await Assert.That(result.HasMoreResults).IsTrue(); + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["pk"].S).IsEqualTo("pk1"); + await Assert.That(result.LastEvaluatedKey!["sk"].S).IsEqualTo("sk1"); + } + + [Test] + public async Task ReadScanResponse_ShouldHandleEmptyItems() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0 + } + """u8; + + var result = DynamoResponseReader.ReadScanResponse(json, ReadTestEntity); + + await Assert.That(result.Count).IsEqualTo(0); + await Assert.That(result.ScannedCount).IsEqualTo(0); + await Assert.That(result.HasMoreResults).IsFalse(); + } + + // === GetItemResponse parsing === + + [Test] + public async Task ReadGetItemResponse_ShouldDeserializeItem() + { + var json = """ + { + "Item": {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "hello"}} + } + """u8; + + var result = DynamoResponseReader.ReadGetItemResponse(json, ReadTestEntity); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Pk).IsEqualTo("pk1"); + await Assert.That(result!.Sk).IsEqualTo("sk1"); + await Assert.That(result!.Data).IsEqualTo("hello"); + } + + [Test] + public async Task ReadGetItemResponse_ShouldReturnDefault_WhenNoItemProperty() + { + var json = """ + {} + """u8; + + var result = DynamoResponseReader.ReadGetItemResponse(json, ReadTestEntity); + + await Assert.That(result).IsNull(); + } + + [Test] + public async Task ReadGetItemResponse_ShouldSkipUnknownProperties() + { + var json = """ + { + "ConsumedCapacity": {"TableName": "t", "CapacityUnits": 1.0}, + "Item": {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}} + } + """u8; + + var result = DynamoResponseReader.ReadGetItemResponse(json, ReadTestEntity); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Pk).IsEqualTo("pk1"); + } + + // === ConsumedCapacity parsing === + + [Test] + public async Task ReadQueryResponse_ShouldParseConsumedCapacity() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "ConsumedCapacity": { + "TableName": "TestTable", + "CapacityUnits": 5.0, + "ReadCapacityUnits": 3.0, + "WriteCapacityUnits": 2.0 + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.TableName).IsEqualTo("TestTable"); + await Assert.That(result.ConsumedCapacity!.CapacityUnits).IsEqualTo(5.0); + await Assert.That(result.ConsumedCapacity!.ReadCapacityUnits).IsEqualTo(3.0); + await Assert.That(result.ConsumedCapacity!.WriteCapacityUnits).IsEqualTo(2.0); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseConsumedCapacity_WithTableDetail() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "ConsumedCapacity": { + "TableName": "TestTable", + "CapacityUnits": 5.0, + "Table": { + "CapacityUnits": 4.0, + "ReadCapacityUnits": 2.5, + "WriteCapacityUnits": 1.5 + } + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.Table).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.Table!.CapacityUnits).IsEqualTo(4.0); + await Assert.That(result.ConsumedCapacity!.Table!.ReadCapacityUnits).IsEqualTo(2.5); + await Assert.That(result.ConsumedCapacity!.Table!.WriteCapacityUnits).IsEqualTo(1.5); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseConsumedCapacity_WithIndexes() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "ConsumedCapacity": { + "TableName": "TestTable", + "CapacityUnits": 10.0, + "GlobalSecondaryIndexes": { + "GSI1": { + "CapacityUnits": 3.0, + "ReadCapacityUnits": 2.0, + "WriteCapacityUnits": 1.0 + } + }, + "LocalSecondaryIndexes": { + "LSI1": { + "CapacityUnits": 2.0, + "ReadCapacityUnits": 1.5, + "WriteCapacityUnits": 0.5 + } + } + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.GlobalSecondaryIndexes).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.GlobalSecondaryIndexes!["GSI1"].CapacityUnits).IsEqualTo(3.0); + await Assert.That(result.ConsumedCapacity!.GlobalSecondaryIndexes!["GSI1"].ReadCapacityUnits).IsEqualTo(2.0); + await Assert.That(result.ConsumedCapacity!.GlobalSecondaryIndexes!["GSI1"].WriteCapacityUnits).IsEqualTo(1.0); + await Assert.That(result.ConsumedCapacity!.LocalSecondaryIndexes).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.LocalSecondaryIndexes!["LSI1"].CapacityUnits).IsEqualTo(2.0); + await Assert.That(result.ConsumedCapacity!.LocalSecondaryIndexes!["LSI1"].ReadCapacityUnits).IsEqualTo(1.5); + await Assert.That(result.ConsumedCapacity!.LocalSecondaryIndexes!["LSI1"].WriteCapacityUnits).IsEqualTo(0.5); + } + + [Test] + public async Task ReadQueryResponse_ShouldParseFullResponse() + { + var json = """ + { + "Items": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "d1"}}, + {"pk": {"S": "pk2"}, "sk": {"S": "sk2"}, "data": {"S": "d2"}} + ], + "Count": 2, + "ScannedCount": 10, + "LastEvaluatedKey": { + "pk": {"S": "pk2"}, + "sk": {"S": "sk2"} + }, + "ConsumedCapacity": { + "TableName": "FullTable", + "CapacityUnits": 7.5 + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(2); + await Assert.That(result.Items[0].Pk).IsEqualTo("pk1"); + await Assert.That(result.Items[0].Sk).IsEqualTo("sk1"); + await Assert.That(result.Items[0].Data).IsEqualTo("d1"); + await Assert.That(result.Items[1].Pk).IsEqualTo("pk2"); + await Assert.That(result.Items[1].Sk).IsEqualTo("sk2"); + await Assert.That(result.Items[1].Data).IsEqualTo("d2"); + await Assert.That(result.Count).IsEqualTo(2); + await Assert.That(result.ScannedCount).IsEqualTo(10); + await Assert.That(result.HasMoreResults).IsTrue(); + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["pk"].S).IsEqualTo("pk2"); + await Assert.That(result.LastEvaluatedKey!["sk"].S).IsEqualTo("sk2"); + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.TableName).IsEqualTo("FullTable"); + await Assert.That(result.ConsumedCapacity!.CapacityUnits).IsEqualTo(7.5); + } + +} diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs new file mode 100644 index 00000000..8cb89df5 --- /dev/null +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs @@ -0,0 +1,174 @@ +using ErrorOr; +using Goa.Clients.Dynamo.Models; +using Goa.Clients.Dynamo.Operations.Query; +using Goa.Clients.Dynamo.Operations.Scan; +using Moq; + +namespace Goa.Clients.Dynamo.Tests; + +public class TypedExtensionTests +{ + private record TestModel(string Id, string Name); + + private static readonly DynamoItemReader TestReader = (ref System.Text.Json.Utf8JsonReader _) => new TestModel("", ""); + + // Tests for QueryAllAsync auto-paginating + [Test] + public async Task QueryAllAsync_T_PaginatesAutomatically() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = new AttributeValue { S = "lastPk" } + }; + + ErrorOr> firstPage = new QueryResult + { + Items = [new TestModel("1", "Alice")], + LastEvaluatedKey = lastKey + }; + ErrorOr> secondPage = new QueryResult + { + Items = [new TestModel("2", "Bob")], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.QueryAllAsync("TestTable", TestReader, _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0].Id).IsEqualTo("1"); + await Assert.That(items[1].Id).IsEqualTo("2"); + } + + // Tests for ScanAllAsync auto-paginating + [Test] + public async Task ScanAllAsync_T_PaginatesAutomatically() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = new AttributeValue { S = "lastPk" } + }; + + ErrorOr> firstPage = new ScanResult + { + Items = [new TestModel("1", "Alice")], + LastEvaluatedKey = lastKey + }; + ErrorOr> secondPage = new ScanResult + { + Items = [new TestModel("2", "Bob")], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.ScanAllAsync("TestTable", TestReader, _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0].Id).IsEqualTo("1"); + await Assert.That(items[1].Id).IsEqualTo("2"); + } + + // Test for error mid-pagination + [Test] + public async Task QueryAllAsync_T_StopsOnError_MidPagination() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = new AttributeValue { S = "lastPk" } + }; + + ErrorOr> firstPage = new QueryResult + { + Items = [new TestModel("1", "Alice")], + LastEvaluatedKey = lastKey + }; + ErrorOr> secondPage = Error.Failure("DynamoDb.Error", "Something went wrong"); + + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny>(), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.QueryAllAsync("TestTable", TestReader, _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(1); + await Assert.That(items[0].Id).IsEqualTo("1"); + await Assert.That(items[0].Name).IsEqualTo("Alice"); + } + + // Tests for non-generic pagination + [Test] + public async Task QueryAllAsync_DynamoRecord_PaginatesAutomatically() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = new AttributeValue { S = "lastPk" } + }; + + ErrorOr firstPage = new QueryResponse + { + Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk1" } }], + LastEvaluatedKey = lastKey + }; + ErrorOr secondPage = new QueryResponse + { + Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk2" } }], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.QueryAllAsync("TestTable", _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); + await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); + } + + [Test] + public async Task ScanAllAsync_DynamoRecord_PaginatesAutomatically() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = new AttributeValue { S = "lastPk" } + }; + + ErrorOr firstPage = new ScanResponse + { + Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk1" } }], + LastEvaluatedKey = lastKey + }; + ErrorOr secondPage = new ScanResponse + { + Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk2" } }], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.ScanAllAsync("TestTable", _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); + await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); + } +} From 3abb032a2605901053a0ba0aed9e421536863045 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sat, 7 Mar 2026 17:55:50 +0000 Subject: [PATCH 02/23] Add typed write path with DynamoItemWriter and PutItemAsync --- .../ReaderRegistrationGenerator.cs | 1 + .../Goa.Clients.Dynamo/DynamoItemWriter.cs | 9 +++ .../DynamoItemWriterRegistry.cs | 28 ++++++++ .../Goa.Clients.Dynamo/DynamoServiceClient.cs | 32 +++++++++ .../Goa.Clients.Dynamo/IDynamoClient.cs | 5 ++ .../DynamoItemWriterRegistryTests.cs | 67 +++++++++++++++++++ 6 files changed, 142 insertions(+) create mode 100644 src/Clients/Goa.Clients.Dynamo/DynamoItemWriter.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs create mode 100644 tests/Clients/Goa.Clients.Dynamo.Tests/DynamoItemWriterRegistryTests.cs diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs index c0fa72aa..9584712a 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs @@ -42,6 +42,7 @@ public string GenerateCode(IEnumerable types, GenerationContext { var normalizedName = NamingHelpers.NormalizeTypeName(type.Name); builder.AppendLine($"DynamoItemReaderRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.ReadFromJson);"); + builder.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.WriteToJson);"); } } builder.CloseBrace(); diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemWriter.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriter.cs new file mode 100644 index 00000000..2fba1ea4 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriter.cs @@ -0,0 +1,9 @@ +namespace Goa.Clients.Dynamo; + +/// +/// Delegate that writes a single DynamoDB item to a UTF-8 JSON writer. +/// +/// The type to serialize. +/// The UTF-8 JSON writer to write to. +/// The item to serialize. +public delegate void DynamoItemWriter(System.Text.Json.Utf8JsonWriter writer, T item); diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs new file mode 100644 index 00000000..dcb9d9a4 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs @@ -0,0 +1,28 @@ +namespace Goa.Clients.Dynamo; + +/// +/// Registry for DynamoItemWriter delegates, populated by source generators. +/// +public static class DynamoItemWriterRegistry +{ + /// + /// Registers a writer delegate for the specified type. + /// + /// The type the writer serializes. + /// The writer delegate. + public static void Register(DynamoItemWriter writer) => Cache.Writer = writer; + + /// + /// Gets the registered writer delegate for the specified type. + /// + /// The type to get the writer for. + /// The registered writer delegate. + /// Thrown when no writer is registered for the type. + public static DynamoItemWriter Get() => Cache.Writer + ?? throw new InvalidOperationException($"No DynamoItemWriter registered for {typeof(T).Name}. Ensure the type has [DynamoModel] attribute and the source generator has run."); + + private static class Cache + { + public static DynamoItemWriter? Writer; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index c61c7e2f..bfdb6ec7 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -315,6 +315,38 @@ public async Task>> ScanAsync(ScanRequest request, Dyna return DynamoResponseReader.ReadGetItemResponse(buffer.Span, itemReader); } + /// + public async Task> PutItemAsync(string tableName, T item, DynamoItemWriter itemWriter, CancellationToken cancellationToken = default) + { + using var buffer = new System.IO.MemoryStream(); + using var writer = new System.Text.Json.Utf8JsonWriter(buffer); + writer.WriteStartObject(); + writer.WriteString("TableName", tableName); + writer.WritePropertyName("Item"); + itemWriter(writer, item); + writer.WriteEndObject(); + writer.Flush(); + + var content = buffer.ToArray(); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.PutItem"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.PutItem", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var responseBuffer = await ReadResponseBytesAsync(response, cancellationToken); + if (responseBuffer.Length == 0) + return new PutItemResponse(); + + var reader = DynamoJsonContext.GetReader(); + var jsonReader = new System.Text.Json.Utf8JsonReader(responseBuffer.Span); + return reader(ref jsonReader); + } + private async Task HandleTypedErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var errorPayload = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs index 6458c4e7..839b1a19 100644 --- a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs @@ -110,4 +110,9 @@ public interface IDynamoClient /// Gets an item from a DynamoDB table with direct typed deserialization. /// Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); + + /// + /// Puts a typed item into a DynamoDB table using a direct item writer for zero-copy serialization. + /// + Task> PutItemAsync(string tableName, T item, DynamoItemWriter itemWriter, CancellationToken cancellationToken = default); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoItemWriterRegistryTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoItemWriterRegistryTests.cs new file mode 100644 index 00000000..4a8ebcad --- /dev/null +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoItemWriterRegistryTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +namespace Goa.Clients.Dynamo.Tests; + +public class DynamoItemWriterRegistryTests +{ + private sealed class TestItem + { + public string? Name { get; set; } + public int Value { get; set; } + } + + private sealed class UnregisteredItem + { + public string? Id { get; set; } + } + + private static void WriteTestItem(Utf8JsonWriter writer, TestItem item) + { + writer.WriteStartObject(); + if (item.Name is not null) + { + writer.WritePropertyName("name"); + writer.WriteStartObject(); + writer.WriteString("S", item.Name); + writer.WriteEndObject(); + } + writer.WritePropertyName("value"); + writer.WriteStartObject(); + writer.WriteNumber("N", item.Value); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + [Test] + public async Task Register_and_Get_roundtrip_returns_registered_writer() + { + DynamoItemWriterRegistry.Register(WriteTestItem); + + var writer = DynamoItemWriterRegistry.Get(); + + await Assert.That(writer).IsEqualTo((DynamoItemWriter)WriteTestItem); + } + + [Test] + public void Get_without_registration_throws_InvalidOperationException() + { + Assert.Throws(() => DynamoItemWriterRegistry.Get()); + } + + [Test] + public async Task Re_registering_overwrites_previous_writer() + { + static void originalWriter(Utf8JsonWriter writer, TestItem item) + { + writer.WriteStartObject(); + writer.WriteEndObject(); + } + + DynamoItemWriterRegistry.Register(originalWriter); + DynamoItemWriterRegistry.Register(WriteTestItem); + + var result = DynamoItemWriterRegistry.Get(); + + await Assert.That(result).IsEqualTo((DynamoItemWriter)WriteTestItem); + } +} From 5d209e5ee87b0f164d26166c583071feba84e3ee Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sat, 7 Mar 2026 17:56:04 +0000 Subject: [PATCH 03/23] Add typed BatchGetItem and TransactGetItems with auto-pagination --- .../Goa.Clients.Dynamo/DynamoExtensions.cs | 36 +++++ .../Goa.Clients.Dynamo/DynamoServiceClient.cs | 42 ++++++ .../Goa.Clients.Dynamo/IDynamoClient.cs | 10 ++ .../Internal/DynamoResponseReader.cs | 112 ++++++++++++++++ .../Operations/Batch/BatchGetResult.cs | 33 ----- .../Operations/Batch/BatchGetResult{T}.cs | 29 +++++ .../Transactions/TransactGetResult{T}.cs | 19 +++ .../DynamoResponseReaderTests.cs | 123 ++++++++++++++++++ 8 files changed, 371 insertions(+), 33 deletions(-) delete mode 100644 src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult{T}.cs create mode 100644 src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult{T}.cs diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs index 75ab47d5..edf5d4cc 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs @@ -321,6 +321,42 @@ public static async IAsyncEnumerable> BatchGe while (true); } + /// + /// Executes a typed DynamoDB BatchGetItem operation with automatic retry of unprocessed keys using an async iterator. + /// + public static async IAsyncEnumerable> BatchGetAllAsync(this IDynamoClient client, DynamoItemReader itemReader, Action builder, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var _builder = new BatchGetItemBuilder(); + builder(_builder); + var request = _builder.Build(); + + do + { + var result = await client.BatchGetItemAsync(request, itemReader, cancellationToken); + if (result.IsError) + { + yield break; + } + + foreach (var tableResponse in result.Value.Responses) + { + foreach (var item in tableResponse.Value) + { + yield return new KeyValuePair(tableResponse.Key, item); + } + } + + if (!result.Value.HasUnprocessedKeys) + { + break; + } + + // Retry with unprocessed keys + request.RequestItems = result.Value.UnprocessedKeys!; + } + while (true); + } + /// /// Converts an async enumerable to a list asynchronously. /// diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index bfdb6ec7..412af4e1 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -347,6 +347,48 @@ public async Task> PutItemAsync(string tableName, T return reader(ref jsonReader); } + /// + public async Task>> BatchGetItemAsync(BatchGetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = SerializeToUtf8Bytes(request); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.BatchGetItem"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.BatchGetItem", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var buffer = await ReadResponseBytesAsync(response, cancellationToken); + if (buffer.Length == 0) + return new BatchGetResult(); + + return DynamoResponseReader.ReadBatchGetItemResponse(buffer.Span, itemReader); + } + + /// + public async Task>> TransactGetItemsAsync(TransactGetRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = SerializeToUtf8Bytes(request); + var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + new MediaTypeHeaderValue("application/x-amz-json-1.0")); + requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.TransactGetItems"); + + using var response = await SendAsync(requestMessage, "DynamoDB_20120810.TransactGetItems", cancellationToken); + + if (!response.IsSuccessStatusCode) + return await HandleTypedErrorAsync(response, cancellationToken); + + using var buffer = await ReadResponseBytesAsync(response, cancellationToken); + if (buffer.Length == 0) + return new TransactGetResult(); + + return DynamoResponseReader.ReadTransactGetItemResponse(buffer.Span, itemReader); + } + private async Task HandleTypedErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var errorPayload = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs index 839b1a19..f0c09a3b 100644 --- a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs @@ -115,4 +115,14 @@ public interface IDynamoClient /// Puts a typed item into a DynamoDB table using a direct item writer for zero-copy serialization. /// Task> PutItemAsync(string tableName, T item, DynamoItemWriter itemWriter, CancellationToken cancellationToken = default); + + /// + /// Gets multiple items from DynamoDB tables with direct typed deserialization. + /// + Task>> BatchGetItemAsync(BatchGetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); + + /// + /// Executes a transactional get with direct typed deserialization. + /// + Task>> TransactGetItemsAsync(TransactGetRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); } diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index ed994616..b9f7afe5 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Goa.Clients.Dynamo.Models; +using Goa.Clients.Dynamo.Operations.Batch; using Goa.Clients.Dynamo.Operations.Query; using Goa.Clients.Dynamo.Operations.Scan; +using Goa.Clients.Dynamo.Operations.Transactions; namespace Goa.Clients.Dynamo.Internal; @@ -282,4 +284,114 @@ private static Dictionary ReadCapacityDetailMap(ref Ut } return map; } + + public static BatchGetResult ReadBatchGetItemResponse(ReadOnlySpan utf8Json, DynamoItemReader itemReader) + { + var result = new BatchGetResult(); + var reader = new Utf8JsonReader(utf8Json); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Responses": + result.Responses = ReadTableResponses(ref reader, itemReader); + break; + case "UnprocessedKeys": + // Skip for now - complex structure, users can retry with original request + reader.Skip(); + break; + case "ConsumedCapacity": + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + return result; + } + + public static TransactGetResult ReadTransactGetItemResponse(ReadOnlySpan utf8Json, DynamoItemReader itemReader) + { + var result = new TransactGetResult(); + var reader = new Utf8JsonReader(utf8Json); + + reader.Read(); // StartObject + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Responses": + result.Items = ReadTransactGetResponses(ref reader, itemReader); + break; + case "ConsumedCapacity": + result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + return result; + } + + private static Dictionary> ReadTableResponses(ref Utf8JsonReader reader, DynamoItemReader itemReader) + { + var responses = new Dictionary>(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var tableName = reader.GetString()!; + reader.Read(); // StartArray + responses[tableName] = ReadItems(ref reader, itemReader); + } + return responses; + } + + private static List ReadConsumedCapacityList(ref Utf8JsonReader reader) + { + var list = new List(); + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(ReadConsumedCapacity(ref reader)); + } + return list; + } + + private static List ReadTransactGetResponses(ref Utf8JsonReader reader, DynamoItemReader itemReader) + { + var items = new List(); + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + // Each element is an object like {"Item": {...}} + T? item = default; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propName = reader.GetString(); + reader.Read(); + if (propName == "Item") + { + item = itemReader(ref reader); + } + else + { + reader.Skip(); + } + } + items.Add(item); + } + return items; + } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs deleted file mode 100644 index ca3deb41..00000000 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; -using Goa.Clients.Dynamo.Models; - -namespace Goa.Clients.Dynamo.Operations.Batch; - -/// -/// Result wrapper for BatchGet operations. -/// -public class BatchGetResult -{ - /// - /// The items retrieved by the BatchGetItem operation. - /// - [JsonPropertyName("Items")] - public List Items { get; set; } = new(); - - /// - /// Keys that were not processed during the BatchGetItem operation. - /// - [JsonPropertyName("UnprocessedKeys")] - public List> UnprocessedKeys { get; set; } = new(); - - /// - /// Gets a value indicating whether there are unprocessed keys that require additional requests. - /// - public bool HasUnprocessedKeys => UnprocessedKeys.Count > 0; - - /// - /// The number of capacity units consumed by the operation. - /// - [JsonPropertyName("ConsumedCapacityUnits")] - public double ConsumedCapacityUnits { get; set; } -} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult{T}.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult{T}.cs new file mode 100644 index 00000000..64ad0637 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult{T}.cs @@ -0,0 +1,29 @@ +using Goa.Clients.Dynamo.Models; + +namespace Goa.Clients.Dynamo.Operations.Batch; + +/// +/// Typed result wrapper for BatchGetItem operations with direct deserialization support. +/// +public sealed class BatchGetResult +{ + /// + /// The items retrieved by table name. + /// + public Dictionary> Responses { get; set; } = new(); + + /// + /// Keys that were not processed and need to be retried. + /// + public Dictionary? UnprocessedKeys { get; set; } + + /// + /// Gets a value indicating whether there are unprocessed keys requiring additional requests. + /// + public bool HasUnprocessedKeys => UnprocessedKeys?.Count > 0; + + /// + /// The capacity consumed by the operation. + /// + public List? ConsumedCapacity { get; set; } +} diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult{T}.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult{T}.cs new file mode 100644 index 00000000..8bd5b1f4 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactGetResult{T}.cs @@ -0,0 +1,19 @@ +using Goa.Clients.Dynamo.Models; + +namespace Goa.Clients.Dynamo.Operations.Transactions; + +/// +/// Typed result wrapper for TransactGetItems operations with direct deserialization support. +/// +public sealed class TransactGetResult +{ + /// + /// The items retrieved, in the same order as the request. Null entries indicate items that were not found. + /// + public List Items { get; set; } = new(); + + /// + /// The capacity consumed by the operation. + /// + public List? ConsumedCapacity { get; set; } +} diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs index ab28e591..48af1bfa 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs @@ -519,6 +519,129 @@ public async Task ReadQueryResponse_ShouldParseConsumedCapacity_WithIndexes() await Assert.That(result.ConsumedCapacity!.LocalSecondaryIndexes!["LSI1"].WriteCapacityUnits).IsEqualTo(0.5); } + // === BatchGetItemResponse parsing === + + [Test] + public async Task ReadBatchGetItemResponse_ShouldDeserializeItems() + { + var json = """ + { + "Responses": { + "TestTable": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "data1"}}, + {"pk": {"S": "pk2"}, "sk": {"S": "sk2"}, "data": {"S": "data2"}} + ], + "OtherTable": [ + {"pk": {"S": "pk3"}, "sk": {"S": "sk3"}, "data": {"S": "data3"}} + ] + } + } + """u8; + + var result = DynamoResponseReader.ReadBatchGetItemResponse(json, ReadTestEntity); + + await Assert.That(result.Responses.Count).IsEqualTo(2); + await Assert.That(result.Responses["TestTable"].Count).IsEqualTo(2); + await Assert.That(result.Responses["TestTable"][0].Pk).IsEqualTo("pk1"); + await Assert.That(result.Responses["TestTable"][0].Data).IsEqualTo("data1"); + await Assert.That(result.Responses["TestTable"][1].Pk).IsEqualTo("pk2"); + await Assert.That(result.Responses["OtherTable"].Count).IsEqualTo(1); + await Assert.That(result.Responses["OtherTable"][0].Pk).IsEqualTo("pk3"); + await Assert.That(result.HasUnprocessedKeys).IsFalse(); + } + + [Test] + public async Task ReadBatchGetItemResponse_ShouldHandleEmptyResponses() + { + var json = """ + { + "Responses": {} + } + """u8; + + var result = DynamoResponseReader.ReadBatchGetItemResponse(json, ReadTestEntity); + + await Assert.That(result.Responses.Count).IsEqualTo(0); + await Assert.That(result.HasUnprocessedKeys).IsFalse(); + } + + [Test] + public async Task ReadBatchGetItemResponse_ShouldParseConsumedCapacity() + { + var json = """ + { + "Responses": { + "TestTable": [ + {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}} + ] + }, + "ConsumedCapacity": [ + { + "TableName": "TestTable", + "CapacityUnits": 5.0, + "ReadCapacityUnits": 3.0 + } + ] + } + """u8; + + var result = DynamoResponseReader.ReadBatchGetItemResponse(json, ReadTestEntity); + + await Assert.That(result.ConsumedCapacity).IsNotNull(); + await Assert.That(result.ConsumedCapacity!.Count).IsEqualTo(1); + await Assert.That(result.ConsumedCapacity![0].TableName).IsEqualTo("TestTable"); + await Assert.That(result.ConsumedCapacity![0].CapacityUnits).IsEqualTo(5.0); + await Assert.That(result.ConsumedCapacity![0].ReadCapacityUnits).IsEqualTo(3.0); + } + + // === TransactGetItemResponse parsing === + + [Test] + public async Task ReadTransactGetItemResponse_ShouldDeserializeItems() + { + var json = """ + { + "Responses": [ + {"Item": {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "data1"}}}, + {"Item": {"pk": {"S": "pk2"}, "sk": {"S": "sk2"}, "data": {"S": "data2"}}} + ] + } + """u8; + + var result = DynamoResponseReader.ReadTransactGetItemResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(2); + await Assert.That(result.Items[0]).IsNotNull(); + await Assert.That(result.Items[0]!.Pk).IsEqualTo("pk1"); + await Assert.That(result.Items[0]!.Data).IsEqualTo("data1"); + await Assert.That(result.Items[1]).IsNotNull(); + await Assert.That(result.Items[1]!.Pk).IsEqualTo("pk2"); + await Assert.That(result.Items[1]!.Data).IsEqualTo("data2"); + } + + [Test] + public async Task ReadTransactGetItemResponse_ShouldHandleEmptyItemObjects() + { + var json = """ + { + "Responses": [ + {"Item": {"pk": {"S": "pk1"}, "sk": {"S": "sk1"}, "data": {"S": "data1"}}}, + {}, + {"Item": {"pk": {"S": "pk3"}, "sk": {"S": "sk3"}, "data": {"S": "data3"}}} + ] + } + """u8; + + var result = DynamoResponseReader.ReadTransactGetItemResponse(json, ReadTestEntity); + + await Assert.That(result.Items.Count).IsEqualTo(3); + await Assert.That(result.Items[0]).IsNotNull(); + await Assert.That(result.Items[0]!.Pk).IsEqualTo("pk1"); + await Assert.That(result.Items[1]).IsNull(); + await Assert.That(result.Items[2]).IsNotNull(); + await Assert.That(result.Items[2]!.Pk).IsEqualTo("pk3"); + } + [Test] public async Task ReadQueryResponse_ShouldParseFullResponse() { From fff08456a568ea52c13fee1e54a18fc93b028a6b Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sat, 7 Mar 2026 17:56:10 +0000 Subject: [PATCH 04/23] Add DynamoDB typed API usage documentation --- docs/DynamoDbTypedApi.md | 445 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 docs/DynamoDbTypedApi.md diff --git a/docs/DynamoDbTypedApi.md b/docs/DynamoDbTypedApi.md new file mode 100644 index 00000000..abbdfba7 --- /dev/null +++ b/docs/DynamoDbTypedApi.md @@ -0,0 +1,445 @@ +# DynamoDB Typed API + +The Goa DynamoDB client provides a **zero-copy typed deserialization path** that bypasses intermediate `DynamoRecord` allocations. This is the recommended approach for all DynamoDB operations in performance-sensitive applications. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Defining Models](#defining-models) +- [Custom Converters](#custom-converters) +- [Reading Items](#reading-items) +- [Writing Items](#writing-items) +- [Pagination](#pagination) +- [Registry-Based Usage](#registry-based-usage) +- [Migration from Untyped API](#migration-from-untyped-api) + +--- + +## Getting Started + +### 1. Define your model + +```csharp +using Goa.Clients.Dynamo; + +[DynamoModel(PK = "USER#", SK = "PROFILE#")] +public record UserProfile(string Id, string Email, string DisplayName, int Age); +``` + +### 2. Initialize the registry at startup + +```csharp +// Call once during application startup (e.g., in Program.cs) +DynamoReaderRegistration.Initialize(); +``` + +This triggers the source-generated static constructor that registers all `[DynamoModel]` types with `DynamoItemReaderRegistry` and `DynamoItemWriterRegistry`. + +### 3. Use the typed API + +```csharp +// Read +var result = await client.QueryAsync(request, DynamoItemReaderRegistry.Get()); + +// Write +await client.PutItemAsync("Users", user, DynamoItemWriterRegistry.Get()); +``` + +--- + +## Defining Models + +### Basic Model + +```csharp +[DynamoModel(PK = "USER#", SK = "PROFILE#")] +public record UserProfile(string Id, string Email, string DisplayName, int Age); +``` + +The `PK` and `SK` patterns use `` placeholders that map to properties on the type. + +### With Global Secondary Indexes + +```csharp +[DynamoModel(PK = "USER#", SK = "PROFILE#")] +[GlobalSecondaryIndex(Name = "EmailIndex", PK = "EMAIL#", SK = "USER#")] +public record UserProfile(string Id, string Email, string DisplayName, int Age); +``` + +### Inheritance + +```csharp +[DynamoModel(PK = "ENTITY#", SK = "DATA#")] +public abstract record BaseEntity(string Id); + +public record Order(string Id, string CustomerId, decimal Total) : BaseEntity(Id); +public record Invoice(string Id, string OrderId, DateTime IssuedAt) : BaseEntity(Id); +``` + +The generator automatically adds a type discriminator field for polymorphic deserialization. + +### Property Attributes + +```csharp +[DynamoModel(PK = "USER#", SK = "DATA")] +public class User +{ + public string Id { get; set; } = ""; + + [SerializedName("user_name")] // Custom DynamoDB attribute name + public string UserName { get; set; } = ""; + + [UnixTimestamp] // Store as Unix timestamp (seconds) + public DateTime CreatedAt { get; set; } + + [UnixTimestamp(Format = Format.Milliseconds)] // Higher precision + public DateTime UpdatedAt { get; set; } + + [Ignore] // Exclude from serialization entirely + public string ComputedField { get; set; } = ""; + + [Ignore(Direction = Direction.WhenWriting)] // Read but don't write + public string ReadOnlyField { get; set; } = ""; +} +``` + +--- + +## Custom Converters + +For properties that need custom serialization (compressed data, MemoryPack, binary formats), use `[DynamoConverter]`. + +### Define a converter + +Implement `IDynamoPropertyConverter`: + +```csharp +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Goa.Clients.Dynamo; + +public class CompressedStringConverter : IDynamoPropertyConverter +{ + public string Read(ref Utf8JsonReader reader) + { + // Read from DynamoDB wire format: {"B": "base64data"} + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.GetString() == "B") + { + reader.Read(); + var base64 = reader.GetString()!; + var compressed = Convert.FromBase64String(base64); + using var input = new MemoryStream(compressed); + using var brotli = new BrotliStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + brotli.CopyTo(output); + return Encoding.UTF8.GetString(output.ToArray()); + } + reader.Skip(); + } + return ""; + } + + public void Write(Utf8JsonWriter writer, string value) + { + // Write as DynamoDB binary: {"B": "base64data"} + var bytes = Encoding.UTF8.GetBytes(value); + using var output = new MemoryStream(); + using (var brotli = new BrotliStream(output, CompressionLevel.Fastest)) + { + brotli.Write(bytes); + } + writer.WriteStartObject(); + writer.WriteString("B", Convert.ToBase64String(output.ToArray())); + writer.WriteEndObject(); + } +} +``` + +### Apply to a property + +```csharp +[DynamoModel(PK = "PAGE#", SK = "CONTENT")] +public class PageContent +{ + public string Id { get; set; } = ""; + + [DynamoConverter(typeof(CompressedStringConverter))] + public string HtmlBody { get; set; } = ""; + + [DynamoConverter(typeof(MemoryPackConverter))] + public Metadata Meta { get; set; } = new(); +} +``` + +The source generator emits converter calls instead of inline serialization for these properties — no reflection, fully AOT-compatible. + +### Entity-level override + +For full control over an entire type's serialization, register a custom reader/writer after initialization: + +```csharp +DynamoReaderRegistration.Initialize(); + +// Replace the generated reader for a specific type +DynamoItemReaderRegistry.Register(MyCustomReader.Read); +DynamoItemWriterRegistry.Register(MyCustomWriter.Write); +``` + +--- + +## Reading Items + +### GetItem + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +var user = await client.GetItemAsync( + new GetItemRequest + { + TableName = "Users", + Key = DynamoKeyFactory.UserProfile.PK("123").WithSK(DynamoKeyFactory.UserProfile.SK("john@example.com")) + }, + reader); + +if (user.IsError) +{ + // Handle error + return; +} + +var profile = user.Value; // UserProfile? — null if not found +``` + +### Query + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +var result = await client.QueryAsync( + new QueryRequest + { + TableName = "Users", + KeyConditionExpression = "PK = :pk", + ExpressionAttributeValues = new() + { + [":pk"] = new AttributeValue { S = "USER#123" } + } + }, + reader); + +foreach (var profile in result.Value.Items) +{ + Console.WriteLine(profile.DisplayName); +} + +// Check for more pages +if (result.Value.HasMoreResults) +{ + // Use result.Value.LastEvaluatedKey for next page +} +``` + +### Scan + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +var result = await client.ScanAsync( + new ScanRequest { TableName = "Users" }, + reader); +``` + +### BatchGetItem + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +var result = await client.BatchGetItemAsync( + new BatchGetItemRequest + { + RequestItems = new() + { + ["Users"] = new BatchGetRequestItem + { + Keys = new List> + { + new() { ["PK"] = new() { S = "USER#1" }, ["SK"] = new() { S = "PROFILE#a@b.com" } }, + new() { ["PK"] = new() { S = "USER#2" }, ["SK"] = new() { S = "PROFILE#c@d.com" } } + } + } + } + }, + reader); + +// Items grouped by table name +foreach (var (tableName, items) in result.Value.Responses) +{ + foreach (var item in items) + { + Console.WriteLine(item.DisplayName); + } +} + +// Check for unprocessed keys +if (result.Value.HasUnprocessedKeys) +{ + // Retry with result.Value.UnprocessedKeys +} +``` + +### TransactGetItems + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +var result = await client.TransactGetItemsAsync( + new TransactGetRequest + { + TransactItems = new List + { + new() { Get = new TransactGetRequest { TableName = "Users", Key = key1 } }, + new() { Get = new TransactGetRequest { TableName = "Users", Key = key2 } } + } + }, + reader); + +// Items in same order as request — null if not found +foreach (var item in result.Value.Items) +{ + if (item is not null) + Console.WriteLine(item.DisplayName); +} +``` + +--- + +## Writing Items + +### PutItem (typed) + +```csharp +var writer = DynamoItemWriterRegistry.Get(); + +var user = new UserProfile("123", "john@example.com", "John", 30); + +var result = await client.PutItemAsync("Users", user, writer); +``` + +This serializes the item directly to DynamoDB JSON wire format — no intermediate `DynamoRecord` allocation. + +--- + +## Pagination + +### Auto-paginating Query + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +await foreach (var profile in client.QueryAllAsync("Users", reader, b => b + .WithKeyCondition("PK = :pk") + .WithExpressionValue(":pk", "USER#123"))) +{ + Console.WriteLine(profile.DisplayName); +} +``` + +### Auto-paginating Scan + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +await foreach (var profile in client.ScanAllAsync("Users", reader, b => b + .WithFilterExpression("Age > :minAge") + .WithExpressionValue(":minAge", "18"))) +{ + Console.WriteLine(profile.DisplayName); +} +``` + +### Auto-paginating BatchGet + +```csharp +var reader = DynamoItemReaderRegistry.Get(); + +await foreach (var (tableName, profile) in client.BatchGetAllAsync(reader, b => b + .AddTable("Users", keys))) +{ + Console.WriteLine(profile.DisplayName); +} +``` + +This automatically retries unprocessed keys until all items are retrieved. + +--- + +## Registry-Based Usage + +The source generator automatically registers readers and writers for all `[DynamoModel]` types when you call `DynamoReaderRegistration.Initialize()`. + +```csharp +// At startup +DynamoReaderRegistration.Initialize(); + +// Later — get reader/writer from registry +var reader = DynamoItemReaderRegistry.Get(); +var writer = DynamoItemWriterRegistry.Get(); + +// Use them +var result = await client.QueryAsync(request, reader); +await client.PutItemAsync("Users", user, writer); +``` + +### Custom overrides + +You can replace any generated converter by calling `Register` after initialization: + +```csharp +DynamoReaderRegistration.Initialize(); + +// Override with custom implementation +DynamoItemReaderRegistry.Register(CustomUserProfileReader.Read); +DynamoItemWriterRegistry.Register(CustomUserProfileWriter.Write); +``` + +--- + +## Migration from Untyped API + +The untyped API (`QueryAsync` returning `QueryResponse` with `List`) remains fully supported. You can migrate incrementally. + +### Before (untyped) + +```csharp +var response = await client.QueryAsync(request); +foreach (var record in response.Value.Items) +{ + var name = record["DisplayName"]?.S; + var age = int.Parse(record["Age"]?.N ?? "0"); +} +``` + +### After (typed) + +```csharp +var result = await client.QueryAsync(request, DynamoItemReaderRegistry.Get()); +foreach (var profile in result.Value.Items) +{ + var name = profile.DisplayName; // Strongly typed + var age = profile.Age; // Already an int +} +``` + +### Key differences + +| Aspect | Untyped | Typed | +|--------|---------|-------| +| Return type | `QueryResponse` with `List` | `QueryResult` with `List` | +| Allocations | Intermediate `DynamoRecord` dictionaries | Zero-copy, direct to object | +| Type safety | Manual string-keyed dictionary access | Compile-time checked properties | +| Performance | Good | Best (26x fewer allocations) | +| Schema | Flexible/dynamic | Requires `[DynamoModel]` | From ed0e82cafa4be3ea818114f7887675423e475450 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 10 Mar 2026 00:09:50 +0000 Subject: [PATCH 05/23] Adapt typed DynamoDB API to work without Goa.Json.Generator - Replace DynamoJsonContext with standard JsonSerializable attributes - Add ResolveJsonTypeInfo override to DynamoServiceClient - Fix JsonMapperGenerator to handle records with primary constructors and init-only properties - Add DynamoConverter attribute support to generator PropertyInfo model - Update BuilderChainingTests to use STJ serialization --- .../DynamoConverterAttributeHandler.cs | 44 +++ .../CodeGeneration/JsonMapperGenerator.cs | 324 ++++++++++++++++-- .../Models/DynamoConverterAttributeInfo.cs | 9 + .../Models/PropertyInfo.cs | 5 + .../Goa.Clients.Dynamo/DynamoServiceClient.cs | 89 ++--- .../Serialization/DynamoJsonContext.cs | 83 +++-- .../BuilderChainingTests.cs | 6 +- 7 files changed, 444 insertions(+), 116 deletions(-) create mode 100644 src/Clients/Goa.Clients.Dynamo.Generator/Attributes/DynamoConverterAttributeHandler.cs create mode 100644 src/Clients/Goa.Clients.Dynamo.Generator/Models/DynamoConverterAttributeInfo.cs diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/Attributes/DynamoConverterAttributeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/Attributes/DynamoConverterAttributeHandler.cs new file mode 100644 index 00000000..dbd4e90c --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/Attributes/DynamoConverterAttributeHandler.cs @@ -0,0 +1,44 @@ +using Microsoft.CodeAnalysis; +using Goa.Clients.Dynamo.Generator.Models; + +namespace Goa.Clients.Dynamo.Generator.Attributes; + +/// +/// Handles the DynamoConverterAttribute. +/// +public class DynamoConverterAttributeHandler : IAttributeHandler +{ + public string AttributeTypeName => "Goa.Clients.Dynamo.DynamoConverterAttribute"; + + public bool CanHandle(AttributeData attributeData) + { + return attributeData.AttributeClass?.ToDisplayString() == AttributeTypeName; + } + + public AttributeInfo? ParseAttribute(AttributeData attributeData) + { + if (!CanHandle(attributeData)) + { + return null; + } + + // Extract converter type from constructor arg + if (attributeData.ConstructorArguments.Length > 0 && + attributeData.ConstructorArguments[0].Value is INamedTypeSymbol converterType) + { + return new DynamoConverterAttributeInfo + { + AttributeData = attributeData, + AttributeTypeName = AttributeTypeName, + ConverterTypeName = converterType.ToDisplayString() + }; + } + + return null; + } + + public void ValidateAttribute(AttributeInfo attributeInfo, ISymbol symbol, Action reportDiagnostic) + { + // No additional validation needed at this stage + } +} diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index bfb12a1f..ed717106 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -138,7 +138,7 @@ private void GenerateConcreteWriteToJson(CodeBuilder builder, DynamoTypeInfo typ var allProperties = GetAllProperties(type); var supportedProperties = allProperties - .Where(p => _typeHandlerRegistry.CanHandle(p) && !p.IsIgnored(IgnoreDirection.WhenWriting)); + .Where(p => (p.ConverterTypeName != null || _typeHandlerRegistry.CanHandle(p)) && !p.IsIgnored(IgnoreDirection.WhenWriting)); foreach (var property in supportedProperties) { @@ -155,6 +155,14 @@ private void GenerateWriteProperty(CodeBuilder builder, PropertyInfo property, s var underlyingType = property.UnderlyingType; var isNullable = property.IsNullable; + // Custom converter handling - delegate entirely to the converter + if (property.ConverterTypeName != null) + { + builder.AppendLine($"writer.WritePropertyName(\"{attrName}\");"); + builder.AppendLine($"new {property.ConverterTypeName}().Write(writer, {accessExpr});"); + return; + } + // Check for [UnixTimestamp] attribute var hasUnixTimestamp = property.Attributes.Any(a => a is UnixTimestampAttributeInfo); @@ -545,6 +553,23 @@ private void GenerateConcreteReadFromJson(CodeBuilder builder, DynamoTypeInfo ty builder.AppendLine("if (reader.TokenType != JsonTokenType.StartObject)"); builder.Indent().AppendLine("reader.Read();").Unindent(); builder.AppendLine(); + + var constructor = FindBestConstructor(type); + var hasInitOnlyProperties = GetAllProperties(type) + .Any(p => p.Symbol?.SetMethod is { IsInitOnly: true }); + + if (constructor != null && (constructor.Parameters.Any() || hasInitOnlyProperties)) + { + GenerateConstructorReadFromJson(builder, type, constructor); + } + else + { + GenerateParameterlessReadFromJson(builder, type); + } + } + + private void GenerateParameterlessReadFromJson(CodeBuilder builder, DynamoTypeInfo type) + { builder.AppendLine($"var result = new {type.FullName}();"); builder.OpenBraceWithLine("while (reader.Read())"); builder.AppendLine("if (reader.TokenType == JsonTokenType.EndObject) break;"); @@ -552,7 +577,7 @@ private void GenerateConcreteReadFromJson(CodeBuilder builder, DynamoTypeInfo ty var allProperties = GetAllProperties(type); var readableProperties = allProperties - .Where(p => _typeHandlerRegistry.CanHandle(p) && !p.IsIgnored(IgnoreDirection.WhenReading) && p.Symbol?.SetMethod != null) + .Where(p => (p.ConverterTypeName != null || _typeHandlerRegistry.CanHandle(p)) && !p.IsIgnored(IgnoreDirection.WhenReading) && p.Symbol?.SetMethod != null && IsSupportedForJsonRead(p)) .ToList(); var isFirst = true; @@ -581,30 +606,146 @@ private void GenerateConcreteReadFromJson(CodeBuilder builder, DynamoTypeInfo ty builder.AppendLine("return result;"); } - private void GenerateReadProperty(CodeBuilder builder, PropertyInfo property, string resultVar) + private void GenerateConstructorReadFromJson(CodeBuilder builder, DynamoTypeInfo type, IMethodSymbol constructor) + { + var allProperties = GetAllProperties(type); + + // Build mapping of constructor parameters to properties + var ctorParams = constructor.Parameters; + var ctorParamProperties = new List<(IParameterSymbol Param, PropertyInfo? Property)>(); + var ctorParamNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var param in ctorParams) + { + var matchedProperty = FindPropertyIgnoreCase(type, param.Name); + ctorParamProperties.Add((param, matchedProperty)); + if (matchedProperty != null) + ctorParamNames.Add(matchedProperty.Name); + } + + // Readable properties: include properties that match constructor params (even without setter) + // or properties that have a setter + var readableProperties = allProperties + .Where(p => (p.ConverterTypeName != null || _typeHandlerRegistry.CanHandle(p)) + && !p.IsIgnored(IgnoreDirection.WhenReading) + && (p.Symbol?.SetMethod != null || ctorParamNames.Contains(p.Name)) + && IsSupportedForJsonRead(p)) + .ToList(); + + // Additional settable properties: not matched to constructor params, and have a setter + var additionalProperties = readableProperties + .Where(p => !ctorParamNames.Contains(p.Name) && p.Symbol?.SetMethod != null) + .ToList(); + + // Declare local variables for all readable properties + foreach (var property in readableProperties) + { + var varName = GetLocalVarName(property.Name); + var typeName = GetFullyQualifiedTypeName(property); + var defaultExpr = GetDefaultValueExpression(property.Type, property.IsNullable); + builder.AppendLine($"{typeName} {varName} = {defaultExpr};"); + } + + builder.AppendLine(); + builder.OpenBraceWithLine("while (reader.Read())"); + builder.AppendLine("if (reader.TokenType == JsonTokenType.EndObject) break;"); + builder.AppendLine(); + + var isFirst = true; + foreach (var property in readableProperties) + { + var attrName = property.GetDynamoAttributeName(); + var keyword = isFirst ? "if" : "else if"; + builder.OpenBraceWithLine($"{keyword} (reader.ValueTextEquals(\"{attrName}\"u8))"); + GenerateReadProperty(builder, property, "result", assignmentTarget: GetLocalVarName(property.Name)); + builder.CloseBrace(); + isFirst = false; + } + + if (readableProperties.Count > 0) + { + builder.OpenBraceWithLine("else"); + } + builder.AppendLine("reader.Read(); // Move past property name to value"); + builder.AppendLine("reader.Skip(); // Skip the type wrapper object"); + if (readableProperties.Count > 0) + { + builder.CloseBrace(); + } + + builder.CloseBrace(); // while + + // Build constructor arguments + var readablePropertyNames = new HashSet(readableProperties.Select(p => p.Name)); + var ctorArgs = new List(); + foreach (var (param, matchedProperty) in ctorParamProperties) + { + if (matchedProperty != null && readablePropertyNames.Contains(matchedProperty.Name)) + { + ctorArgs.Add(GetLocalVarName(matchedProperty.Name)); + } + else + { + // Use default! for non-nullable reference types to suppress nullable warnings + var paramType = param.Type; + var isParamNullable = paramType.NullableAnnotation == NullableAnnotation.Annotated; + ctorArgs.Add(!paramType.IsValueType && !isParamNullable ? "default!" : "default"); + } + } + + var ctorArgString = string.Join(", ", ctorArgs); + + if (additionalProperties.Count > 0) + { + builder.AppendLine($"return new {type.FullName}({ctorArgString})"); + builder.AppendLine("{"); + builder.Indent(); + foreach (var prop in additionalProperties) + { + builder.AppendLine($"{prop.Name} = {GetLocalVarName(prop.Name)},"); + } + builder.Unindent(); + builder.AppendLine("};"); + } + else + { + builder.AppendLine($"return new {type.FullName}({ctorArgString});"); + } + } + + private void GenerateReadProperty(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) { var underlyingType = property.UnderlyingType; var isNullable = property.IsNullable; var hasUnixTimestamp = property.Attributes.Any(a => a is UnixTimestampAttributeInfo); + // Custom converter handling - delegate entirely to the converter + if (property.ConverterTypeName != null) + { + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; + builder.AppendLine("reader.Read(); // Move past property name to value"); + builder.AppendLine($"{target} = new {property.ConverterTypeName}().Read(ref reader);"); + return; + } + // Dictionary handling if (property.IsDictionary && property.DictionaryTypes.HasValue) { - GenerateReadDictionary(builder, property, resultVar); + GenerateReadDictionary(builder, property, resultVar, assignmentTarget); return; } // Collection handling if (property.IsCollection && property.ElementType != null) { - GenerateReadCollection(builder, property, resultVar); + GenerateReadCollection(builder, property, resultVar, assignmentTarget); return; } // Complex type handling if (IsComplexType(underlyingType)) { - GenerateReadComplexType(builder, property, resultVar); + GenerateReadComplexType(builder, property, resultVar, assignmentTarget); return; } @@ -614,29 +755,30 @@ private void GenerateReadProperty(CodeBuilder builder, PropertyInfo property, st if (isNullable) { + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; // Check if NULL descriptor using zero-allocation UTF-8 comparison builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"NULL\"u8))"); builder.AppendLine("reader.Read(); // value (true)"); - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value"); - EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); builder.CloseBrace(); } else { builder.AppendLine("reader.Read(); // value"); - EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); } builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } - private void EmitReadPrimitiveAssignment(CodeBuilder builder, PropertyInfo property, string resultVar, bool hasUnixTimestamp) + private void EmitReadPrimitiveAssignment(CodeBuilder builder, PropertyInfo property, string resultVar, bool hasUnixTimestamp, string? assignmentTarget = null) { var underlyingType = property.UnderlyingType; - var propAccess = $"{resultVar}.{property.Name}"; + var propAccess = assignmentTarget ?? $"{resultVar}.{property.Name}"; // UnixTimestamp DateTime/DateTimeOffset -> read N as number if (hasUnixTimestamp) @@ -698,25 +840,26 @@ private void EmitReadPrimitiveAssignment(CodeBuilder builder, PropertyInfo prope } } - private void GenerateReadComplexType(CodeBuilder builder, PropertyInfo property, string resultVar) + private void GenerateReadComplexType(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) { var normalizedTypeName = NamingHelpers.NormalizeTypeName(property.UnderlyingType.Name); + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; builder.AppendLine("reader.Read(); // StartObject of type wrapper"); builder.AppendLine("reader.Read(); // type descriptor (M or NULL)"); builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"M\"u8))"); builder.AppendLine("reader.Read(); // StartObject of the nested entity"); - builder.AppendLine($"{resultVar}.{property.Name} = DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader);"); + builder.AppendLine($"{target} = DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader);"); builder.CloseBrace(); builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value (true for NULL)"); if (property.IsNullable) - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } - private void GenerateReadCollection(CodeBuilder builder, PropertyInfo property, string resultVar) + private void GenerateReadCollection(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) { var elementType = property.ElementType!; var isSetType = IsSetType(property.Type); @@ -726,25 +869,27 @@ private void GenerateReadCollection(CodeBuilder builder, PropertyInfo property, if (isSetType && elementType.SpecialType == SpecialType.System_String) { // SS type - GenerateReadStringSet(builder, property, resultVar); + GenerateReadStringSet(builder, property, resultVar, assignmentTarget); return; } if (isSetType && IsNumericType(elementType)) { // NS type - GenerateReadNumberSet(builder, property, resultVar, elementType); + GenerateReadNumberSet(builder, property, resultVar, elementType, assignmentTarget); return; } - // L type (general list) - GenerateReadList(builder, property, resultVar, elementType); + // L type (general list) — for set types with non-string/non-numeric elements, + // wrap in HashSet to satisfy the target type constraint. + GenerateReadList(builder, property, resultVar, elementType, assignmentTarget, wrapAsSet: isSetType); } - private void GenerateReadStringSet(CodeBuilder builder, PropertyInfo property, string resultVar) + private void GenerateReadStringSet(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) { var collectionTypeName = GetCollectionTypeName(property.Type); var varName = GetUniqueVarName("ss"); + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; builder.AppendLine("reader.Read(); // StartObject of type wrapper"); builder.AppendLine("reader.Read(); // type descriptor (SS or NULL)"); @@ -754,20 +899,21 @@ private void GenerateReadStringSet(CodeBuilder builder, PropertyInfo property, s builder.OpenBraceWithLine($"while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)"); builder.AppendLine($"{varName}Set.Add(reader.GetString()!);"); builder.CloseBrace(); - builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, property.ElementType!, $"{varName}Set")};"); + builder.AppendLine($"{target} = {ConvertToTargetCollection(property.Type, property.ElementType!, $"{varName}Set")};"); builder.CloseBrace(); builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value (true for NULL)"); if (property.IsNullable) - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } - private void GenerateReadNumberSet(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType) + private void GenerateReadNumberSet(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType, string? assignmentTarget = null) { var elementTypeName = elementType.ToDisplayString(); var varName = GetUniqueVarName("ns"); + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; builder.AppendLine("reader.Read(); // StartObject of type wrapper"); builder.AppendLine("reader.Read(); // type descriptor (NS or NULL)"); @@ -788,20 +934,21 @@ private void GenerateReadNumberSet(CodeBuilder builder, PropertyInfo property, s builder.AppendLine($"{varName}Set.Add({parseExpr});"); } builder.CloseBrace(); - builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}Set")};"); + builder.AppendLine($"{target} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}Set")};"); builder.CloseBrace(); builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value (true for NULL)"); if (property.IsNullable) - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } - private void GenerateReadList(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType) + private void GenerateReadList(CodeBuilder builder, PropertyInfo property, string resultVar, ITypeSymbol elementType, string? assignmentTarget = null, bool wrapAsSet = false) { var elementTypeName = elementType.ToDisplayString(); var varName = GetUniqueVarName("l"); + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; builder.AppendLine("reader.Read(); // StartObject of type wrapper"); builder.AppendLine("reader.Read(); // type descriptor (L or NULL)"); @@ -814,12 +961,21 @@ private void GenerateReadList(CodeBuilder builder, PropertyInfo property, string EmitReadElementValue(builder, elementType, $"{varName}List"); builder.CloseBrace(); // while - builder.AppendLine($"{resultVar}.{property.Name} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}List")};"); + if (wrapAsSet) + { + // Target is a set type but elements are not string/numeric, + // so we need to wrap the list in a HashSet to match the target type. + builder.AppendLine($"{target} = new HashSet<{elementTypeName}>({varName}List);"); + } + else + { + builder.AppendLine($"{target} = {ConvertToTargetCollection(property.Type, elementType, $"{varName}List")};"); + } builder.CloseBrace(); // if L builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value (true for NULL)"); if (property.IsNullable) - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } @@ -904,7 +1060,7 @@ private void EmitReadElementValue(CodeBuilder builder, ITypeSymbol elementType, builder.AppendLine("reader.Read(); // EndObject of element wrapper"); } - private void GenerateReadDictionary(CodeBuilder builder, PropertyInfo property, string resultVar) + private void GenerateReadDictionary(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) { var dictionaryTypes = property.DictionaryTypes!.Value; var keyType = dictionaryTypes.KeyType; @@ -912,6 +1068,7 @@ private void GenerateReadDictionary(CodeBuilder builder, PropertyInfo property, var keyTypeName = keyType.ToDisplayString(); var valueTypeName = valueType.ToDisplayString(); var varName = GetUniqueVarName("dict"); + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; builder.AppendLine("reader.Read(); // StartObject of type wrapper"); builder.AppendLine("reader.Read(); // type descriptor (M or NULL)"); @@ -925,12 +1082,12 @@ private void GenerateReadDictionary(CodeBuilder builder, PropertyInfo property, EmitReadDictionaryValue(builder, valueType, $"{varName}Map", $"{varName}Key"); builder.CloseBrace(); // while - builder.AppendLine($"{resultVar}.{property.Name} = {varName}Map;"); + builder.AppendLine($"{target} = {varName}Map;"); builder.CloseBrace(); // if M builder.OpenBraceWithLine("else"); builder.AppendLine("reader.Read(); // value (true for NULL)"); if (property.IsNullable) - builder.AppendLine($"{resultVar}.{property.Name} = null;"); + builder.AppendLine($"{target} = null;"); builder.CloseBrace(); builder.AppendLine("reader.Read(); // EndObject of type wrapper"); } @@ -1000,6 +1157,111 @@ private void EmitReadDictionaryValue(CodeBuilder builder, ITypeSymbol valueType, // ─── Helper methods ───────────────────────────────────────────────── + private static IMethodSymbol? FindBestConstructor(DynamoTypeInfo type) + { + var constructors = type.Symbol.Constructors + .Where(c => c.DeclaredAccessibility == Accessibility.Public) + // Exclude the synthesized record copy constructor (single parameter of the same type) + .Where(c => !(c.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(c.Parameters[0].Type, type.Symbol))) + .ToList(); + + // Prefer constructor with parameters (primary constructor) + var paramConstructor = constructors.FirstOrDefault(c => c.Parameters.Any()); + if (paramConstructor != null) + return paramConstructor; + + // Fallback to parameterless constructor + return constructors.FirstOrDefault(c => !c.Parameters.Any()); + } + + private static PropertyInfo? FindPropertyIgnoreCase(DynamoTypeInfo type, string propertyName) + { + var current = type; + while (current != null) + { + var property = current.Properties.FirstOrDefault(p => + string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)); + if (property != null) + return property; + + current = current.BaseType; + } + return null; + } + + private static string GetLocalVarName(string propertyName) + { + return "__" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + } + + /// + /// Checks whether a property type is supported for JSON wire format reading. + /// Nested collections and dictionaries with non-string keys are not yet supported. + /// + private static bool IsSupportedForJsonRead(PropertyInfo property) + { + // Nested collections (e.g., IEnumerable>) are not supported + if (property.IsCollection && property.ElementType != null) + { + var elementType = property.ElementType; + if (elementType is IArrayTypeSymbol) + return false; + if (elementType is INamedTypeSymbol namedElem && namedElem.IsGenericType) + { + var name = namedElem.Name; + if (name is "List" or "IList" or "ICollection" or "IEnumerable" or + "HashSet" or "ISet" or "IReadOnlyCollection" or + "IReadOnlyList" or "IReadOnlySet" or "Collection" or + "Dictionary" or "IDictionary" or "IReadOnlyDictionary") + return false; + } + } + + // Dictionaries with non-string keys (e.g., Dictionary) are not supported + if (property.IsDictionary && property.DictionaryTypes.HasValue) + { + var keyType = property.DictionaryTypes.Value.KeyType; + if (keyType.SpecialType != SpecialType.System_String) + return false; + + // Also check for nested collection/dictionary values + var valueType = property.DictionaryTypes.Value.ValueType; + if (valueType is INamedTypeSymbol namedVal && namedVal.IsGenericType) + { + var name = namedVal.Name; + if (name is "List" or "IList" or "ICollection" or "IEnumerable" or + "HashSet" or "ISet" or "IReadOnlyCollection" or + "IReadOnlyList" or "IReadOnlySet" or "Collection" or + "Dictionary" or "IDictionary" or "IReadOnlyDictionary") + return false; + } + if (valueType is IArrayTypeSymbol) + return false; + } + + return true; + } + + private static string GetFullyQualifiedTypeName(PropertyInfo property) + { + var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // FullyQualifiedFormat does not include nullable reference type annotations, + // so we need to append '?' manually for nullable reference types. + if (property.IsNullable && !property.Type.IsValueType && !typeName.EndsWith("?")) + typeName += "?"; + return typeName; + } + + private static string GetDefaultValueExpression(ITypeSymbol type, bool isNullable) + { + if (isNullable) + return "default"; + if (type.IsValueType) + return "default"; + return "default!"; + } + private List GetAllProperties(DynamoTypeInfo type) { var properties = new List(); diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/Models/DynamoConverterAttributeInfo.cs b/src/Clients/Goa.Clients.Dynamo.Generator/Models/DynamoConverterAttributeInfo.cs new file mode 100644 index 00000000..74ca6c23 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/Models/DynamoConverterAttributeInfo.cs @@ -0,0 +1,9 @@ +namespace Goa.Clients.Dynamo.Generator.Models; + +/// +/// Represents DynamoConverter attribute information. +/// +public class DynamoConverterAttributeInfo : AttributeInfo +{ + public string ConverterTypeName { get; set; } = string.Empty; +} diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/Models/PropertyInfo.cs b/src/Clients/Goa.Clients.Dynamo.Generator/Models/PropertyInfo.cs index a6071a05..fd7b87ce 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/Models/PropertyInfo.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/Models/PropertyInfo.cs @@ -50,6 +50,11 @@ public ITypeSymbol UnderlyingType /// For non-dictionary types, returns null. /// public (ITypeSymbol KeyType, ITypeSymbol ValueType)? DictionaryTypes { get; set; } + + /// + /// The fully qualified type name of a custom DynamoConverter for this property, if any. + /// + public string? ConverterTypeName { get; set; } /// /// Gets the effective name for this property in DynamoDB records. diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index 412af4e1..fc890a65 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ErrorOr; using Goa.Clients.Core; using Goa.Clients.Core.Http; @@ -13,7 +14,6 @@ using Goa.Clients.Dynamo.Operations.UpdateItem; using Goa.Clients.Dynamo.Serialization; using Microsoft.Extensions.Logging; -using System.Net.Http.Headers; namespace Goa.Clients.Dynamo; @@ -30,46 +30,16 @@ public class DynamoServiceClient : JsonAwsServiceClientLogger instance for logging operations. /// Configuration for the DynamoDB service. public DynamoServiceClient(IHttpClientFactory httpClientFactory, ILogger logger, DynamoServiceClientConfiguration configuration) - : base(httpClientFactory, logger, configuration, Goa.Clients.Core.AwsServiceClientSerializationContext.Default) + : base(httpClientFactory, logger, configuration) { } /// - protected override byte[] SerializeToUtf8Bytes(TRequest request) + protected override System.Text.Json.Serialization.Metadata.JsonTypeInfo ResolveJsonTypeInfo() { - var writer = DynamoJsonContext.GetWriter(); - using var buffer = new System.IO.MemoryStream(); - using var jsonWriter = new System.Text.Json.Utf8JsonWriter(buffer); - writer(jsonWriter, request); - jsonWriter.Flush(); - return buffer.ToArray(); - } - - /// - protected override async Task> ProcessJsonResponseAsync(HttpResponseMessage response) - { - if (typeof(TResponse) == typeof(string)) - return await base.ProcessJsonResponseAsync(response); - - if (!response.IsSuccessStatusCode) - return await base.ProcessJsonResponseAsync(response); - - try - { - var reader = DynamoJsonContext.GetReader(); - using var pooledBuffer = await ReadResponseBytesAsync(response, default); - if (pooledBuffer.Length == 0) - return new Core.Http.ApiResponse(default(TResponse)); - - var jsonReader = new System.Text.Json.Utf8JsonReader(pooledBuffer.Span); - var result = reader(ref jsonReader); - var headers = Core.Http.ResponseHeaders.FromHttpResponse(response.Headers); - return new Core.Http.ApiResponse(result, headers); - } - catch (InvalidOperationException) - { - return await base.ProcessJsonResponseAsync(response); - } + return DynamoJsonContext.Default.GetTypeInfo(typeof(TValue)) + as System.Text.Json.Serialization.Metadata.JsonTypeInfo + ?? throw new InvalidOperationException($"Cannot find type {typeof(TValue).Name} in serialization context"); } /// @@ -255,10 +225,10 @@ public async Task> TransactGetItemsAsync(Transa /// public async Task>> QueryAsync(QueryRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) { - var content = SerializeToUtf8Bytes(request); - var requestMessage = CreateRequestMessage( + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.Query"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.Query", cancellationToken); @@ -276,10 +246,10 @@ public async Task>> QueryAsync(QueryRequest request, D /// public async Task>> ScanAsync(ScanRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) { - var content = SerializeToUtf8Bytes(request); - var requestMessage = CreateRequestMessage( + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.Scan"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.Scan", cancellationToken); @@ -297,10 +267,10 @@ public async Task>> ScanAsync(ScanRequest request, Dyna /// public async Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) { - var content = SerializeToUtf8Bytes(request); - var requestMessage = CreateRequestMessage( + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.GetItem"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.GetItem", cancellationToken); @@ -318,8 +288,8 @@ public async Task>> ScanAsync(ScanRequest request, Dyna /// public async Task> PutItemAsync(string tableName, T item, DynamoItemWriter itemWriter, CancellationToken cancellationToken = default) { - using var buffer = new System.IO.MemoryStream(); - using var writer = new System.Text.Json.Utf8JsonWriter(buffer); + var bufferWriter = new System.Buffers.ArrayBufferWriter(256); + using var writer = new Utf8JsonWriter(bufferWriter); writer.WriteStartObject(); writer.WriteString("TableName", tableName); writer.WritePropertyName("Item"); @@ -327,10 +297,10 @@ public async Task> PutItemAsync(string tableName, T writer.WriteEndObject(); writer.Flush(); - var content = buffer.ToArray(); - var requestMessage = CreateRequestMessage( + var content = bufferWriter.WrittenSpan.ToArray(); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.PutItem"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.PutItem", cancellationToken); @@ -342,18 +312,17 @@ public async Task> PutItemAsync(string tableName, T if (responseBuffer.Length == 0) return new PutItemResponse(); - var reader = DynamoJsonContext.GetReader(); - var jsonReader = new System.Text.Json.Utf8JsonReader(responseBuffer.Span); - return reader(ref jsonReader); + var jsonReader = new Utf8JsonReader(responseBuffer.Span); + return JsonSerializer.Deserialize(ref jsonReader, ResolveJsonTypeInfo())!; } /// public async Task>> BatchGetItemAsync(BatchGetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) { - var content = SerializeToUtf8Bytes(request); - var requestMessage = CreateRequestMessage( + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.BatchGetItem"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.BatchGetItem", cancellationToken); @@ -371,10 +340,10 @@ public async Task>> BatchGetItemAsync(BatchGetItemR /// public async Task>> TransactGetItemsAsync(TransactGetRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) { - var content = SerializeToUtf8Bytes(request); - var requestMessage = CreateRequestMessage( + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( HttpMethod.Post, "/", content, - new MediaTypeHeaderValue("application/x-amz-json-1.0")); + JsonContentType); requestMessage.Headers.Add("X-Amz-Target", "DynamoDB_20120810.TransactGetItems"); using var response = await SendAsync(requestMessage, "DynamoDB_20120810.TransactGetItems", cancellationToken); @@ -393,9 +362,7 @@ private async Task HandleTypedErrorAsync(HttpResponseMessage response, Ca { var errorPayload = await response.Content.ReadAsStringAsync(cancellationToken); if (string.IsNullOrWhiteSpace(errorPayload)) - { return Error.Failure("Goa.DynamoDb.Unknown", "Request not successful."); - } var error = DeserializeJsonError(errorPayload); if (error is not null) diff --git a/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs b/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs index a5d81dd3..ce125a46 100644 --- a/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs +++ b/src/Clients/Goa.Clients.Dynamo/Serialization/DynamoJsonContext.cs @@ -1,4 +1,6 @@ -using Goa.Json.Generator; +using Goa.Clients.Core.Http; +using Goa.Clients.Dynamo.Enums; +using Goa.Clients.Dynamo.Models; using Goa.Clients.Dynamo.Operations.Batch; using Goa.Clients.Dynamo.Operations.DeleteItem; using Goa.Clients.Dynamo.Operations.GetItem; @@ -7,29 +9,66 @@ using Goa.Clients.Dynamo.Operations.Scan; using Goa.Clients.Dynamo.Operations.Transactions; using Goa.Clients.Dynamo.Operations.UpdateItem; +using System.Text.Json.Serialization; namespace Goa.Clients.Dynamo.Serialization; -[JsonGenerator(typeof(GetItemRequest))] -[JsonGenerator(typeof(GetItemResponse))] -[JsonGenerator(typeof(PutItemRequest))] -[JsonGenerator(typeof(PutItemResponse))] -[JsonGenerator(typeof(UpdateItemRequest))] -[JsonGenerator(typeof(UpdateItemResponse))] -[JsonGenerator(typeof(DeleteItemRequest))] -[JsonGenerator(typeof(DeleteItemResponse))] -[JsonGenerator(typeof(QueryRequest))] -[JsonGenerator(typeof(QueryResponse))] -[JsonGenerator(typeof(ScanRequest))] -[JsonGenerator(typeof(ScanResponse))] -[JsonGenerator(typeof(BatchGetItemRequest))] -[JsonGenerator(typeof(BatchGetItemResponse))] -[JsonGenerator(typeof(BatchWriteItemRequest))] -[JsonGenerator(typeof(BatchWriteItemResponse))] -[JsonGenerator(typeof(TransactWriteRequest))] -[JsonGenerator(typeof(TransactWriteItemResponse))] -[JsonGenerator(typeof(TransactGetRequest))] -[JsonGenerator(typeof(TransactGetItemResponse))] -internal partial class DynamoJsonContext +/// +/// JSON source generator context for all DynamoDB types to enable AOT compilation and improved performance. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified, + GenerationMode = JsonSourceGenerationMode.Default, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(AttributeValue))] +[JsonSerializable(typeof(DynamoRecord))] +[JsonSerializable(typeof(GetItemRequest))] +[JsonSerializable(typeof(GetItemResponse))] +[JsonSerializable(typeof(PutItemRequest))] +[JsonSerializable(typeof(PutItemResponse))] +[JsonSerializable(typeof(UpdateItemRequest))] +[JsonSerializable(typeof(UpdateItemResponse))] +[JsonSerializable(typeof(DeleteItemRequest))] +[JsonSerializable(typeof(DeleteItemResponse))] +[JsonSerializable(typeof(QueryRequest))] +[JsonSerializable(typeof(QueryResponse))] +[JsonSerializable(typeof(ScanRequest))] +[JsonSerializable(typeof(ScanResponse))] +[JsonSerializable(typeof(BatchGetItemRequest))] +[JsonSerializable(typeof(BatchGetItemResponse))] +[JsonSerializable(typeof(BatchGetRequestItem))] +[JsonSerializable(typeof(BatchWriteItemRequest))] +[JsonSerializable(typeof(BatchWriteItemResponse))] +[JsonSerializable(typeof(BatchWriteRequestItem))] +[JsonSerializable(typeof(PutRequest))] +[JsonSerializable(typeof(DeleteRequest))] +[JsonSerializable(typeof(TransactWriteRequest))] +[JsonSerializable(typeof(TransactWriteItemResponse))] +[JsonSerializable(typeof(TransactWriteItem))] +[JsonSerializable(typeof(TransactPutItem))] +[JsonSerializable(typeof(TransactUpdateItem))] +[JsonSerializable(typeof(TransactDeleteItem))] +[JsonSerializable(typeof(TransactConditionCheckItem))] +[JsonSerializable(typeof(TransactGetRequest))] +[JsonSerializable(typeof(TransactGetItemResponse))] +[JsonSerializable(typeof(TransactGetItem))] +[JsonSerializable(typeof(TransactGetItemRequest))] +[JsonSerializable(typeof(TransactGetResult))] +[JsonSerializable(typeof(ConsumedCapacity))] +[JsonSerializable(typeof(CapacityDetail))] +[JsonSerializable(typeof(ReturnConsumedCapacity))] +[JsonSerializable(typeof(ReturnValues))] +[JsonSerializable(typeof(ReturnValuesOnConditionCheckFailure))] +[JsonSerializable(typeof(ReturnItemCollectionMetrics))] +[JsonSerializable(typeof(ItemCollectionMetrics))] +[JsonSerializable(typeof(Select))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ApiError))] +public partial class DynamoJsonContext : JsonSerializerContext { } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs index fce74fb9..a857439e 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs @@ -338,10 +338,12 @@ public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_AllO private static string SerializeWithGeneratedWriter(T value) { - var writer = DynamoJsonContext.GetWriter(); + var typeInfo = DynamoJsonContext.Default.GetTypeInfo(typeof(T)) + as System.Text.Json.Serialization.Metadata.JsonTypeInfo + ?? throw new System.InvalidOperationException($"Cannot find type {typeof(T).Name} in serialization context"); using var buffer = new System.IO.MemoryStream(); using var jsonWriter = new System.Text.Json.Utf8JsonWriter(buffer); - writer(jsonWriter, value); + System.Text.Json.JsonSerializer.Serialize(jsonWriter, value, typeInfo); jsonWriter.Flush(); return System.Text.Encoding.UTF8.GetString(buffer.ToArray()); } From 91df8dd8cc1f42f4b01e08bc6fb834c8786089f8 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:43:50 +0000 Subject: [PATCH 06/23] fix: Correct TransactGetItemRequest type name in docs --- docs/DynamoDbTypedApi.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/DynamoDbTypedApi.md b/docs/DynamoDbTypedApi.md index abbdfba7..b9327b1c 100644 --- a/docs/DynamoDbTypedApi.md +++ b/docs/DynamoDbTypedApi.md @@ -297,12 +297,12 @@ if (result.Value.HasUnprocessedKeys) var reader = DynamoItemReaderRegistry.Get(); var result = await client.TransactGetItemsAsync( - new TransactGetRequest + new TransactGetItemRequest { TransactItems = new List { - new() { Get = new TransactGetRequest { TableName = "Users", Key = key1 } }, - new() { Get = new TransactGetRequest { TableName = "Users", Key = key2 } } + new() { Get = new TransactGetItemRequest { TableName = "Users", Key = key1 } }, + new() { Get = new TransactGetItemRequest { TableName = "Users", Key = key2 } } } }, reader); From 4ae789590e99ada72b97e22b4a24953d9daaf8b3 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:43:56 +0000 Subject: [PATCH 07/23] fix: Replace string.IsNullOrEmpty with null check in JsonMapperGenerator Empty strings are valid DynamoDB attribute values and should not be treated as NULL. Use != null instead of IsNullOrEmpty for null checks in generated writer code. --- .../CodeGeneration/JsonMapperGenerator.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index ed717106..caa039ba 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -192,10 +192,7 @@ private void GenerateWriteProperty(CodeBuilder builder, PropertyInfo property, s if (isNullable) { - var isString = underlyingType.SpecialType == SpecialType.System_String; - var nullCheck = isString - ? $"!string.IsNullOrEmpty({accessExpr})" - : $"{accessExpr} != null"; + var nullCheck = $"{accessExpr} != null"; builder.OpenBraceWithLine($"if ({nullCheck})"); EmitPrimitiveTypeWrapper(builder, property, isNullable, accessExpr, hasUnixTimestamp); @@ -209,7 +206,7 @@ private void GenerateWriteProperty(CodeBuilder builder, PropertyInfo property, s // Non-nullable strings are reference types that could still be null at runtime if (underlyingType.SpecialType == SpecialType.System_String) { - builder.OpenBraceWithLine($"if (!string.IsNullOrEmpty({accessExpr}))"); + builder.OpenBraceWithLine($"if ({accessExpr} != null)"); EmitPrimitiveTypeWrapper(builder, property, isNullable, accessExpr, hasUnixTimestamp); builder.CloseBrace(); builder.OpenBraceWithLine("else"); From 8f9bb988d2e0a0dabe6cfcfdd8975cfafe007965 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:44:03 +0000 Subject: [PATCH 08/23] fix: Throw DynamoPaginationException in typed pagination methods Typed QueryAllAsync, ScanAllAsync, and BatchGetAllAsync now throw DynamoPaginationException on mid-pagination errors, consistent with the non-generic pagination methods. --- src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs | 6 +++--- .../Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs index a4772d6a..95859b23 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs @@ -232,7 +232,7 @@ public static async IAsyncEnumerable QueryAllAsync(this IDynamoClient clie var result = await client.QueryAsync(request, itemReader, cancellationToken); if (result.IsError) { - yield break; + throw new DynamoPaginationException(result.FirstError); } foreach (var item in result.Value.Items) @@ -264,7 +264,7 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClient clien var result = await client.ScanAsync(request, itemReader, cancellationToken); if (result.IsError) { - yield break; + throw new DynamoPaginationException(result.FirstError); } foreach (var item in result.Value.Items) @@ -336,7 +336,7 @@ public static async IAsyncEnumerable> BatchGetAllAsync(); var lastKey = new Dictionary @@ -101,11 +101,8 @@ public async Task QueryAllAsync_T_StopsOnError_MidPagination() mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny>(), It.IsAny())) .ReturnsAsync(secondPage); - var items = await mock.Object.QueryAllAsync("TestTable", TestReader, _ => { }).ToListAsync(); - - await Assert.That(items).Count().IsEqualTo(1); - await Assert.That(items[0].Id).IsEqualTo("1"); - await Assert.That(items[0].Name).IsEqualTo("Alice"); + await Assert.ThrowsAsync( + async () => await mock.Object.QueryAllAsync("TestTable", TestReader, _ => { }).ToListAsync()); } // Tests for non-generic pagination From 66ec988e5daa72236425b3e1f373cb82293adf1b Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:44:38 +0000 Subject: [PATCH 09/23] fix: Preserve error metadata in HandleTypedErrorAsync Include Payload and StatusCode in Error.Failure metadata so callers can inspect AWS error details for diagnostics. --- src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index fc890a65..f3fab413 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -372,9 +372,13 @@ private async Task HandleTypedErrorAsync(HttpResponseMessage response, Ca } var errorType = error?.Type ?? error?.Code ?? "Unknown"; + var metadata = new Dictionary(); + if (error?.Payload is not null) metadata["Payload"] = error.Payload; + if (error?.StatusCode is not null) metadata["StatusCode"] = error.StatusCode; return Error.Failure( code: MapErrorCodeToDynamo(errorType), - description: error?.Message ?? "An error occurred while processing the request."); + description: error?.Message ?? "An error occurred while processing the request.", + metadata: metadata); } /// From 7490569f6c8ed0a09b360b006d9b523d5199785c Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:44:43 +0000 Subject: [PATCH 10/23] fix: Parse UnprocessedKeys in typed BatchGetItemResponse Replace reader.Skip() with proper parsing of UnprocessedKeys so typed BatchGetAllAsync can retry unprocessed keys correctly. --- .../Internal/DynamoResponseReader.cs | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index b9f7afe5..b9acc0df 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -302,8 +302,7 @@ public static BatchGetResult ReadBatchGetItemResponse(ReadOnlySpan u result.Responses = ReadTableResponses(ref reader, itemReader); break; case "UnprocessedKeys": - // Skip for now - complex structure, users can retry with original request - reader.Skip(); + result.UnprocessedKeys = ReadUnprocessedKeys(ref reader); break; case "ConsumedCapacity": result.ConsumedCapacity = ReadConsumedCapacityList(ref reader); @@ -369,6 +368,74 @@ private static List ReadConsumedCapacityList(ref Utf8JsonReade return list; } + private static Dictionary ReadUnprocessedKeys(ref Utf8JsonReader reader) + { + var map = new Dictionary(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var tableName = reader.GetString()!; + reader.Read(); // StartObject + map[tableName] = ReadBatchGetRequestItem(ref reader); + } + return map; + } + + private static BatchGetRequestItem ReadBatchGetRequestItem(ref Utf8JsonReader reader) + { + var item = new BatchGetRequestItem(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Keys": + item.Keys = ReadKeysList(ref reader); + break; + case "ProjectionExpression": + item.ProjectionExpression = reader.GetString(); + break; + case "ConsistentRead": + item.ConsistentRead = reader.GetBoolean(); + break; + case "ExpressionAttributeNames": + item.ExpressionAttributeNames = ReadStringMap(ref reader); + break; + default: + reader.Skip(); + break; + } + } + return item; + } + + private static List> ReadKeysList(ref Utf8JsonReader reader) + { + var list = new List>(); + // reader is at StartArray + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(ReadAttributeMap(ref reader)); + } + return list; + } + + private static Dictionary ReadStringMap(ref Utf8JsonReader reader) + { + var map = new Dictionary(); + // reader is at StartObject + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var key = reader.GetString()!; + reader.Read(); + map[key] = reader.GetString()!; + } + return map; + } + private static List ReadTransactGetResponses(ref Utf8JsonReader reader, DynamoItemReader itemReader) { var items = new List(); From 6708b7bdc147833482f7c13ece595ca101d35150 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 11:45:58 +0000 Subject: [PATCH 11/23] fix: Add missing JsonPropertyName attributes to B and BS properties --- src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index d7853901..1cec6793 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -43,11 +43,13 @@ public class AttributeValue /// /// An attribute of type Binary. DynamoDB sends binary data as a base64-encoded string. /// + [JsonPropertyName("B")] public byte[]? B { get; set; } /// /// An attribute of type Binary Set. A set of binary values, each base64-encoded. /// + [JsonPropertyName("BS")] public List? BS { get; set; } /// From 1098d8476f51e7dc27a33c9badd2bd4100c3ffb2 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:28:23 +0000 Subject: [PATCH 12/23] chore: Remove DynamoDB optimization and typed API docs These documentation files are no longer needed. --- docs/DynamoDbOptimisations.md | 372 ---------------------------- docs/DynamoDbTypedApi.md | 445 ---------------------------------- 2 files changed, 817 deletions(-) delete mode 100644 docs/DynamoDbOptimisations.md delete mode 100644 docs/DynamoDbTypedApi.md diff --git a/docs/DynamoDbOptimisations.md b/docs/DynamoDbOptimisations.md deleted file mode 100644 index 8b2fe99a..00000000 --- a/docs/DynamoDbOptimisations.md +++ /dev/null @@ -1,372 +0,0 @@ -# DynamoDB Client Optimisations: Source-Generated Direct JSON Serialization - -## The Problem - -The original Goa DynamoDB client used a two-pass deserialization strategy inherited from the standard System.Text.Json (STJ) approach: - -``` -HTTP Response bytes - -> STJ deserializes to QueryResponse { List } (Pass 1) - -> DynamoMapper converts DynamoRecord to Entity (Pass 2) -``` - -Each `DynamoRecord` is a `Dictionary`, and each `AttributeValue` is a class with properties for every possible DynamoDB type (S, N, BOOL, NULL, M, L, SS, NS, etc.). For a query returning 1000 items with 11 properties each, this creates: - -- 1000 `DynamoRecord` dictionary allocations -- 11,000+ `AttributeValue` object allocations -- Thousands of `string` allocations for dictionary keys -- All of this intermediate data is immediately discarded after mapping to entities - -Benchmarks confirmed the impact — Goa was **4.4x slower** than EfficientDynamoDb and allocated **6.8x more memory** at 1000 entities. - -## The Solution: Source-Generated Direct JSON Readers - -The core insight: DynamoDB's JSON wire format is well-defined and predictable. Every attribute value is wrapped in a type descriptor: - -```json -{ - "Items": [ - { - "pk": {"S": "user#123"}, - "sk": {"S": "profile"}, - "age": {"N": "30"}, - "active": {"BOOL": true}, - "tags": {"SS": ["admin", "user"]}, - "metadata": {"M": {"key": {"S": "value"}}}, - "scores": {"L": [{"N": "95"}, {"N": "87"}]} - } - ], - "Count": 1, - "ScannedCount": 1 -} -``` - -Instead of letting STJ parse this into generic dictionaries, we source-generate `Utf8JsonReader` code that reads directly from the JSON bytes into strongly-typed entity properties — **zero intermediate allocations**. - -### Architecture - -``` -HTTP Response bytes - | - +-- QueryAsync() -> STJ -> QueryResponse { List } (existing, unchanged) - | - +-- QueryAsync() -> Generated Utf8JsonReader code: - 1. DynamoResponseReader parses envelope (Items/Count/ScannedCount/LastEvaluatedKey) - 2. For each item -> DynamoJsonMapper.T.ReadFromJson(ref reader) - 3. Returns QueryResult { List, ... } -``` - -The existing untyped path remains completely unchanged. The new typed path is opt-in via a different overload. - -### Usage - -```csharp -// Existing path (unchanged) - returns DynamoRecord dictionaries -var result = await client.QueryAsync(request); -List items = result.Value.Items; -var entities = items.Select(r => DynamoMapper.MyEntity.FromDynamoRecord(r)).ToList(); - -// New typed path - direct JSON to entity, zero intermediate allocations -var result = await client.QueryAsync( - request, - DynamoJsonMapper.MyEntity.ReadFromJson); -List items = result.Value.Items; -``` - -## Implementation Details - -### 1. Source Generator: `JsonMapperGenerator` - -**File:** `src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs` - -For each `[DynamoModel]` entity, the generator produces a `DynamoJsonMapper.{EntityName}` static class with: - -- `ReadFromJson(ref Utf8JsonReader reader)` — reads DynamoDB JSON directly into the entity -- `WriteToJson(Utf8JsonWriter writer, T model)` — writes the entity directly as DynamoDB JSON - -Example generated code for a simple entity: - -```csharp -public static class DynamoJsonMapper -{ - public static class MyEntity - { - public static MyEntity ReadFromJson(ref Utf8JsonReader reader) - { - if (reader.TokenType != JsonTokenType.StartObject) - reader.Read(); - - var result = new MyEntity(); - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndObject) break; - - if (reader.ValueTextEquals("pk"u8)) - { - reader.Read(); // StartObject of {"S": "..."} - reader.Read(); // "S" - reader.Read(); // value - result.Pk = reader.GetString()!; - reader.Read(); // EndObject - } - else if (reader.ValueTextEquals("n"u8)) - { - reader.Read(); reader.Read(); reader.Read(); - result.N = int.Parse(reader.GetString()!, CultureInfo.InvariantCulture); - reader.Read(); - } - else - { - reader.Read(); - reader.Skip(); - } - } - return result; - } - } -} -``` - -The generator handles all DynamoDB types: - -| DynamoDB Type | C# Types | Wire Format | -|---|---|---| -| S | `string`, `Guid`, `DateTime`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`, `char`, enums | `{"S": "value"}` | -| N | `int`, `long`, `double`, `decimal`, `float`, `byte`, `short`, and unsigned variants | `{"N": "123"}` | -| BOOL | `bool` | `{"BOOL": true}` | -| NULL | nullable types | `{"NULL": true}` | -| M | complex types (nested `[DynamoModel]` entities), `Dictionary` | `{"M": {...}}` | -| L | `List`, `IList`, `IReadOnlyList` | `{"L": [...]}` | -| SS | `HashSet`, `ISet`, `IReadOnlySet` | `{"SS": [...]}` | -| NS | `HashSet`, `HashSet`, etc. | `{"NS": [...]}` | - -Additional features: -- **Inheritance / abstract types**: Uses a type discriminator field to dispatch to the correct concrete type's reader via `Utf8JsonReader` copy + peek -- **`[SerializedName]`**: Respects custom attribute name mappings -- **`[Ignore]`**: Skips properties marked for read/write ignore -- **`[UnixTimestamp]`**: Converts `DateTime`/`DateTimeOffset` to/from Unix epoch (seconds or milliseconds) -- **Nullable types**: Checks for `"NULL"` type descriptor before attempting to parse - -### 2. Response Envelope Reader - -**File:** `src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs` - -A hand-written (not generated) utility that parses DynamoDB response envelopes using `Utf8JsonReader`: - -```csharp -internal static class DynamoResponseReader -{ - public static QueryResult ReadQueryResponse( - ReadOnlySpan utf8Json, - DynamoJsonReader itemReader) - { - var reader = new Utf8JsonReader(utf8Json); - // Parses Items array, Count, ScannedCount, LastEvaluatedKey - // Delegates each item to the generated itemReader - } -} -``` - -This cleanly separates envelope parsing (shared across all entity types) from entity deserialization (generated per type). - -### 3. Typed Client API - -**File:** `src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs` - -```csharp -public interface IDynamoClient -{ - // Existing (unchanged) - Task> QueryAsync(QueryRequest request, CancellationToken ct = default); - - // New typed overload - Task>> QueryAsync( - QueryRequest request, - DynamoJsonReader itemReader, - CancellationToken cancellationToken = default); -} -``` - -The implementation in `DynamoServiceClient` uses `SendRawRequestAsync` to get the raw `HttpResponseMessage`, reads the bytes, and passes them to `DynamoResponseReader` — bypassing STJ entirely. - -### 4. UTF-8 Property Name Matching - -**Optimisation applied to:** `JsonMapperGenerator.GenerateConcreteReadFromJson` - -The initial implementation used `reader.GetString()` to read property names, which allocates a new `string` for every property on every item: - -```csharp -// BEFORE: allocates a string per property per item -var propName = reader.GetString(); -switch (propName) -{ - case "pk": ... - case "sk": ... -} -``` - -Replaced with `Utf8JsonReader.ValueTextEquals()` which compares directly against UTF-8 byte sequences — **zero allocation per property name**: - -```csharp -// AFTER: zero-allocation property matching using UTF-8 literals -if (reader.ValueTextEquals("pk"u8)) -{ ... } -else if (reader.ValueTextEquals("sk"u8)) -{ ... } -``` - -For a query returning 1000 items with 11 properties each, this eliminates **11,000 string allocations** per query. The `u8` suffix (C# 11+) creates a `ReadOnlySpan` at compile time with no runtime cost. - -This optimisation extends beyond property names to **type descriptor comparisons** inside each property handler. The DynamoDB type wrappers (`"M"`, `"L"`, `"SS"`, `"NS"`, `"NULL"`) are also matched with `ValueTextEquals` instead of `GetString()`: - -```csharp -// BEFORE: allocates a string for every type descriptor check -var typeDesc = reader.GetString(); -if (typeDesc == "M") { ... } - -// AFTER: zero-allocation type descriptor matching -if (reader.ValueTextEquals("M"u8)) { ... } -``` - -For nullable types, collections, maps, and nested objects, this eliminates an additional string allocation per property per item. - -### 5. Pooled Response Buffers - -**File:** `src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs` - -The original implementation used `ReadAsByteArrayAsync` which allocates a new `byte[]` for every HTTP response. For a query returning 1000 items, the response JSON can be 200-500+ KB — allocated and immediately eligible for GC after deserialization. - -Replaced with `ArrayPool.Shared` rented buffers: - -```csharp -var contentLength = (int)(response.Content.Headers.ContentLength ?? 4096); -var rentedBuffer = ArrayPool.Shared.Rent(contentLength); -try -{ - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - // Read into rented buffer with dynamic growth if needed - var result = DynamoResponseReader.ReadQueryResponse( - rentedBuffer.AsSpan(0, bytesRead), itemReader); - return result; -} -finally -{ - ArrayPool.Shared.Return(rentedBuffer); -} -``` - -The `Content-Length` header (always present in DynamoDB responses) allows right-sizing the initial rent, avoiding growth in the common case. The buffer is returned to the pool after deserialization, producing **zero GC pressure** from response buffering. - -## Benchmark Results - -All benchmarks use a mixed entity type with strings, numbers, booleans, nested objects, lists of objects (3 lists), string sets, and number sets — 11 properties total. - -### Before: Goa (mapped) vs EfficientDynamoDb - -| Method | Entities | Mean | Allocated | -|---|---|---|---| -| EfficientDynamoDb | 10 | 18.55 us | 16.97 KB | -| Goa (mapped) | 10 | 38.30 us | 88.61 KB | -| EfficientDynamoDb | 100 | 141.12 us | 119.63 KB | -| Goa (mapped) | 100 | 318.86 us | 789.92 KB | -| EfficientDynamoDb | 1000 | 1,368.03 us | 1,146.20 KB | -| Goa (mapped) | 1000 | 6,031.58 us | 7,804.96 KB | - -**Gap at 1000 entities:** 4.4x slower, 6.8x more memory. - -### After: Goa (typed) vs EfficientDynamoDb - -Includes UTF-8 property name matching (optimisation #4). Pooled buffers and type descriptor `ValueTextEquals` (#5) applied after this benchmark run. - -| Method | Entities | Mean | Median | Allocated | -|---|---|---|---|---| -| EfficientDynamoDb | 10 | 18.84 us | 18.76 us | 16.97 KB | -| Goa (mapped) | 10 | 39.94 us | 39.77 us | 88.61 KB | -| **Goa (typed)** | **10** | **20.47 us** | **20.51 us** | **34.20 KB** | -| EfficientDynamoDb | 100 | 141.93 us | 141.99 us | 119.63 KB | -| Goa (mapped) | 100 | 323.26 us | 321.01 us | 789.92 KB | -| **Goa (typed)** | **100** | **136.92 us** | **136.05 us** | **262.34 KB** | -| EfficientDynamoDb | 1000 | 1,427.12 us | 1,425.08 us | 1,146.20 KB | -| Goa (mapped) | 1000 | 5,760.03 us | 5,419.53 us | 7,805.15 KB | -| **Goa (typed)** | **1000** | **1,582.85 us** | **1,568.38 us** | **2,543.50 KB** | - -### Improvement: Goa (typed) vs Goa (mapped) - -| Entities | Goa (mapped) | Goa (typed) | Speedup | Memory Reduction | -|---|---|---|---|---| -| 10 | 39.94 us / 88.61 KB | 20.47 us / 34.20 KB | **1.95x faster** | **2.59x less** | -| 100 | 323.26 us / 789.92 KB | 136.92 us / 262.34 KB | **2.36x faster** | **3.01x less** | -| 1000 | 5,760.03 us / 7,805.15 KB | 1,582.85 us / 2,543.50 KB | **3.64x faster** | **3.07x less** | - -The improvement scales with entity count — at 1000 entities, the typed path is **3.6x faster** with **3x less memory**. - -### vs EfficientDynamoDb - -| Entities | EfficientDynamoDb | Goa (typed) | Latency ratio | Memory ratio | -|---|---|---|---|---| -| 10 | 18.84 us / 16.97 KB | 20.47 us / 34.20 KB | 1.09x slower | 2.02x more | -| 100 | 141.93 us / 119.63 KB | 136.92 us / 262.34 KB | **0.96x (faster!)** | 2.19x more | -| 1000 | 1,427.12 us / 1,146.20 KB | 1,582.85 us / 2,543.50 KB | 1.11x slower | 2.22x more | - -At 100 entities, Goa (typed) is actually **faster** than EfficientDynamoDb. At 1000 entities, latency is within 11%. The remaining ~2.2x memory gap is addressed by pooled response buffers (optimisation #5, applied after this benchmark run). - -## What Changed (File Summary) - -| File | Action | Description | -|---|---|---| -| `Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs` | New | Source generator for `ReadFromJson`/`WriteToJson` methods | -| `Goa.Clients.Dynamo.Generator/DynamoMapperIncrementalGenerator.cs` | Modified | Wires `JsonMapperGenerator` into the incremental generator pipeline | -| `Goa.Clients.Core/AwsServiceClient.cs` | Modified | Added `SendRawRequestAsync` for raw HTTP response access | -| `Goa.Clients.Core/JsonAwsServiceClient.cs` | Modified | Changed helper methods to `protected` for reuse | -| `Goa.Clients.Dynamo/IDynamoClient.cs` | Modified | Added `QueryAsync` overload | -| `Goa.Clients.Dynamo/DynamoServiceClient.cs` | Modified | Implemented typed `QueryAsync` with direct deserialization | -| `Goa.Clients.Dynamo/Serialization/DynamoJsonReader.cs` | New | `DynamoJsonReader` and `DynamoJsonWriter` delegate types | -| `Goa.Clients.Dynamo/Operations/Query/QueryResultOfT.cs` | New | `QueryResult` — typed query result without `DynamoRecord` | -| `Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs` | New | Response envelope parser using `Utf8JsonReader` | - -## Design Decisions - -### Why delegates instead of a generic constraint? - -The typed `QueryAsync` takes a `DynamoJsonReader` delegate rather than using a generic constraint like `where T : IDynamoEntity`. This avoids: - -1. Requiring entities to implement an interface (source generator produces static methods) -2. Virtual dispatch overhead on every item deserialization -3. Boxing for value types (though unlikely for DynamoDB entities) - -The delegate approach also allows the compiler to inline the generated reader at the call site. - -### Why not replace the existing path? - -The untyped `QueryAsync()` returning `DynamoRecord` is still valuable for: - -- Dynamic queries where the schema isn't known at compile time -- Debugging and inspection of raw DynamoDB responses -- Backwards compatibility with existing code - -Both paths coexist with zero interference. - -### Why `ref Utf8JsonReader` instead of `ReadOnlySpan`? - -The `DynamoJsonReader` delegate takes `ref Utf8JsonReader` because: - -1. The reader maintains position state as it traverses nested objects -2. The caller (`DynamoResponseReader`) positions the reader at each item's `StartObject` and the reader continues from where the entity reader leaves off -3. This enables single-pass parsing of the entire response with no backtracking - -## Future Optimisations - -### Remaining Memory Gap - -After pooled response buffers, the remaining memory allocations come from: - -1. **`List` growth** — The items list starts empty and grows via array doubling. Pre-allocating based on `Count` from the response would eliminate resize copies, but DynamoDB doesn't guarantee field ordering (Count may appear after Items). -2. **String value allocations** — `reader.GetString()` for property *values* (not names or type descriptors — those are already optimised) still allocates. This is inherent for string properties but could be reduced for numeric parsing by using `reader.GetInt32()` directly on the N value where the number fits. -3. **Collection allocations** — Each `List`, `HashSet` per entity property allocates. Could use pooled collections or array-backed storage. - -### Planned Features - -- **Typed `PutItemAsync`** — Use generated `WriteToJson` for zero-allocation request serialization -- **Typed `ScanAsync`, `GetItemAsync`** — Same pattern as `QueryAsync`, minimal additional code -- **Generated request serialization** — Bypass STJ for `QueryRequest` serialization too -- **`Utf8JsonReader.GetInt32()` for N type** — Skip the string allocation for numeric DynamoDB values when the target type matches directly diff --git a/docs/DynamoDbTypedApi.md b/docs/DynamoDbTypedApi.md deleted file mode 100644 index b9327b1c..00000000 --- a/docs/DynamoDbTypedApi.md +++ /dev/null @@ -1,445 +0,0 @@ -# DynamoDB Typed API - -The Goa DynamoDB client provides a **zero-copy typed deserialization path** that bypasses intermediate `DynamoRecord` allocations. This is the recommended approach for all DynamoDB operations in performance-sensitive applications. - -## Table of Contents - -- [Getting Started](#getting-started) -- [Defining Models](#defining-models) -- [Custom Converters](#custom-converters) -- [Reading Items](#reading-items) -- [Writing Items](#writing-items) -- [Pagination](#pagination) -- [Registry-Based Usage](#registry-based-usage) -- [Migration from Untyped API](#migration-from-untyped-api) - ---- - -## Getting Started - -### 1. Define your model - -```csharp -using Goa.Clients.Dynamo; - -[DynamoModel(PK = "USER#", SK = "PROFILE#")] -public record UserProfile(string Id, string Email, string DisplayName, int Age); -``` - -### 2. Initialize the registry at startup - -```csharp -// Call once during application startup (e.g., in Program.cs) -DynamoReaderRegistration.Initialize(); -``` - -This triggers the source-generated static constructor that registers all `[DynamoModel]` types with `DynamoItemReaderRegistry` and `DynamoItemWriterRegistry`. - -### 3. Use the typed API - -```csharp -// Read -var result = await client.QueryAsync(request, DynamoItemReaderRegistry.Get()); - -// Write -await client.PutItemAsync("Users", user, DynamoItemWriterRegistry.Get()); -``` - ---- - -## Defining Models - -### Basic Model - -```csharp -[DynamoModel(PK = "USER#", SK = "PROFILE#")] -public record UserProfile(string Id, string Email, string DisplayName, int Age); -``` - -The `PK` and `SK` patterns use `` placeholders that map to properties on the type. - -### With Global Secondary Indexes - -```csharp -[DynamoModel(PK = "USER#", SK = "PROFILE#")] -[GlobalSecondaryIndex(Name = "EmailIndex", PK = "EMAIL#", SK = "USER#")] -public record UserProfile(string Id, string Email, string DisplayName, int Age); -``` - -### Inheritance - -```csharp -[DynamoModel(PK = "ENTITY#", SK = "DATA#")] -public abstract record BaseEntity(string Id); - -public record Order(string Id, string CustomerId, decimal Total) : BaseEntity(Id); -public record Invoice(string Id, string OrderId, DateTime IssuedAt) : BaseEntity(Id); -``` - -The generator automatically adds a type discriminator field for polymorphic deserialization. - -### Property Attributes - -```csharp -[DynamoModel(PK = "USER#", SK = "DATA")] -public class User -{ - public string Id { get; set; } = ""; - - [SerializedName("user_name")] // Custom DynamoDB attribute name - public string UserName { get; set; } = ""; - - [UnixTimestamp] // Store as Unix timestamp (seconds) - public DateTime CreatedAt { get; set; } - - [UnixTimestamp(Format = Format.Milliseconds)] // Higher precision - public DateTime UpdatedAt { get; set; } - - [Ignore] // Exclude from serialization entirely - public string ComputedField { get; set; } = ""; - - [Ignore(Direction = Direction.WhenWriting)] // Read but don't write - public string ReadOnlyField { get; set; } = ""; -} -``` - ---- - -## Custom Converters - -For properties that need custom serialization (compressed data, MemoryPack, binary formats), use `[DynamoConverter]`. - -### Define a converter - -Implement `IDynamoPropertyConverter`: - -```csharp -using System.IO.Compression; -using System.Text; -using System.Text.Json; -using Goa.Clients.Dynamo; - -public class CompressedStringConverter : IDynamoPropertyConverter -{ - public string Read(ref Utf8JsonReader reader) - { - // Read from DynamoDB wire format: {"B": "base64data"} - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.GetString() == "B") - { - reader.Read(); - var base64 = reader.GetString()!; - var compressed = Convert.FromBase64String(base64); - using var input = new MemoryStream(compressed); - using var brotli = new BrotliStream(input, CompressionMode.Decompress); - using var output = new MemoryStream(); - brotli.CopyTo(output); - return Encoding.UTF8.GetString(output.ToArray()); - } - reader.Skip(); - } - return ""; - } - - public void Write(Utf8JsonWriter writer, string value) - { - // Write as DynamoDB binary: {"B": "base64data"} - var bytes = Encoding.UTF8.GetBytes(value); - using var output = new MemoryStream(); - using (var brotli = new BrotliStream(output, CompressionLevel.Fastest)) - { - brotli.Write(bytes); - } - writer.WriteStartObject(); - writer.WriteString("B", Convert.ToBase64String(output.ToArray())); - writer.WriteEndObject(); - } -} -``` - -### Apply to a property - -```csharp -[DynamoModel(PK = "PAGE#", SK = "CONTENT")] -public class PageContent -{ - public string Id { get; set; } = ""; - - [DynamoConverter(typeof(CompressedStringConverter))] - public string HtmlBody { get; set; } = ""; - - [DynamoConverter(typeof(MemoryPackConverter))] - public Metadata Meta { get; set; } = new(); -} -``` - -The source generator emits converter calls instead of inline serialization for these properties — no reflection, fully AOT-compatible. - -### Entity-level override - -For full control over an entire type's serialization, register a custom reader/writer after initialization: - -```csharp -DynamoReaderRegistration.Initialize(); - -// Replace the generated reader for a specific type -DynamoItemReaderRegistry.Register(MyCustomReader.Read); -DynamoItemWriterRegistry.Register(MyCustomWriter.Write); -``` - ---- - -## Reading Items - -### GetItem - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -var user = await client.GetItemAsync( - new GetItemRequest - { - TableName = "Users", - Key = DynamoKeyFactory.UserProfile.PK("123").WithSK(DynamoKeyFactory.UserProfile.SK("john@example.com")) - }, - reader); - -if (user.IsError) -{ - // Handle error - return; -} - -var profile = user.Value; // UserProfile? — null if not found -``` - -### Query - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -var result = await client.QueryAsync( - new QueryRequest - { - TableName = "Users", - KeyConditionExpression = "PK = :pk", - ExpressionAttributeValues = new() - { - [":pk"] = new AttributeValue { S = "USER#123" } - } - }, - reader); - -foreach (var profile in result.Value.Items) -{ - Console.WriteLine(profile.DisplayName); -} - -// Check for more pages -if (result.Value.HasMoreResults) -{ - // Use result.Value.LastEvaluatedKey for next page -} -``` - -### Scan - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -var result = await client.ScanAsync( - new ScanRequest { TableName = "Users" }, - reader); -``` - -### BatchGetItem - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -var result = await client.BatchGetItemAsync( - new BatchGetItemRequest - { - RequestItems = new() - { - ["Users"] = new BatchGetRequestItem - { - Keys = new List> - { - new() { ["PK"] = new() { S = "USER#1" }, ["SK"] = new() { S = "PROFILE#a@b.com" } }, - new() { ["PK"] = new() { S = "USER#2" }, ["SK"] = new() { S = "PROFILE#c@d.com" } } - } - } - } - }, - reader); - -// Items grouped by table name -foreach (var (tableName, items) in result.Value.Responses) -{ - foreach (var item in items) - { - Console.WriteLine(item.DisplayName); - } -} - -// Check for unprocessed keys -if (result.Value.HasUnprocessedKeys) -{ - // Retry with result.Value.UnprocessedKeys -} -``` - -### TransactGetItems - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -var result = await client.TransactGetItemsAsync( - new TransactGetItemRequest - { - TransactItems = new List - { - new() { Get = new TransactGetItemRequest { TableName = "Users", Key = key1 } }, - new() { Get = new TransactGetItemRequest { TableName = "Users", Key = key2 } } - } - }, - reader); - -// Items in same order as request — null if not found -foreach (var item in result.Value.Items) -{ - if (item is not null) - Console.WriteLine(item.DisplayName); -} -``` - ---- - -## Writing Items - -### PutItem (typed) - -```csharp -var writer = DynamoItemWriterRegistry.Get(); - -var user = new UserProfile("123", "john@example.com", "John", 30); - -var result = await client.PutItemAsync("Users", user, writer); -``` - -This serializes the item directly to DynamoDB JSON wire format — no intermediate `DynamoRecord` allocation. - ---- - -## Pagination - -### Auto-paginating Query - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -await foreach (var profile in client.QueryAllAsync("Users", reader, b => b - .WithKeyCondition("PK = :pk") - .WithExpressionValue(":pk", "USER#123"))) -{ - Console.WriteLine(profile.DisplayName); -} -``` - -### Auto-paginating Scan - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -await foreach (var profile in client.ScanAllAsync("Users", reader, b => b - .WithFilterExpression("Age > :minAge") - .WithExpressionValue(":minAge", "18"))) -{ - Console.WriteLine(profile.DisplayName); -} -``` - -### Auto-paginating BatchGet - -```csharp -var reader = DynamoItemReaderRegistry.Get(); - -await foreach (var (tableName, profile) in client.BatchGetAllAsync(reader, b => b - .AddTable("Users", keys))) -{ - Console.WriteLine(profile.DisplayName); -} -``` - -This automatically retries unprocessed keys until all items are retrieved. - ---- - -## Registry-Based Usage - -The source generator automatically registers readers and writers for all `[DynamoModel]` types when you call `DynamoReaderRegistration.Initialize()`. - -```csharp -// At startup -DynamoReaderRegistration.Initialize(); - -// Later — get reader/writer from registry -var reader = DynamoItemReaderRegistry.Get(); -var writer = DynamoItemWriterRegistry.Get(); - -// Use them -var result = await client.QueryAsync(request, reader); -await client.PutItemAsync("Users", user, writer); -``` - -### Custom overrides - -You can replace any generated converter by calling `Register` after initialization: - -```csharp -DynamoReaderRegistration.Initialize(); - -// Override with custom implementation -DynamoItemReaderRegistry.Register(CustomUserProfileReader.Read); -DynamoItemWriterRegistry.Register(CustomUserProfileWriter.Write); -``` - ---- - -## Migration from Untyped API - -The untyped API (`QueryAsync` returning `QueryResponse` with `List`) remains fully supported. You can migrate incrementally. - -### Before (untyped) - -```csharp -var response = await client.QueryAsync(request); -foreach (var record in response.Value.Items) -{ - var name = record["DisplayName"]?.S; - var age = int.Parse(record["Age"]?.N ?? "0"); -} -``` - -### After (typed) - -```csharp -var result = await client.QueryAsync(request, DynamoItemReaderRegistry.Get()); -foreach (var profile in result.Value.Items) -{ - var name = profile.DisplayName; // Strongly typed - var age = profile.Age; // Already an int -} -``` - -### Key differences - -| Aspect | Untyped | Typed | -|--------|---------|-------| -| Return type | `QueryResponse` with `List` | `QueryResult` with `List` | -| Allocations | Intermediate `DynamoRecord` dictionaries | Zero-copy, direct to object | -| Type safety | Manual string-keyed dictionary access | Compile-time checked properties | -| Performance | Good | Best (26x fewer allocations) | -| Schema | Flexible/dynamic | Requires `[DynamoModel]` | From 5c59b2fdc40553eb309b1fe18094785df1674c05 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:28:51 +0000 Subject: [PATCH 13/23] fix: Handle NULL token for non-nullable strings in generated JSON mapper The write path emits {"NULL":true} for runtime-null non-nullable strings, but the read path would call reader.GetString() on a boolean token. Now checks for NULL descriptor and assigns string.Empty as default. --- .../CodeGeneration/JsonMapperGenerator.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs index caa039ba..a44966d1 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -765,8 +765,25 @@ private void GenerateReadProperty(CodeBuilder builder, PropertyInfo property, st } else { - builder.AppendLine("reader.Read(); // value"); - EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); + // Non-nullable strings are reference types that could still be null at runtime. + // The write path emits {"NULL":true} for these, so the read path must handle it. + if (underlyingType.SpecialType == SpecialType.System_String) + { + var target = assignmentTarget ?? $"{resultVar}.{property.Name}"; + builder.OpenBraceWithLine("if (reader.ValueTextEquals(\"NULL\"u8))"); + builder.AppendLine("reader.Read(); // value (true)"); + builder.AppendLine($"{target} = string.Empty;"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value"); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); + builder.CloseBrace(); + } + else + { + builder.AppendLine("reader.Read(); // value"); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); + } } builder.AppendLine("reader.Read(); // EndObject of type wrapper"); From 7f3247ee8998129e0b11774461ac481275cd2789 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:29:07 +0000 Subject: [PATCH 14/23] fix: Remove dead error enrichment in DynamoServiceClient The error was enriched with Payload/StatusCode then immediately rebuilt from scratch extracting those same fields. Simplified to build metadata directly from the available variables. --- src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index 641a853b..7b7e2f3a 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -360,15 +360,14 @@ private async Task HandleTypedErrorAsync(HttpResponseMessage response, Ca var error = DeserializeJsonError(errorPayload); if (error is not null) - { - error = error with { Payload = errorPayload, StatusCode = response.StatusCode }; error = ProcessAwsErrorHeaders(response, error); - } var errorType = error?.Type ?? error?.Code ?? "Unknown"; - var metadata = new Dictionary(); - if (error?.Payload is not null) metadata["Payload"] = error.Payload; - if (error?.StatusCode is not null) metadata["StatusCode"] = error.StatusCode; + var metadata = new Dictionary + { + ["Payload"] = errorPayload, + ["StatusCode"] = response.StatusCode + }; return Error.Failure( code: MapErrorCodeToDynamo(errorType), description: error?.Message ?? "An error occurred while processing the request.", From 32e1b93aa9c8a71923be2f8087bf79bae821433d Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:32:19 +0000 Subject: [PATCH 15/23] fix: Correct response model types for PutItem, Query, and Scan PutItemResponse.ItemCollectionMetrics used raw dictionary instead of the ItemCollectionMetrics model. QueryResult and ScanResult used ConsumedCapacityUnits (double) which never matched the DynamoDB wire format; aligned to ConsumedCapacity object type. --- .../Operations/PutItem/PutItemResponse.cs | 2 +- .../Goa.Clients.Dynamo/Operations/Query/QueryResult.cs | 6 +++--- .../Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs index b133ebb4..feb4f3ab 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs @@ -25,5 +25,5 @@ public class PutItemResponse /// Information about item collections, if any, that were affected by the operation. /// [JsonPropertyName("ItemCollectionMetrics")] - public Dictionary>>? ItemCollectionMetrics { get; set; } + public Dictionary>? ItemCollectionMetrics { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs index 71eccb70..1fb5b96e 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs @@ -41,10 +41,10 @@ public class QueryResult public int ScannedCount { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity consumed by the operation. /// - [JsonPropertyName("ConsumedCapacityUnits")] - public double ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs index 6f8751ee..4cd76854 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs @@ -41,10 +41,10 @@ public class ScanResult public int ScannedCount { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity consumed by the operation. /// - [JsonPropertyName("ConsumedCapacityUnits")] - public double ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } } /// From ae94472ad0a1879581b6dac6ba3611308a2eedb0 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:19:05 +0000 Subject: [PATCH 16/23] fix: Add exponential backoff with jitter for BatchGetItem retries Both BatchGetAllAsync overloads immediately retried unprocessed keys with no delay. Now uses exponential backoff (100ms base, 25.6s cap) with randomized jitter and a max of 10 retry attempts. --- .../Goa.Clients.Dynamo/DynamoExtensions.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs index 95859b23..d244a479 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs @@ -291,9 +291,11 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClient clien /// An async enumerable that yields items from all tables in the batch get result, retrying unprocessed keys. public static async IAsyncEnumerable> BatchGetAllAsync(this IDynamoClient client, Action builder, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + const int maxAttempts = 10; var _builder = new BatchGetItemBuilder(); builder(_builder); var request = _builder.Build(); + int attempts = 0; do { @@ -316,8 +318,18 @@ public static async IAsyncEnumerable> BatchGe break; } - // Retry with unprocessed keys + if (attempts >= maxAttempts) + { + throw new DynamoPaginationException( + ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); + } + + // Retry with unprocessed keys using exponential backoff with jitter request.RequestItems = result.Value.UnprocessedKeys!; + var baseDelay = Math.Min(100 * (1 << attempts), 25_600); + var jitter = Random.Shared.Next(0, baseDelay); + await Task.Delay(baseDelay + jitter, cancellationToken); + attempts++; } while (true); } @@ -327,9 +339,11 @@ public static async IAsyncEnumerable> BatchGe /// public static async IAsyncEnumerable> BatchGetAllAsync(this IDynamoClient client, DynamoItemReader itemReader, Action builder, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + const int maxAttempts = 10; var _builder = new BatchGetItemBuilder(); builder(_builder); var request = _builder.Build(); + int attempts = 0; do { @@ -352,8 +366,18 @@ public static async IAsyncEnumerable> BatchGetAllAsync= maxAttempts) + { + throw new DynamoPaginationException( + ErrorOr.Error.Failure("Goa.DynamoDb.MaxRetriesExceeded", $"BatchGetItem still has unprocessed keys after {maxAttempts} retry attempts.")); + } + + // Retry with unprocessed keys using exponential backoff with jitter request.RequestItems = result.Value.UnprocessedKeys!; + var baseDelay = Math.Min(100 * (1 << attempts), 25_600); + var jitter = Random.Shared.Next(0, baseDelay); + await Task.Delay(baseDelay + jitter, cancellationToken); + attempts++; } while (true); } From 23a29cd5f2466a14f63ef0359c62e26cee4a9d31 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:22:03 +0000 Subject: [PATCH 17/23] fix: Correct response model types for single-item and batch operations Single-item ops (Delete/Put/Update) use ItemCollectionMetrics? and ConsumedCapacity?. TransactWriteItemResponse uses Dictionary>? for batch shape. --- .../Operations/DeleteItem/DeleteItemResponse.cs | 8 ++++---- .../Operations/PutItem/PutItemResponse.cs | 2 +- .../Operations/Transactions/TransactWriteItemResponse.cs | 2 +- .../Operations/UpdateItem/UpdateItemResponse.cs | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs index e0f3a8d2..44575d12 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/DeleteItem/DeleteItemResponse.cs @@ -15,14 +15,14 @@ public class DeleteItemResponse public DynamoRecord? Attributes { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity units consumed by the operation. /// - [JsonPropertyName("ConsumedCapacityUnits")] - public double? ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// [JsonPropertyName("ItemCollectionMetrics")] - public Dictionary>? ItemCollectionMetrics { get; set; } + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs index feb4f3ab..264f2e06 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/PutItem/PutItemResponse.cs @@ -25,5 +25,5 @@ public class PutItemResponse /// Information about item collections, if any, that were affected by the operation. /// [JsonPropertyName("ItemCollectionMetrics")] - public Dictionary>? ItemCollectionMetrics { get; set; } + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs index 44f4524e..08bf2f0b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Transactions/TransactWriteItemResponse.cs @@ -18,5 +18,5 @@ public class TransactWriteItemResponse /// A list of tables that were processed by TransactWriteItem and, for each table, information about any item collections that were affected by individual operations. /// [JsonPropertyName("ItemCollectionMetrics")] - public Dictionary? ItemCollectionMetrics { get; set; } + public Dictionary>? ItemCollectionMetrics { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs index ffd0e18d..330778fc 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemResponse.cs @@ -15,14 +15,14 @@ public class UpdateItemResponse public DynamoRecord? Attributes { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity units consumed by the operation. /// - [JsonPropertyName("ConsumedCapacityUnits")] - public double? ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// [JsonPropertyName("ItemCollectionMetrics")] - public Dictionary>? ItemCollectionMetrics { get; set; } + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } } From d44fc239181afa0e12e99ea8c4c4123f76d28994 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:22:10 +0000 Subject: [PATCH 18/23] fix: Add class constraint to GetItemAsync for correct null semantics Without where T : class, default(T) for value types is indistinguishable from a valid result, making "not found" undetectable. --- src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs | 2 +- src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index 7b7e2f3a..2db6b367 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -263,7 +263,7 @@ public async Task>> ScanAsync(ScanRequest request, Dyna } /// - public async Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + public async Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) where T : class { var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); using var requestMessage = CreateRequestMessage( diff --git a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs index f0c09a3b..13a1aec7 100644 --- a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs @@ -109,7 +109,7 @@ public interface IDynamoClient /// /// Gets an item from a DynamoDB table with direct typed deserialization. /// - Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default); + Task> GetItemAsync(GetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) where T : class; /// /// Puts a typed item into a DynamoDB table using a direct item writer for zero-copy serialization. From a7434af646adf81fb5724049601efb41691699eb Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:22:25 +0000 Subject: [PATCH 19/23] fix: Mark registry cache fields as volatile for cross-thread visibility Without volatile, JIT/CPU could cache stale null reads of Reader/Writer fields when registration and retrieval happen on different threads. --- src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs | 2 +- src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs index 490fe5ab..d25e6533 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs @@ -23,6 +23,6 @@ public static DynamoItemReader Get() => Cache.Reader private static class Cache { - public static DynamoItemReader? Reader; + public static volatile DynamoItemReader? Reader; } } diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs index dcb9d9a4..57fc7671 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs @@ -23,6 +23,6 @@ public static DynamoItemWriter Get() => Cache.Writer private static class Cache { - public static DynamoItemWriter? Writer; + public static volatile DynamoItemWriter? Writer; } } From b042edf0791016ad0783fc485fef14ebf4fe9500 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:22:33 +0000 Subject: [PATCH 20/23] fix: Add missing JsonPropertyName attributes for serialization resilience ConsumedCapacity lacked attributes on GlobalSecondaryIndexes and LocalSecondaryIndexes. QueryResult and ScanResult lacked attributes on all properties, making them naming-policy dependent. --- src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs | 2 ++ .../Goa.Clients.Dynamo/Operations/Query/QueryResult.cs | 4 ++++ src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs index 0f2d6941..5deb7288 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/ConsumedCapacity.cs @@ -34,11 +34,13 @@ public class ConsumedCapacity /// /// The capacity consumed by the global secondary indexes affected by the operation. /// + [JsonPropertyName("GlobalSecondaryIndexes")] public Dictionary? GlobalSecondaryIndexes { get; set; } /// /// The capacity consumed by the local secondary indexes affected by the operation. /// + [JsonPropertyName("LocalSecondaryIndexes")] public Dictionary? LocalSecondaryIndexes { get; set; } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs index 1fb5b96e..c3695f8b 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Query/QueryResult.cs @@ -54,9 +54,11 @@ public class QueryResult public class QueryResult { /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } /// @@ -66,10 +68,12 @@ public class QueryResult public bool HasMoreResults => LastEvaluatedKey?.Count > 0; /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } /// /// The capacity consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs index 4cd76854..a47eb3ea 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Scan/ScanResult.cs @@ -54,9 +54,11 @@ public class ScanResult public class ScanResult { /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } /// @@ -66,10 +68,12 @@ public class ScanResult public bool HasMoreResults => LastEvaluatedKey?.Count > 0; /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } /// /// The capacity consumed by the operation. /// + [JsonPropertyName("ConsumedCapacity")] public ConsumedCapacity? ConsumedCapacity { get; set; } } From e4237fb01c9eb3edcf859cff7f4f3a1eb5bac8c7 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 11:07:36 +0000 Subject: [PATCH 21/23] fix: Update TypedExtensionTests to use AttributeValue factory methods AttributeValue is now a readonly struct after PR #70 merge; property setters no longer exist. --- .../Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs index f4287d11..63c6c46e 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs @@ -20,7 +20,7 @@ public async Task QueryAllAsync_T_PaginatesAutomatically() var mock = new Mock(); var lastKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; ErrorOr> firstPage = new QueryResult @@ -53,7 +53,7 @@ public async Task ScanAllAsync_T_PaginatesAutomatically() var mock = new Mock(); var lastKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; ErrorOr> firstPage = new ScanResult @@ -86,7 +86,7 @@ public async Task QueryAllAsync_T_ThrowsOnError_MidPagination() var mock = new Mock(); var lastKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; ErrorOr> firstPage = new QueryResult From 1f00580725a9ea0243624ae2209f58cd3705fedd Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 11:12:24 +0000 Subject: [PATCH 22/23] feat: Enable typed API benchmark for 100-item query Add BenchmarkItem model with DynamoModel source generator and uncomment the Goa_Query_100Items_Typed benchmark for apples-to-apples comparison against existing benchmark methods. --- .../Benchmarks/QueryAsyncBenchmarks.cs | 47 ++++++++++--------- .../Goa.Clients.Dynamo.Benchmarks.csproj | 1 + .../Models/BenchmarkItem.cs | 12 +++++ 3 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 827b094c..b9b69b66 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -3,6 +3,7 @@ using EfficientDynamoDb; using EfficientDynamoDb.Attributes; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Benchmarks.Models; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; using GoaModels = Goa.Clients.Dynamo.Models; using GoaQueryRequest = Goa.Clients.Dynamo.Operations.Query.QueryRequest; @@ -299,29 +300,29 @@ public async Task Goa_Query_100Items_DynamoRecord() return count; } - // [Benchmark, BenchmarkCategory("100 Items")] - // public async Task Goa_Query_100Items_Typed() - // { - // var count = 0; - // Dictionary? lastKey = null; - // do - // { - // var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest - // { - // TableName = _fixture.TableName, - // KeyConditionExpression = "pk = :pk", - // ExpressionAttributeValues = new Dictionary - // { - // [":pk"] = GoaModels.AttributeValue.String("query-100") - // }, - // Limit = 25, - // ExclusiveStartKey = lastKey - // }, DynamoItemReaderRegistry.Get()); - // count += result.Value.Items.Count; - // lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; - // } while (lastKey != null); - // return count; - // } + [Benchmark, BenchmarkCategory("100 Items")] + public async Task Goa_Query_100Items_Typed() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + { + TableName = _fixture.TableName, + KeyConditionExpression = "pk = :pk", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = GoaModels.AttributeValue.String("query-100") + }, + Limit = 25, + ExclusiveStartKey = lastKey + }, DynamoItemReaderRegistry.Get()); + count += result.Value.Items.Count; + lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } [Benchmark, BenchmarkCategory("100 Items")] public async Task Efficient_Query_100Items() diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Goa.Clients.Dynamo.Benchmarks.csproj b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Goa.Clients.Dynamo.Benchmarks.csproj index f741877a..25a78b30 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Goa.Clients.Dynamo.Benchmarks.csproj +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Goa.Clients.Dynamo.Benchmarks.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs new file mode 100644 index 00000000..4ee96798 --- /dev/null +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs @@ -0,0 +1,12 @@ +using Goa.Clients.Dynamo; + +namespace Goa.Clients.Dynamo.Benchmarks.Models; + +[DynamoModel(PK = "", SK = "", PKName = "pk", SKName = "sk")] +public record BenchmarkItem( + string Pk, + string Sk, + [property: SerializedName("data")] string Data, + [property: SerializedName("number")] int Number, + [property: SerializedName("status")] string Status +); From 868730b11a99bf559321e689c51fb8128807850b Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 11:15:51 +0000 Subject: [PATCH 23/23] refactor: Combine BenchmarkItem into BenchmarkEntity with dual attributes Single class now carries both EfficientDynamoDb and Goa attributes, eliminating the separate BenchmarkItem model file. --- .../Benchmarks/QueryAsyncBenchmarks.cs | 21 ++++++++++--------- .../Models/BenchmarkItem.cs | 12 ----------- 2 files changed, 11 insertions(+), 22 deletions(-) delete mode 100644 tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index b9b69b66..194ca5e8 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -3,7 +3,7 @@ using EfficientDynamoDb; using EfficientDynamoDb.Attributes; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; -using Goa.Clients.Dynamo.Benchmarks.Models; +using Goa.Clients.Dynamo; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; using GoaModels = Goa.Clients.Dynamo.Models; using GoaQueryRequest = Goa.Clients.Dynamo.Operations.Query.QueryRequest; @@ -12,6 +12,7 @@ namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; [DynamoDbTable("benchmark-table")] +[DynamoModel(PK = "", SK = "", PKName = "pk", SKName = "sk")] public class BenchmarkEntity { [DynamoDbProperty("pk", DynamoDbAttributeType.PartitionKey)] @@ -20,13 +21,13 @@ public class BenchmarkEntity [DynamoDbProperty("sk", DynamoDbAttributeType.SortKey)] public string Sk { get; set; } = ""; - [DynamoDbProperty("data")] + [DynamoDbProperty("data"), SerializedName("data")] public string Data { get; set; } = ""; - [DynamoDbProperty("number")] + [DynamoDbProperty("number"), SerializedName("number")] public int Number { get; set; } - [DynamoDbProperty("status")] + [DynamoDbProperty("status"), SerializedName("status")] public string Status { get; set; } = ""; } @@ -191,7 +192,7 @@ public void Cleanup() // Dictionary? lastKey = null; // do // { - // var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + // var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest // { // TableName = _fixture.TableName, // KeyConditionExpression = "pk = :pk", @@ -201,7 +202,7 @@ public void Cleanup() // }, // Limit = 5, // ExclusiveStartKey = lastKey - // }, DynamoItemReaderRegistry.Get()); + // }, DynamoItemReaderRegistry.Get()); // count += result.Value.Items.Count; // lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; // } while (lastKey != null); @@ -307,7 +308,7 @@ public async Task Goa_Query_100Items_Typed() Dictionary? lastKey = null; do { - var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest { TableName = _fixture.TableName, KeyConditionExpression = "pk = :pk", @@ -317,7 +318,7 @@ public async Task Goa_Query_100Items_Typed() }, Limit = 25, ExclusiveStartKey = lastKey - }, DynamoItemReaderRegistry.Get()); + }, DynamoItemReaderRegistry.Get()); count += result.Value.Items.Count; lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; } while (lastKey != null); @@ -421,7 +422,7 @@ public async Task Efficient_Query_100Items_Typed() // Dictionary? lastKey = null; // do // { - // var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + // var result = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest // { // TableName = _fixture.TableName, // KeyConditionExpression = "pk = :pk", @@ -430,7 +431,7 @@ public async Task Efficient_Query_100Items_Typed() // [":pk"] = GoaModels.AttributeValue.String("nonexistent-pk") // }, // ExclusiveStartKey = lastKey - // }, DynamoItemReaderRegistry.Get()); + // }, DynamoItemReaderRegistry.Get()); // count += result.Value.Items.Count; // lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; // } while (lastKey != null); diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs deleted file mode 100644 index 4ee96798..00000000 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Models/BenchmarkItem.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Goa.Clients.Dynamo; - -namespace Goa.Clients.Dynamo.Benchmarks.Models; - -[DynamoModel(PK = "", SK = "", PKName = "pk", SKName = "sk")] -public record BenchmarkItem( - string Pk, - string Sk, - [property: SerializedName("data")] string Data, - [property: SerializedName("number")] int Number, - [property: SerializedName("status")] string Status -);