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 new file mode 100644 index 00000000..a44966d1 --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/JsonMapperGenerator.cs @@ -0,0 +1,1490 @@ +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 => (p.ConverterTypeName != null || _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; + + // 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); + + // 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 nullCheck = $"{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 ({accessExpr} != null)"); + 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(); + + 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;"); + builder.AppendLine(); + + var allProperties = GetAllProperties(type); + var readableProperties = allProperties + .Where(p => (p.ConverterTypeName != null || _typeHandlerRegistry.CanHandle(p)) && !p.IsIgnored(IgnoreDirection.WhenReading) && p.Symbol?.SetMethod != null && IsSupportedForJsonRead(p)) + .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 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, assignmentTarget); + return; + } + + // Collection handling + if (property.IsCollection && property.ElementType != null) + { + GenerateReadCollection(builder, property, resultVar, assignmentTarget); + return; + } + + // Complex type handling + if (IsComplexType(underlyingType)) + { + GenerateReadComplexType(builder, property, resultVar, assignmentTarget); + 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) + { + 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($"{target} = null;"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value"); + EmitReadPrimitiveAssignment(builder, property, resultVar, hasUnixTimestamp, assignmentTarget); + builder.CloseBrace(); + } + else + { + // 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"); + } + + private void EmitReadPrimitiveAssignment(CodeBuilder builder, PropertyInfo property, string resultVar, bool hasUnixTimestamp, string? assignmentTarget = null) + { + var underlyingType = property.UnderlyingType; + var propAccess = assignmentTarget ?? $"{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, 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($"{target} = DynamoJsonMapper.{normalizedTypeName}.ReadFromJson(ref reader);"); + builder.CloseBrace(); + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{target} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + private void GenerateReadCollection(CodeBuilder builder, PropertyInfo property, string resultVar, string? assignmentTarget = null) + { + 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, assignmentTarget); + return; + } + + if (isSetType && IsNumericType(elementType)) + { + // NS type + GenerateReadNumberSet(builder, property, resultVar, elementType, assignmentTarget); + return; + } + + // 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, 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)"); + 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($"{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($"{target} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + 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)"); + 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($"{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($"{target} = null;"); + builder.CloseBrace(); + builder.AppendLine("reader.Read(); // EndObject of type wrapper"); + } + + 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)"); + 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 + 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($"{target} = 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, string? assignmentTarget = null) + { + 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"); + 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 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($"{target} = {varName}Map;"); + builder.CloseBrace(); // if M + builder.OpenBraceWithLine("else"); + builder.AppendLine("reader.Read(); // value (true for NULL)"); + if (property.IsNullable) + builder.AppendLine($"{target} = 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 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(); + 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..9584712a --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/ReaderRegistrationGenerator.cs @@ -0,0 +1,83 @@ +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.AppendLine($"DynamoItemWriterRegistry.Register<{type.FullName}>(DynamoJsonMapper.{normalizedName}.WriteToJson);"); + } + } + 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 +/// 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/DynamoExtensions.cs b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs index 0e72a588..d244a479 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoExtensions.cs @@ -218,6 +218,70 @@ public static async IAsyncEnumerable 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) + { + throw new DynamoPaginationException(result.FirstError); + } + + 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) + { + throw new DynamoPaginationException(result.FirstError); + } + + 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. /// @@ -227,9 +291,11 @@ public static async IAsyncEnumerable ScanAllAsync(this IDynamoClie /// 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 { @@ -252,8 +318,66 @@ 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); + } + + /// + /// 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) + { + const int maxAttempts = 10; + var _builder = new BatchGetItemBuilder(); + builder(_builder); + var request = _builder.Build(); + int attempts = 0; + + do + { + var result = await client.BatchGetItemAsync(request, itemReader, cancellationToken); + if (result.IsError) + { + throw new DynamoPaginationException(result.FirstError); + } + + 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; + } + + 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); } diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemReaderRegistry.cs new file mode 100644 index 00000000..d25e6533 --- /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 volatile DynamoItemReader? Reader; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs b/src/Clients/Goa.Clients.Dynamo/DynamoItemWriterRegistry.cs new file mode 100644 index 00000000..57fc7671 --- /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 volatile DynamoItemWriter? Writer; + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs index b4d39675..2db6b367 100644 --- a/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/DynamoServiceClient.cs @@ -1,7 +1,9 @@ +using System.Text.Json; using ErrorOr; 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; @@ -220,6 +222,158 @@ public async Task> TransactGetItemsAsync(Transa return ConvertApiResponse(response); } + /// + public async Task>> QueryAsync(QueryRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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 = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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) where T : class + { + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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); + } + + /// + public async Task> PutItemAsync(string tableName, T item, DynamoItemWriter itemWriter, CancellationToken cancellationToken = default) + { + var bufferWriter = new System.Buffers.ArrayBufferWriter(256); + using var writer = new Utf8JsonWriter(bufferWriter); + writer.WriteStartObject(); + writer.WriteString("TableName", tableName); + writer.WritePropertyName("Item"); + itemWriter(writer, item); + writer.WriteEndObject(); + writer.Flush(); + + var content = bufferWriter.WrittenSpan.ToArray(); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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 jsonReader = new Utf8JsonReader(responseBuffer.Span); + return JsonSerializer.Deserialize(ref jsonReader, ResolveJsonTypeInfo())!; + } + + /// + public async Task>> BatchGetItemAsync(BatchGetItemRequest request, DynamoItemReader itemReader, CancellationToken cancellationToken = default) + { + var content = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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 = JsonSerializer.SerializeToUtf8Bytes(request, ResolveJsonTypeInfo()); + using var requestMessage = CreateRequestMessage( + HttpMethod.Post, "/", content, + JsonContentType); + + 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); + if (string.IsNullOrWhiteSpace(errorPayload)) + return Error.Failure("Goa.DynamoDb.Unknown", "Request not successful."); + + var error = DeserializeJsonError(errorPayload); + if (error is not null) + error = ProcessAwsErrorHeaders(response, error); + + var errorType = error?.Type ?? error?.Code ?? "Unknown"; + 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.", + metadata: metadata); + } + /// /// 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 5dbcc214..5baaaaa0 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..13a1aec7 100644 --- a/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs +++ b/src/Clients/Goa.Clients.Dynamo/IDynamoClient.cs @@ -95,4 +95,34 @@ 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) where T : class; + + /// + /// 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/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 e4f9d56b..5deb7288 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 deleted file mode 100644 index 708a82d9..00000000 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetResult.cs +++ /dev/null @@ -1,29 +0,0 @@ -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. - /// - public List Items { get; set; } = new(); - - /// - /// Keys that were not processed during the BatchGetItem operation. - /// - 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. - /// - 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..44575d12 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. + /// The capacity units consumed by the operation. /// - public double? ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// - public Dictionary>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file + [JsonPropertyName("ItemCollectionMetrics")] + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } +} 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..264f2e06 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. /// - public Dictionary>>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file + [JsonPropertyName("ItemCollectionMetrics")] + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } +} 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 5455be7c..c3695f8b 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,14 +11,16 @@ 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. This is deliberately computed from /// because DynamoDB's Count always equals the number of items returned (post-filter). @@ -34,12 +37,14 @@ public class QueryResult /// The number of items evaluated before any filter expression was applied. /// If no filter was used, this equals . /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity consumed by the operation. /// - public double ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } } /// @@ -49,9 +54,11 @@ public class QueryResult public class QueryResult { /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } /// @@ -61,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; } -} \ No newline at end of file +} 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 c22bd26f..a47eb3ea 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,14 +11,16 @@ 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. This is deliberately computed from /// because DynamoDB's Count always equals the number of items returned (post-filter). @@ -34,12 +37,14 @@ public class ScanResult /// The number of items evaluated before any filter expression was applied. /// If no filter was used, this equals . /// + [JsonPropertyName("ScannedCount")] public int ScannedCount { get; set; } /// - /// The number of capacity units consumed by the operation. + /// The capacity consumed by the operation. /// - public double ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } } /// @@ -49,9 +54,11 @@ public class ScanResult public class ScanResult { /// + [JsonPropertyName("Items")] public List Items { get; set; } = new(); /// + [JsonPropertyName("LastEvaluatedKey")] public Dictionary? LastEvaluatedKey { get; set; } /// @@ -61,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; } -} \ No newline at end of file +} 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..08bf2f0b 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. /// - public Dictionary? ItemCollectionMetrics { get; set; } -} \ No newline at end of file + [JsonPropertyName("ItemCollectionMetrics")] + public Dictionary>? ItemCollectionMetrics { get; set; } +} 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..330778fc 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. + /// The capacity units consumed by the operation. /// - public double? ConsumedCapacityUnits { get; set; } + [JsonPropertyName("ConsumedCapacity")] + public ConsumedCapacity? ConsumedCapacity { get; set; } /// /// Information about item collections, if any, that were affected by the operation. /// - public Dictionary>? ItemCollectionMetrics { get; set; } -} \ No newline at end of file + [JsonPropertyName("ItemCollectionMetrics")] + public ItemCollectionMetrics? ItemCollectionMetrics { get; set; } +} diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 827b094c..194ca5e8 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; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; using GoaModels = Goa.Clients.Dynamo.Models; using GoaQueryRequest = Goa.Clients.Dynamo.Operations.Query.QueryRequest; @@ -11,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)] @@ -19,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; } = ""; } @@ -190,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", @@ -200,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); @@ -299,29 +301,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() @@ -420,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", @@ -429,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/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.Tests/BuilderChainingTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs index 23c91b7b..dea59a87 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,20 @@ 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 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); + System.Text.Json.JsonSerializer.Serialize(jsonWriter, value, typeInfo); + jsonWriter.Flush(); + return System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + } } 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); + } +} diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs index f362a66a..63c6c46e 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs @@ -1,14 +1,110 @@ +using ErrorOr; using Goa.Clients.Dynamo.Exceptions; using Goa.Clients.Dynamo.Models; using Goa.Clients.Dynamo.Operations.Query; using Goa.Clients.Dynamo.Operations.Scan; -using ErrorOr; 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"] = AttributeValue.String("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"] = AttributeValue.String("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_ThrowsOnError_MidPagination() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = AttributeValue.String("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); + + await Assert.ThrowsAsync( + async () => await mock.Object.QueryAllAsync("TestTable", TestReader, _ => { }).ToListAsync()); + } + // Tests for non-generic pagination [Test] public async Task QueryAllAsync_DynamoRecord_PaginatesAutomatically()