From 098c2153317fac59f5f5370f693163c9c7d1681f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sun, 8 Mar 2026 22:23:40 +0000 Subject: [PATCH 01/18] Convert AttributeValue from class to readonly struct with union layout and typed factory methods --- .../Models/AttributeType.cs | 26 ++ .../Models/AttributeValue.cs | 274 +++++++++++++++--- .../Goa.Clients.Dynamo/Models/DynamoRecord.cs | 20 +- 3 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 src/Clients/Goa.Clients.Dynamo/Models/AttributeType.cs diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeType.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeType.cs new file mode 100644 index 00000000..822a3f4b --- /dev/null +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeType.cs @@ -0,0 +1,26 @@ +namespace Goa.Clients.Dynamo.Models; + +/// +/// Represents the DynamoDB attribute value type. +/// +public enum AttributeType : byte +{ + /// An attribute of type String. + String, + /// An attribute of type Number (stored as string). + Number, + /// An attribute of type Boolean. + Bool, + /// An attribute of type Null. + Null, + /// An attribute of type String Set. + StringSet, + /// An attribute of type Number Set. + NumberSet, + /// An attribute of type List. + List, + /// An attribute of type Map. + Map, + /// An attribute of type Binary. + Binary +} diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index f61f58e6..c13c5c8c 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -1,88 +1,125 @@ using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Goa.Clients.Dynamo.Models; /// /// Represents a DynamoDB attribute value with type information for proper serialization. -/// This class mirrors AWS DynamoDB AttributeValue structure but without SDK dependencies. +/// Uses a union layout to avoid heap allocations for each attribute value. /// -public class AttributeValue +[StructLayout(LayoutKind.Explicit, Pack = 1)] +[JsonConverter(typeof(AttributeValueJsonConverter))] +public readonly struct AttributeValue { - /// - /// An attribute of type String. Strings are UTF-8 encoded and can be up to 400 KB in size. - /// - public string? S { get; set; } + [FieldOffset(0)] private readonly object? _referenceValue; // string, List<>, Dictionary<> + [FieldOffset(8)] private readonly AttributeType _type; + [FieldOffset(9)] private readonly bool _boolValue; /// - /// An attribute of type Number. Numbers are sent across the network to DynamoDB as strings, - /// to maximize compatibility across platforms and languages. + /// Gets the type of this attribute value. /// - public string? N { get; set; } + public AttributeType Type => _type; - /// - /// An attribute of type Boolean. For example: "BOOL": true - /// - public bool? BOOL { get; set; } + private AttributeValue(object? value, AttributeType type) + { + _referenceValue = value; + _type = type; + _boolValue = false; + } - /// - /// An attribute of type String Set. A set of Strings. - /// - public List? SS { get; set; } + private AttributeValue(bool value) + { + _referenceValue = null; + _type = AttributeType.Bool; + _boolValue = value; + } - /// - /// An attribute of type Number Set. A set of Numbers. - /// - public List? NS { get; set; } + /// Creates an AttributeValue of type String. + public static AttributeValue String(string value) => new(value, AttributeType.String); - /// - /// An attribute of type List. Lists are ordered collections of values. - /// - public List? L { get; set; } + /// Creates an AttributeValue of type Number. + public static AttributeValue Number(string value) => new(value, AttributeType.Number); - /// - /// An attribute of type Map. Maps are unordered collections of key-value pairs. - /// - public Dictionary? M { get; set; } + /// Creates an AttributeValue of type Bool. + public static AttributeValue Bool(bool value) => new(value); + + /// Creates an AttributeValue of type Null. + public static AttributeValue Null() => new(null, AttributeType.Null); + + /// Creates an AttributeValue from a String Set. + public static AttributeValue FromStringSet(List value) => new(value, AttributeType.StringSet); + + /// Creates an AttributeValue from a Number Set. + public static AttributeValue FromNumberSet(List value) => new(value, AttributeType.NumberSet); + + /// Creates an AttributeValue from a List of AttributeValues. + public static AttributeValue FromList(List value) => new(value, AttributeType.List); + + /// Creates an AttributeValue from a Map of string to AttributeValue. + public static AttributeValue FromMap(Dictionary value) => new(value, AttributeType.Map); /// - /// An attribute of type Null. When sending a null value, you must send the NULL attribute type. + /// An attribute of type String. Returns null if this is not a String attribute. /// - public bool? NULL { get; set; } + public string? S => _type == AttributeType.String ? (string?)_referenceValue : null; /// - /// Implicitly converts a string to an AttributeValue with S type. + /// An attribute of type Number. Returns null if this is not a Number attribute. /// - public static implicit operator AttributeValue(string value) => new() { S = value }; + public string? N => _type == AttributeType.Number ? (string?)_referenceValue : null; /// - /// Implicitly converts an int to an AttributeValue with N type. + /// An attribute of type Boolean. Returns null if this is not a Bool attribute. /// - public static implicit operator AttributeValue(int value) => new() { N = value.ToString() }; + public bool? BOOL => _type == AttributeType.Bool ? _boolValue : null; /// - /// Implicitly converts a long to an AttributeValue with N type. + /// An attribute of type Null. Returns true if this is a Null attribute, null otherwise. /// - public static implicit operator AttributeValue(long value) => new() { N = value.ToString() }; + public bool? NULL => _type == AttributeType.Null ? true : null; /// - /// Implicitly converts a double to an AttributeValue with N type. + /// An attribute of type String Set. Returns null if this is not a StringSet attribute. /// - public static implicit operator AttributeValue(double value) => new() { N = value.ToString(CultureInfo.InvariantCulture) }; + public List? SS => _type == AttributeType.StringSet ? (List?)_referenceValue : null; /// - /// Implicitly converts a decimal to an AttributeValue with N type. + /// An attribute of type Number Set. Returns null if this is not a NumberSet attribute. /// - public static implicit operator AttributeValue(decimal value) => new() { N = value.ToString(CultureInfo.InvariantCulture) }; + public List? NS => _type == AttributeType.NumberSet ? (List?)_referenceValue : null; /// - /// Implicitly converts a bool to an AttributeValue with BOOL type. + /// An attribute of type List. Returns null if this is not a List attribute. /// - public static implicit operator AttributeValue(bool value) => new() { BOOL = value }; + public List? L => _type == AttributeType.List ? (List?)_referenceValue : null; /// - /// Implicitly converts a List<string> to an AttributeValue with SS type. + /// An attribute of type Map. Returns null if this is not a Map attribute. /// - public static implicit operator AttributeValue(List value) => new() { SS = value }; + public Dictionary? M => _type == AttributeType.Map ? (Dictionary?)_referenceValue : null; + + /// Implicitly converts a string to an AttributeValue with S type. + public static implicit operator AttributeValue(string value) => String(value); + + /// Implicitly converts an int to an AttributeValue with N type. + public static implicit operator AttributeValue(int value) => Number(value.ToString()); + + /// Implicitly converts a long to an AttributeValue with N type. + public static implicit operator AttributeValue(long value) => Number(value.ToString()); + + /// Implicitly converts a double to an AttributeValue with N type. + public static implicit operator AttributeValue(double value) => Number(value.ToString(CultureInfo.InvariantCulture)); + + /// Implicitly converts a decimal to an AttributeValue with N type. + public static implicit operator AttributeValue(decimal value) => Number(value.ToString(CultureInfo.InvariantCulture)); + + /// Implicitly converts a bool to an AttributeValue with BOOL type. + public static implicit operator AttributeValue(bool value) => Bool(value); + + /// Implicitly converts a List<string> to an AttributeValue with SS type. + public static implicit operator AttributeValue(List value) => FromStringSet(value); /// /// Converts the AttributeValue to a strongly-typed value of the specified type. @@ -91,7 +128,7 @@ public class AttributeValue /// The converted value, or default(T) if conversion fails or the value is NULL. public T? ToValue() { - if (NULL.GetValueOrDefault()) return default; + if (_type == AttributeType.Null) return default; return typeof(T) switch { @@ -106,3 +143,148 @@ public class AttributeValue }; } } + +/// +/// JSON converter for AttributeValue that handles the DynamoDB JSON format. +/// +public sealed class AttributeValueJsonConverter : JsonConverter +{ + /// + public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + return default; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + continue; + + if (reader.ValueTextEquals("S"u8)) + { + reader.Read(); + var v = reader.GetString()!; + reader.Read(); // EndObject + return AttributeValue.String(v); + } + if (reader.ValueTextEquals("N"u8)) + { + reader.Read(); + var v = reader.GetString()!; + reader.Read(); // EndObject + return AttributeValue.Number(v); + } + if (reader.ValueTextEquals("BOOL"u8)) + { + reader.Read(); + var v = reader.GetBoolean(); + reader.Read(); // EndObject + return AttributeValue.Bool(v); + } + if (reader.ValueTextEquals("NULL"u8)) + { + reader.Read(); + reader.GetBoolean(); + reader.Read(); // EndObject + return AttributeValue.Null(); + } + if (reader.ValueTextEquals("SS"u8)) + { + reader.Read(); + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + list.Add(reader.GetString()!); + reader.Read(); // EndObject + return AttributeValue.FromStringSet(list); + } + if (reader.ValueTextEquals("NS"u8)) + { + reader.Read(); + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + list.Add(reader.GetString()!); + reader.Read(); // EndObject + return AttributeValue.FromNumberSet(list); + } + if (reader.ValueTextEquals("L"u8)) + { + reader.Read(); + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + list.Add(Read(ref reader, typeToConvert, options)); + reader.Read(); // EndObject + return AttributeValue.FromList(list); + } + if (reader.ValueTextEquals("M"u8)) + { + reader.Read(); + var map = new Dictionary(8); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var key = reader.GetString()!; + reader.Read(); + map[key] = Read(ref reader, typeToConvert, options); + } + reader.Read(); // EndObject + return AttributeValue.FromMap(map); + } + + // Skip unknown properties + reader.Read(); + reader.Skip(); + } + + return default; + } + + /// + public override void Write(Utf8JsonWriter writer, AttributeValue value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + switch (value.Type) + { + case AttributeType.String: + writer.WriteString("S"u8, value.S); + break; + case AttributeType.Number: + writer.WriteString("N"u8, value.N); + break; + case AttributeType.Bool: + writer.WriteBoolean("BOOL"u8, value.BOOL.GetValueOrDefault()); + break; + case AttributeType.Null: + writer.WriteBoolean("NULL"u8, true); + break; + case AttributeType.StringSet: + writer.WriteStartArray("SS"u8); + foreach (var s in value.SS!) + writer.WriteStringValue(s); + writer.WriteEndArray(); + break; + case AttributeType.NumberSet: + writer.WriteStartArray("NS"u8); + foreach (var s in value.NS!) + writer.WriteStringValue(s); + writer.WriteEndArray(); + break; + case AttributeType.List: + writer.WriteStartArray("L"u8); + foreach (var item in value.L!) + Write(writer, item, options); + writer.WriteEndArray(); + break; + case AttributeType.Map: + writer.WriteStartObject("M"u8); + foreach (var kvp in value.M!) + { + writer.WritePropertyName(kvp.Key); + Write(writer, kvp.Value, options); + } + writer.WriteEndObject(); + break; + } + + writer.WriteEndObject(); + } +} diff --git a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs index 269d9fd5..71a9ea12 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/DynamoRecord.cs @@ -1,4 +1,4 @@ -namespace Goa.Clients.Dynamo.Models; +namespace Goa.Clients.Dynamo.Models; /// /// Represents a DynamoDB record with strongly-typed attribute values. @@ -8,7 +8,15 @@ public class DynamoRecord : Dictionary /// /// Initializes a new, empty instance of the class. /// - public DynamoRecord() + public DynamoRecord() : base(StringComparer.Ordinal) + { + } + + /// + /// Initializes a new instance of the class with the specified capacity. + /// + /// The initial capacity of the record. + public DynamoRecord(int capacity) : base(capacity, StringComparer.Ordinal) { } @@ -17,7 +25,7 @@ public DynamoRecord() /// by copying all key-value pairs from the specified dictionary. /// /// A dictionary containing DynamoDB attribute names and their values. - public DynamoRecord(Dictionary record) + public DynamoRecord(Dictionary record) : base(StringComparer.Ordinal) { foreach (var item in record) this[item.Key] = item.Value; @@ -30,13 +38,13 @@ public DynamoRecord(Dictionary record) /// The attribute value or null if not found. public new AttributeValue? this[string attributeName] { - get => base.TryGetValue(attributeName, out var attributeValue) ? attributeValue : null; + get => base.TryGetValue(attributeName, out var attributeValue) ? (AttributeValue?)attributeValue : null; set { - if (value == null) + if (value is null) Remove(attributeName); else - base[attributeName] = value; + base[attributeName] = value.Value; } } } From 1500733c6193918722d5779f2ed2963a9d40e0dd Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sun, 8 Mar 2026 22:23:48 +0000 Subject: [PATCH 02/18] Migrate all DynamoDB operations and Bedrock conversation store to AttributeValue factory methods --- .../DynamoConversationStore.cs | 54 ++++---- .../Internal/ContentBlockSerializer.cs | 21 ++- .../Extensions/DynamoRecordExtensions.cs | 120 +++++++++--------- .../Operations/Batch/BatchGetTableBuilder.cs | 2 +- .../Operations/Condition.cs | 14 +- .../UpdateItem/UpdateItemBuilder.cs | 22 ++-- 6 files changed, 112 insertions(+), 121 deletions(-) diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs index 5c84f852..35a739a8 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/DynamoConversationStore.cs @@ -68,9 +68,9 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto [_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId), [_configuration.SortKeyName] = _configuration.ConversationSkValue, [IdAttribute] = conversationId, - [CreatedAtAttribute] = new AttributeValue { N = now.ToUnixTimeSeconds().ToString() }, - [UpdatedAtAttribute] = new AttributeValue { N = now.ToUnixTimeSeconds().ToString() }, - [MessageCountAttribute] = new AttributeValue { N = "0" } + [CreatedAtAttribute] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()), + [UpdatedAtAttribute] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()), + [MessageCountAttribute] = AttributeValue.Number("0") }; if (metadata is not null) @@ -81,7 +81,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto if (_configuration.DefaultTtl.HasValue) { var expiresAt = now.Add(_configuration.DefaultTtl.Value).ToUnixTimeSeconds(); - item[_configuration.TtlAttributeName] = new AttributeValue { N = expiresAt.ToString() }; + item[_configuration.TtlAttributeName] = AttributeValue.Number(expiresAt.ToString()); } var request = new PutItemRequest @@ -261,9 +261,9 @@ private async Task>> AddMessagesInter [_configuration.SortKeyName] = _configuration.MessageSkFormat(conversationId, sequenceNumber), [IdAttribute] = messageId, [ConversationIdAttribute] = conversationId, - [SequenceNumberAttribute] = new AttributeValue { N = sequenceNumber.ToString() }, + [SequenceNumberAttribute] = AttributeValue.Number(sequenceNumber.ToString()), [RoleAttribute] = role.ToString(), - [CreatedAtAttribute] = new AttributeValue { N = now.ToUnixTimeSeconds().ToString() } + [CreatedAtAttribute] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) }; // Serialize content blocks @@ -275,14 +275,14 @@ private async Task>> AddMessagesInter return serialized.Errors; contentList.Add(serialized.Value); } - messageItem[ContentAttribute] = new AttributeValue { L = contentList }; + messageItem[ContentAttribute] = AttributeValue.FromList(contentList); // Add token usage if present if (tokenUsage is not null) { - messageItem[InputTokensAttribute] = new AttributeValue { N = tokenUsage.InputTokens.ToString() }; - messageItem[OutputTokensAttribute] = new AttributeValue { N = tokenUsage.OutputTokens.ToString() }; - messageItem[TokensAttribute] = new AttributeValue { N = tokenUsage.TotalTokens.ToString() }; + messageItem[InputTokensAttribute] = AttributeValue.Number(tokenUsage.InputTokens.ToString()); + messageItem[OutputTokensAttribute] = AttributeValue.Number(tokenUsage.OutputTokens.ToString()); + messageItem[TokensAttribute] = AttributeValue.Number(tokenUsage.TotalTokens.ToString()); totalInputTokens += tokenUsage.InputTokens; totalOutputTokens += tokenUsage.OutputTokens; @@ -298,18 +298,18 @@ private async Task>> AddMessagesInter var tagValues = new List(); foreach (var value in kvp.Value) { - tagValues.Add(new AttributeValue { S = value }); + tagValues.Add(AttributeValue.String(value)); } - tagsMap[kvp.Key] = new AttributeValue { L = tagValues }; + tagsMap[kvp.Key] = AttributeValue.FromList(tagValues); } - messageItem[ExtractedTagsAttribute] = new AttributeValue { M = tagsMap }; + messageItem[ExtractedTagsAttribute] = AttributeValue.FromMap(tagsMap); } // Inherit conversation TTL if (_configuration.DefaultTtl.HasValue) { var expiresAt = now.Add(_configuration.DefaultTtl.Value).ToUnixTimeSeconds(); - messageItem[_configuration.TtlAttributeName] = new AttributeValue { N = expiresAt.ToString() }; + messageItem[_configuration.TtlAttributeName] = AttributeValue.Number(expiresAt.ToString()); } transactItems.Add(new TransactWriteItem @@ -343,8 +343,8 @@ private async Task>> AddMessagesInter var updateExpressionValues = new Dictionary { - [":msgCount"] = new AttributeValue { N = messageList.Count.ToString() }, - [":updatedAt"] = new AttributeValue { N = now.ToUnixTimeSeconds().ToString() } + [":msgCount"] = AttributeValue.Number(messageList.Count.ToString()), + [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) }; if (totalTokens > 0) @@ -352,10 +352,10 @@ private async Task>> AddMessagesInter updateParts.Add($"{TotalInputTokensAttribute} = if_not_exists({TotalInputTokensAttribute}, :zero) + :inputTokens"); updateParts.Add($"{TotalOutputTokensAttribute} = if_not_exists({TotalOutputTokensAttribute}, :zero) + :outputTokens"); updateParts.Add($"{TotalTokensAttribute} = if_not_exists({TotalTokensAttribute}, :zero) + :totalTokens"); - updateExpressionValues[":zero"] = new AttributeValue { N = "0" }; - updateExpressionValues[":inputTokens"] = new AttributeValue { N = totalInputTokens.ToString() }; - updateExpressionValues[":outputTokens"] = new AttributeValue { N = totalOutputTokens.ToString() }; - updateExpressionValues[":totalTokens"] = new AttributeValue { N = totalTokens.ToString() }; + updateExpressionValues[":zero"] = AttributeValue.Number("0"); + updateExpressionValues[":inputTokens"] = AttributeValue.Number(totalInputTokens.ToString()); + updateExpressionValues[":outputTokens"] = AttributeValue.Number(totalOutputTokens.ToString()); + updateExpressionValues[":totalTokens"] = AttributeValue.Number(totalTokens.ToString()); } transactItems.Add(new TransactWriteItem @@ -396,7 +396,7 @@ private async Task>> AddMessagesInter var updateParts = new List { $"{UpdatedAtAttribute} = :updatedAt" }; var expressionAttributeValues = new Dictionary { - [":updatedAt"] = new AttributeValue { N = now.ToUnixTimeSeconds().ToString() } + [":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString()) }; var expressionAttributeNames = new Dictionary(); @@ -415,7 +415,7 @@ private async Task>> AddMessagesInter if (metadata.Tags.Count > 0) { updateParts.Add($"{TagsAttribute} = :tags"); - expressionAttributeValues[":tags"] = new AttributeValue { SS = metadata.Tags }; + expressionAttributeValues[":tags"] = AttributeValue.FromStringSet(metadata.Tags); } if (metadata.CustomData.Count > 0) @@ -427,7 +427,7 @@ private async Task>> AddMessagesInter { customDataMap[kvp.Key] = kvp.Value; } - expressionAttributeValues[":customData"] = new AttributeValue { M = customDataMap }; + expressionAttributeValues[":customData"] = AttributeValue.FromMap(customDataMap); } var updateRequest = new UpdateItemRequest @@ -488,8 +488,8 @@ public async Task> DeleteConversationAsync(string conversationI { allItems.Add(new Dictionary { - [_configuration.PartitionKeyName] = item[_configuration.PartitionKeyName]!, - [_configuration.SortKeyName] = item[_configuration.SortKeyName]! + [_configuration.PartitionKeyName] = item[_configuration.PartitionKeyName]!.Value, + [_configuration.SortKeyName] = item[_configuration.SortKeyName]!.Value }); } @@ -535,7 +535,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< item[ModelIdAttribute] = metadata.ModelId; if (metadata.Tags.Count > 0) - item[TagsAttribute] = new AttributeValue { SS = metadata.Tags }; + item[TagsAttribute] = AttributeValue.FromStringSet(metadata.Tags); if (metadata.CustomData.Count > 0) { @@ -544,7 +544,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary< { customDataMap[kvp.Key] = kvp.Value; } - item[CustomDataAttribute] = new AttributeValue { M = customDataMap }; + item[CustomDataAttribute] = AttributeValue.FromMap(customDataMap); } } diff --git a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs index 825d11c9..95a772d9 100644 --- a/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs +++ b/src/Clients/Goa.Clients.Bedrock.Conversation.Dynamo/Internal/ContentBlockSerializer.cs @@ -26,14 +26,11 @@ public static ErrorOr Serialize(ContentBlock block) { if (block.Text is not null) { - return new AttributeValue + return AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - [TypeAttribute] = TextType, - ["text"] = block.Text - } - }; + [TypeAttribute] = TextType, + ["text"] = block.Text + }); } if (block.Image is not null) @@ -55,7 +52,7 @@ public static ErrorOr Serialize(ContentBlock block) imageMap["s3BucketOwner"] = block.Image.Source.S3Location.BucketOwner; } - return new AttributeValue { M = imageMap }; + return AttributeValue.FromMap(imageMap); } if (block.Document is not null) @@ -78,7 +75,7 @@ public static ErrorOr Serialize(ContentBlock block) docMap["s3BucketOwner"] = block.Document.Source.S3Location.BucketOwner; } - return new AttributeValue { M = docMap }; + return AttributeValue.FromMap(docMap); } if (block.ToolUse is not null) @@ -91,7 +88,7 @@ public static ErrorOr Serialize(ContentBlock block) ["input"] = block.ToolUse.Input.GetRawText() }; - return new AttributeValue { M = toolUseMap }; + return AttributeValue.FromMap(toolUseMap); } if (block.ToolResult is not null) @@ -109,13 +106,13 @@ public static ErrorOr Serialize(ContentBlock block) { [TypeAttribute] = ToolResultType, ["toolUseId"] = block.ToolResult.ToolUseId, - ["content"] = new AttributeValue { L = contentList } + ["content"] = AttributeValue.FromList(contentList) }; if (block.ToolResult.Status is not null) toolResultMap["status"] = block.ToolResult.Status; - return new AttributeValue { M = toolResultMap }; + return AttributeValue.FromMap(toolResultMap); } return Error.Validation(ConversationErrorCodes.ContentBlockEmpty, "ContentBlock must have at least one content type set"); diff --git a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs index a7150fe2..620c621a 100644 --- a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs @@ -20,7 +20,7 @@ public static bool TryGetNullableString(this DynamoRecord record, string columnN if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -46,7 +46,7 @@ public static bool TryGetString(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true || string.IsNullOrEmpty(attributeValue.S)) + if (attributeValue.Type == AttributeType.Null || string.IsNullOrEmpty(attributeValue.S)) return false; value = attributeValue.S; @@ -66,7 +66,7 @@ public static bool TryGetBool(this DynamoRecord record, string columnName, out b if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; if (attributeValue.BOOL == null) @@ -89,7 +89,7 @@ public static bool TryGetNullableBool(this DynamoRecord record, string columnNam if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -112,7 +112,7 @@ public static bool TryGetShort(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && short.TryParse(attributeValue.N, out value); @@ -131,7 +131,7 @@ public static bool TryGetNullableShort(this DynamoRecord record, string columnNa if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -161,7 +161,7 @@ public static bool TryGetInt(this DynamoRecord record, string columnName, out in if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && int.TryParse(attributeValue.N, out value); @@ -180,7 +180,7 @@ public static bool TryGetNullableInt(this DynamoRecord record, string columnName if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -210,7 +210,7 @@ public static bool TryGetLong(this DynamoRecord record, string columnName, out l if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && long.TryParse(attributeValue.N, out value); @@ -229,7 +229,7 @@ public static bool TryGetNullableLong(this DynamoRecord record, string columnNam if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -259,7 +259,7 @@ public static bool TryGetDouble(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && double.TryParse(attributeValue.N, out value); @@ -278,7 +278,7 @@ public static bool TryGetNullableDouble(this DynamoRecord record, string columnN if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -308,7 +308,7 @@ public static bool TryGetFloat(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && float.TryParse(attributeValue.N, out value); @@ -327,7 +327,7 @@ public static bool TryGetNullableFloat(this DynamoRecord record, string columnNa if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -357,7 +357,7 @@ public static bool TryGetDecimal(this DynamoRecord record, string columnName, ou if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && decimal.TryParse(attributeValue.N, out value); @@ -376,7 +376,7 @@ public static bool TryGetNullableDecimal(this DynamoRecord record, string column if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -406,7 +406,7 @@ public static bool TryGetDateTime(this DynamoRecord record, string columnName, o if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.S) && DateTime.TryParse(attributeValue.S, out value); @@ -425,7 +425,7 @@ public static bool TryGetNullableDateTime(this DynamoRecord record, string colum if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -456,7 +456,7 @@ public static bool TryGetEnum(this DynamoRecord record, string columnName, ou if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.S) && Enum.TryParse(attributeValue.S, out value); @@ -476,7 +476,7 @@ public static bool TryGetNullableEnum(this DynamoRecord record, string column if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -506,7 +506,7 @@ public static bool TryGetStringSet(this DynamoRecord record, string columnName, if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -532,7 +532,7 @@ public static bool TryGetIntSet(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -560,7 +560,7 @@ public static bool TryGetLongSet(this DynamoRecord record, string columnName, ou if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -588,7 +588,7 @@ public static bool TryGetDoubleSet(this DynamoRecord record, string columnName, if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -617,7 +617,7 @@ public static bool TryGetEnumSet(this DynamoRecord record, string columnName, if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -645,7 +645,7 @@ public static bool TryGetByte(this DynamoRecord record, string columnName, out b if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && byte.TryParse(attributeValue.N, out value); @@ -664,7 +664,7 @@ public static bool TryGetNullableByte(this DynamoRecord record, string columnNam if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -694,7 +694,7 @@ public static bool TryGetSByte(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && sbyte.TryParse(attributeValue.N, out value); @@ -713,7 +713,7 @@ public static bool TryGetNullableSByte(this DynamoRecord record, string columnNa if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -743,7 +743,7 @@ public static bool TryGetUShort(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && ushort.TryParse(attributeValue.N, out value); @@ -762,7 +762,7 @@ public static bool TryGetNullableUShort(this DynamoRecord record, string columnN if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -792,7 +792,7 @@ public static bool TryGetUInt(this DynamoRecord record, string columnName, out u if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && uint.TryParse(attributeValue.N, out value); @@ -811,7 +811,7 @@ public static bool TryGetNullableUInt(this DynamoRecord record, string columnNam if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -841,7 +841,7 @@ public static bool TryGetULong(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.N) && ulong.TryParse(attributeValue.N, out value); @@ -860,7 +860,7 @@ public static bool TryGetNullableULong(this DynamoRecord record, string columnNa if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -890,7 +890,7 @@ public static bool TryGetDateTimeOffset(this DynamoRecord record, string columnN if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.S) && DateTimeOffset.TryParse(attributeValue.S, out value); @@ -909,7 +909,7 @@ public static bool TryGetNullableDateTimeOffset(this DynamoRecord record, string if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -939,7 +939,7 @@ public static bool TryGetTimeSpan(this DynamoRecord record, string columnName, o if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.S) && TimeSpan.TryParse(attributeValue.S, out value); @@ -958,7 +958,7 @@ public static bool TryGetNullableTimeSpan(this DynamoRecord record, string colum if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -988,7 +988,7 @@ public static bool TryGetGuid(this DynamoRecord record, string columnName, out G if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; return !string.IsNullOrEmpty(attributeValue.S) && Guid.TryParse(attributeValue.S, out value); @@ -1007,7 +1007,7 @@ public static bool TryGetNullableGuid(this DynamoRecord record, string columnNam if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1037,7 +1037,7 @@ public static bool TryGetDateTimeSet(this DynamoRecord record, string columnName if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = Enumerable.Empty(); return true; @@ -1065,7 +1065,7 @@ public static bool TryGetStringDictionary(this DynamoRecord record, string colum if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return true; // Empty dictionary for NULL if (attributeValue.M == null) @@ -1073,7 +1073,7 @@ public static bool TryGetStringDictionary(this DynamoRecord record, string colum foreach (var kvp in attributeValue.M) { - if (kvp.Value?.S != null) + if (kvp.Value.S != null) { value[kvp.Key] = kvp.Value.S; } @@ -1094,7 +1094,7 @@ public static bool TryGetNullableStringDictionary(this DynamoRecord record, stri if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1106,7 +1106,7 @@ public static bool TryGetNullableStringDictionary(this DynamoRecord record, stri value = new Dictionary(); foreach (var kvp in attributeValue.M) { - if (kvp.Value?.S != null) + if (kvp.Value.S != null) { value[kvp.Key] = kvp.Value.S; } @@ -1127,7 +1127,7 @@ public static bool TryGetStringIntDictionary(this DynamoRecord record, string co if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return true; // Empty dictionary for NULL if (attributeValue.M == null) @@ -1135,7 +1135,7 @@ public static bool TryGetStringIntDictionary(this DynamoRecord record, string co foreach (var kvp in attributeValue.M) { - if (kvp.Value?.N != null && int.TryParse(kvp.Value.N, out var intValue)) + if (kvp.Value.N != null && int.TryParse(kvp.Value.N, out var intValue)) { value[kvp.Key] = intValue; } @@ -1156,7 +1156,7 @@ public static bool TryGetNullableStringIntDictionary(this DynamoRecord record, s if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1168,7 +1168,7 @@ public static bool TryGetNullableStringIntDictionary(this DynamoRecord record, s value = new Dictionary(); foreach (var kvp in attributeValue.M) { - if (kvp.Value?.N != null && int.TryParse(kvp.Value.N, out var intValue)) + if (kvp.Value.N != null && int.TryParse(kvp.Value.N, out var intValue)) { value[kvp.Key] = intValue; } @@ -1189,7 +1189,7 @@ public static bool TryGetUnixTimestampSeconds(this DynamoRecord record, string c if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; if (string.IsNullOrEmpty(attributeValue.N)) @@ -1223,7 +1223,7 @@ public static bool TryGetNullableUnixTimestampSeconds(this DynamoRecord record, if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1260,7 +1260,7 @@ public static bool TryGetUnixTimestampMilliseconds(this DynamoRecord record, str if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; if (string.IsNullOrEmpty(attributeValue.N)) @@ -1294,7 +1294,7 @@ public static bool TryGetNullableUnixTimestampMilliseconds(this DynamoRecord rec if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1331,7 +1331,7 @@ public static bool TryGetUnixTimestampSecondsAsOffset(this DynamoRecord record, if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; if (string.IsNullOrEmpty(attributeValue.N)) @@ -1365,7 +1365,7 @@ public static bool TryGetNullableUnixTimestampSecondsAsOffset(this DynamoRecord if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1402,7 +1402,7 @@ public static bool TryGetUnixTimestampMillisecondsAsOffset(this DynamoRecord rec if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) return false; if (string.IsNullOrEmpty(attributeValue.N)) @@ -1436,7 +1436,7 @@ public static bool TryGetNullableUnixTimestampMillisecondsAsOffset(this DynamoRe if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1473,7 +1473,7 @@ public static bool TryGetMap(this DynamoRecord record, string columnName, out Dy if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; @@ -1499,7 +1499,7 @@ public static bool TryGetList(this DynamoRecord record, string columnName, out L if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue == null || attributeValue.NULL == true) + if (attributeValue.Type == AttributeType.Null) { value = null; return true; diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs index c5373358..64f66bcc 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Batch/BatchGetTableBuilder.cs @@ -33,7 +33,7 @@ public BatchGetTableBuilder WithKey(string attributeName, string value) { _request.Keys.Add(new Dictionary { - [attributeName] = new AttributeValue { S = value } + [attributeName] = AttributeValue.String(value) }); return this; } diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs index 445abca4..8b304601 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/Condition.cs @@ -176,7 +176,7 @@ public static Condition NotContains(string attributeName, AttributeValue value) public static Condition SizeEquals(string attributeName, int size) { return new Condition($"size(#{attributeName}) = :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -188,7 +188,7 @@ public static Condition SizeEquals(string attributeName, int size) public static Condition SizeNotEquals(string attributeName, int size) { return new Condition($"size(#{attributeName}) <> :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -200,7 +200,7 @@ public static Condition SizeNotEquals(string attributeName, int size) public static Condition SizeGreaterThan(string attributeName, int size) { return new Condition($"size(#{attributeName}) > :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -212,7 +212,7 @@ public static Condition SizeGreaterThan(string attributeName, int size) public static Condition SizeGreaterThanOrEquals(string attributeName, int size) { return new Condition($"size(#{attributeName}) >= :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -224,7 +224,7 @@ public static Condition SizeGreaterThanOrEquals(string attributeName, int size) public static Condition SizeLessThan(string attributeName, int size) { return new Condition($"size(#{attributeName}) < :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -236,7 +236,7 @@ public static Condition SizeLessThan(string attributeName, int size) public static Condition SizeLessThanOrEquals(string attributeName, int size) { return new Condition($"size(#{attributeName}) <= :{attributeName}", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { N = size.ToString() })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.Number(size.ToString()))]); } /// @@ -270,7 +270,7 @@ public static Condition AttributeNotExists(string attributeName) public static Condition AttributeType(string attributeName, string type) { return new Condition($"attribute_type(#{attributeName}, :{attributeName})", [new KeyValuePair($"#{attributeName}", attributeName) - ], [new KeyValuePair($":{attributeName}", new AttributeValue { S = type })]); + ], [new KeyValuePair($":{attributeName}", AttributeValue.String(type))]); } /// diff --git a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs index b307c75a..76ae1e43 100644 --- a/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs +++ b/src/Clients/Goa.Clients.Dynamo/Operations/UpdateItem/UpdateItemBuilder.cs @@ -266,7 +266,7 @@ private UpdateItemBuilder IncrementInternal(string attributeName, string valueSt var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { N = valueString }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.Number(valueString); _setActions.Add($"{nameKey} = {nameKey} + {valueKey}"); return this; @@ -347,7 +347,7 @@ private UpdateItemBuilder DecrementInternal(string attributeName, string valueSt var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { N = valueString }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.Number(valueString); _setActions.Add($"{nameKey} = {nameKey} - {valueKey}"); return this; @@ -428,7 +428,7 @@ private UpdateItemBuilder IncrementAtomicallyInternal(string attributeName, stri var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { N = valueString }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.Number(valueString); _addActions.Add($"{nameKey} {valueKey}"); return this; @@ -509,7 +509,7 @@ private UpdateItemBuilder DecrementAtomicallyInternal(string attributeName, stri var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { N = negatedValueString }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.Number(negatedValueString); _addActions.Add($"{nameKey} {valueKey}"); return this; @@ -663,7 +663,7 @@ public UpdateItemBuilder AddToStringSet(string attributeName, params string[] va var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { SS = values.ToList() }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); _addActions.Add($"{nameKey} {valueKey}"); } @@ -687,10 +687,7 @@ public UpdateItemBuilder AddToNumberSet(string attributeName, params decimal[] v var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue - { - NS = values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList() - }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); _addActions.Add($"{nameKey} {valueKey}"); } @@ -757,7 +754,7 @@ public UpdateItemBuilder RemoveFromStringSet(string attributeName, params string var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue { SS = values.ToList() }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromStringSet(values.ToList()); _deleteActions.Add($"{nameKey} {valueKey}"); } @@ -781,10 +778,7 @@ public UpdateItemBuilder RemoveFromNumberSet(string attributeName, params decima var valueKey = $":v{_request.ExpressionAttributeValues.Count}"; _request.ExpressionAttributeNames[nameKey] = attributeName; - _request.ExpressionAttributeValues[valueKey] = new AttributeValue - { - NS = values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList() - }; + _request.ExpressionAttributeValues[valueKey] = AttributeValue.FromNumberSet(values.Select(v => v.ToString(CultureInfo.InvariantCulture)).ToList()); _deleteActions.Add($"{nameKey} {valueKey}"); } From 21be57e499351ecce1d763af8213a8c7d1188260 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sun, 8 Mar 2026 22:24:03 +0000 Subject: [PATCH 03/18] Update DynamoDB source generator to emit AttributeValue factory methods instead of object initializers --- .../CodeGeneration/MapperGenerator.cs | 12 ++-- .../TypeHandlers/CollectionTypeHandler.cs | 42 ++++++------- .../TypeHandlers/ComplexTypeHandler.cs | 20 +++--- .../TypeHandlers/DateOnlyTypeHandler.cs | 4 +- .../TypeHandlers/DateTimeTypeHandler.cs | 4 +- .../TypeHandlers/EnumTypeHandler.cs | 4 +- .../TypeHandlers/PrimitiveTypeHandler.cs | 62 +++++++++---------- .../TypeHandlers/TimeOnlyTypeHandler.cs | 4 +- .../TypeHandlers/TypeHandlerRegistry.cs | 6 +- .../TypeHandlers/UnixTimestampTypeHandler.cs | 8 +-- .../UnsupportedDictionaryHandler.cs | 2 +- 11 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs index 8eab99ed..3a0487b3 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/CodeGeneration/MapperGenerator.cs @@ -173,7 +173,7 @@ private void GenerateConcreteToDynamoRecord(CodeBuilder builder, DynamoTypeInfo var typeNameField = (dynamoModelAttr?.TypeName != "Type" ? dynamoModelAttr?.TypeName : null) ?? GetInheritedDynamoModelAttribute(type)?.TypeName ?? "Type"; - builder.AppendLine($"record[\"{typeNameField}\"] = new AttributeValue {{ S = \"{type.FullName}\" }};"); + builder.AppendLine($"record[\"{typeNameField}\"] = AttributeValue.String(\"{type.FullName}\");"); } // Add property mappings, excluding properties already emitted as GSI keys @@ -187,8 +187,8 @@ private void GeneratePrimaryKeyAssignment(CodeBuilder builder, DynamoTypeInfo ty var pkCode = GenerateKeyCode(type, dynamoAttr.PK, "PK"); var skCode = GenerateKeyCode(type, dynamoAttr.SK, "SK"); - builder.AppendLine($"record[\"{dynamoAttr.PKName}\"] = new AttributeValue {{ S = {pkCode} }};"); - builder.AppendLine($"record[\"{dynamoAttr.SKName}\"] = new AttributeValue {{ S = {skCode} }};"); + builder.AppendLine($"record[\"{dynamoAttr.PKName}\"] = AttributeValue.String({pkCode});"); + builder.AppendLine($"record[\"{dynamoAttr.SKName}\"] = AttributeValue.String({skCode});"); } private void GenerateGSIKeyAssignment(CodeBuilder builder, DynamoTypeInfo type, GSIAttributeInfo gsiAttr) @@ -208,7 +208,7 @@ private void GenerateGSIKeyAssignment(CodeBuilder builder, DynamoTypeInfo type, else { var pkCode = GenerateKeyCode(type, gsiAttr.PK, gsiAttr.PKName!); - builder.AppendLine($"record[\"{gsiAttr.PKName}\"] = new AttributeValue {{ S = {pkCode} }};"); + builder.AppendLine($"record[\"{gsiAttr.PKName}\"] = AttributeValue.String({pkCode});"); } // Generate SK assignment - conditional if single nullable property, otherwise direct @@ -219,7 +219,7 @@ private void GenerateGSIKeyAssignment(CodeBuilder builder, DynamoTypeInfo type, else { var skCode = GenerateKeyCode(type, gsiAttr.SK, gsiAttr.SKName!); - builder.AppendLine($"record[\"{gsiAttr.SKName}\"] = new AttributeValue {{ S = {skCode} }};"); + builder.AppendLine($"record[\"{gsiAttr.SKName}\"] = AttributeValue.String({skCode});"); } } @@ -773,7 +773,7 @@ private void GenerateConditionalGSIKeyAssignment(CodeBuilder builder, string att } builder.OpenBraceWithLine($"if ({nullCheck})"); - builder.AppendLine($"record[\"{attributeName}\"] = new AttributeValue {{ S = {valueCode} }};"); + builder.AppendLine($"record[\"{attributeName}\"] = AttributeValue.String({valueCode});"); builder.CloseBrace(); } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs index f2df6b68..f05aa2cf 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs @@ -31,7 +31,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) if (propertyInfo.ElementType == null || _registry == null) { - return "new AttributeValue { NULL = true }"; // Skip invalid collections + return "AttributeValue.Null()"; // Skip invalid collections } var elementType = propertyInfo.ElementType; @@ -48,11 +48,11 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) var elementMapping = GenerateElementToAttributeValue(elementType, "item"); if (elementMapping != null) { - return $"model.{propertyName} != null ? new AttributeValue {{ L = model.{propertyName}.Select(item => {elementMapping}).ToList() }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromList(model.{propertyName}.Select(item => {elementMapping}).ToList()) : AttributeValue.Null()"; } // Fallback to NULL for unsupported types - return "new AttributeValue { NULL = true }"; + return "AttributeValue.Null()"; } /// @@ -68,7 +68,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) { // Use just the type name (not the full namespace) to match the mapper naming convention var normalizedTypeName = NamingHelpers.NormalizeTypeName(elementType.Name); - return $"({itemVarName} != null ? new AttributeValue {{ M = DynamoMapper.{normalizedTypeName}.ToDynamoRecord({itemVarName}) }} : new AttributeValue {{ NULL = true }})"; + return $"({itemVarName} != null ? AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord({itemVarName})) : AttributeValue.Null())"; } // Check if it's a nested collection @@ -89,7 +89,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) var nestedElementMapping = GenerateElementToAttributeValue(nestedElementType, nestedVarName); if (nestedElementMapping != null) { - return $"({itemVarName} != null ? new AttributeValue {{ L = {itemVarName}.Select({nestedVarName} => {nestedElementMapping}).ToList() }} : new AttributeValue {{ NULL = true }})"; + return $"({itemVarName} != null ? AttributeValue.FromList({itemVarName}.Select({nestedVarName} => {nestedElementMapping}).ToList()) : AttributeValue.Null())"; } } } @@ -104,16 +104,16 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) { return elementType.SpecialType switch { - SpecialType.System_String => "(item != null && item.Any() ? new AttributeValue { SS = item.ToList() } : new AttributeValue { NULL = true })", + SpecialType.System_String => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.ToList()) : AttributeValue.Null())", 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 => "(item != null && item.Any() ? new AttributeValue { NS = item.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList() } : new AttributeValue { NULL = true })", - SpecialType.System_Boolean => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })", - SpecialType.System_DateTime => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString(\"o\")).ToList() } : new AttributeValue { NULL = true })", - _ when elementType.Name == nameof(Guid) => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })", - _ when elementType.Name == nameof(TimeSpan) => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })", - _ when elementType.Name == nameof(DateTimeOffset) => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString(\"o\")).ToList() } : new AttributeValue { NULL = true })", - _ when elementType.TypeKind == TypeKind.Enum => "(item != null && item.Any() ? new AttributeValue { SS = item.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })", + SpecialType.System_Decimal or SpecialType.System_Single or SpecialType.System_Double => "(item != null && item.Any() ? AttributeValue.FromNumberSet(item.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList()) : AttributeValue.Null())", + SpecialType.System_Boolean => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + SpecialType.System_DateTime => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString(\"o\")).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(Guid) => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(TimeSpan) => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(DateTimeOffset) => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString(\"o\")).ToList()) : AttributeValue.Null())", + _ when elementType.TypeKind == TypeKind.Enum => "(item != null && item.Any() ? AttributeValue.FromStringSet(item.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", _ => null }; } @@ -173,16 +173,16 @@ private static bool IsCollectionType(ITypeSymbol type) { return elementType.SpecialType switch { - SpecialType.System_String => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.ToList() }} : new AttributeValue {{ NULL = true }})", + SpecialType.System_String => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.ToList()) : AttributeValue.Null())", 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 => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ NS = model.{propertyName}.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList() }} : new AttributeValue {{ NULL = true }})", - SpecialType.System_Boolean => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString()).ToList() }} : new AttributeValue {{ NULL = true }})", - SpecialType.System_DateTime => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString(\"o\")).ToList() }} : new AttributeValue {{ NULL = true }})", - _ when elementType.Name == nameof(Guid) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString()).ToList() }} : new AttributeValue {{ NULL = true }})", - _ when elementType.Name == nameof(TimeSpan) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString()).ToList() }} : new AttributeValue {{ NULL = true }})", - _ when elementType.Name == nameof(DateTimeOffset) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString(\"o\")).ToList() }} : new AttributeValue {{ NULL = true }})", - _ when elementType.TypeKind == TypeKind.Enum => $"(model.{propertyName} != null && model.{propertyName}.Any() ? new AttributeValue {{ SS = model.{propertyName}.Select(x => x.ToString()).ToList() }} : new AttributeValue {{ NULL = true }})", + SpecialType.System_Decimal or SpecialType.System_Single or SpecialType.System_Double => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromNumberSet(model.{propertyName}.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList()) : AttributeValue.Null())", + SpecialType.System_Boolean => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + SpecialType.System_DateTime => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString(\"o\")).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(Guid) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(TimeSpan) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", + _ when elementType.Name == nameof(DateTimeOffset) => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString(\"o\")).ToList()) : AttributeValue.Null())", + _ when elementType.TypeKind == TypeKind.Enum => $"(model.{propertyName} != null && model.{propertyName}.Any() ? AttributeValue.FromStringSet(model.{propertyName}.Select(x => x.ToString()).ToList()) : AttributeValue.Null())", _ => null // Use composition for complex types }; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs index 8f6d4007..3fe79840 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs @@ -68,7 +68,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) namedValueType.TypeArguments.Length == 1 && namedValueType.TypeArguments[0].SpecialType == SpecialType.System_String) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ SS = kvp.Value ?? new List() }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.FromStringSet(kvp.Value ?? new List()))) : AttributeValue.Null()"; } // Special case: Dictionary> @@ -78,7 +78,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) namedValueType2.TypeArguments[0].SpecialType == SpecialType.System_String && namedValueType2.TypeArguments[1].SpecialType == SpecialType.System_String) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ M = (kvp.Value ?? new Dictionary()).ToDictionary(innerKvp => innerKvp.Key, innerKvp => innerKvp.Value != null ? new AttributeValue {{ S = innerKvp.Value }} : new AttributeValue {{ NULL = true }}) }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.FromMap((kvp.Value ?? new Dictionary()).ToDictionary(innerKvp => innerKvp.Key, innerKvp => innerKvp.Value != null ? AttributeValue.String(innerKvp.Value) : AttributeValue.Null())))) : AttributeValue.Null()"; } // Fallback to primitive handling for other types @@ -86,12 +86,12 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) } // For unsupported dictionary types, return NULL - return "new AttributeValue { NULL = true }"; + return "AttributeValue.Null()"; } // Handle complex types (nested objects) var normalizedTypeName = propertyInfo.UnderlyingType.Name.Replace(".", "_").Replace("`", "_"); - return $"model.{propertyName} != null ? new AttributeValue {{ M = DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName})) : AttributeValue.Null()"; } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) @@ -326,22 +326,22 @@ private string GeneratePrimitiveDictionary(string propertyName, ITypeSymbol valu { if (valueType.SpecialType == SpecialType.System_String) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ S = kvp.Value }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value))) : AttributeValue.Null()"; } else if (IsNumericType(valueType)) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ N = kvp.Value.ToString(CultureInfo.InvariantCulture) }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.Number(kvp.Value.ToString(CultureInfo.InvariantCulture)))) : AttributeValue.Null()"; } else if (valueType.SpecialType == SpecialType.System_DateTime) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ S = kvp.Value.ToString(\"o\") }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value.ToString(\"o\")))) : AttributeValue.Null()"; } else if (valueType.TypeKind == TypeKind.Enum) { - return $"model.{propertyName} != null ? new AttributeValue {{ M = model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue {{ S = kvp.Value.ToString() }}) }} : new AttributeValue {{ NULL = true }}"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value.ToString()))) : AttributeValue.Null()"; } - + // For unsupported types, return NULL - return "new AttributeValue { NULL = true }"; + return "AttributeValue.Null()"; } } \ No newline at end of file diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs index 9a08de27..dc7bfdf7 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs @@ -24,7 +24,7 @@ public bool CanHandle(PropertyInfo propertyInfo) #pragma warning disable CS8603 // Possible null reference return - intentional for conditional assignment ? null // Use conditional assignment instead #pragma warning restore CS8603 - : $"new AttributeValue {{ S = model.{propertyName}.ToString(\"yyyy-MM-dd\") }}"; + : $"AttributeValue.String(model.{propertyName}.ToString(\"yyyy-MM-dd\"))"; } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) @@ -61,7 +61,7 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = new AttributeValue {{ S = model.{propertyName}.Value.ToString(""yyyy-MM-dd"") }}; + {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""yyyy-MM-dd"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs index b8dc1e64..6c62426c 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs @@ -33,7 +33,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) #pragma warning disable CS8603 // Possible null reference return - intentional for conditional assignment return isNullable ? null // This will trigger conditional assignment generation in MapperGenerator - : $"new AttributeValue {{ S = model.{propertyName}.ToString(\"o\") }}"; + : $"AttributeValue.String(model.{propertyName}.ToString(\"o\"))"; #pragma warning restore CS8603 } @@ -42,7 +42,7 @@ public string GenerateConditionalAssignment(PropertyInfo propertyInfo, string re var propertyName = propertyInfo.Name; return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = new AttributeValue {{ S = model.{propertyName}.Value.ToString(""o"") }}; + {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""o"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs index 19e472be..4a684661 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs @@ -25,7 +25,7 @@ public bool CanHandle(PropertyInfo propertyInfo) #pragma warning disable CS8603 // Possible null reference return - intentional for conditional assignment ? null // Use conditional assignment instead #pragma warning restore CS8603 - : $"new AttributeValue {{ S = model.{propertyName}.ToString() }}"; + : $"AttributeValue.String(model.{propertyName}.ToString())"; } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) @@ -68,7 +68,7 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = new AttributeValue {{ S = model.{propertyName}.Value.ToString() }}; + {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString()); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs index 45981535..e7101342 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs @@ -52,35 +52,35 @@ 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 => - $"new AttributeValue {{ N = model.{propertyName}.ToString(CultureInfo.InvariantCulture) }}", - SpecialType.System_Boolean when isNullable => + $"AttributeValue.Number(model.{propertyName}.ToString(CultureInfo.InvariantCulture))", + SpecialType.System_Boolean when isNullable => null, // Use conditional assignment instead - SpecialType.System_Boolean => - $"new AttributeValue {{ BOOL = model.{propertyName} }}", - SpecialType.System_Char when isNullable => + SpecialType.System_Boolean => + $"AttributeValue.Bool(model.{propertyName})", + SpecialType.System_Char when isNullable => null, // Use conditional assignment instead - SpecialType.System_Char => - $"new AttributeValue {{ S = model.{propertyName}.ToString() }}", - SpecialType.System_DateTime when isNullable => + SpecialType.System_Char => + $"AttributeValue.String(model.{propertyName}.ToString())", + SpecialType.System_DateTime when isNullable => null, // Use conditional assignment instead (handled by DateTimeTypeHandler) - SpecialType.System_DateTime => - $"new AttributeValue {{ S = model.{propertyName}.ToString(\\\"o\\\") }}", - _ when underlyingType.Name == nameof(Guid) && isNullable => + SpecialType.System_DateTime => + $"AttributeValue.String(model.{propertyName}.ToString(\\\"o\\\"))", + _ when underlyingType.Name == nameof(Guid) && isNullable => null, // Use conditional assignment instead - _ when underlyingType.Name == nameof(Guid) => - $"new AttributeValue {{ S = model.{propertyName}.ToString() }}", - _ when underlyingType.Name == nameof(TimeSpan) && isNullable => + _ when underlyingType.Name == nameof(Guid) => + $"AttributeValue.String(model.{propertyName}.ToString())", + _ when underlyingType.Name == nameof(TimeSpan) && isNullable => null, // Use conditional assignment instead - _ when underlyingType.Name == nameof(TimeSpan) => - $"new AttributeValue {{ S = model.{propertyName}.ToString() }}", - _ when underlyingType.Name == nameof(DateTimeOffset) && isNullable => + _ when underlyingType.Name == nameof(TimeSpan) => + $"AttributeValue.String(model.{propertyName}.ToString())", + _ when underlyingType.Name == nameof(DateTimeOffset) && isNullable => null, // Use conditional assignment instead - _ when underlyingType.Name == nameof(DateTimeOffset) => - $"new AttributeValue {{ S = model.{propertyName}.ToString(\\\"o\\\") }}", - _ when underlyingType.TypeKind == TypeKind.Enum && isNullable => + _ when underlyingType.Name == nameof(DateTimeOffset) => + $"AttributeValue.String(model.{propertyName}.ToString(\\\"o\\\"))", + _ when underlyingType.TypeKind == TypeKind.Enum && isNullable => null, // Use conditional assignment instead - _ when underlyingType.TypeKind == TypeKind.Enum => - $"new AttributeValue {{ S = model.{propertyName}.ToString() }}", + _ when underlyingType.TypeKind == TypeKind.Enum => + $"AttributeValue.String(model.{propertyName}.ToString())", _ => string.Empty }; } @@ -161,27 +161,27 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) var attributeValue = underlyingType.SpecialType switch { SpecialType.System_String => - $"new AttributeValue {{ S = model.{propertyName} }}", + $"AttributeValue.String(model.{propertyName})", 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 => - $"new AttributeValue {{ N = model.{propertyName}.Value.ToString(CultureInfo.InvariantCulture) }}", + $"AttributeValue.Number(model.{propertyName}.Value.ToString(CultureInfo.InvariantCulture))", SpecialType.System_Boolean => - $"new AttributeValue {{ BOOL = model.{propertyName}.Value }}", + $"AttributeValue.Bool(model.{propertyName}.Value)", SpecialType.System_Char => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString() }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString())", SpecialType.System_DateTime => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString(\\\"o\\\") }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString(\\\"o\\\"))", _ when underlyingType.Name == nameof(Guid) => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString() }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ when underlyingType.Name == nameof(TimeSpan) => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString() }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ when underlyingType.Name == nameof(DateTimeOffset) => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString(\\\"o\\\") }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString(\\\"o\\\"))", _ when underlyingType.TypeKind == TypeKind.Enum => - $"new AttributeValue {{ S = model.{propertyName}.Value.ToString() }}", + $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ => null }; diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs index 6f12c44c..719e439d 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs @@ -24,7 +24,7 @@ public bool CanHandle(PropertyInfo propertyInfo) #pragma warning disable CS8603 // Possible null reference return - intentional for conditional assignment ? null // Use conditional assignment instead #pragma warning restore CS8603 - : $"new AttributeValue {{ S = model.{propertyName}.ToString(\"HH:mm:ss.fffffff\") }}"; + : $"AttributeValue.String(model.{propertyName}.ToString(\"HH:mm:ss.fffffff\"))"; } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) @@ -61,7 +61,7 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = new AttributeValue {{ S = model.{propertyName}.Value.ToString(""HH:mm:ss.fffffff"") }}; + {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""HH:mm:ss.fffffff"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TypeHandlerRegistry.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TypeHandlerRegistry.cs index 3519a9cf..2b72c4d0 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TypeHandlerRegistry.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TypeHandlerRegistry.cs @@ -105,7 +105,7 @@ public string GenerateNestedToAttributeValue(ITypeSymbol type, string valueExpre { if (depth > MaxRecursionDepth) { - return "new AttributeValue { NULL = true }"; // Prevent infinite recursion + return "AttributeValue.Null()"; // Prevent infinite recursion } // Create a temporary PropertyInfo for the nested type @@ -114,12 +114,12 @@ public string GenerateNestedToAttributeValue(ITypeSymbol type, string valueExpre if (handler == null) { - return "new AttributeValue { NULL = true }"; + return "AttributeValue.Null()"; } // Replace the property access with the provided value expression var attributeValueCode = handler.GenerateToAttributeValue(nestedProperty); - return attributeValueCode?.Replace($"model.{nestedProperty.Name}", valueExpression) ?? "new AttributeValue { NULL = true }"; + return attributeValueCode?.Replace($"model.{nestedProperty.Name}", valueExpression) ?? "AttributeValue.Null()"; } /// diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnixTimestampTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnixTimestampTypeHandler.cs index 4eeea4dd..b429d800 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnixTimestampTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnixTimestampTypeHandler.cs @@ -35,8 +35,8 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) else { return isMilliseconds - ? $"new AttributeValue {{ N = ((DateTimeOffset)model.{propertyName}).ToUnixTimeMilliseconds().ToString() }}" - : $"new AttributeValue {{ N = ((DateTimeOffset)model.{propertyName}).ToUnixTimeSeconds().ToString() }}"; + ? $"AttributeValue.Number(((DateTimeOffset)model.{propertyName}).ToUnixTimeMilliseconds().ToString())" + : $"AttributeValue.Number(((DateTimeOffset)model.{propertyName}).ToUnixTimeSeconds().ToString())"; } } @@ -57,11 +57,11 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) return isMilliseconds ? $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{dynamoAttributeName}""] = new AttributeValue {{ N = ((DateTimeOffset)model.{propertyName}.Value).ToUnixTimeMilliseconds().ToString() }}; + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.Number(((DateTimeOffset)model.{propertyName}.Value).ToUnixTimeMilliseconds().ToString()); }}" : $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{dynamoAttributeName}""] = new AttributeValue {{ N = ((DateTimeOffset)model.{propertyName}.Value).ToUnixTimeSeconds().ToString() }}; + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.Number(((DateTimeOffset)model.{propertyName}.Value).ToUnixTimeSeconds().ToString()); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnsupportedDictionaryHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnsupportedDictionaryHandler.cs index 8dd8cbd3..5a2dc57f 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnsupportedDictionaryHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/UnsupportedDictionaryHandler.cs @@ -25,7 +25,7 @@ public bool CanHandle(PropertyInfo propertyInfo) public string GenerateToAttributeValue(PropertyInfo propertyInfo) { // Unsupported dictionary types always serialize as NULL - return "new AttributeValue { NULL = true }"; + return "AttributeValue.Null()"; } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) From f09cfccb759e5486249b9e1409bd58ad7cef5f94 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Sun, 8 Mar 2026 22:24:30 +0000 Subject: [PATCH 04/18] Update all tests to use AttributeValue factory methods and struct semantics --- .../ContentBlockSerializerTests.cs | 109 ++--- .../Benchmarks/BatchGetItemAsyncBenchmarks.cs | 122 +++-- .../BatchWriteItemAsyncBenchmarks.cs | 167 +++++-- .../Benchmarks/DeleteItemAsyncBenchmarks.cs | 78 +++- .../Benchmarks/GetItemAsyncBenchmarks.cs | 127 ++++- .../Benchmarks/PutItemAsyncBenchmarks.cs | 217 +++++++-- .../Benchmarks/QueryAsyncBenchmarks.cs | 440 ++++++++++++++++-- .../Benchmarks/ScanAsyncBenchmarks.cs | 225 ++++++++- .../TransactGetItemsAsyncBenchmarks.cs | 114 ++++- .../TransactWriteItemsAsyncBenchmarks.cs | 246 ++++++++-- .../Benchmarks/UpdateItemAsyncBenchmarks.cs | 205 ++++++-- .../CodeGeneration/MapperGeneratorTests.cs | 30 +- .../NullAwarenessIntegrationTests.cs | 12 +- .../CollectionTypeHandlerTests.cs | 32 +- .../TypeHandlers/ComplexTypeHandlerTests.cs | 8 +- .../TypeHandlers/DateOnlyTypeHandlerTests.cs | 2 +- .../TypeHandlers/DateTimeTypeHandlerTests.cs | 4 +- .../TypeHandlers/EnumTypeHandlerTests.cs | 2 +- .../TypeHandlers/PrimitiveTypeHandlerTests.cs | 10 +- .../TypeHandlers/StringEmptyHandlingTests.cs | 4 +- .../TypeHandlers/TimeOnlyTypeHandlerTests.cs | 2 +- .../UnixTimestampTypeHandlerTests.cs | 4 +- .../BuilderChainingTests.cs | 22 +- .../DynamoClientIntegrationTests.cs | 178 ++++--- .../TypedExtensionTests.cs | 172 +++++++ 25 files changed, 2063 insertions(+), 469 deletions(-) create mode 100644 tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs diff --git a/tests/Clients/Goa.Clients.Bedrock.Conversation.Dynamo.Tests/ContentBlockSerializerTests.cs b/tests/Clients/Goa.Clients.Bedrock.Conversation.Dynamo.Tests/ContentBlockSerializerTests.cs index e70bbe1d..61f6315f 100644 --- a/tests/Clients/Goa.Clients.Bedrock.Conversation.Dynamo.Tests/ContentBlockSerializerTests.cs +++ b/tests/Clients/Goa.Clients.Bedrock.Conversation.Dynamo.Tests/ContentBlockSerializerTests.cs @@ -180,14 +180,11 @@ public async Task Serialize_EmptyBlock_ShouldReturnError() [Test] public async Task Deserialize_TextBlock_ShouldDeserializeCorrectly() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "text" }, - ["text"] = new() { S = "Hello, world!" } - } - }; + ["type"] = AttributeValue.String("text"), + ["text"] = AttributeValue.String("Hello, world!") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -198,16 +195,13 @@ public async Task Deserialize_TextBlock_ShouldDeserializeCorrectly() [Test] public async Task Deserialize_ImageBlock_ShouldDeserializeCorrectly() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "image" }, - ["format"] = new() { S = "png" }, - ["s3Uri"] = new() { S = "s3://bucket/image.png" }, - ["s3BucketOwner"] = new() { S = "123456789012" } - } - }; + ["type"] = AttributeValue.String("image"), + ["format"] = AttributeValue.String("png"), + ["s3Uri"] = AttributeValue.String("s3://bucket/image.png"), + ["s3BucketOwner"] = AttributeValue.String("123456789012") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -222,16 +216,13 @@ public async Task Deserialize_ImageBlock_ShouldDeserializeCorrectly() [Test] public async Task Deserialize_DocumentBlock_ShouldDeserializeCorrectly() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "document" }, - ["format"] = new() { S = "pdf" }, - ["name"] = new() { S = "document.pdf" }, - ["s3Uri"] = new() { S = "s3://bucket/document.pdf" } - } - }; + ["type"] = AttributeValue.String("document"), + ["format"] = AttributeValue.String("pdf"), + ["name"] = AttributeValue.String("document.pdf"), + ["s3Uri"] = AttributeValue.String("s3://bucket/document.pdf") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -245,16 +236,13 @@ public async Task Deserialize_DocumentBlock_ShouldDeserializeCorrectly() [Test] public async Task Deserialize_ToolUseBlock_ShouldDeserializeCorrectly() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "toolUse" }, - ["toolUseId"] = new() { S = "tool-123" }, - ["name"] = new() { S = "search" }, - ["input"] = new() { S = "{\"query\":\"test\"}" } - } - }; + ["type"] = AttributeValue.String("toolUse"), + ["toolUseId"] = AttributeValue.String("tool-123"), + ["name"] = AttributeValue.String("search"), + ["input"] = AttributeValue.String("{\"query\":\"test\"}") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -268,29 +256,20 @@ public async Task Deserialize_ToolUseBlock_ShouldDeserializeCorrectly() [Test] public async Task Deserialize_ToolResultBlock_ShouldDeserializeCorrectly() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "toolResult" }, - ["toolUseId"] = new() { S = "tool-123" }, - ["status"] = new() { S = "success" }, - ["content"] = new() + ["type"] = AttributeValue.String("toolResult"), + ["toolUseId"] = AttributeValue.String("tool-123"), + ["status"] = AttributeValue.String("success"), + ["content"] = AttributeValue.FromList( + [ + AttributeValue.FromMap(new Dictionary { - L = - [ - new AttributeValue - { - M = new Dictionary - { - ["type"] = new() { S = "text" }, - ["text"] = new() { S = "Result" } - } - } - ] - } - } - }; + ["type"] = AttributeValue.String("text"), + ["text"] = AttributeValue.String("Result") + }) + ]) + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -305,7 +284,7 @@ public async Task Deserialize_ToolResultBlock_ShouldDeserializeCorrectly() [Test] public async Task Deserialize_InvalidFormat_ShouldReturnError() { - var attributeValue = new AttributeValue { S = "not a map" }; + var attributeValue = AttributeValue.String("not a map"); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -316,13 +295,10 @@ public async Task Deserialize_InvalidFormat_ShouldReturnError() [Test] public async Task Deserialize_MissingType_ShouldReturnError() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["text"] = new() { S = "Hello" } - } - }; + ["text"] = AttributeValue.String("Hello") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); @@ -333,13 +309,10 @@ public async Task Deserialize_MissingType_ShouldReturnError() [Test] public async Task Deserialize_UnknownType_ShouldReturnError() { - var attributeValue = new AttributeValue + var attributeValue = AttributeValue.FromMap(new Dictionary { - M = new Dictionary - { - ["type"] = new() { S = "unknown" } - } - }; + ["type"] = AttributeValue.String("unknown") + }); var result = ContentBlockSerializer.Deserialize(attributeValue); diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchGetItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchGetItemAsyncBenchmarks.cs index 8fe886a6..465f1146 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchGetItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchGetItemAsyncBenchmarks.cs @@ -1,8 +1,12 @@ -using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Operations.Batch; using GoaModels = Goa.Clients.Dynamo.Models; +using AwsBatchGetItemRequest = Amazon.DynamoDBv2.Model.BatchGetItemRequest; +using GoaBatchGetItemRequest = Goa.Clients.Dynamo.Operations.Batch.BatchGetItemRequest; +using EfficientBatchGetItemRequest = EfficientDynamoDb.Operations.BatchGetItem.BatchGetItemRequest; +using EfficientBatchGetItemResponse = EfficientDynamoDb.Operations.BatchGetItem.BatchGetItemResponse; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -17,75 +21,127 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsAsync("batch-get-bench", 25).GetAwaiter().GetResult(); + _fixture.SeedItemsAsync("batch-get", 25).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - [Benchmark(Baseline = true), BenchmarkCategory("Batch Get 25 Items")] - public async Task AwsSdk_BatchGetItem() + private static List> CreateAwsKeys(int count) { var keys = new List>(); - for (var i = 0; i < 25; i++) + for (var i = 0; i < count; i++) { keys.Add(new Dictionary { - ["pk"] = new("batch-get-bench"), - ["sk"] = new($"item-{i:D4}") + ["pk"] = new AttributeValue("batch-get"), + ["sk"] = new AttributeValue($"item-{i:D4}") }); } + return keys; + } - return await _fixture.AwsSdkClient.BatchGetItemAsync(new BatchGetItemRequest + private static List> CreateGoaKeys(int count) + { + var keys = new List>(); + for (var i = 0; i < count; i++) { - RequestItems = new Dictionary + keys.Add(new Dictionary { - [_fixture.TableName] = new() { Keys = keys } - } - }); + ["pk"] = GoaModels.AttributeValue.String("batch-get"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}") + }); + } + return keys; } - [Benchmark, BenchmarkCategory("Batch Get 25 Items")] - public async Task Goa_BatchGetItem() + private static List> CreateEfficientKeys(int count) { - var keys = new List>(); - for (var i = 0; i < 25; i++) + var keys = new List>(); + for (var i = 0; i < count; i++) { - keys.Add(new Dictionary + keys.Add(new Dictionary { - ["pk"] = new() { S = "batch-get-bench" }, - ["sk"] = new() { S = $"item-{i:D4}" } + ["pk"] = "batch-get", + ["sk"] = $"item-{i:D4}" }); } + return keys; + } + + [Benchmark(Baseline = true), BenchmarkCategory("Batch Get 10 Items")] + public async Task AwsSdk_BatchGet_10Items() + { + return await _fixture.AwsSdkClient.BatchGetItemAsync(new AwsBatchGetItemRequest + { + RequestItems = new Dictionary + { + [_fixture.TableName] = new() { Keys = CreateAwsKeys(10) } + } + }); + } - var response = await _fixture.GoaClient.BatchGetItemAsync(new Goa.Clients.Dynamo.Operations.Batch.BatchGetItemRequest + [Benchmark, BenchmarkCategory("Batch Get 10 Items")] + public async Task Goa_BatchGet_10Items() + { + var response = await _fixture.GoaClient.BatchGetItemAsync(new GoaBatchGetItemRequest { - RequestItems = new Dictionary + RequestItems = new Dictionary { - [_fixture.TableName] = new() { Keys = keys } + [_fixture.TableName] = new() { Keys = CreateGoaKeys(10) } } }); return response.Value; } + [Benchmark, BenchmarkCategory("Batch Get 10 Items")] + public async Task Efficient_BatchGet_10Items() + { + return await _fixture.EfficientClient.BatchGetItemAsync(new EfficientBatchGetItemRequest + { + RequestItems = new Dictionary + { + [_fixture.TableName] = new() { Keys = CreateEfficientKeys(10) } + } + }); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Batch Get 25 Items")] + public async Task AwsSdk_BatchGet_25Items() + { + return await _fixture.AwsSdkClient.BatchGetItemAsync(new AwsBatchGetItemRequest + { + RequestItems = new Dictionary + { + [_fixture.TableName] = new() { Keys = CreateAwsKeys(25) } + } + }); + } + [Benchmark, BenchmarkCategory("Batch Get 25 Items")] - public async Task Efficient_BatchGetItem() + public async Task Goa_BatchGet_25Items() { - var keys = new List>(); - for (var i = 0; i < 25; i++) + var response = await _fixture.GoaClient.BatchGetItemAsync(new GoaBatchGetItemRequest { - keys.Add(new Dictionary + RequestItems = new Dictionary { - ["pk"] = "batch-get-bench", - ["sk"] = $"item-{i:D4}" - }); - } + [_fixture.TableName] = new() { Keys = CreateGoaKeys(25) } + } + }); + return response.Value; + } - return await _fixture.EfficientClient.BatchGetItemAsync(new EfficientDynamoDb.Operations.BatchGetItem.BatchGetItemRequest + [Benchmark, BenchmarkCategory("Batch Get 25 Items")] + public async Task Efficient_BatchGet_25Items() + { + return await _fixture.EfficientClient.BatchGetItemAsync(new EfficientBatchGetItemRequest { RequestItems = new Dictionary { - [_fixture.TableName] = new() { Keys = keys } + [_fixture.TableName] = new() { Keys = CreateEfficientKeys(25) } } }); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchWriteItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchWriteItemAsyncBenchmarks.cs index 20cdd8f5..6657cd7d 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchWriteItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/BatchWriteItemAsyncBenchmarks.cs @@ -1,9 +1,15 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb.DocumentModel; +using EfficientDynamoDb.Operations.BatchWriteItem; +using AwsAttributeValue = Amazon.DynamoDBv2.Model.AttributeValue; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Operations.Batch; using GoaModels = Goa.Clients.Dynamo.Models; +using AwsBatchWriteItemRequest = Amazon.DynamoDBv2.Model.BatchWriteItemRequest; +using GoaBatchWriteItemRequest = Goa.Clients.Dynamo.Operations.Batch.BatchWriteItemRequest; +using GoaPutRequest = Goa.Clients.Dynamo.Operations.Batch.PutRequest; +using EfficientBatchWriteItemRequest = EfficientDynamoDb.Operations.BatchWriteItem.BatchWriteItemRequest; +using EfficientBatchWriteItemResponse = EfficientDynamoDb.Operations.BatchWriteItem.BatchWriteItemResponse; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -21,84 +27,161 @@ public void Setup() } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - [Benchmark(Baseline = true), BenchmarkCategory("Batch Write 25 Items")] - public async Task AwsSdk_BatchWriteItem() + [Benchmark(Baseline = true), BenchmarkCategory("Batch Write 10 Items")] + public async Task AwsSdk_BatchWrite_10Items() { - var baseCounter = Interlocked.Add(ref _counter, 25); - var items = new List(); - for (var i = 0; i < 25; i++) + var batch = Interlocked.Increment(ref _counter); + var requests = new List(); + for (var i = 0; i < 10; i++) { - items.Add(new WriteRequest(new PutRequest(new Dictionary + requests.Add(new Amazon.DynamoDBv2.Model.WriteRequest(new Amazon.DynamoDBv2.Model.PutRequest( + new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-bw-{batch}"), + ["sk"] = new AwsAttributeValue($"item-{i:D4}"), + ["data"] = new AwsAttributeValue($"value-{i}") + }))); + } + return await _fixture.AwsSdkClient.BatchWriteItemAsync(new AwsBatchWriteItemRequest + { + RequestItems = new Dictionary> { - ["pk"] = new($"bw-aws-{baseCounter + i}"), - ["sk"] = new("item"), - ["data"] = new($"value-{baseCounter + i}") + [_fixture.TableName] = requests + } + }); + } + + [Benchmark, BenchmarkCategory("Batch Write 10 Items")] + public async Task Goa_BatchWrite_10Items() + { + var batch = Interlocked.Increment(ref _counter); + var requests = new List(); + for (var i = 0; i < 10; i++) + { + requests.Add(new BatchWriteRequestItem + { + PutRequest = new GoaPutRequest + { + Item = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-bw-{batch}"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}"), + ["data"] = GoaModels.AttributeValue.String($"value-{i}") + } + } + }); + } + var response = await _fixture.GoaClient.BatchWriteItemAsync(new GoaBatchWriteItemRequest + { + RequestItems = new Dictionary> + { + [_fixture.TableName] = requests + } + }); + return !response.IsError; + } + + [Benchmark, BenchmarkCategory("Batch Write 10 Items")] + public async Task Efficient_BatchWrite_10Items() + { + var batch = Interlocked.Increment(ref _counter); + var operations = new List(); + for (var i = 0; i < 10; i++) + { + operations.Add(new BatchWriteOperation(new BatchWritePutRequest(new Document + { + ["pk"] = $"eff-bw-{batch}", + ["sk"] = $"item-{i:D4}", + ["data"] = $"value-{i}" }))); } + return await _fixture.EfficientClient.BatchWriteItemAsync(new EfficientBatchWriteItemRequest + { + RequestItems = new Dictionary> + { + [_fixture.TableName] = operations + } + }); + } - return await _fixture.AwsSdkClient.BatchWriteItemAsync(new BatchWriteItemRequest + [Benchmark(Baseline = true), BenchmarkCategory("Batch Write 25 Items")] + public async Task AwsSdk_BatchWrite_25Items() + { + var batch = Interlocked.Increment(ref _counter); + var requests = new List(); + for (var i = 0; i < 25; i++) + { + requests.Add(new Amazon.DynamoDBv2.Model.WriteRequest(new Amazon.DynamoDBv2.Model.PutRequest( + new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-bw25-{batch}"), + ["sk"] = new AwsAttributeValue($"item-{i:D4}"), + ["data"] = new AwsAttributeValue($"value-{i}") + }))); + } + return await _fixture.AwsSdkClient.BatchWriteItemAsync(new AwsBatchWriteItemRequest { - RequestItems = new Dictionary> + RequestItems = new Dictionary> { - [_fixture.TableName] = items + [_fixture.TableName] = requests } }); } [Benchmark, BenchmarkCategory("Batch Write 25 Items")] - public async Task Goa_BatchWriteItem() + public async Task Goa_BatchWrite_25Items() { - var baseCounter = Interlocked.Add(ref _counter, 25); - var items = new List(); + var batch = Interlocked.Increment(ref _counter); + var requests = new List(); for (var i = 0; i < 25; i++) { - items.Add(new Goa.Clients.Dynamo.Operations.Batch.BatchWriteRequestItem + requests.Add(new BatchWriteRequestItem { - PutRequest = new Goa.Clients.Dynamo.Operations.Batch.PutRequest + PutRequest = new GoaPutRequest { Item = new Dictionary { - ["pk"] = new() { S = $"bw-goa-{baseCounter + i}" }, - ["sk"] = new() { S = "item" }, - ["data"] = new() { S = $"value-{baseCounter + i}" } + ["pk"] = GoaModels.AttributeValue.String($"goa-bw25-{batch}"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}"), + ["data"] = GoaModels.AttributeValue.String($"value-{i}") } } }); } - - var response = await _fixture.GoaClient.BatchWriteItemAsync(new Goa.Clients.Dynamo.Operations.Batch.BatchWriteItemRequest + var response = await _fixture.GoaClient.BatchWriteItemAsync(new GoaBatchWriteItemRequest { - RequestItems = new Dictionary> + RequestItems = new Dictionary> { - [_fixture.TableName] = items + [_fixture.TableName] = requests } }); - return response.Value; + return !response.IsError; } [Benchmark, BenchmarkCategory("Batch Write 25 Items")] - public async Task Efficient_BatchWriteItem() + public async Task Efficient_BatchWrite_25Items() { - var baseCounter = Interlocked.Add(ref _counter, 25); - var items = new List(); + var batch = Interlocked.Increment(ref _counter); + var operations = new List(); for (var i = 0; i < 25; i++) { - items.Add(new EfficientDynamoDb.Operations.BatchWriteItem.BatchWriteOperation( - new EfficientDynamoDb.Operations.BatchWriteItem.BatchWritePutRequest(new Document - { - ["pk"] = $"bw-eff-{baseCounter + i}", - ["sk"] = "item", - ["data"] = $"value-{baseCounter + i}" - }))); + operations.Add(new BatchWriteOperation(new BatchWritePutRequest(new Document + { + ["pk"] = $"eff-bw25-{batch}", + ["sk"] = $"item-{i:D4}", + ["data"] = $"value-{i}" + }))); } - - return await _fixture.EfficientClient.BatchWriteItemAsync(new EfficientDynamoDb.Operations.BatchWriteItem.BatchWriteItemRequest + return await _fixture.EfficientClient.BatchWriteItemAsync(new EfficientBatchWriteItemRequest { - RequestItems = new Dictionary> + RequestItems = new Dictionary> { - [_fixture.TableName] = items + [_fixture.TableName] = operations } }); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/DeleteItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/DeleteItemAsyncBenchmarks.cs index 847bcd47..b9fb5b14 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/DeleteItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/DeleteItemAsyncBenchmarks.cs @@ -4,6 +4,7 @@ using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; using GoaModels = Goa.Clients.Dynamo.Models; +using EfficientDeleteItemRequest = EfficientDynamoDb.Operations.DeleteItem.DeleteItemRequest; using EfficientDeleteItemResponse = EfficientDynamoDb.Operations.DeleteItem.DeleteItemResponse; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -11,7 +12,6 @@ namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; [MemoryDiagnoser, GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory), Orderer(SummaryOrderPolicy.FastestToSlowest)] public class DeleteItemAsyncBenchmarks { - private const int PoolSize = 10_000; private LocalStackFixture _fixture = null!; private int _counter; @@ -20,53 +20,99 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsByPkPrefixAsync("del-aws-", "item", PoolSize).GetAwaiter().GetResult(); - _fixture.SeedItemsByPkPrefixAsync("del-goa-", "item", PoolSize).GetAwaiter().GetResult(); - _fixture.SeedItemsByPkPrefixAsync("del-eff-", "item", PoolSize).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } [Benchmark(Baseline = true), BenchmarkCategory("Delete Item")] public async Task AwsSdk_DeleteItem() { - var i = Interlocked.Increment(ref _counter); + // Delete a non-existent item (no-op but exercises full request path) + var id = Interlocked.Increment(ref _counter); return await _fixture.AwsSdkClient.DeleteItemAsync(new DeleteItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new($"del-aws-{i}"), - ["sk"] = new("item") + ["pk"] = new AttributeValue($"del-aws-{id}"), + ["sk"] = new AttributeValue("item") } }); } [Benchmark, BenchmarkCategory("Delete Item")] - public async Task Goa_DeleteItem() + public async Task Goa_DeleteItem() { - var i = Interlocked.Increment(ref _counter); + var id = Interlocked.Increment(ref _counter); var response = await _fixture.GoaClient.DeleteItemAsync(new Goa.Clients.Dynamo.Operations.DeleteItem.DeleteItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new() { S = $"del-goa-{i}" }, - ["sk"] = new() { S = "item" } + ["pk"] = GoaModels.AttributeValue.String($"del-goa-{id}"), + ["sk"] = GoaModels.AttributeValue.String("item") } }); - return response.Value; + return !response.IsError; } [Benchmark, BenchmarkCategory("Delete Item")] public async Task Efficient_DeleteItem() { - var i = Interlocked.Increment(ref _counter); - return await _fixture.EfficientClient.DeleteItemAsync(new EfficientDynamoDb.Operations.DeleteItem.DeleteItemRequest + var id = Interlocked.Increment(ref _counter); + return await _fixture.EfficientClient.DeleteItemAsync(new EfficientDeleteItemRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", $"del-eff-{id}", "sk", "item") + }); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Delete Item With Return Values")] + public async Task AwsSdk_DeleteItem_WithReturnValues() + { + var id = Interlocked.Increment(ref _counter); + return await _fixture.AwsSdkClient.DeleteItemAsync(new DeleteItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AttributeValue($"delret-aws-{id}"), + ["sk"] = new AttributeValue("item") + }, + ReturnValues = ReturnValue.ALL_OLD + }); + } + + [Benchmark, BenchmarkCategory("Delete Item With Return Values")] + public async Task Goa_DeleteItem_WithReturnValues() + { + var id = Interlocked.Increment(ref _counter); + var response = await _fixture.GoaClient.DeleteItemAsync(new Goa.Clients.Dynamo.Operations.DeleteItem.DeleteItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"delret-goa-{id}"), + ["sk"] = GoaModels.AttributeValue.String("item") + }, + ReturnValues = Goa.Clients.Dynamo.Enums.ReturnValues.ALL_OLD + }); + return response.Value.Attributes; + } + + [Benchmark, BenchmarkCategory("Delete Item With Return Values")] + public async Task Efficient_DeleteItem_WithReturnValues() + { + var id = Interlocked.Increment(ref _counter); + return await _fixture.EfficientClient.DeleteItemAsync(new EfficientDeleteItemRequest { TableName = _fixture.TableName, - Key = new PrimaryKey("pk", $"del-eff-{i}", "sk", "item") + Key = new PrimaryKey("pk", $"delret-eff-{id}", "sk", "item"), + ReturnValues = ReturnValues.AllOld }); } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs index 14bfe278..e3656b4c 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs @@ -1,8 +1,8 @@ -using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Benchmarks.Models; using GoaModels = Goa.Clients.Dynamo.Models; using EfficientGetItemResponse = EfficientDynamoDb.Operations.GetItem.GetItemResponse; @@ -18,11 +18,16 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); + + // Seed one item for "hit" benchmarks _fixture.SeedItemsAsync("get-bench", 1).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } [Benchmark(Baseline = true), BenchmarkCategory("Get Item")] public async Task AwsSdk_GetItem() @@ -32,27 +37,52 @@ public async Task AwsSdk_GetItem() TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new("get-bench"), - ["sk"] = new("item-0000") + ["pk"] = new AttributeValue("get-bench"), + ["sk"] = new AttributeValue("item-0000") } }); } [Benchmark, BenchmarkCategory("Get Item")] - public async Task Goa_GetItem() + public async Task Goa_GetItem_DynamoRecord() { var response = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new() { S = "get-bench" }, - ["sk"] = new() { S = "item-0000" } + ["pk"] = GoaModels.AttributeValue.String("get-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0000") } }); return response.Value.Item; } + [Benchmark, BenchmarkCategory("Get Item")] + public async Task Goa_GetItem_Typed() + { + var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("get-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0000") + } + }, DynamoItemReaderRegistry.Get()); + return result.Value; + } + + [Benchmark, BenchmarkCategory("Get Item")] + public async Task Efficient_GetItem() + { + return await _fixture.EfficientClient.GetItemAsync(new EfficientDynamoDb.Operations.GetItem.GetItemRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", "get-bench", "sk", "item-0000") + }); + } + [Benchmark(Baseline = true), BenchmarkCategory("Get Item Miss")] public async Task AwsSdk_GetItem_Miss() { @@ -61,35 +91,40 @@ public async Task AwsSdk_GetItem_Miss() TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new("nonexistent"), - ["sk"] = new("nonexistent") + ["pk"] = new AttributeValue("nonexistent"), + ["sk"] = new AttributeValue("nonexistent") } }); } [Benchmark, BenchmarkCategory("Get Item Miss")] - public async Task Goa_GetItem_Miss() + public async Task Goa_GetItem_Miss_DynamoRecord() { var response = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new() { S = "nonexistent" }, - ["sk"] = new() { S = "nonexistent" } + ["pk"] = GoaModels.AttributeValue.String("nonexistent"), + ["sk"] = GoaModels.AttributeValue.String("nonexistent") } }); return response.Value.Item; } - [Benchmark, BenchmarkCategory("Get Item")] - public async Task Efficient_GetItem() + [Benchmark, BenchmarkCategory("Get Item Miss")] + public async Task Goa_GetItem_Miss_Typed() { - return await _fixture.EfficientClient.GetItemAsync(new EfficientDynamoDb.Operations.GetItem.GetItemRequest + var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest { TableName = _fixture.TableName, - Key = new PrimaryKey("pk", "get-bench", "sk", "item-0000") - }); + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("nonexistent"), + ["sk"] = GoaModels.AttributeValue.String("nonexistent") + } + }, DynamoItemReaderRegistry.Get()); + return result.Value; } [Benchmark, BenchmarkCategory("Get Item Miss")] @@ -101,4 +136,62 @@ public async Task Efficient_GetItem_Miss() Key = new PrimaryKey("pk", "nonexistent", "sk", "nonexistent") }); } + + [Benchmark(Baseline = true), BenchmarkCategory("Get Item Consistent Read")] + public async Task AwsSdk_GetItem_ConsistentRead() + { + return await _fixture.AwsSdkClient.GetItemAsync(new GetItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AttributeValue("get-bench"), + ["sk"] = new AttributeValue("item-0000") + }, + ConsistentRead = true + }); + } + + [Benchmark, BenchmarkCategory("Get Item Consistent Read")] + public async Task Goa_GetItem_ConsistentRead_DynamoRecord() + { + var response = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("get-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0000") + }, + ConsistentRead = true + }); + return response.Value.Item; + } + + [Benchmark, BenchmarkCategory("Get Item Consistent Read")] + public async Task Goa_GetItem_ConsistentRead_Typed() + { + var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("get-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0000") + }, + ConsistentRead = true + }, DynamoItemReaderRegistry.Get()); + return result.Value; + } + + [Benchmark, BenchmarkCategory("Get Item Consistent Read")] + public async Task Efficient_GetItem_ConsistentRead() + { + return await _fixture.EfficientClient.GetItemAsync(new EfficientDynamoDb.Operations.GetItem.GetItemRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", "get-bench", "sk", "item-0000"), + ConsistentRead = true + }); + } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/PutItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/PutItemAsyncBenchmarks.cs index f95f7f50..e17f3341 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/PutItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/PutItemAsyncBenchmarks.cs @@ -1,9 +1,9 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb.DocumentModel; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; using GoaModels = Goa.Clients.Dynamo.Models; +using AwsAttributeValue = Amazon.DynamoDBv2.Model.AttributeValue; +using EfficientPutItemRequest = EfficientDynamoDb.Operations.PutItem.PutItemRequest; using EfficientPutItemResponse = EfficientDynamoDb.Operations.PutItem.PutItemResponse; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -22,59 +22,214 @@ public void Setup() } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Put Item Simple")] + public async Task AwsSdk_PutItem_Simple() + { + var id = Interlocked.Increment(ref _counter); + return await _fixture.AwsSdkClient.PutItemAsync(new Amazon.DynamoDBv2.Model.PutItemRequest + { + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-put-{id}"), + ["sk"] = new AwsAttributeValue("simple"), + ["data"] = new AwsAttributeValue("test-value"), + ["number"] = new AwsAttributeValue { N = "42" } + } + }); + } + + [Benchmark, BenchmarkCategory("Put Item Simple")] + public async Task Goa_PutItem_Simple() + { + var id = Interlocked.Increment(ref _counter); + var response = await _fixture.GoaClient.PutItemAsync(new Goa.Clients.Dynamo.Operations.PutItem.PutItemRequest + { + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-put-{id}"), + ["sk"] = GoaModels.AttributeValue.String("simple"), + ["data"] = GoaModels.AttributeValue.String("test-value"), + ["number"] = GoaModels.AttributeValue.Number("42") + } + }); + return !response.IsError; + } + + [Benchmark, BenchmarkCategory("Put Item Simple")] + public async Task Efficient_PutItem_Simple() + { + var id = Interlocked.Increment(ref _counter); + return await _fixture.EfficientClient.PutItemAsync(new EfficientPutItemRequest + { + TableName = _fixture.TableName, + Item = new Document + { + ["pk"] = $"eff-put-{id}", + ["sk"] = "simple", + ["data"] = "test-value", + ["number"] = 42 + } + }); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Put Item Wide")] + public async Task AwsSdk_PutItem_WideItem() + { + var id = Interlocked.Increment(ref _counter); + var item = new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-wide-{id}"), + ["sk"] = new AwsAttributeValue("wide") + }; + for (var i = 0; i < 20; i++) + { + item[$"attr_{i}"] = new AwsAttributeValue($"value-{i}-{new string('x', 100)}"); + } + return await _fixture.AwsSdkClient.PutItemAsync(new Amazon.DynamoDBv2.Model.PutItemRequest + { + TableName = _fixture.TableName, + Item = item + }); + } + + [Benchmark, BenchmarkCategory("Put Item Wide")] + public async Task Goa_PutItem_WideItem() + { + var id = Interlocked.Increment(ref _counter); + var item = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-wide-{id}"), + ["sk"] = GoaModels.AttributeValue.String("wide") + }; + for (var i = 0; i < 20; i++) + { + item[$"attr_{i}"] = GoaModels.AttributeValue.String($"value-{i}-{new string('x', 100)}"); + } + var response = await _fixture.GoaClient.PutItemAsync(new Goa.Clients.Dynamo.Operations.PutItem.PutItemRequest + { + TableName = _fixture.TableName, + Item = item + }); + return !response.IsError; + } + + [Benchmark, BenchmarkCategory("Put Item Wide")] + public async Task Efficient_PutItem_WideItem() + { + var id = Interlocked.Increment(ref _counter); + var item = new Document + { + ["pk"] = $"eff-wide-{id}", + ["sk"] = "wide" + }; + for (var i = 0; i < 20; i++) + { + item[$"attr_{i}"] = $"value-{i}-{new string('x', 100)}"; + } + return await _fixture.EfficientClient.PutItemAsync(new EfficientPutItemRequest + { + TableName = _fixture.TableName, + Item = item + }); + } - [Benchmark(Baseline = true), BenchmarkCategory("Put Item")] - public async Task AwsSdk_PutItem() + [Benchmark(Baseline = true), BenchmarkCategory("Put Item Complex")] + public async Task AwsSdk_PutItem_ComplexItem() { - var i = Interlocked.Increment(ref _counter); - return await _fixture.AwsSdkClient.PutItemAsync(new PutItemRequest + var id = Interlocked.Increment(ref _counter); + return await _fixture.AwsSdkClient.PutItemAsync(new Amazon.DynamoDBv2.Model.PutItemRequest { TableName = _fixture.TableName, - Item = new Dictionary + Item = new Dictionary { - ["pk"] = new($"put-aws-{i}"), - ["sk"] = new("item"), - ["data"] = new($"value-{i}"), - ["number"] = new() { N = i.ToString() }, - ["status"] = new("active") + ["pk"] = new AwsAttributeValue($"aws-complex-{id}"), + ["sk"] = new AwsAttributeValue("complex"), + ["data"] = new AwsAttributeValue("test"), + ["tags"] = new AwsAttributeValue { SS = ["tag1", "tag2", "tag3"] }, + ["scores"] = new AwsAttributeValue { NS = ["1", "2", "3"] }, + ["metadata"] = new AwsAttributeValue + { + M = new Dictionary + { + ["nested1"] = new AwsAttributeValue("val1"), + ["nested2"] = new AwsAttributeValue { N = "99" } + } + }, + ["items"] = new AwsAttributeValue + { + L = + [ + new AwsAttributeValue("listval1"), + new AwsAttributeValue { N = "42" }, + new AwsAttributeValue { BOOL = true } + ] + } } }); } - [Benchmark, BenchmarkCategory("Put Item")] - public async Task Goa_PutItem() + [Benchmark, BenchmarkCategory("Put Item Complex")] + public async Task Goa_PutItem_ComplexItem() { - var i = Interlocked.Increment(ref _counter); + var id = Interlocked.Increment(ref _counter); var response = await _fixture.GoaClient.PutItemAsync(new Goa.Clients.Dynamo.Operations.PutItem.PutItemRequest { TableName = _fixture.TableName, Item = new Dictionary { - ["pk"] = new() { S = $"put-goa-{i}" }, - ["sk"] = new() { S = "item" }, - ["data"] = new() { S = $"value-{i}" }, - ["number"] = new() { N = i.ToString() }, - ["status"] = new() { S = "active" } + ["pk"] = GoaModels.AttributeValue.String($"goa-complex-{id}"), + ["sk"] = GoaModels.AttributeValue.String("complex"), + ["data"] = GoaModels.AttributeValue.String("test"), + ["tags"] = GoaModels.AttributeValue.FromStringSet(["tag1", "tag2", "tag3"]), + ["scores"] = GoaModels.AttributeValue.FromNumberSet(["1", "2", "3"]), + ["metadata"] = GoaModels.AttributeValue.FromMap(new Dictionary + { + ["nested1"] = GoaModels.AttributeValue.String("val1"), + ["nested2"] = GoaModels.AttributeValue.Number("99") + }), + ["items"] = GoaModels.AttributeValue.FromList( + [ + GoaModels.AttributeValue.String("listval1"), + GoaModels.AttributeValue.Number("42"), + GoaModels.AttributeValue.Bool(true) + ]) } }); - return response.Value; + return !response.IsError; } - [Benchmark, BenchmarkCategory("Put Item")] - public async Task Efficient_PutItem() + [Benchmark, BenchmarkCategory("Put Item Complex")] + public async Task Efficient_PutItem_ComplexItem() { - var i = Interlocked.Increment(ref _counter); - return await _fixture.EfficientClient.PutItemAsync(new EfficientDynamoDb.Operations.PutItem.PutItemRequest + var id = Interlocked.Increment(ref _counter); + return await _fixture.EfficientClient.PutItemAsync(new EfficientPutItemRequest { TableName = _fixture.TableName, Item = new Document { - ["pk"] = $"put-eff-{i}", - ["sk"] = "item", - ["data"] = $"value-{i}", - ["number"] = i, - ["status"] = "active" + ["pk"] = $"eff-complex-{id}", + ["sk"] = "complex", + ["data"] = "test", + ["tags"] = new StringSetAttributeValue(new HashSet { "tag1", "tag2", "tag3" }), + ["scores"] = new NumberSetAttributeValue(new HashSet { "1", "2", "3" }), + ["metadata"] = new Document + { + ["nested1"] = "val1", + ["nested2"] = new NumberAttributeValue("99") + }, + ["items"] = new ListAttributeValue(new List + { + new StringAttributeValue("listval1"), + new NumberAttributeValue("42"), + new BoolAttributeValue(true) + }) } }); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index a5b3595c..feef2891 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -1,17 +1,37 @@ -using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb; +using EfficientDynamoDb.Attributes; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; using Goa.Clients.Dynamo.Benchmarks.Models; +using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; using GoaModels = Goa.Clients.Dynamo.Models; using GoaQueryRequest = Goa.Clients.Dynamo.Operations.Query.QueryRequest; using EfficientQueryRequest = EfficientDynamoDb.Operations.Query.QueryRequest; -using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; -[MemoryDiagnoser, GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory), Orderer(SummaryOrderPolicy.FastestToSlowest)] +[DynamoDbTable("benchmark-table")] +public class BenchmarkEntity +{ + [DynamoDbProperty("pk", DynamoDbAttributeType.PartitionKey)] + public string Pk { get; set; } = ""; + + [DynamoDbProperty("sk", DynamoDbAttributeType.SortKey)] + public string Sk { get; set; } = ""; + + [DynamoDbProperty("data")] + public string Data { get; set; } = ""; + + [DynamoDbProperty("number")] + public int Number { get; set; } + + [DynamoDbProperty("status")] + public string Status { get; set; } = ""; +} + +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory), Orderer(SummaryOrderPolicy.FastestToSlowest)] public class QueryAsyncBenchmarks { private LocalStackFixture _fixture = null!; @@ -21,28 +41,36 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsAsync("query-bench", 100).GetAwaiter().GetResult(); + + // Seed items for query benchmarks + _fixture.SeedItemsAsync("query-1", 1).GetAwaiter().GetResult(); + _fixture.SeedItemsAsync("query-10", 10).GetAwaiter().GetResult(); + _fixture.SeedItemsAsync("query-100", 100).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - [Benchmark(Baseline = true), BenchmarkCategory("Query 100 Items")] - public async Task AwsSdk_Query() + // --- Single item query --- + + [Benchmark(Baseline = true), BenchmarkCategory("1 Item")] + public async Task AwsSdk_Query_1Item() { var count = 0; Dictionary? lastKey = null; do { - var response = await _fixture.AwsSdkClient.QueryAsync(new QueryRequest + var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest { TableName = _fixture.TableName, KeyConditionExpression = "pk = :pk", ExpressionAttributeValues = new Dictionary { - [":pk"] = new("query-bench") + [":pk"] = new AttributeValue("query-1") }, - Limit = 20, ExclusiveStartKey = lastKey }); count += response.Items.Count; @@ -51,8 +79,8 @@ public async Task AwsSdk_Query() return count; } - [Benchmark, BenchmarkCategory("Query 100 Items")] - public async Task Goa_Query() + [Benchmark, BenchmarkCategory("1 Item")] + public async Task Goa_Query_1Item_DynamoRecord() { var count = 0; Dictionary? lastKey = null; @@ -64,19 +92,42 @@ public async Task Goa_Query() KeyConditionExpression = "pk = :pk", ExpressionAttributeValues = new Dictionary { - [":pk"] = new() { S = "query-bench" } + [":pk"] = GoaModels.AttributeValue.String("query-1") }, - Limit = 20, ExclusiveStartKey = lastKey }); + count += response.Value.Items.Count; lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; } while (lastKey != null); return count; } - [Benchmark, BenchmarkCategory("Query 100 Items")] - public async Task Efficient_Query() + [Benchmark, BenchmarkCategory("1 Item")] + public async Task Goa_Query_1Item_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-1") + }, + ExclusiveStartKey = lastKey + }, DynamoItemReaderRegistry.Get()); + count += result.Value.Items.Count; + lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("1 Item")] + public async Task Efficient_Query_1Item() { var count = 0; IReadOnlyDictionary? lastKey = null; @@ -88,9 +139,8 @@ public async Task Efficient_Query() KeyConditionExpression = "pk = :pk", ExpressionAttributeValues = new Dictionary { - [":pk"] = "query-bench" + [":pk"] = "query-1" }, - Limit = 20, ExclusiveStartKey = lastKey }); count += response.Items.Count; @@ -99,21 +149,345 @@ public async Task Efficient_Query() return count; } - [Benchmark, BenchmarkCategory("Query 100 Items")] - public async Task Efficient_Query_Typed() + [Benchmark, BenchmarkCategory("1 Item")] + public async Task Efficient_Query_1Item_Typed() { - var count = 0; - string? paginationToken = null; - do - { - var page = await _fixture.EfficientContext.Query() - .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-bench")) - .WithLimit(20) - .WithPaginationToken(paginationToken) - .ToPageAsync(); - count += page.Items.Count; - paginationToken = page.PaginationToken; - } while (paginationToken != null); - return count; + return (await _fixture.EfficientContext.Query() + .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-1")) + .ToListAsync()).Count; } + + // --- 10 item query (Limit=5, forces 2 pages) --- + + // [Benchmark(Baseline = true), BenchmarkCategory("10 Items")] + // public async Task AwsSdk_Query_10Items() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = new AttributeValue("query-10") + // }, + // Limit = 5, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("10 Items")] + // public async Task Goa_Query_10Items_DynamoRecord() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = GoaModels.AttributeValue.String("query-10") + // }, + // Limit = 5, + // ExclusiveStartKey = lastKey + // }); + // count += response.Value.Items.Count; + // lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("10 Items")] + // public async Task Goa_Query_10Items_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-10") + // }, + // Limit = 5, + // ExclusiveStartKey = lastKey + // }, DynamoItemReaderRegistry.Get()); + // count += result.Value.Items.Count; + // lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("10 Items")] + // public async Task Efficient_Query_10Items() + // { + // var count = 0; + // IReadOnlyDictionary? lastKey = null; + // do + // { + // var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = "query-10" + // }, + // Limit = 5, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("10 Items")] + // public async Task Efficient_Query_10Items_Typed() + // { + // var count = 0; + // string? paginationToken = null; + // do + // { + // var page = await _fixture.EfficientContext.Query() + // .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-10")) + // .WithLimit(5) + // .WithPaginationToken(paginationToken) + // .ToPageAsync(); + // count += page.Items.Count; + // paginationToken = page.PaginationToken; + // } while (paginationToken != null); + // return count; + // } + // + // // --- 100 item query (Limit=25, forces 4 pages) --- + // + // [Benchmark(Baseline = true), BenchmarkCategory("100 Items")] + // public async Task AwsSdk_Query_100Items() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = new AttributeValue("query-100") + // }, + // Limit = 25, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("100 Items")] + // public async Task Goa_Query_100Items_DynamoRecord() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = 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 + // }); + // count += response.Value.Items.Count; + // lastKey = response.Value.HasMoreResults ? response.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() + // { + // var count = 0; + // IReadOnlyDictionary? lastKey = null; + // do + // { + // var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = "query-100" + // }, + // Limit = 25, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("100 Items")] + // public async Task Efficient_Query_100Items_Typed() + // { + // var count = 0; + // string? paginationToken = null; + // do + // { + // var page = await _fixture.EfficientContext.Query() + // .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-100")) + // .WithLimit(25) + // .WithPaginationToken(paginationToken) + // .ToPageAsync(); + // count += page.Items.Count; + // paginationToken = page.PaginationToken; + // } while (paginationToken != null); + // return count; + // } + // + // // --- Empty query --- + // + // [Benchmark(Baseline = true), BenchmarkCategory("No Results")] + // public async Task AwsSdk_Query_NoResults() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = new AttributeValue("nonexistent-pk") + // }, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("No Results")] + // public async Task Goa_Query_NoResults_DynamoRecord() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = GoaModels.AttributeValue.String("nonexistent-pk") + // }, + // ExclusiveStartKey = lastKey + // }); + // count += response.Value.Items.Count; + // lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("No Results")] + // public async Task Goa_Query_NoResults_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("nonexistent-pk") + // }, + // ExclusiveStartKey = lastKey + // }, DynamoItemReaderRegistry.Get()); + // count += result.Value.Items.Count; + // lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("No Results")] + // public async Task Efficient_Query_NoResults() + // { + // var count = 0; + // IReadOnlyDictionary? lastKey = null; + // do + // { + // var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = "nonexistent-pk" + // }, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("No Results")] + // public async Task Efficient_Query_NoResults_Typed() + // { + // return (await _fixture.EfficientContext.Query() + // .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("nonexistent-pk")) + // .ToListAsync()).Count; + // } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs index 414e2e20..2090fa15 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs @@ -1,11 +1,13 @@ -using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; +using EfficientDynamoDb.DocumentModel; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Benchmarks.Models; using GoaModels = Goa.Clients.Dynamo.Models; using GoaScanRequest = Goa.Clients.Dynamo.Operations.Scan.ScanRequest; using EfficientScanRequest = EfficientDynamoDb.Operations.Scan.ScanRequest; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; +using AwsAttributeValue = Amazon.DynamoDBv2.Model.AttributeValue; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -19,23 +21,31 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsAsync("scan-bench", 100).GetAwaiter().GetResult(); + + // Seed items spread across different partition keys for scan + _fixture.SeedItemsAsync("scan-a", 50).GetAwaiter().GetResult(); + _fixture.SeedItemsAsync("scan-b", 50).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + // --- Scan (100 items, Limit=25, forces 4 pages) --- [Benchmark(Baseline = true), BenchmarkCategory("Scan")] public async Task AwsSdk_Scan() { var count = 0; - Dictionary? lastKey = null; + Dictionary? lastKey = null; do { var response = await _fixture.AwsSdkClient.ScanAsync(new ScanRequest { TableName = _fixture.TableName, - Limit = 20, + Limit = 25, ExclusiveStartKey = lastKey }); count += response.Items.Count; @@ -45,7 +55,7 @@ public async Task AwsSdk_Scan() } [Benchmark, BenchmarkCategory("Scan")] - public async Task Goa_Scan() + public async Task Goa_Scan_DynamoRecord() { var count = 0; Dictionary? lastKey = null; @@ -54,7 +64,7 @@ public async Task Goa_Scan() var response = await _fixture.GoaClient.ScanAsync(new GoaScanRequest { TableName = _fixture.TableName, - Limit = 20, + Limit = 25, ExclusiveStartKey = lastKey }); count += response.Value.Items.Count; @@ -63,6 +73,25 @@ public async Task Goa_Scan() return count; } + [Benchmark, BenchmarkCategory("Scan")] + public async Task Goa_Scan_Typed() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest + { + TableName = _fixture.TableName, + 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("Scan")] public async Task Efficient_Scan() { @@ -73,7 +102,187 @@ public async Task Efficient_Scan() var response = await _fixture.EfficientClient.ScanAsync(new EfficientScanRequest { TableName = _fixture.TableName, - Limit = 20, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey; + } while (lastKey != null); + return count; + } + + // --- Scan With Filter (Limit=25, paginate through filtered results) --- + + [Benchmark(Baseline = true), BenchmarkCategory("Scan With Filter")] + public async Task AwsSdk_Scan_WithFilter() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = await _fixture.AwsSdkClient.ScanAsync(new ScanRequest + { + TableName = _fixture.TableName, + FilterExpression = "#n > :minNum", + ExpressionAttributeNames = new Dictionary { ["#n"] = "number" }, + ExpressionAttributeValues = new Dictionary + { + [":minNum"] = new AwsAttributeValue { N = "25" } + }, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("Scan With Filter")] + public async Task Goa_Scan_WithFilter_DynamoRecord() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = await _fixture.GoaClient.ScanAsync(new GoaScanRequest + { + TableName = _fixture.TableName, + FilterExpression = "#n > :minNum", + ExpressionAttributeNames = new Dictionary { ["#n"] = "number" }, + ExpressionAttributeValues = new Dictionary + { + [":minNum"] = GoaModels.AttributeValue.Number("25") + }, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Value.Items.Count; + lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("Scan With Filter")] + public async Task Goa_Scan_WithFilter_Typed() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest + { + TableName = _fixture.TableName, + FilterExpression = "#n > :minNum", + ExpressionAttributeNames = new Dictionary { ["#n"] = "number" }, + ExpressionAttributeValues = new Dictionary + { + [":minNum"] = GoaModels.AttributeValue.Number("25") + }, + 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("Scan With Filter")] + public async Task Efficient_Scan_WithFilter() + { + var count = 0; + IReadOnlyDictionary? lastKey = null; + do + { + var response = await _fixture.EfficientClient.ScanAsync(new EfficientScanRequest + { + TableName = _fixture.TableName, + FilterExpression = "#n > :minNum", + ExpressionAttributeNames = new Dictionary { ["#n"] = "number" }, + ExpressionAttributeValues = new Dictionary + { + [":minNum"] = new NumberAttributeValue("25") + }, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey; + } while (lastKey != null); + return count; + } + + // --- Scan With Limit (Limit=10, paginate through all results) --- + + [Benchmark(Baseline = true), BenchmarkCategory("Scan With Limit")] + public async Task AwsSdk_Scan_WithLimit() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = await _fixture.AwsSdkClient.ScanAsync(new ScanRequest + { + TableName = _fixture.TableName, + Limit = 10, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("Scan With Limit")] + public async Task Goa_Scan_WithLimit_DynamoRecord() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = await _fixture.GoaClient.ScanAsync(new GoaScanRequest + { + TableName = _fixture.TableName, + Limit = 10, + ExclusiveStartKey = lastKey + }); + count += response.Value.Items.Count; + lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("Scan With Limit")] + public async Task Goa_Scan_WithLimit_Typed() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest + { + TableName = _fixture.TableName, + Limit = 10, + ExclusiveStartKey = lastKey + }, DynamoItemReaderRegistry.Get()); + count += result.Value.Items.Count; + lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("Scan With Limit")] + public async Task Efficient_Scan_WithLimit() + { + var count = 0; + IReadOnlyDictionary? lastKey = null; + do + { + var response = await _fixture.EfficientClient.ScanAsync(new EfficientScanRequest + { + TableName = _fixture.TableName, + Limit = 10, ExclusiveStartKey = lastKey }); count += response.Items.Count; diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactGetItemsAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactGetItemsAsyncBenchmarks.cs index d0ada961..b06f9423 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactGetItemsAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactGetItemsAsyncBenchmarks.cs @@ -1,9 +1,12 @@ -using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Operations.Transactions; using GoaModels = Goa.Clients.Dynamo.Models; +using EfficientTransactGetItemsRequest = EfficientDynamoDb.Operations.TransactGetItems.TransactGetItemsRequest; +using EfficientTransactGetItemsResponse = EfficientDynamoDb.Operations.TransactGetItems.TransactGetItemsResponse; +using EfficientTransactGetRequest = EfficientDynamoDb.Operations.TransactGetItems.TransactGetRequest; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -17,32 +20,103 @@ public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsAsync("txget-bench", 10).GetAwaiter().GetResult(); + _fixture.SeedItemsAsync("transact-get", 10).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Transact Get 5 Items")] + public async Task AwsSdk_TransactGet_5Items() + { + var items = new List(); + for (var i = 0; i < 5; i++) + { + items.Add(new Amazon.DynamoDBv2.Model.TransactGetItem + { + Get = new Get + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AttributeValue("transact-get"), + ["sk"] = new AttributeValue($"item-{i:D4}") + } + } + }); + } + return await _fixture.AwsSdkClient.TransactGetItemsAsync(new TransactGetItemsRequest + { + TransactItems = items + }); + } + + [Benchmark, BenchmarkCategory("Transact Get 5 Items")] + public async Task Goa_TransactGet_5Items() + { + var items = new List(); + for (var i = 0; i < 5; i++) + { + items.Add(new Goa.Clients.Dynamo.Operations.Transactions.TransactGetItem + { + Get = new TransactGetItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("transact-get"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}") + } + } + }); + } + var response = await _fixture.GoaClient.TransactGetItemsAsync(new TransactGetRequest + { + TransactItems = items + }); + return response.Value; + } + + [Benchmark, BenchmarkCategory("Transact Get 5 Items")] + public async Task Efficient_TransactGet_5Items() + { + var items = new List(); + for (var i = 0; i < 5; i++) + { + items.Add(new EfficientTransactGetRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", "transact-get", "sk", $"item-{i:D4}") + }); + } + return await _fixture.EfficientClient.TransactGetItemsAsync(new EfficientTransactGetItemsRequest + { + TransactItems = items + }); + } [Benchmark(Baseline = true), BenchmarkCategory("Transact Get 10 Items")] - public async Task AwsSdk_TransactGetItems() + public async Task AwsSdk_TransactGet_10Items() { - var items = new List(); + var items = new List(); for (var i = 0; i < 10; i++) { - items.Add(new TransactGetItem + items.Add(new Amazon.DynamoDBv2.Model.TransactGetItem { Get = new Get { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new("txget-bench"), - ["sk"] = new($"item-{i:D4}") + ["pk"] = new AttributeValue("transact-get"), + ["sk"] = new AttributeValue($"item-{i:D4}") } } }); } - return await _fixture.AwsSdkClient.TransactGetItemsAsync(new TransactGetItemsRequest { TransactItems = items @@ -50,26 +124,25 @@ public async Task AwsSdk_TransactGetItems() } [Benchmark, BenchmarkCategory("Transact Get 10 Items")] - public async Task Goa_TransactGetItems() + public async Task Goa_TransactGet_10Items() { var items = new List(); for (var i = 0; i < 10; i++) { items.Add(new Goa.Clients.Dynamo.Operations.Transactions.TransactGetItem { - Get = new Goa.Clients.Dynamo.Operations.Transactions.TransactGetItemRequest + Get = new TransactGetItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new() { S = "txget-bench" }, - ["sk"] = new() { S = $"item-{i:D4}" } + ["pk"] = GoaModels.AttributeValue.String("transact-get"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}") } } }); } - - var response = await _fixture.GoaClient.TransactGetItemsAsync(new Goa.Clients.Dynamo.Operations.Transactions.TransactGetRequest + var response = await _fixture.GoaClient.TransactGetItemsAsync(new TransactGetRequest { TransactItems = items }); @@ -77,19 +150,18 @@ public async Task AwsSdk_TransactGetItems() } [Benchmark, BenchmarkCategory("Transact Get 10 Items")] - public async Task Efficient_TransactGetItems() + public async Task Efficient_TransactGet_10Items() { - var items = new List(); + var items = new List(); for (var i = 0; i < 10; i++) { - items.Add(new EfficientDynamoDb.Operations.TransactGetItems.TransactGetRequest + items.Add(new EfficientTransactGetRequest { TableName = _fixture.TableName, - Key = new PrimaryKey("pk", "txget-bench", "sk", $"item-{i:D4}") + Key = new PrimaryKey("pk", "transact-get", "sk", $"item-{i:D4}") }); } - - return await _fixture.EfficientClient.TransactGetItemsAsync(new EfficientDynamoDb.Operations.TransactGetItems.TransactGetItemsRequest + return await _fixture.EfficientClient.TransactGetItemsAsync(new EfficientTransactGetItemsRequest { TransactItems = items }); diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactWriteItemsAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactWriteItemsAsyncBenchmarks.cs index 531ffca8..1673f229 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactWriteItemsAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/TransactWriteItemsAsyncBenchmarks.cs @@ -1,9 +1,18 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; using BenchmarkDotNet.Order; using EfficientDynamoDb.DocumentModel; +using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; +using Goa.Clients.Dynamo.Operations.Transactions; using GoaModels = Goa.Clients.Dynamo.Models; +using AwsTransactWriteItem = Amazon.DynamoDBv2.Model.TransactWriteItem; +using EfficientTransactWriteItemsRequest = EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItemsRequest; +using EfficientTransactWriteItemsResponse = EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItemsResponse; +using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; +using AwsTransactWriteItemsRequest = Amazon.DynamoDBv2.Model.TransactWriteItemsRequest; +using AwsTransactWriteItemsResponse = Amazon.DynamoDBv2.Model.TransactWriteItemsResponse; +using AwsAttributeValue = Amazon.DynamoDBv2.Model.AttributeValue; +using AwsPut = Amazon.DynamoDBv2.Model.Put; +using AwsUpdate = Amazon.DynamoDBv2.Model.Update; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -21,88 +30,241 @@ public void Setup() } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - [Benchmark(Baseline = true), BenchmarkCategory("Transact Write 10 Items")] - public async Task AwsSdk_TransactWriteItems() + [Benchmark(Baseline = true), BenchmarkCategory("Transact Write 5 Items")] + public async Task AwsSdk_TransactWrite_5Items() { - var baseCounter = Interlocked.Add(ref _counter, 10); - var items = new List(); - for (var i = 0; i < 10; i++) + var batch = Interlocked.Increment(ref _counter); + var items = new List(); + for (var i = 0; i < 5; i++) { - items.Add(new TransactWriteItem + items.Add(new AwsTransactWriteItem { - Put = new Put + Put = new AwsPut { TableName = _fixture.TableName, - Item = new Dictionary + Item = new Dictionary { - ["pk"] = new($"txw-aws-{baseCounter + i}"), - ["sk"] = new("item"), - ["data"] = new($"value-{baseCounter + i}") + ["pk"] = new AwsAttributeValue($"aws-tw-{batch}"), + ["sk"] = new AwsAttributeValue($"item-{i:D4}"), + ["data"] = new AwsAttributeValue($"value-{i}") } } }); } - - return await _fixture.AwsSdkClient.TransactWriteItemsAsync(new TransactWriteItemsRequest + return await _fixture.AwsSdkClient.TransactWriteItemsAsync(new AwsTransactWriteItemsRequest { TransactItems = items }); } - [Benchmark, BenchmarkCategory("Transact Write 10 Items")] - public async Task Goa_TransactWriteItems() + [Benchmark, BenchmarkCategory("Transact Write 5 Items")] + public async Task Goa_TransactWrite_5Items() { - var baseCounter = Interlocked.Add(ref _counter, 10); + var batch = Interlocked.Increment(ref _counter); var items = new List(); - for (var i = 0; i < 10; i++) + for (var i = 0; i < 5; i++) { items.Add(new Goa.Clients.Dynamo.Operations.Transactions.TransactWriteItem { - Put = new Goa.Clients.Dynamo.Operations.Transactions.TransactPutItem + Put = new TransactPutItem { TableName = _fixture.TableName, Item = new Dictionary { - ["pk"] = new() { S = $"txw-goa-{baseCounter + i}" }, - ["sk"] = new() { S = "item" }, - ["data"] = new() { S = $"value-{baseCounter + i}" } + ["pk"] = GoaModels.AttributeValue.String($"goa-tw-{batch}"), + ["sk"] = GoaModels.AttributeValue.String($"item-{i:D4}"), + ["data"] = GoaModels.AttributeValue.String($"value-{i}") } } }); } - - var response = await _fixture.GoaClient.TransactWriteItemsAsync(new Goa.Clients.Dynamo.Operations.Transactions.TransactWriteRequest + var response = await _fixture.GoaClient.TransactWriteItemsAsync(new TransactWriteRequest { TransactItems = items }); - return response.Value; + return !response.IsError; } - [Benchmark, BenchmarkCategory("Transact Write 10 Items")] - public async Task Efficient_TransactWriteItems() + [Benchmark, BenchmarkCategory("Transact Write 5 Items")] + public async Task Efficient_TransactWrite_5Items() { - var baseCounter = Interlocked.Add(ref _counter, 10); + var batch = Interlocked.Increment(ref _counter); var items = new List(); - for (var i = 0; i < 10; i++) + for (var i = 0; i < 5; i++) { - items.Add(new EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItem( - new EfficientDynamoDb.Operations.TransactWriteItems.TransactPutItem + items.Add(new EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItem(new EfficientDynamoDb.Operations.TransactWriteItems.TransactPutItem + { + TableName = _fixture.TableName, + Item = new Document { - TableName = _fixture.TableName, - Item = new Document + ["pk"] = $"eff-tw-{batch}", + ["sk"] = $"item-{i:D4}", + ["data"] = $"value-{i}" + } + })); + } + return await _fixture.EfficientClient.TransactWriteItemsAsync(new EfficientTransactWriteItemsRequest + { + TransactItems = items + }); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Transact Write Mixed Ops")] + public async Task AwsSdk_TransactWrite_MixedOps() + { + var batch = Interlocked.Increment(ref _counter); + // First seed an item to update/delete + await _fixture.AwsSdkClient.PutItemAsync(new Amazon.DynamoDBv2.Model.PutItemRequest + { + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-twm-{batch}"), + ["sk"] = new AwsAttributeValue("existing"), + ["data"] = new AwsAttributeValue("seed") + } + }); + + return await _fixture.AwsSdkClient.TransactWriteItemsAsync(new AwsTransactWriteItemsRequest + { + TransactItems = + [ + new AwsTransactWriteItem + { + Put = new AwsPut { - ["pk"] = $"txw-eff-{baseCounter + i}", - ["sk"] = "item", - ["data"] = $"value-{baseCounter + i}" + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-twm-{batch}"), + ["sk"] = new AwsAttributeValue("new-item"), + ["data"] = new AwsAttributeValue("created") + } } - })); - } + }, + new AwsTransactWriteItem + { + Update = new AwsUpdate + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AwsAttributeValue($"aws-twm-{batch}"), + ["sk"] = new AwsAttributeValue("existing") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = new AwsAttributeValue("updated") + } + } + } + ] + }); + } - return await _fixture.EfficientClient.TransactWriteItemsAsync(new EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItemsRequest + [Benchmark, BenchmarkCategory("Transact Write Mixed Ops")] + public async Task Goa_TransactWrite_MixedOps() + { + var batch = Interlocked.Increment(ref _counter); + await _fixture.GoaClient.PutItemAsync(new Goa.Clients.Dynamo.Operations.PutItem.PutItemRequest { - TransactItems = items + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-twm-{batch}"), + ["sk"] = GoaModels.AttributeValue.String("existing"), + ["data"] = GoaModels.AttributeValue.String("seed") + } + }); + + var response = await _fixture.GoaClient.TransactWriteItemsAsync(new TransactWriteRequest + { + TransactItems = + [ + new Goa.Clients.Dynamo.Operations.Transactions.TransactWriteItem + { + Put = new TransactPutItem + { + TableName = _fixture.TableName, + Item = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-twm-{batch}"), + ["sk"] = GoaModels.AttributeValue.String("new-item"), + ["data"] = GoaModels.AttributeValue.String("created") + } + } + }, + new Goa.Clients.Dynamo.Operations.Transactions.TransactWriteItem + { + Update = new TransactUpdateItem + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String($"goa-twm-{batch}"), + ["sk"] = GoaModels.AttributeValue.String("existing") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = GoaModels.AttributeValue.String("updated") + } + } + } + ] + }); + return !response.IsError; + } + + [Benchmark, BenchmarkCategory("Transact Write Mixed Ops")] + public async Task Efficient_TransactWrite_MixedOps() + { + var batch = Interlocked.Increment(ref _counter); + await _fixture.EfficientClient.PutItemAsync(new EfficientDynamoDb.Operations.PutItem.PutItemRequest + { + TableName = _fixture.TableName, + Item = new Document + { + ["pk"] = $"eff-twm-{batch}", + ["sk"] = "existing", + ["data"] = "seed" + } + }); + + return await _fixture.EfficientClient.TransactWriteItemsAsync(new EfficientTransactWriteItemsRequest + { + TransactItems = + [ + new EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItem(new EfficientDynamoDb.Operations.TransactWriteItems.TransactPutItem + { + TableName = _fixture.TableName, + Item = new Document + { + ["pk"] = $"eff-twm-{batch}", + ["sk"] = "new-item", + ["data"] = "created" + } + }), + new EfficientDynamoDb.Operations.TransactWriteItems.TransactWriteItem(new EfficientDynamoDb.Operations.TransactWriteItems.TransactUpdateItem + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", $"eff-twm-{batch}", "sk", "existing"), + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = "updated" + } + }) + ] }); } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/UpdateItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/UpdateItemAsyncBenchmarks.cs index 9c4de7ad..e56a97c7 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/UpdateItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/UpdateItemAsyncBenchmarks.cs @@ -4,8 +4,9 @@ using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; using GoaModels = Goa.Clients.Dynamo.Models; -using EfficientUpdateItemResponse = EfficientDynamoDb.Operations.UpdateItem.UpdateItemResponse; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; +using EfficientUpdateItemRequest = EfficientDynamoDb.Operations.UpdateItem.UpdateItemRequest; +using EfficientUpdateItemResponse = EfficientDynamoDb.Operations.UpdateItem.UpdateItemResponse; namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; @@ -13,76 +14,224 @@ namespace Goa.Clients.Dynamo.Benchmarks.Benchmarks; public class UpdateItemAsyncBenchmarks { private LocalStackFixture _fixture = null!; - private int _counter; [GlobalSetup] public void Setup() { _fixture = new LocalStackFixture(); _fixture.StartAsync().GetAwaiter().GetResult(); - _fixture.SeedItemsAsync("update-bench", 1).GetAwaiter().GetResult(); + + // Seed items for update benchmarks + _fixture.SeedItemsAsync("update-bench", 10).GetAwaiter().GetResult(); } [GlobalCleanup] - public void Cleanup() => _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public void Cleanup() + { + _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - [Benchmark(Baseline = true), BenchmarkCategory("Update Item")] - public async Task AwsSdk_UpdateItem() + [Benchmark(Baseline = true), BenchmarkCategory("Update Single Attribute")] + public async Task AwsSdk_UpdateItem_SingleAttribute() { - var i = Interlocked.Increment(ref _counter); return await _fixture.AwsSdkClient.UpdateItemAsync(new UpdateItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new("update-bench"), - ["sk"] = new("item-0000") + ["pk"] = new AttributeValue("update-bench"), + ["sk"] = new AttributeValue("item-0000") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data" }, - UpdateExpression = "SET #d = :d", - ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, ExpressionAttributeValues = new Dictionary { - [":d"] = new($"updated-{i}") + [":val"] = new AttributeValue("updated-value") } }); } - [Benchmark, BenchmarkCategory("Update Item")] - public async Task Goa_UpdateItem() + [Benchmark, BenchmarkCategory("Update Single Attribute")] + public async Task Goa_UpdateItem_SingleAttribute() { - var i = Interlocked.Increment(ref _counter); var response = await _fixture.GoaClient.UpdateItemAsync(new Goa.Clients.Dynamo.Operations.UpdateItem.UpdateItemRequest { TableName = _fixture.TableName, Key = new Dictionary { - ["pk"] = new() { S = "update-bench" }, - ["sk"] = new() { S = "item-0000" } + ["pk"] = GoaModels.AttributeValue.String("update-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0000") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data" }, - UpdateExpression = "SET #d = :d", - ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, ExpressionAttributeValues = new Dictionary { - [":d"] = new() { S = $"updated-{i}" } + [":val"] = GoaModels.AttributeValue.String("updated-value") } }); - return response.Value; + return !response.IsError; } - [Benchmark, BenchmarkCategory("Update Item")] - public async Task Efficient_UpdateItem() + [Benchmark, BenchmarkCategory("Update Single Attribute")] + public async Task Efficient_UpdateItem_SingleAttribute() { - var i = Interlocked.Increment(ref _counter); - return await _fixture.EfficientClient.UpdateItemAsync(new EfficientDynamoDb.Operations.UpdateItem.UpdateItemRequest + return await _fixture.EfficientClient.UpdateItemAsync(new EfficientUpdateItemRequest { TableName = _fixture.TableName, Key = new PrimaryKey("pk", "update-bench", "sk", "item-0000"), - UpdateExpression = "SET #d = :d", - ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data" + }, ExpressionAttributeValues = new Dictionary { - [":d"] = $"updated-{i}" + [":val"] = "updated-value" } }); } + + [Benchmark(Baseline = true), BenchmarkCategory("Update Multiple Attributes")] + public async Task AwsSdk_UpdateItem_MultipleAttributes() + { + return await _fixture.AwsSdkClient.UpdateItemAsync(new UpdateItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AttributeValue("update-bench"), + ["sk"] = new AttributeValue("item-0001") + }, + UpdateExpression = "SET #d = :val, #s = :status, #n = #n + :inc", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data", + ["#s"] = "status", + ["#n"] = "number" + }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = new AttributeValue("multi-updated"), + [":status"] = new AttributeValue("modified"), + [":inc"] = new AttributeValue { N = "1" } + } + }); + } + + [Benchmark, BenchmarkCategory("Update Multiple Attributes")] + public async Task Goa_UpdateItem_MultipleAttributes() + { + var response = await _fixture.GoaClient.UpdateItemAsync(new Goa.Clients.Dynamo.Operations.UpdateItem.UpdateItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("update-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0001") + }, + UpdateExpression = "SET #d = :val, #s = :status, #n = #n + :inc", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data", + ["#s"] = "status", + ["#n"] = "number" + }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = GoaModels.AttributeValue.String("multi-updated"), + [":status"] = GoaModels.AttributeValue.String("modified"), + [":inc"] = GoaModels.AttributeValue.Number("1") + } + }); + return !response.IsError; + } + + [Benchmark, BenchmarkCategory("Update Multiple Attributes")] + public async Task Efficient_UpdateItem_MultipleAttributes() + { + return await _fixture.EfficientClient.UpdateItemAsync(new EfficientUpdateItemRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", "update-bench", "sk", "item-0001"), + UpdateExpression = "SET #d = :val, #s = :status, #n = #n + :inc", + ExpressionAttributeNames = new Dictionary + { + ["#d"] = "data", + ["#s"] = "status", + ["#n"] = "number" + }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = "multi-updated", + [":status"] = "modified", + [":inc"] = new EfficientDynamoDb.DocumentModel.NumberAttributeValue("1") + } + }); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Update With Return Values")] + public async Task AwsSdk_UpdateItem_WithReturnValues() + { + return await _fixture.AwsSdkClient.UpdateItemAsync(new UpdateItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = new AttributeValue("update-bench"), + ["sk"] = new AttributeValue("item-0002") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = new AttributeValue("return-val") + }, + ReturnValues = ReturnValue.ALL_NEW + }); + } + + [Benchmark, BenchmarkCategory("Update With Return Values")] + public async Task Goa_UpdateItem_WithReturnValues() + { + var response = await _fixture.GoaClient.UpdateItemAsync(new Goa.Clients.Dynamo.Operations.UpdateItem.UpdateItemRequest + { + TableName = _fixture.TableName, + Key = new Dictionary + { + ["pk"] = GoaModels.AttributeValue.String("update-bench"), + ["sk"] = GoaModels.AttributeValue.String("item-0002") + }, + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = GoaModels.AttributeValue.String("return-val") + }, + ReturnValues = Goa.Clients.Dynamo.Enums.ReturnValues.ALL_NEW + }); + return response.Value.Attributes; + } + + [Benchmark, BenchmarkCategory("Update With Return Values")] + public async Task Efficient_UpdateItem_WithReturnValues() + { + return await _fixture.EfficientClient.UpdateItemAsync(new EfficientUpdateItemRequest + { + TableName = _fixture.TableName, + Key = new PrimaryKey("pk", "update-bench", "sk", "item-0002"), + UpdateExpression = "SET #d = :val", + ExpressionAttributeNames = new Dictionary { ["#d"] = "data" }, + ExpressionAttributeValues = new Dictionary + { + [":val"] = "return-val" + }, + ReturnValues = EfficientDynamoDb.Operations.Shared.ReturnValues.AllNew + }); + } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs index 8cfa683b..01f08637 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/CodeGeneration/MapperGeneratorTests.cs @@ -103,9 +103,9 @@ public async Task GenerateCode_WithPrimaryKeys_ShouldGenerateKeyAssignments() // Assert await Assert.That(result) - .Contains("record[\"PK\"] = new AttributeValue { S = $\"USER#{ model.Id?.ToString() ?? \"\" }\" };"); + .Contains("record[\"PK\"] = AttributeValue.String($\"USER#{ model.Id?.ToString() ?? \"\" }\");"); await Assert.That(result) - .Contains("record[\"SK\"] = new AttributeValue { S = \"METADATA\" };"); + .Contains("record[\"SK\"] = AttributeValue.String(\"METADATA\");"); } [Test] @@ -140,9 +140,9 @@ public async Task GenerateCode_WithCustomPKSKNames_ShouldUseCustomNames() // Assert await Assert.That(result) - .Contains("record[\"HashKey\"] = new AttributeValue { S = $\"USER#{ model.Id?.ToString() ?? \"\" }\" };"); + .Contains("record[\"HashKey\"] = AttributeValue.String($\"USER#{ model.Id?.ToString() ?? \"\" }\");"); await Assert.That(result) - .Contains("record[\"RangeKey\"] = new AttributeValue { S = \"METADATA\" };"); + .Contains("record[\"RangeKey\"] = AttributeValue.String(\"METADATA\");"); } [Test] @@ -184,9 +184,9 @@ public async Task GenerateCode_WithGSI_ShouldGenerateGSIAssignments() // Assert - Based on actual generated output await Assert.That(result) - .Contains("record[\"GSI1PK\"] = new AttributeValue { S = $\"EMAIL#{ model.Email?.ToString() ?? \"\" }\" };"); + .Contains("record[\"GSI1PK\"] = AttributeValue.String($\"EMAIL#{ model.Email?.ToString() ?? \"\" }\");"); await Assert.That(result) - .Contains("record[\"GSI1SK\"] = new AttributeValue { S = $\"USER#{ model.Id?.ToString() ?? \"\" }\" };"); + .Contains("record[\"GSI1SK\"] = AttributeValue.String($\"USER#{ model.Id?.ToString() ?? \"\" }\");"); } [Test] @@ -219,13 +219,17 @@ public async Task GenerateCode_WithPropertyMappings_ShouldGeneratePropertyCode() // Act var result = _generator.GenerateCode(types, context); - // Assert - Based on actual output from debug test + // Assert - Strings use conditional assignment to skip empty strings, Age uses direct assignment + await Assert.That(result) + .Contains("if (!string.IsNullOrEmpty(model.Id))"); + await Assert.That(result) + .Contains("record[\"Id\"] = AttributeValue.String(model.Id);"); await Assert.That(result) - .Contains("record[\"Id\"] = new AttributeValue { S = model.Id };"); + .Contains("if (!string.IsNullOrEmpty(model.Name))"); await Assert.That(result) - .Contains("record[\"Name\"] = new AttributeValue { S = model.Name };"); + .Contains("record[\"Name\"] = AttributeValue.String(model.Name);"); await Assert.That(result) - .Contains("record[\"Age\"] = new AttributeValue { N = model.Age.ToString(CultureInfo.InvariantCulture) };"); + .Contains("record[\"Age\"] = AttributeValue.Number(model.Age.ToString(CultureInfo.InvariantCulture));"); } [Test] @@ -260,11 +264,11 @@ public async Task GenerateCode_WithComplexProperties_ShouldUseTypeHandlers() // Assert // DateTime should use DateTimeTypeHandler await Assert.That(result) - .Contains("record[\"CreatedAt\"] = new AttributeValue { S = model.CreatedAt.ToString(\"o\") };"); + .Contains("record[\"CreatedAt\"] = AttributeValue.String(model.CreatedAt.ToString(\"o\"));"); // UnixTimestamp should use UnixTimestampTypeHandler await Assert.That(result) - .Contains("record[\"UpdatedAt\"] = new AttributeValue { N = ((DateTimeOffset)model.UpdatedAt).ToUnixTimeSeconds().ToString() };"); + .Contains("record[\"UpdatedAt\"] = AttributeValue.Number(((DateTimeOffset)model.UpdatedAt).ToUnixTimeSeconds().ToString());"); } [Test] @@ -303,7 +307,7 @@ public async Task GenerateCode_WithCollectionProperties_ShouldUseCollectionHandl // Assert - Based on actual CollectionTypeHandler output await Assert.That(result) - .Contains("record[\"Tags\"] = (model.Tags != null && model.Tags.Any() ? new AttributeValue { SS = model.Tags.ToList() } : new AttributeValue { NULL = true });"); + .Contains("record[\"Tags\"] = (model.Tags != null && model.Tags.Any() ? AttributeValue.FromStringSet(model.Tags.ToList()) : AttributeValue.Null());"); } [Test] diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/Integration/NullAwarenessIntegrationTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/Integration/NullAwarenessIntegrationTests.cs index 3f6f5fbe..4eab0874 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/Integration/NullAwarenessIntegrationTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/Integration/NullAwarenessIntegrationTests.cs @@ -72,7 +72,7 @@ public async Task NonNullableIntProperty_ShouldGenerateCorrectToAttributeValueCo var result = _typeHandlerRegistry.GenerateToAttributeValue(property); - var expected = "new AttributeValue { N = model.Id.ToString(CultureInfo.InvariantCulture) }"; + var expected = "AttributeValue.Number(model.Id.ToString(CultureInfo.InvariantCulture))"; await Assert.That(result).IsEqualTo(expected); } @@ -144,13 +144,13 @@ public async Task CompleteModel_ShouldGenerateCorrectMapperCode() // Verify the generated code contains correct null handling // Non-nullable strings now use conditional assignment to skip empty strings await Assert.That(generatedCode).Contains("if (!string.IsNullOrEmpty(model.RequiredName))"); - await Assert.That(generatedCode).Contains("record[\"RequiredName\"] = new AttributeValue { S = model.RequiredName };"); + await Assert.That(generatedCode).Contains("record[\"RequiredName\"] = AttributeValue.String(model.RequiredName);"); // Nullable strings also use conditional assignment with IsNullOrEmpty check await Assert.That(generatedCode).Contains("if (!string.IsNullOrEmpty(model.OptionalDescription))"); - await Assert.That(generatedCode).Contains("record[\"OptionalDescription\"] = new AttributeValue { S = model.OptionalDescription };"); + await Assert.That(generatedCode).Contains("record[\"OptionalDescription\"] = AttributeValue.String(model.OptionalDescription);"); // Nullable properties use conditional assignment for sparse GSI compatibility await Assert.That(generatedCode).Contains("if (model.OptionalCount.HasValue)"); - await Assert.That(generatedCode).Contains("new AttributeValue { N = model.OptionalCount.Value.ToString(CultureInfo.InvariantCulture) }"); + await Assert.That(generatedCode).Contains("AttributeValue.Number(model.OptionalCount.Value.ToString(CultureInfo.InvariantCulture))"); // Verify MissingAttributeException for non-nullable types await Assert.That(generatedCode).Contains("MissingAttributeException.Throw(\"RequiredName\""); @@ -181,7 +181,7 @@ public async Task DateOnlyProperties_ShouldHandleNullabilityCorrectly() await Assert.That(nullableResult).IsNull(); await Assert.That(nonNullableResult).DoesNotContain("HasValue"); - await Assert.That(nonNullableResult).DoesNotContain("NULL = true"); + await Assert.That(nonNullableResult).DoesNotContain("AttributeValue.Null()"); } [Test] @@ -446,7 +446,7 @@ await Assert.That(toAttributeValue) .Because("Should check each item for null before converting"); await Assert.That(toAttributeValue) - .Contains("NULL = true") + .Contains("AttributeValue.Null()") .Because("Null items should be preserved as DynamoDB NULL attributes"); await Assert.That(toAttributeValue) diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs index 1358f8c0..9765bdb4 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs @@ -118,7 +118,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateStringSet_ForStringColl var property = TestModelBuilders.CreateCollectionPropertyInfo(propName, collectionType, MockSymbolFactory.PrimitiveTypes.String); var result = _handler.GenerateToAttributeValue(property); - var expected = $"(model.{propName} != null && model.{propName}.Any() ? new AttributeValue {{ SS = model.{propName}.ToList() }} : new AttributeValue {{ NULL = true }})"; + var expected = $"(model.{propName} != null && model.{propName}.Any() ? AttributeValue.FromStringSet(model.{propName}.ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because($"String collections should generate SS (String Set) attribute value only when non-empty"); @@ -140,7 +140,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateNumberSet_ForNumericCol var property = TestModelBuilders.CreateCollectionPropertyInfo(propName, collectionType, elementType); var result = _handler.GenerateToAttributeValue(property); - var expected = $"(model.{propName} != null && model.{propName}.Any() ? new AttributeValue {{ NS = model.{propName}.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList() }} : new AttributeValue {{ NULL = true }})"; + var expected = $"(model.{propName} != null && model.{propName}.Any() ? AttributeValue.FromNumberSet(model.{propName}.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because($"Numeric collections should generate NS (Number Set) attribute value only when non-empty, with invariant culture"); @@ -155,7 +155,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateStringSet_ForBooleanCol var result = _handler.GenerateToAttributeValue(property); - var expected = "(model.BoolArray != null && model.BoolArray.Any() ? new AttributeValue { SS = model.BoolArray.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })"; + var expected = "(model.BoolArray != null && model.BoolArray.Any() ? AttributeValue.FromStringSet(model.BoolArray.Select(x => x.ToString()).ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because("Boolean collections should generate SS with ToString() conversion only when non-empty"); @@ -169,7 +169,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateStringSet_ForGuidCollec var result = _handler.GenerateToAttributeValue(property); - var expected = "(model.GuidArray != null && model.GuidArray.Any() ? new AttributeValue { SS = model.GuidArray.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })"; + var expected = "(model.GuidArray != null && model.GuidArray.Any() ? AttributeValue.FromStringSet(model.GuidArray.Select(x => x.ToString()).ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because("Guid collections should generate SS with ToString() conversion only when non-empty"); @@ -183,7 +183,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateStringSet_ForDateTimeCo var result = _handler.GenerateToAttributeValue(property); - var expected = "(model.DateTimeArray != null && model.DateTimeArray.Any() ? new AttributeValue { SS = model.DateTimeArray.Select(x => x.ToString(\"o\")).ToList() } : new AttributeValue { NULL = true })"; + var expected = "(model.DateTimeArray != null && model.DateTimeArray.Any() ? AttributeValue.FromStringSet(model.DateTimeArray.Select(x => x.ToString(\"o\")).ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because("DateTime collections should generate SS with ISO format only when non-empty"); @@ -202,7 +202,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateStringSet_ForEnumCollec var result = _handler.GenerateToAttributeValue(property); - var expected = "(model.PriorityArray != null && model.PriorityArray.Any() ? new AttributeValue { SS = model.PriorityArray.Select(x => x.ToString()).ToList() } : new AttributeValue { NULL = true })"; + var expected = "(model.PriorityArray != null && model.PriorityArray.Any() ? AttributeValue.FromStringSet(model.PriorityArray.Select(x => x.ToString()).ToList()) : AttributeValue.Null())"; await Assert.That(result) .IsEqualTo(expected) .Because("Enum collections should generate SS with ToString() conversion only when non-empty"); @@ -383,7 +383,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateMapList_ForComplexColle var result = _handler.GenerateToAttributeValue(property); - var expected = "model.CustomList != null ? new AttributeValue { L = model.CustomList.Select(item => (item != null ? new AttributeValue { M = DynamoMapper.CustomClass.ToDynamoRecord(item) } : new AttributeValue { NULL = true })).ToList() } : new AttributeValue { NULL = true }"; + var expected = "model.CustomList != null ? AttributeValue.FromList(model.CustomList.Select(item => (item != null ? AttributeValue.FromMap(DynamoMapper.CustomClass.ToDynamoRecord(item)) : AttributeValue.Null())).ToList()) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected) .Because("Complex type collections should generate L (List) attribute with M (Map) elements and handle null items"); @@ -446,7 +446,7 @@ await Assert.That(result) .Because("Complex type collections should check for null items before converting"); await Assert.That(result) - .Contains("new AttributeValue { NULL = true }") + .Contains("AttributeValue.Null()") .Because("Null items should be represented as DynamoDB NULL attribute"); } @@ -471,8 +471,8 @@ public async Task GenerateToAttributeValue_NestedStringCollection_TwoLevels_Shou // Assert await Assert.That(result) - .Contains("L =") - .Because("Nested collections should use L (List) attribute type"); + .Contains("AttributeValue.FromList(") + .Because("Nested collections should use List attribute type"); await Assert.That(result) .IsNotNull() @@ -500,8 +500,8 @@ public async Task GenerateToAttributeValue_NestedIntCollection_TwoLevels_ShouldG // Assert await Assert.That(result) - .Contains("L =") - .Because("Nested numeric collections should use L (List) attribute type"); + .Contains("AttributeValue.FromList(") + .Because("Nested numeric collections should use List attribute type"); await Assert.That(result) .IsNotNull() @@ -533,7 +533,7 @@ await Assert.That(result) .Because("Nested collections should check for null inner collections"); await Assert.That(result) - .Contains("NULL = true") + .Contains("AttributeValue.Null()") .Because("Null inner collections should be represented as DynamoDB NULL"); } @@ -639,7 +639,7 @@ await Assert.That(result) .Because("Three-level nested collections should be supported"); await Assert.That(result) - .Contains("L =") + .Contains("AttributeValue.FromList(") .Because("Deep nested collections should use List attribute type"); } @@ -656,11 +656,11 @@ public async Task GenerateToAttributeValue_CollectionOfNullableElements_ShouldNo // Assert await Assert.That(result) - .Contains("item != null ? new AttributeValue { M =") + .Contains("item != null ? AttributeValue.FromMap(") .Because("Should check each item for null"); await Assert.That(result) - .Contains(": new AttributeValue { NULL = true }") + .Contains(": AttributeValue.Null()") .Because("Null items should be preserved as NULL attributes, not filtered out"); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs index 1cff4877..74822cb9 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs @@ -209,7 +209,7 @@ public async Task GenerateToAttributeValue_ComplexType_ShouldGenerateCorrectCode var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "model.HomeAddress != null ? new AttributeValue { M = DynamoMapper.Address.ToDynamoRecord(model.HomeAddress) } : new AttributeValue { NULL = true }"; + var expected = "model.HomeAddress != null ? AttributeValue.FromMap(DynamoMapper.Address.ToDynamoRecord(model.HomeAddress)) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected); } @@ -236,7 +236,7 @@ public async Task GenerateToAttributeValue_StringStringDictionary_ShouldGenerate var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "model.Metadata != null ? new AttributeValue { M = model.Metadata.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { S = kvp.Value }) } : new AttributeValue { NULL = true }"; + var expected = "model.Metadata != null ? AttributeValue.FromMap(model.Metadata.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value))) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected); } @@ -263,7 +263,7 @@ public async Task GenerateToAttributeValue_StringIntDictionary_ShouldGenerateCor var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "model.Scores != null ? new AttributeValue { M = model.Scores.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { N = kvp.Value.ToString(CultureInfo.InvariantCulture) }) } : new AttributeValue { NULL = true }"; + var expected = "model.Scores != null ? AttributeValue.FromMap(model.Scores.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.Number(kvp.Value.ToString(CultureInfo.InvariantCulture)))) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected); } @@ -296,7 +296,7 @@ public async Task GenerateToAttributeValue_StringListDictionary_ShouldGenerateCo var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "model.TagsByCategory != null ? new AttributeValue { M = model.TagsByCategory.ToDictionary(kvp => kvp.Key, kvp => new AttributeValue { SS = kvp.Value ?? new List() }) } : new AttributeValue { NULL = true }"; + var expected = "model.TagsByCategory != null ? AttributeValue.FromMap(model.TagsByCategory.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.FromStringSet(kvp.Value ?? new List()))) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateOnlyTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateOnlyTypeHandlerTests.cs index 5c586fc9..fc620c1a 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateOnlyTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateOnlyTypeHandlerTests.cs @@ -61,7 +61,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateCorrectCode_ForNonNulla var result = _handler.GenerateToAttributeValue(property); - var expected = "new AttributeValue { S = model.CreatedDate.ToString(\"yyyy-MM-dd\") }"; + var expected = "AttributeValue.String(model.CreatedDate.ToString(\"yyyy-MM-dd\"))"; await Assert.That(result).IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateTimeTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateTimeTypeHandlerTests.cs index 2fe97733..3fd94b43 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateTimeTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/DateTimeTypeHandlerTests.cs @@ -123,7 +123,7 @@ public async Task GenerateToAttributeValue_NonNullableDateTime_ShouldGenerateCor var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "new AttributeValue { S = model.CreatedAt.ToString(\"o\") }"; + var expected = "AttributeValue.String(model.CreatedAt.ToString(\"o\"))"; await Assert.That(result) .IsEqualTo(expected); } @@ -161,7 +161,7 @@ public async Task GenerateToAttributeValue_NonNullableDateTimeOffset_ShouldGener var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "new AttributeValue { S = model.CreatedAt.ToString(\"o\") }"; + var expected = "AttributeValue.String(model.CreatedAt.ToString(\"o\"))"; await Assert.That(result) .IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/EnumTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/EnumTypeHandlerTests.cs index faa00e8f..57a32135 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/EnumTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/EnumTypeHandlerTests.cs @@ -80,7 +80,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateCorrectCode_ForNonNulla var result = _handler.GenerateToAttributeValue(property); - var expected = "new AttributeValue { S = model.Status.ToString() }"; + var expected = "AttributeValue.String(model.Status.ToString())"; await Assert.That(result).IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/PrimitiveTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/PrimitiveTypeHandlerTests.cs index 77016664..b78c8b9c 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/PrimitiveTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/PrimitiveTypeHandlerTests.cs @@ -63,12 +63,12 @@ public async Task GenerateToAttributeValue_ShouldGenerateCorrectCode_ForNonNulla { var testCases = new[] { - (MockSymbolFactory.PrimitiveTypes.Boolean, "BoolProp", "new AttributeValue { BOOL = model.BoolProp }"), - (MockSymbolFactory.PrimitiveTypes.Int32, "IntProp", "new AttributeValue { N = model.IntProp.ToString(CultureInfo.InvariantCulture) }"), + (MockSymbolFactory.PrimitiveTypes.Boolean, "BoolProp", "AttributeValue.Bool(model.BoolProp)"), + (MockSymbolFactory.PrimitiveTypes.Int32, "IntProp", "AttributeValue.Number(model.IntProp.ToString(CultureInfo.InvariantCulture))"), (MockSymbolFactory.PrimitiveTypes.String, "StringProp", (string?)null), // Strings now use conditional assignment to skip empty strings - (MockSymbolFactory.PrimitiveTypes.Decimal, "DecimalProp", "new AttributeValue { N = model.DecimalProp.ToString(CultureInfo.InvariantCulture) }"), - (MockSymbolFactory.PrimitiveTypes.Double, "DoubleProp", "new AttributeValue { N = model.DoubleProp.ToString(CultureInfo.InvariantCulture) }"), - (MockSymbolFactory.PrimitiveTypes.Guid, "GuidProp", "new AttributeValue { S = model.GuidProp.ToString() }") + (MockSymbolFactory.PrimitiveTypes.Decimal, "DecimalProp", "AttributeValue.Number(model.DecimalProp.ToString(CultureInfo.InvariantCulture))"), + (MockSymbolFactory.PrimitiveTypes.Double, "DoubleProp", "AttributeValue.Number(model.DoubleProp.ToString(CultureInfo.InvariantCulture))"), + (MockSymbolFactory.PrimitiveTypes.Guid, "GuidProp", "AttributeValue.String(model.GuidProp.ToString())") }; foreach (var (type, propName, expectedCode) in testCases) diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/StringEmptyHandlingTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/StringEmptyHandlingTests.cs index ca79fe81..e17ab67a 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/StringEmptyHandlingTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/StringEmptyHandlingTests.cs @@ -75,7 +75,7 @@ await Assert.That(result) .Because("Non-nullable strings should check for both null and empty"); await Assert.That(result) - .Contains("record[\"Name\"] = new AttributeValue { S = model.Name };") + .Contains("record[\"Name\"] = AttributeValue.String(model.Name);") .Because("Should assign S attribute when string is not null or empty"); } @@ -101,7 +101,7 @@ await Assert.That(result) .Because("Nullable strings should check for both null and empty"); await Assert.That(result) - .Contains("record[\"Description\"] = new AttributeValue { S = model.Description };") + .Contains("record[\"Description\"] = AttributeValue.String(model.Description);") .Because("Should assign S attribute when string is not null or empty"); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/TimeOnlyTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/TimeOnlyTypeHandlerTests.cs index b7c6cdaf..dae73af1 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/TimeOnlyTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/TimeOnlyTypeHandlerTests.cs @@ -61,7 +61,7 @@ public async Task GenerateToAttributeValue_ShouldGenerateCorrectCode_ForNonNulla var result = _handler.GenerateToAttributeValue(property); - var expected = "new AttributeValue { S = model.StartTime.ToString(\"HH:mm:ss.fffffff\") }"; + var expected = "AttributeValue.String(model.StartTime.ToString(\"HH:mm:ss.fffffff\"))"; await Assert.That(result).IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/UnixTimestampTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/UnixTimestampTypeHandlerTests.cs index ac615862..6ca862c8 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/UnixTimestampTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/UnixTimestampTypeHandlerTests.cs @@ -107,7 +107,7 @@ public async Task GenerateToAttributeValue_NonNullableDateTime_SecondsFormat_Sho var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "new AttributeValue { N = ((DateTimeOffset)model.CreatedAt).ToUnixTimeSeconds().ToString() }"; + var expected = "AttributeValue.Number(((DateTimeOffset)model.CreatedAt).ToUnixTimeSeconds().ToString())"; await Assert.That(result) .IsEqualTo(expected); } @@ -127,7 +127,7 @@ public async Task GenerateToAttributeValue_NonNullableDateTime_MillisecondsForma var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "new AttributeValue { N = ((DateTimeOffset)model.CreatedAt).ToUnixTimeMilliseconds().ToString() }"; + var expected = "AttributeValue.Number(((DateTimeOffset)model.CreatedAt).ToUnixTimeMilliseconds().ToString())"; await Assert.That(result) .IsEqualTo(expected); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs index 43fa2cf5..23c91b7b 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/BuilderChainingTests.cs @@ -121,8 +121,8 @@ public async Task QueryBuilder_WithExclusiveStartKey_SetsKey() { var startKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" }, - ["sk"] = new AttributeValue { S = "lastSk" } + ["pk"] = AttributeValue.String("lastPk"), + ["sk"] = AttributeValue.String("lastSk") }; var builder = new QueryBuilder("TestTable") @@ -167,7 +167,7 @@ public async Task ScanBuilder_WithExclusiveStartKey_SetsKey() { var startKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; var builder = new ScanBuilder("TestTable") @@ -198,7 +198,7 @@ public async Task BatchWriteItemBuilder_WithReturnConsumedCapacity_SetsCapacity( { var builder = new BatchWriteItemBuilder() .WithTable("TestTable", table => table - .WithPut(new Dictionary { ["pk"] = new AttributeValue { S = "value1" } })) + .WithPut(new Dictionary { ["pk"] = AttributeValue.String("value1") })) .WithReturnConsumedCapacity(ReturnConsumedCapacity.TOTAL) .WithReturnItemCollectionMetrics(ReturnItemCollectionMetrics.SIZE); @@ -212,7 +212,7 @@ public async Task BatchWriteItemBuilder_WithReturnConsumedCapacity_SetsCapacity( public async Task DeleteItemBuilder_WithReturnConsumedCapacity_SetsCapacity() { var builder = new DeleteItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .WithReturnConsumedCapacity(ReturnConsumedCapacity.INDEXES) .WithReturnItemCollectionMetrics(ReturnItemCollectionMetrics.SIZE); @@ -226,7 +226,7 @@ public async Task DeleteItemBuilder_WithReturnConsumedCapacity_SetsCapacity() public async Task DeleteItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_SetsValue() { var builder = new DeleteItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); var request = builder.Build(); @@ -238,7 +238,7 @@ public async Task DeleteItemBuilder_WithReturnValuesOnConditionCheckFailure_AllO public async Task DeleteItemBuilder_WithReturnValuesOnConditionCheckFailure_None_SetsValue() { var builder = new DeleteItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.NONE); var request = builder.Build(); @@ -274,7 +274,7 @@ public async Task PutItemBuilder_WithReturnValuesOnConditionCheckFailure_None_Se public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_SetsValue() { var builder = new UpdateItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .Set("status", "active") .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); @@ -287,7 +287,7 @@ public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_AllO public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_None_SetsValue() { var builder = new UpdateItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .Set("status", "active") .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.NONE); @@ -300,7 +300,7 @@ public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_None public async Task DeleteItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_SerializesCorrectly() { var builder = new DeleteItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); var request = builder.Build(); @@ -326,7 +326,7 @@ public async Task PutItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_ public async Task UpdateItemBuilder_WithReturnValuesOnConditionCheckFailure_AllOld_SerializesCorrectly() { var builder = new UpdateItemBuilder("TestTable") - .WithKey("pk", new AttributeValue { S = "value1" }) + .WithKey("pk", AttributeValue.String("value1")) .Set("status", "active") .WithReturnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD); diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoClientIntegrationTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoClientIntegrationTests.cs index 3874cbba..aa76f81c 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoClientIntegrationTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoClientIntegrationTests.cs @@ -25,10 +25,10 @@ public async Task PutItemAsync_ShouldStoreItem_WhenValidItemProvided() { var item = new Dictionary { - ["pk"] = new() { S = "test-pk" }, - ["sk"] = new() { S = "test-sk" }, - ["data"] = new() { S = "test-data" }, - ["number"] = new() { N = "123" } + ["pk"] = AttributeValue.String("test-pk"), + ["sk"] = AttributeValue.String("test-sk"), + ["data"] = AttributeValue.String("test-data"), + ["number"] = AttributeValue.Number("123") }; var request = new PutItemRequest @@ -47,9 +47,9 @@ public async Task GetItemAsync_ShouldReturnItem_WhenItemExists() { var putItem = new Dictionary { - ["pk"] = new() { S = "get-test-pk" }, - ["sk"] = new() { S = "get-test-sk" }, - ["data"] = new() { S = "retrieved-data" } + ["pk"] = AttributeValue.String("get-test-pk"), + ["sk"] = AttributeValue.String("get-test-sk"), + ["data"] = AttributeValue.String("retrieved-data") }; var putRequest = new PutItemRequest @@ -65,8 +65,8 @@ public async Task GetItemAsync_ShouldReturnItem_WhenItemExists() TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "get-test-pk" }, - ["sk"] = new() { S = "get-test-sk" } + ["pk"] = AttributeValue.String("get-test-pk"), + ["sk"] = AttributeValue.String("get-test-sk") } }; @@ -76,7 +76,7 @@ public async Task GetItemAsync_ShouldReturnItem_WhenItemExists() await Assert.That(() => response.Value.Item).IsNotNull(); var attribute = (response.Value.Item!)["data"]; await Assert.That(attribute).IsNotNull(); - await Assert.That(attribute!.S).IsEqualTo("retrieved-data"); + await Assert.That(attribute!.Value.S).IsEqualTo("retrieved-data"); } [Test] @@ -87,8 +87,8 @@ public async Task GetItemAsync_ShouldReturnEmptyItem_WhenItemDoesNotExist() TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "non-existent-pk" }, - ["sk"] = new() { S = "non-existent-sk" } + ["pk"] = AttributeValue.String("non-existent-pk"), + ["sk"] = AttributeValue.String("non-existent-sk") } }; @@ -103,10 +103,10 @@ public async Task UpdateItemAsync_ShouldModifyItem_WhenItemExists() { var initialItem = new Dictionary { - ["pk"] = new() { S = "update-test-pk" }, - ["sk"] = new() { S = "update-test-sk" }, - ["data"] = new() { S = "initial-data" }, - ["counter"] = new() { N = "1" } + ["pk"] = AttributeValue.String("update-test-pk"), + ["sk"] = AttributeValue.String("update-test-sk"), + ["data"] = AttributeValue.String("initial-data"), + ["counter"] = AttributeValue.Number("1") }; var putRequest = new PutItemRequest @@ -122,8 +122,8 @@ public async Task UpdateItemAsync_ShouldModifyItem_WhenItemExists() TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "update-test-pk" }, - ["sk"] = new() { S = "update-test-sk" } + ["pk"] = AttributeValue.String("update-test-pk"), + ["sk"] = AttributeValue.String("update-test-sk") }, UpdateExpression = "SET #data = :newData, #counter = #counter + :inc", ExpressionAttributeNames = new Dictionary @@ -133,8 +133,8 @@ public async Task UpdateItemAsync_ShouldModifyItem_WhenItemExists() }, ExpressionAttributeValues = new Dictionary { - [":newData"] = new() { S = "updated-data" }, - [":inc"] = new() { N = "1" } + [":newData"] = AttributeValue.String("updated-data"), + [":inc"] = AttributeValue.Number("1") }, ReturnValues = ReturnValues.ALL_NEW }; @@ -156,9 +156,9 @@ public async Task DeleteItemAsync_ShouldRemoveItem_WhenItemExists() { var item = new Dictionary { - ["pk"] = new() { S = "delete-test-pk" }, - ["sk"] = new() { S = "delete-test-sk" }, - ["data"] = new() { S = "to-be-deleted" } + ["pk"] = AttributeValue.String("delete-test-pk"), + ["sk"] = AttributeValue.String("delete-test-sk"), + ["data"] = AttributeValue.String("to-be-deleted") }; var putRequest = new PutItemRequest @@ -174,8 +174,8 @@ public async Task DeleteItemAsync_ShouldRemoveItem_WhenItemExists() TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "delete-test-pk" }, - ["sk"] = new() { S = "delete-test-sk" } + ["pk"] = AttributeValue.String("delete-test-pk"), + ["sk"] = AttributeValue.String("delete-test-sk") }, ReturnValues = ReturnValues.ALL_OLD }; @@ -192,8 +192,8 @@ public async Task DeleteItemAsync_ShouldRemoveItem_WhenItemExists() TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "delete-test-pk" }, - ["sk"] = new() { S = "delete-test-sk" } + ["pk"] = AttributeValue.String("delete-test-pk"), + ["sk"] = AttributeValue.String("delete-test-sk") } }; @@ -208,21 +208,21 @@ public async Task QueryAsync_ShouldReturnMatchingItems_WhenItemsExist() { new Dictionary { - ["pk"] = new() { S = "query-test-pk" }, - ["sk"] = new() { S = "item1" }, - ["data"] = new() { S = "data1" } + ["pk"] = AttributeValue.String("query-test-pk"), + ["sk"] = AttributeValue.String("item1"), + ["data"] = AttributeValue.String("data1") }, new Dictionary { - ["pk"] = new() { S = "query-test-pk" }, - ["sk"] = new() { S = "item2" }, - ["data"] = new() { S = "data2" } + ["pk"] = AttributeValue.String("query-test-pk"), + ["sk"] = AttributeValue.String("item2"), + ["data"] = AttributeValue.String("data2") }, new Dictionary { - ["pk"] = new() { S = "different-pk" }, - ["sk"] = new() { S = "item3" }, - ["data"] = new() { S = "data3" } + ["pk"] = AttributeValue.String("different-pk"), + ["sk"] = AttributeValue.String("item3"), + ["data"] = AttributeValue.String("data3") } }; @@ -242,7 +242,7 @@ public async Task QueryAsync_ShouldReturnMatchingItems_WhenItemsExist() KeyConditionExpression = "pk = :pk", ExpressionAttributeValues = new Dictionary { - [":pk"] = new() { S = "query-test-pk" } + [":pk"] = AttributeValue.String("query-test-pk") } }; @@ -260,15 +260,15 @@ public async Task ScanAsync_ShouldReturnAllItems_WhenNoFilterApplied() { new Dictionary { - ["pk"] = new() { S = "scan-test-pk1" }, - ["sk"] = new() { S = "scan-item1" }, - ["type"] = new() { S = "test" } + ["pk"] = AttributeValue.String("scan-test-pk1"), + ["sk"] = AttributeValue.String("scan-item1"), + ["type"] = AttributeValue.String("test") }, new Dictionary { - ["pk"] = new() { S = "scan-test-pk2" }, - ["sk"] = new() { S = "scan-item2" }, - ["type"] = new() { S = "test" } + ["pk"] = AttributeValue.String("scan-test-pk2"), + ["sk"] = AttributeValue.String("scan-item2"), + ["type"] = AttributeValue.String("test") } }; @@ -292,7 +292,7 @@ public async Task ScanAsync_ShouldReturnAllItems_WhenNoFilterApplied() }, ExpressionAttributeValues = new Dictionary { - [":type"] = new() { S = "test" } + [":type"] = AttributeValue.String("test") } }; @@ -310,15 +310,15 @@ public async Task BatchGetItemAsync_ShouldReturnRequestedItems_WhenItemsExist() { new Dictionary { - ["pk"] = new() { S = "batch-get-pk1" }, - ["sk"] = new() { S = "batch-get-sk1" }, - ["data"] = new() { S = "batch-data1" } + ["pk"] = AttributeValue.String("batch-get-pk1"), + ["sk"] = AttributeValue.String("batch-get-sk1"), + ["data"] = AttributeValue.String("batch-data1") }, new Dictionary { - ["pk"] = new() { S = "batch-get-pk2" }, - ["sk"] = new() { S = "batch-get-sk2" }, - ["data"] = new() { S = "batch-data2" } + ["pk"] = AttributeValue.String("batch-get-pk2"), + ["sk"] = AttributeValue.String("batch-get-sk2"), + ["data"] = AttributeValue.String("batch-data2") } }; @@ -342,13 +342,13 @@ public async Task BatchGetItemAsync_ShouldReturnRequestedItems_WhenItemsExist() { new() { - ["pk"] = new() { S = "batch-get-pk1" }, - ["sk"] = new() { S = "batch-get-sk1" } + ["pk"] = AttributeValue.String("batch-get-pk1"), + ["sk"] = AttributeValue.String("batch-get-sk1") }, new() { - ["pk"] = new() { S = "batch-get-pk2" }, - ["sk"] = new() { S = "batch-get-sk2" } + ["pk"] = AttributeValue.String("batch-get-pk2"), + ["sk"] = AttributeValue.String("batch-get-sk2") } } } @@ -366,16 +366,16 @@ public async Task BatchWriteItemAsync_ShouldProcessMultipleOperations_WhenValidR { var putItem = new Dictionary { - ["pk"] = new() { S = "batch-write-pk1" }, - ["sk"] = new() { S = "batch-write-sk1" }, - ["data"] = new() { S = "batch-put-data" } + ["pk"] = AttributeValue.String("batch-write-pk1"), + ["sk"] = AttributeValue.String("batch-write-sk1"), + ["data"] = AttributeValue.String("batch-put-data") }; var existingItem = new Dictionary { - ["pk"] = new() { S = "batch-write-pk2" }, - ["sk"] = new() { S = "batch-write-sk2" }, - ["data"] = new() { S = "to-be-deleted" } + ["pk"] = AttributeValue.String("batch-write-pk2"), + ["sk"] = AttributeValue.String("batch-write-sk2"), + ["data"] = AttributeValue.String("to-be-deleted") }; var putExistingRequest = new PutItemRequest @@ -401,8 +401,8 @@ public async Task BatchWriteItemAsync_ShouldProcessMultipleOperations_WhenValidR { Key = new Dictionary { - ["pk"] = new() { S = "batch-write-pk2" }, - ["sk"] = new() { S = "batch-write-sk2" } + ["pk"] = AttributeValue.String("batch-write-pk2"), + ["sk"] = AttributeValue.String("batch-write-sk2") } } } @@ -419,8 +419,8 @@ public async Task BatchWriteItemAsync_ShouldProcessMultipleOperations_WhenValidR TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "batch-write-pk1" }, - ["sk"] = new() { S = "batch-write-sk1" } + ["pk"] = AttributeValue.String("batch-write-pk1"), + ["sk"] = AttributeValue.String("batch-write-sk1") } }; @@ -432,12 +432,58 @@ public async Task BatchWriteItemAsync_ShouldProcessMultipleOperations_WhenValidR TableName = _fixture.TestTableName, Key = new Dictionary { - ["pk"] = new() { S = "batch-write-pk2" }, - ["sk"] = new() { S = "batch-write-sk2" } + ["pk"] = AttributeValue.String("batch-write-pk2"), + ["sk"] = AttributeValue.String("batch-write-sk2") } }; var getDeletedResponse = await _fixture.DynamoClient.GetItemAsync(getDeletedRequest); await Assert.That(() => getDeletedResponse.Value.Item).IsNull(); } + + [Test] + public async Task QueryAsync_ShouldPaginate_WhenLimitIsSet() + { + // Insert 10 items + for (var i = 0; i < 10; i++) + { + await _fixture.DynamoClient.PutItemAsync(new PutItemRequest + { + TableName = _fixture.TestTableName, + Item = new Dictionary + { + ["pk"] = AttributeValue.String("paginate-test"), + ["sk"] = AttributeValue.String($"item-{i:D4}"), + ["data"] = AttributeValue.String($"value-{i}") + } + }); + } + + var totalCount = 0; + var pageCount = 0; + Dictionary? lastKey = null; + + do + { + var response = await _fixture.DynamoClient.QueryAsync(new QueryRequest + { + TableName = _fixture.TestTableName, + KeyConditionExpression = "pk = :pk", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = AttributeValue.String("paginate-test") + }, + Limit = 3, + ExclusiveStartKey = lastKey + }); + + await Assert.That(response.IsError).IsFalse(); + totalCount += response.Value.Items.Count; + pageCount++; + lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + } while (lastKey != null); + + await Assert.That(totalCount).IsEqualTo(10); + await Assert.That(pageCount).IsGreaterThanOrEqualTo(4); // 10 items / 3 per page = at least 4 pages + } } diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs new file mode 100644 index 00000000..1cb8e3c0 --- /dev/null +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs @@ -0,0 +1,172 @@ +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 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_StopsOnError_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() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = AttributeValue.String("lastPk") + }; + + ErrorOr firstPage = new QueryResponse + { + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], + LastEvaluatedKey = lastKey + }; + ErrorOr secondPage = new QueryResponse + { + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.QueryAllAsync("TestTable", _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); + await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); + } + + [Test] + public async Task ScanAllAsync_DynamoRecord_PaginatesAutomatically() + { + var mock = new Mock(); + var lastKey = new Dictionary + { + ["pk"] = AttributeValue.String("lastPk") + }; + + ErrorOr firstPage = new ScanResponse + { + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], + LastEvaluatedKey = lastKey + }; + ErrorOr secondPage = new ScanResponse + { + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], + LastEvaluatedKey = null + }; + + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) + .ReturnsAsync(firstPage); + mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) + .ReturnsAsync(secondPage); + + var items = await mock.Object.ScanAllAsync("TestTable", _ => { }).ToListAsync(); + + await Assert.That(items).Count().IsEqualTo(2); + await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); + await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); + } +} From 26846ec2ff83c09c2cc2554abb865d91bbcac0cf Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Mon, 9 Mar 2026 16:36:15 +0000 Subject: [PATCH 05/18] Remove typed benchmark methods that belong to WT6 (perf/typed-dynamo) --- .../Benchmarks/QueryAsyncBenchmarks.cs | 24 ------- .../Benchmarks/ScanAsyncBenchmarks.cs | 64 ------------------- 2 files changed, 88 deletions(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index feef2891..52b3cf6d 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -3,7 +3,6 @@ using EfficientDynamoDb; using EfficientDynamoDb.Attributes; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; -using Goa.Clients.Dynamo.Benchmarks.Models; using EfficientAttributeValue = EfficientDynamoDb.DocumentModel.AttributeValue; using GoaModels = Goa.Clients.Dynamo.Models; using GoaQueryRequest = Goa.Clients.Dynamo.Operations.Query.QueryRequest; @@ -103,29 +102,6 @@ public async Task Goa_Query_1Item_DynamoRecord() return count; } - [Benchmark, BenchmarkCategory("1 Item")] - public async Task Goa_Query_1Item_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-1") - }, - ExclusiveStartKey = lastKey - }, DynamoItemReaderRegistry.Get()); - count += result.Value.Items.Count; - lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; - } while (lastKey != null); - return count; - } - [Benchmark, BenchmarkCategory("1 Item")] public async Task Efficient_Query_1Item() { diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs index 2090fa15..07601bb3 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/ScanAsyncBenchmarks.cs @@ -2,7 +2,6 @@ using BenchmarkDotNet.Order; using EfficientDynamoDb.DocumentModel; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; -using Goa.Clients.Dynamo.Benchmarks.Models; using GoaModels = Goa.Clients.Dynamo.Models; using GoaScanRequest = Goa.Clients.Dynamo.Operations.Scan.ScanRequest; using EfficientScanRequest = EfficientDynamoDb.Operations.Scan.ScanRequest; @@ -73,25 +72,6 @@ public async Task Goa_Scan_DynamoRecord() return count; } - [Benchmark, BenchmarkCategory("Scan")] - public async Task Goa_Scan_Typed() - { - var count = 0; - Dictionary? lastKey = null; - do - { - var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest - { - TableName = _fixture.TableName, - 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("Scan")] public async Task Efficient_Scan() { @@ -163,31 +143,6 @@ public async Task Goa_Scan_WithFilter_DynamoRecord() return count; } - [Benchmark, BenchmarkCategory("Scan With Filter")] - public async Task Goa_Scan_WithFilter_Typed() - { - var count = 0; - Dictionary? lastKey = null; - do - { - var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest - { - TableName = _fixture.TableName, - FilterExpression = "#n > :minNum", - ExpressionAttributeNames = new Dictionary { ["#n"] = "number" }, - ExpressionAttributeValues = new Dictionary - { - [":minNum"] = GoaModels.AttributeValue.Number("25") - }, - 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("Scan With Filter")] public async Task Efficient_Scan_WithFilter() { @@ -253,25 +208,6 @@ public async Task Goa_Scan_WithLimit_DynamoRecord() return count; } - [Benchmark, BenchmarkCategory("Scan With Limit")] - public async Task Goa_Scan_WithLimit_Typed() - { - var count = 0; - Dictionary? lastKey = null; - do - { - var result = await _fixture.GoaClient.ScanAsync(new GoaScanRequest - { - TableName = _fixture.TableName, - Limit = 10, - ExclusiveStartKey = lastKey - }, DynamoItemReaderRegistry.Get()); - count += result.Value.Items.Count; - lastKey = result.Value.HasMoreResults ? result.Value.LastEvaluatedKey : null; - } while (lastKey != null); - return count; - } - [Benchmark, BenchmarkCategory("Scan With Limit")] public async Task Efficient_Scan_WithLimit() { From 0e4d90680e38ede9b22f980cf708a154fbc6475c Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 10 Mar 2026 00:20:01 +0000 Subject: [PATCH 06/18] Remove typed benchmarks and tests that depend on WT6 features Strip GetItem typed benchmarks using BenchmarkItem/DynamoItemReaderRegistry and delete TypedExtensionTests using DynamoItemReader/AttributeValue factories. --- .../Benchmarks/GetItemAsyncBenchmarks.cs | 48 +---- .../TypedExtensionTests.cs | 172 ------------------ 2 files changed, 1 insertion(+), 219 deletions(-) delete mode 100644 tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs index e3656b4c..b559ea55 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/GetItemAsyncBenchmarks.cs @@ -2,7 +2,7 @@ using BenchmarkDotNet.Order; using EfficientDynamoDb.Operations.Shared; using Goa.Clients.Dynamo.Benchmarks.Infrastructure; -using Goa.Clients.Dynamo.Benchmarks.Models; + using GoaModels = Goa.Clients.Dynamo.Models; using EfficientGetItemResponse = EfficientDynamoDb.Operations.GetItem.GetItemResponse; @@ -58,21 +58,6 @@ public async Task AwsSdk_GetItem() return response.Value.Item; } - [Benchmark, BenchmarkCategory("Get Item")] - public async Task Goa_GetItem_Typed() - { - var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest - { - TableName = _fixture.TableName, - Key = new Dictionary - { - ["pk"] = GoaModels.AttributeValue.String("get-bench"), - ["sk"] = GoaModels.AttributeValue.String("item-0000") - } - }, DynamoItemReaderRegistry.Get()); - return result.Value; - } - [Benchmark, BenchmarkCategory("Get Item")] public async Task Efficient_GetItem() { @@ -112,21 +97,6 @@ public async Task AwsSdk_GetItem_Miss() return response.Value.Item; } - [Benchmark, BenchmarkCategory("Get Item Miss")] - public async Task Goa_GetItem_Miss_Typed() - { - var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest - { - TableName = _fixture.TableName, - Key = new Dictionary - { - ["pk"] = GoaModels.AttributeValue.String("nonexistent"), - ["sk"] = GoaModels.AttributeValue.String("nonexistent") - } - }, DynamoItemReaderRegistry.Get()); - return result.Value; - } - [Benchmark, BenchmarkCategory("Get Item Miss")] public async Task Efficient_GetItem_Miss() { @@ -168,22 +138,6 @@ public async Task AwsSdk_GetItem_ConsistentRead() return response.Value.Item; } - [Benchmark, BenchmarkCategory("Get Item Consistent Read")] - public async Task Goa_GetItem_ConsistentRead_Typed() - { - var result = await _fixture.GoaClient.GetItemAsync(new Goa.Clients.Dynamo.Operations.GetItem.GetItemRequest - { - TableName = _fixture.TableName, - Key = new Dictionary - { - ["pk"] = GoaModels.AttributeValue.String("get-bench"), - ["sk"] = GoaModels.AttributeValue.String("item-0000") - }, - ConsistentRead = true - }, DynamoItemReaderRegistry.Get()); - return result.Value; - } - [Benchmark, BenchmarkCategory("Get Item Consistent Read")] public async Task Efficient_GetItem_ConsistentRead() { diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs deleted file mode 100644 index 1cb8e3c0..00000000 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -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 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_StopsOnError_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() - { - var mock = new Mock(); - var lastKey = new Dictionary - { - ["pk"] = AttributeValue.String("lastPk") - }; - - ErrorOr firstPage = new QueryResponse - { - Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], - LastEvaluatedKey = lastKey - }; - ErrorOr secondPage = new QueryResponse - { - Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], - LastEvaluatedKey = null - }; - - mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) - .ReturnsAsync(firstPage); - mock.Setup(c => c.QueryAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) - .ReturnsAsync(secondPage); - - var items = await mock.Object.QueryAllAsync("TestTable", _ => { }).ToListAsync(); - - await Assert.That(items).Count().IsEqualTo(2); - await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); - await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); - } - - [Test] - public async Task ScanAllAsync_DynamoRecord_PaginatesAutomatically() - { - var mock = new Mock(); - var lastKey = new Dictionary - { - ["pk"] = AttributeValue.String("lastPk") - }; - - ErrorOr firstPage = new ScanResponse - { - Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], - LastEvaluatedKey = lastKey - }; - ErrorOr secondPage = new ScanResponse - { - Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], - LastEvaluatedKey = null - }; - - mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey == null), It.IsAny())) - .ReturnsAsync(firstPage); - mock.Setup(c => c.ScanAsync(It.Is(r => r.ExclusiveStartKey != null), It.IsAny())) - .ReturnsAsync(secondPage); - - var items = await mock.Object.ScanAllAsync("TestTable", _ => { }).ToListAsync(); - - await Assert.That(items).Count().IsEqualTo(2); - await Assert.That(items[0]["pk"]?.S).IsEqualTo("pk1"); - await Assert.That(items[1]["pk"]?.S).IsEqualTo("pk2"); - } -} From 1a07628598547d8d6fb068fcd801ff1ad9a4bb1c Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Tue, 10 Mar 2026 22:49:16 +0000 Subject: [PATCH 07/18] Fix TypedExtensionTests to use AttributeValue factory methods after merge --- .../Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs index 3d80fded..f362a66a 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/TypedExtensionTests.cs @@ -16,17 +16,17 @@ public async Task QueryAllAsync_DynamoRecord_PaginatesAutomatically() var mock = new Mock(); var lastKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; ErrorOr firstPage = new QueryResponse { - Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk1" } }], + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], LastEvaluatedKey = lastKey }; ErrorOr secondPage = new QueryResponse { - Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk2" } }], + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], LastEvaluatedKey = null }; @@ -48,17 +48,17 @@ public async Task ScanAllAsync_DynamoRecord_PaginatesAutomatically() var mock = new Mock(); var lastKey = new Dictionary { - ["pk"] = new AttributeValue { S = "lastPk" } + ["pk"] = AttributeValue.String("lastPk") }; ErrorOr firstPage = new ScanResponse { - Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk1" } }], + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk1") }], LastEvaluatedKey = lastKey }; ErrorOr secondPage = new ScanResponse { - Items = [new DynamoRecord { ["pk"] = new AttributeValue { S = "pk2" } }], + Items = [new DynamoRecord { ["pk"] = AttributeValue.String("pk2") }], LastEvaluatedKey = null }; From 3cea13967ca70773bf8b1af8c29d14def85fe45f Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 09:01:35 +0000 Subject: [PATCH 08/18] Fix codegen issues: struct null checks, attribute names, format strings, empty strings - ComplexTypeHandler: differentiate non-nullable structs (no null check), nullable structs (.Value unwrap), and reference types - DateOnly/DateTime/TimeOnly handlers: use GetDynamoAttributeName() for dictionary key in conditional write path - PrimitiveTypeHandler: fix over-escaped DateTime format strings - DynamoRecordExtensions: allow empty strings through TryGetString --- .../TypeHandlers/ComplexTypeHandler.cs | 19 ++++++++++++++++++- .../TypeHandlers/DateOnlyTypeHandler.cs | 7 ++++--- .../TypeHandlers/DateTimeTypeHandler.cs | 3 ++- .../TypeHandlers/PrimitiveTypeHandler.cs | 8 ++++---- .../TypeHandlers/TimeOnlyTypeHandler.cs | 7 ++++--- .../Extensions/DynamoRecordExtensions.cs | 2 +- 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs index 3fe79840..2c979dfd 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs @@ -91,7 +91,24 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) // Handle complex types (nested objects) var normalizedTypeName = propertyInfo.UnderlyingType.Name.Replace(".", "_").Replace("`", "_"); - return $"model.{propertyName} != null ? AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName})) : AttributeValue.Null()"; + var isNullable = propertyInfo.IsNullable; + var isValueType = propertyInfo.Type.IsValueType; + + if (isValueType && !isNullable) + { + // Non-nullable struct: always has a value, no null check needed + return $"AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName}))"; + } + else if (isValueType && isNullable) + { + // Nullable struct: check for null, unwrap with .Value + return $"model.{propertyName} != null ? AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName}.Value)) : AttributeValue.Null()"; + } + else + { + // Reference type: standard null check + return $"model.{propertyName} != null ? AttributeValue.FromMap(DynamoMapper.{normalizedTypeName}.ToDynamoRecord(model.{propertyName})) : AttributeValue.Null()"; + } } public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs index dc7bfdf7..13f5e8ee 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs @@ -52,16 +52,17 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) public string? GenerateConditionalAssignment(PropertyInfo propertyInfo, string recordVariable) { var propertyName = propertyInfo.Name; + var dynamoAttributeName = propertyInfo.GetDynamoAttributeName(); var isNullable = propertyInfo.IsNullable; - + if (!isNullable) { return null; } - + return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""yyyy-MM-dd"")); + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""yyyy-MM-dd"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs index 6c62426c..0b1ee172 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs @@ -40,9 +40,10 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) public string GenerateConditionalAssignment(PropertyInfo propertyInfo, string recordVariable) { var propertyName = propertyInfo.Name; + var dynamoAttributeName = propertyInfo.GetDynamoAttributeName(); return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""o"")); + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""o"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs index e7101342..34fd3f86 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/PrimitiveTypeHandler.cs @@ -64,7 +64,7 @@ SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_DateTime when isNullable => null, // Use conditional assignment instead (handled by DateTimeTypeHandler) SpecialType.System_DateTime => - $"AttributeValue.String(model.{propertyName}.ToString(\\\"o\\\"))", + $"AttributeValue.String(model.{propertyName}.ToString(\"o\"))", _ when underlyingType.Name == nameof(Guid) && isNullable => null, // Use conditional assignment instead _ when underlyingType.Name == nameof(Guid) => @@ -76,7 +76,7 @@ SpecialType.System_Int64 or SpecialType.System_UInt64 or _ when underlyingType.Name == nameof(DateTimeOffset) && isNullable => null, // Use conditional assignment instead _ when underlyingType.Name == nameof(DateTimeOffset) => - $"AttributeValue.String(model.{propertyName}.ToString(\\\"o\\\"))", + $"AttributeValue.String(model.{propertyName}.ToString(\"o\"))", _ when underlyingType.TypeKind == TypeKind.Enum && isNullable => null, // Use conditional assignment instead _ when underlyingType.TypeKind == TypeKind.Enum => @@ -173,13 +173,13 @@ SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_Char => $"AttributeValue.String(model.{propertyName}.Value.ToString())", SpecialType.System_DateTime => - $"AttributeValue.String(model.{propertyName}.Value.ToString(\\\"o\\\"))", + $"AttributeValue.String(model.{propertyName}.Value.ToString(\"o\"))", _ when underlyingType.Name == nameof(Guid) => $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ when underlyingType.Name == nameof(TimeSpan) => $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ when underlyingType.Name == nameof(DateTimeOffset) => - $"AttributeValue.String(model.{propertyName}.Value.ToString(\\\"o\\\"))", + $"AttributeValue.String(model.{propertyName}.Value.ToString(\"o\"))", _ when underlyingType.TypeKind == TypeKind.Enum => $"AttributeValue.String(model.{propertyName}.Value.ToString())", _ => null diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs index 719e439d..456cf565 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs @@ -52,16 +52,17 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) public string? GenerateConditionalAssignment(PropertyInfo propertyInfo, string recordVariable) { var propertyName = propertyInfo.Name; + var dynamoAttributeName = propertyInfo.GetDynamoAttributeName(); var isNullable = propertyInfo.IsNullable; - + if (!isNullable) { return null; } - + return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""HH:mm:ss.fffffff"")); + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.String(model.{propertyName}.Value.ToString(""HH:mm:ss.fffffff"")); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs index 620c621a..7202c357 100644 --- a/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs +++ b/src/Clients/Goa.Clients.Dynamo/Extensions/DynamoRecordExtensions.cs @@ -46,7 +46,7 @@ public static bool TryGetString(this DynamoRecord record, string columnName, out if (!record.TryGetValue(columnName, out var attributeValue)) return false; - if (attributeValue.Type == AttributeType.Null || string.IsNullOrEmpty(attributeValue.S)) + if (attributeValue.Type == AttributeType.Null || attributeValue.S is null) return false; value = attributeValue.S; From 9d3a102423906ec87554834ccf687a624627e02c Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 11:45:59 +0000 Subject: [PATCH 09/18] fix: Add null validation to AttributeValue factory methods --- .../Goa.Clients.Dynamo/Models/AttributeValue.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index d0c3c0d9..89d9905a 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -37,10 +37,10 @@ private AttributeValue(bool value) } /// Creates an AttributeValue of type String. - public static AttributeValue String(string value) => new(value, AttributeType.String); + public static AttributeValue String(string value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.String); } /// Creates an AttributeValue of type Number. - public static AttributeValue Number(string value) => new(value, AttributeType.Number); + public static AttributeValue Number(string value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.Number); } /// Creates an AttributeValue of type Bool. public static AttributeValue Bool(bool value) => new(value); @@ -49,22 +49,22 @@ private AttributeValue(bool value) public static AttributeValue Null() => new(null, AttributeType.Null); /// Creates an AttributeValue from a String Set. - public static AttributeValue FromStringSet(List value) => new(value, AttributeType.StringSet); + public static AttributeValue FromStringSet(List value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.StringSet); } /// Creates an AttributeValue from a Number Set. - public static AttributeValue FromNumberSet(List value) => new(value, AttributeType.NumberSet); + public static AttributeValue FromNumberSet(List value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.NumberSet); } /// Creates an AttributeValue from a List of AttributeValues. - public static AttributeValue FromList(List value) => new(value, AttributeType.List); + public static AttributeValue FromList(List value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.List); } /// Creates an AttributeValue from a Map of string to AttributeValue. - public static AttributeValue FromMap(Dictionary value) => new(value, AttributeType.Map); + public static AttributeValue FromMap(Dictionary value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.Map); } /// Creates an AttributeValue of type Binary. - public static AttributeValue FromBinary(byte[] value) => new(value, AttributeType.Binary); + public static AttributeValue FromBinary(byte[] value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.Binary); } /// Creates an AttributeValue from a Binary Set. - public static AttributeValue FromBinarySet(List value) => new(value, AttributeType.BinarySet); + public static AttributeValue FromBinarySet(List value) { ArgumentNullException.ThrowIfNull(value); return new(value, AttributeType.BinarySet); } /// /// An attribute of type String. Returns null if this is not a String attribute. From 013c9629f8e3f3481b4cd4ca6a4b43a20c0b240a Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 12:12:58 +0000 Subject: [PATCH 10/18] fix: Throw JsonException for invalid AttributeValue JSON and update nested collection test expectations Replace silent `return default` with `throw JsonException` in AttributeValueJsonConverter.Read to fail loudly on malformed input. Update two nested collection test expected strings to use factory method syntax. --- src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs | 4 ++-- .../TypeHandlers/CollectionTypeHandlerTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index 89d9905a..0c12e882 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -177,7 +177,7 @@ public sealed class AttributeValueJsonConverter : JsonConverter public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) - return default; + throw new JsonException($"Invalid AttributeValue JSON: expected StartObject but got {reader.TokenType}"); while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { @@ -274,7 +274,7 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver reader.Skip(); } - return default; + throw new JsonException("Invalid AttributeValue JSON: no recognized DynamoDB type property found"); } /// diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs index 4d07824d..de2cdc09 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/CollectionTypeHandlerTests.cs @@ -685,7 +685,7 @@ public async Task GenerateToAttributeValue_NestedStringCollection_ShouldUseCorre // Assert - The inner primitive collection mapping should use "item" (the outer lambda var), // not a hard-coded identifier that doesn't match the lambda parameter - var expected = "model.Tags != null ? new AttributeValue { L = model.Tags.Select(item => (item != null && item.Any() ? new AttributeValue { SS = item.ToList() } : new AttributeValue { NULL = true })).ToList() } : new AttributeValue { NULL = true }"; + var expected = "model.Tags != null ? AttributeValue.FromList(model.Tags.Select(item => (item != null && item.Any() ? AttributeValue.FromStringSet(item.ToList()) : AttributeValue.Null())).ToList()) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected) .Because("Nested string collection should use the lambda variable 'item' in the inner SS mapping"); @@ -712,7 +712,7 @@ public async Task GenerateToAttributeValue_NestedIntCollection_ShouldUseCorrectI // Assert - The inner primitive collection mapping should use "item" (the outer lambda var), // not a hard-coded identifier that doesn't match the lambda parameter - var expected = "model.Scores != null ? new AttributeValue { L = model.Scores.Select(item => (item != null && item.Any() ? new AttributeValue { NS = item.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList() } : new AttributeValue { NULL = true })).ToList() } : new AttributeValue { NULL = true }"; + var expected = "model.Scores != null ? AttributeValue.FromList(model.Scores.Select(item => (item != null && item.Any() ? AttributeValue.FromNumberSet(item.Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList()) : AttributeValue.Null())).ToList()) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected) .Because("Nested int collection should use the lambda variable 'item' in the inner NS mapping"); From 27b3b278502b14b87d70c2b33ff346e5906a73f1 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:28:24 +0000 Subject: [PATCH 11/18] fix: Use culture-invariant parsing for DynamoDB numeric values double.TryParse and decimal.TryParse in ToValue() used current culture, causing locale-dependent parsing of DynamoDB numbers. --- src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index 0c12e882..5c164752 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -157,8 +157,8 @@ private AttributeValue(bool value) var t when t == typeof(string) => (T?)(object?)S, var t when t == typeof(int) => int.TryParse(N, out var i) ? (T?)(object?)i : default, var t when t == typeof(long) => long.TryParse(N, out var l) ? (T?)(object?)l : default, - var t when t == typeof(double) => double.TryParse(N, out var d) ? (T?)(object?)d : default, - var t when t == typeof(decimal) => decimal.TryParse(N, out var m) ? (T?)(object?)m : default, + var t when t == typeof(double) => double.TryParse(N, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? (T?)(object?)d : default, + var t when t == typeof(decimal) => decimal.TryParse(N, NumberStyles.Any, CultureInfo.InvariantCulture, out var m) ? (T?)(object?)m : default, var t when t == typeof(bool) => (T?)(object?)BOOL, var t when t == typeof(List) => (T?)(object?)SS, var t when t == typeof(byte[]) => (T?)(object?)B, From 929a848a6f096296d6fd81771a42e160d189bcb1 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:28:53 +0000 Subject: [PATCH 12/18] fix: Use DynamoDB attribute name for record lookups in type handlers DateOnly, TimeOnly, DateTime, Enum, and Complex type handlers used propertyInfo.Name instead of propertyInfo.GetDynamoAttributeName() in GenerateFromDynamoRecord, breaking properties with custom DynamoAttribute names. --- .../TypeHandlers/ComplexTypeHandler.cs | 4 ++-- .../TypeHandlers/DateOnlyTypeHandler.cs | 2 +- .../TypeHandlers/DateTimeTypeHandler.cs | 2 +- .../TypeHandlers/EnumTypeHandler.cs | 9 +++++---- .../TypeHandlers/TimeOnlyTypeHandler.cs | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs index 9dd0b2b2..31a517c1 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/ComplexTypeHandler.cs @@ -113,7 +113,7 @@ public string GenerateToAttributeValue(PropertyInfo propertyInfo) public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) { - var memberName = propertyInfo.Name; + var memberName = propertyInfo.GetDynamoAttributeName(); var typeName = propertyInfo.Type.ToDisplayString(); // Check for dictionary types @@ -343,7 +343,7 @@ private string GeneratePrimitiveDictionary(string propertyName, ITypeSymbol valu { if (valueType.SpecialType == SpecialType.System_String) { - return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value))) : AttributeValue.Null()"; + return $"model.{propertyName} != null ? AttributeValue.FromMap(model.{propertyName}.ToDictionary(kvp => kvp.Key, kvp => kvp.Value != null ? AttributeValue.String(kvp.Value) : AttributeValue.Null())) : AttributeValue.Null()"; } else if (IsNumericType(valueType)) { diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs index 13f5e8ee..fe4c8c14 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateOnlyTypeHandler.cs @@ -29,7 +29,7 @@ public bool CanHandle(PropertyInfo propertyInfo) public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) { - var memberName = propertyInfo.Name; + var memberName = propertyInfo.GetDynamoAttributeName(); var isNullable = propertyInfo.IsNullable; // Avoid variable name conflicts with pk/sk extraction variables diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs index 0b1ee172..733137d6 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/DateTimeTypeHandler.cs @@ -49,7 +49,7 @@ public string GenerateConditionalAssignment(PropertyInfo propertyInfo, string re public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) { - var memberName = propertyInfo.Name; + var memberName = propertyInfo.GetDynamoAttributeName(); var underlyingType = propertyInfo.UnderlyingType; var isNullable = propertyInfo.IsNullable; diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs index 4a684661..8c9ce124 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/EnumTypeHandler.cs @@ -30,7 +30,7 @@ public bool CanHandle(PropertyInfo propertyInfo) public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) { - var memberName = propertyInfo.Name; + var memberName = propertyInfo.GetDynamoAttributeName(); var enumType = propertyInfo.UnderlyingType.ToDisplayString(); var isNullable = propertyInfo.IsNullable; @@ -59,16 +59,17 @@ public string GenerateKeyFormatting(PropertyInfo propertyInfo) public string? GenerateConditionalAssignment(PropertyInfo propertyInfo, string recordVariable) { var propertyName = propertyInfo.Name; + var dynamoAttributeName = propertyInfo.GetDynamoAttributeName(); var isNullable = propertyInfo.IsNullable; - + if (!isNullable) { return null; } - + return $@"if (model.{propertyName}.HasValue) {{ - {recordVariable}[""{propertyName}""] = AttributeValue.String(model.{propertyName}.Value.ToString()); + {recordVariable}[""{dynamoAttributeName}""] = AttributeValue.String(model.{propertyName}.Value.ToString()); }}"; } diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs index 456cf565..26de8d6b 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/TimeOnlyTypeHandler.cs @@ -29,7 +29,7 @@ public bool CanHandle(PropertyInfo propertyInfo) public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordVariableName, string pkVariable, string skVariable) { - var memberName = propertyInfo.Name; + var memberName = propertyInfo.GetDynamoAttributeName(); var isNullable = propertyInfo.IsNullable; // Avoid variable name conflicts with pk/sk extraction variables From 02ec8c3057e2971111cbedb615c86bdff6f6369e Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:29:09 +0000 Subject: [PATCH 13/18] fix: Throw JsonException for unrecognized DynamoDB attribute types return default silently created a misleading AttributeType.String struct with null value. Now throws JsonException to surface the error instead of propagating corrupt data. --- src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index d7bcd97c..80e5da74 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -268,7 +268,7 @@ private static AttributeValue ReadAttributeValue(ref Utf8JsonReader reader) reader.Read(); reader.Skip(); } - return default; + throw new JsonException("Unrecognized DynamoDB attribute type: no recognized type property found in attribute value object"); } private static List ReadStringArray(ref Utf8JsonReader reader) From 4886f86e058747d08d3ccc19bfd114b52ed949b4 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 15:32:20 +0000 Subject: [PATCH 14/18] fix: Preserve NULL elements in nullable collections and handle null dict values CollectionTypeHandler filtered out NULL entries via .Where(), changing collection length on round-trip. Now preserves NULLs as null for nullable element types. ComplexTypeHandler threw ArgumentNullException for Dictionary null values; now emits AttributeValue.Null(). --- .../TypeHandlers/CollectionTypeHandler.cs | 13 ++++++++++++- .../TypeHandlers/ComplexTypeHandlerTests.cs | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs index fb1a397c..c6fdc86d 100644 --- a/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs +++ b/src/Clients/Goa.Clients.Dynamo.Generator/TypeHandlers/CollectionTypeHandler.cs @@ -213,7 +213,18 @@ public string GenerateFromDynamoRecord(PropertyInfo propertyInfo, string recordV { var normalizedTypeName = elementType.Name.Replace(".", "_").Replace("`", "_"); var varName = memberName.ToLowerInvariant(); - var sourceExpression = $"{recordVariableName}.TryGetList(\"{memberName}\", out var {varName}List) && {varName}List != null ? {varName}List.Where(item => item.M != null).Select(item => DynamoMapper.{normalizedTypeName}.FromDynamoRecord(new DynamoRecord(item.M!), {pkVariable}, {skVariable})) : null"; + // When element type is explicitly nullable (e.g., List), preserve NULL entries as null + // instead of filtering them out. Non-nullable element types keep the filter to avoid type mismatches. + var isElementNullable = elementType.NullableAnnotation == NullableAnnotation.Annotated; + string sourceExpression; + if (isElementNullable) + { + sourceExpression = $"{recordVariableName}.TryGetList(\"{memberName}\", out var {varName}List) && {varName}List != null ? {varName}List.Select(item => item.M != null ? DynamoMapper.{normalizedTypeName}.FromDynamoRecord(new DynamoRecord(item.M!), {pkVariable}, {skVariable}) : null) : null"; + } + else + { + sourceExpression = $"{recordVariableName}.TryGetList(\"{memberName}\", out var {varName}List) && {varName}List != null ? {varName}List.Where(item => item.M != null).Select(item => DynamoMapper.{normalizedTypeName}.FromDynamoRecord(new DynamoRecord(item.M!), {pkVariable}, {skVariable})) : null"; + } return ConvertToTargetCollectionType(propertyInfo.Type, elementType, sourceExpression); } diff --git a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs index 870b2876..b411d16d 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Generator.Tests/TypeHandlers/ComplexTypeHandlerTests.cs @@ -236,7 +236,7 @@ public async Task GenerateToAttributeValue_StringStringDictionary_ShouldGenerate var result = _handler.GenerateToAttributeValue(propertyInfo); // Assert - var expected = "model.Metadata != null ? AttributeValue.FromMap(model.Metadata.ToDictionary(kvp => kvp.Key, kvp => AttributeValue.String(kvp.Value))) : AttributeValue.Null()"; + var expected = "model.Metadata != null ? AttributeValue.FromMap(model.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value != null ? AttributeValue.String(kvp.Value) : AttributeValue.Null())) : AttributeValue.Null()"; await Assert.That(result) .IsEqualTo(expected); } From 161a2f312b1d396f6703d510bd0912230e42cf3a Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:19:08 +0000 Subject: [PATCH 15/18] fix: Validate NULL attribute boolean and EndObject in DynamoResponseReader Reject {"NULL": false} instead of silently coercing to Null. Add ReadEndObject helper to all 10 type branches to validate the wrapper object is fully consumed, preventing malformed extra-property payloads. --- .../Internal/DynamoResponseReader.cs | 31 ++++++---- .../DynamoResponseReaderTests.cs | 58 +++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs index 80e5da74..f5ab7b83 100644 --- a/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs +++ b/src/Clients/Goa.Clients.Dynamo/Internal/DynamoResponseReader.cs @@ -197,70 +197,72 @@ private static AttributeValue ReadAttributeValue(ref Utf8JsonReader reader) { reader.Read(); var v = reader.GetString()!; - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.String(v); } if (reader.ValueTextEquals("N"u8)) { reader.Read(); var v = reader.GetString()!; - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.Number(v); } if (reader.ValueTextEquals("BOOL"u8)) { reader.Read(); var v = reader.GetBoolean(); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.Bool(v); } if (reader.ValueTextEquals("NULL"u8)) { reader.Read(); - reader.GetBoolean(); - reader.Read(); // EndObject + var isNull = reader.GetBoolean(); + ReadEndObject(ref reader); + if (!isNull) + throw new JsonException("Invalid NULL attribute value: expected true but got false"); return AttributeValue.Null(); } if (reader.ValueTextEquals("SS"u8)) { reader.Read(); var v = ReadStringArray(ref reader); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromStringSet(v); } if (reader.ValueTextEquals("NS"u8)) { reader.Read(); var v = ReadStringArray(ref reader); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromNumberSet(v); } if (reader.ValueTextEquals("L"u8)) { reader.Read(); var v = ReadAttributeValueList(ref reader); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromList(v); } if (reader.ValueTextEquals("M"u8)) { reader.Read(); var v = ReadAttributeMap(ref reader); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromMap(v); } if (reader.ValueTextEquals("B"u8)) { reader.Read(); var v = Convert.FromBase64String(reader.GetString()!); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromBinary(v); } if (reader.ValueTextEquals("BS"u8)) { reader.Read(); var v = ReadBinaryArray(ref reader); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromBinarySet(v); } @@ -271,6 +273,13 @@ private static AttributeValue ReadAttributeValue(ref Utf8JsonReader reader) throw new JsonException("Unrecognized DynamoDB attribute type: no recognized type property found in attribute value object"); } + private static void ReadEndObject(ref Utf8JsonReader reader) + { + reader.Read(); + if (reader.TokenType != JsonTokenType.EndObject) + throw new JsonException("Expected end of attribute value wrapper object"); + } + private static List ReadStringArray(ref Utf8JsonReader reader) { var list = new List(); diff --git a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs index 48f92050..53ded7a0 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Tests/DynamoResponseReaderTests.cs @@ -830,6 +830,64 @@ public async Task ReadGetItemResponse_RefOverload_ShouldReturnNull_WhenNoItemPro await Assert.That(result).IsNull(); } + // === NULL attribute validation === + + [Test] + public async Task ReadAttributeValue_NullTrue_ShouldReturnNullAttributeValue() + { + var json = """ + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "empty": {"NULL": true} + } + } + """u8; + + var result = DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity); + + await Assert.That(result.LastEvaluatedKey).IsNotNull(); + await Assert.That(result.LastEvaluatedKey!["empty"].NULL).IsEqualTo(true); + } + + [Test] + public void ReadAttributeValue_NullFalse_ShouldThrowJsonException() + { + var json = System.Text.Encoding.UTF8.GetBytes(""" + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "empty": {"NULL": false} + } + } + """); + + Assert.Throws(() => DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity)); + } + + // === Wrapper object validation === + + [Test] + public void ReadAttributeValue_ExtraPropertyInWrapper_ShouldThrowJsonException() + { + var json = System.Text.Encoding.UTF8.GetBytes(""" + { + "Items": [], + "Count": 0, + "ScannedCount": 0, + "LastEvaluatedKey": { + "pk": {"S": "value", "Extra": "bad"} + } + } + """); + + Assert.Throws(() => DynamoResponseReader.ReadQueryResponse(json, ReadTestEntity)); + } + // === Binary attribute parsing === [Test] From 8efbf3bb2f5fbbaa16b6f082311ae1e7fc1709d4 Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Wed, 11 Mar 2026 20:45:33 +0000 Subject: [PATCH 16/18] fix: Harden AttributeValueJsonConverter with EndObject and NULL validation Mirror the DynamoResponseReader hardening: validate EndObject token after all 10 type branches and reject {"NULL": false} instead of silently coercing to Null. --- .../Models/AttributeValue.cs | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs index 5c164752..734dfa4f 100644 --- a/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs +++ b/src/Clients/Goa.Clients.Dynamo/Models/AttributeValue.cs @@ -188,28 +188,30 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver { reader.Read(); var v = reader.GetString()!; - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.String(v); } if (reader.ValueTextEquals("N"u8)) { reader.Read(); var v = reader.GetString()!; - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.Number(v); } if (reader.ValueTextEquals("BOOL"u8)) { reader.Read(); var v = reader.GetBoolean(); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.Bool(v); } if (reader.ValueTextEquals("NULL"u8)) { reader.Read(); - reader.GetBoolean(); - reader.Read(); // EndObject + var isNull = reader.GetBoolean(); + ReadEndObject(ref reader); + if (!isNull) + throw new JsonException("Invalid NULL attribute value: expected true but got false"); return AttributeValue.Null(); } if (reader.ValueTextEquals("SS"u8)) @@ -218,7 +220,7 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver var list = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(reader.GetString()!); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromStringSet(list); } if (reader.ValueTextEquals("NS"u8)) @@ -227,7 +229,7 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver var list = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(reader.GetString()!); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromNumberSet(list); } if (reader.ValueTextEquals("L"u8)) @@ -236,7 +238,7 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver var list = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(Read(ref reader, typeToConvert, options)); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromList(list); } if (reader.ValueTextEquals("M"u8)) @@ -249,14 +251,14 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver reader.Read(); map[key] = Read(ref reader, typeToConvert, options); } - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromMap(map); } if (reader.ValueTextEquals("B"u8)) { reader.Read(); var v = Convert.FromBase64String(reader.GetString()!); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromBinary(v); } if (reader.ValueTextEquals("BS"u8)) @@ -265,7 +267,7 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver var list = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) list.Add(Convert.FromBase64String(reader.GetString()!)); - reader.Read(); // EndObject + ReadEndObject(ref reader); return AttributeValue.FromBinarySet(list); } @@ -277,6 +279,13 @@ public override AttributeValue Read(ref Utf8JsonReader reader, Type typeToConver throw new JsonException("Invalid AttributeValue JSON: no recognized DynamoDB type property found"); } + private static void ReadEndObject(ref Utf8JsonReader reader) + { + reader.Read(); + if (reader.TokenType != JsonTokenType.EndObject) + throw new JsonException("Expected end of attribute value wrapper object"); + } + /// public override void Write(Utf8JsonWriter writer, AttributeValue value, JsonSerializerOptions options) { From a574b0466e6ac92b8d870a7729f1c677014e34cd Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 10:42:52 +0000 Subject: [PATCH 17/18] chore: Uncomment 100-item query benchmarks for apples-to-apples comparison Only the 1-item benchmarks were active, giving misleading perf numbers. Typed Goa benchmark stays commented until PR #69 merges. --- .../Benchmarks/QueryAsyncBenchmarks.cs | 186 +++++++++--------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 52b3cf6d..3934ae93 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -249,56 +249,56 @@ public async Task Efficient_Query_1Item_Typed() // return count; // } // - // // --- 100 item query (Limit=25, forces 4 pages) --- - // - // [Benchmark(Baseline = true), BenchmarkCategory("100 Items")] - // public async Task AwsSdk_Query_100Items() - // { - // var count = 0; - // Dictionary? lastKey = null; - // do - // { - // var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest - // { - // TableName = _fixture.TableName, - // KeyConditionExpression = "pk = :pk", - // ExpressionAttributeValues = new Dictionary - // { - // [":pk"] = new AttributeValue("query-100") - // }, - // Limit = 25, - // ExclusiveStartKey = lastKey - // }); - // count += response.Items.Count; - // lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; - // } while (lastKey != null); - // return count; - // } - // - // [Benchmark, BenchmarkCategory("100 Items")] - // public async Task Goa_Query_100Items_DynamoRecord() - // { - // var count = 0; - // Dictionary? lastKey = null; - // do - // { - // var response = 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 - // }); - // count += response.Value.Items.Count; - // lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; - // } while (lastKey != null); - // return count; - // } - // + // --- 100 item query (Limit=25, forces 4 pages) --- + + [Benchmark(Baseline = true), BenchmarkCategory("100 Items")] + public async Task AwsSdk_Query_100Items() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest + { + TableName = _fixture.TableName, + KeyConditionExpression = "pk = :pk", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = new AttributeValue("query-100") + }, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("100 Items")] + public async Task Goa_Query_100Items_DynamoRecord() + { + var count = 0; + Dictionary? lastKey = null; + do + { + var response = 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 + }); + count += response.Value.Items.Count; + lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + } while (lastKey != null); + return count; + } + // [Benchmark, BenchmarkCategory("100 Items")] // public async Task Goa_Query_100Items_Typed() // { @@ -322,49 +322,49 @@ public async Task Efficient_Query_1Item_Typed() // } while (lastKey != null); // return count; // } - // - // [Benchmark, BenchmarkCategory("100 Items")] - // public async Task Efficient_Query_100Items() - // { - // var count = 0; - // IReadOnlyDictionary? lastKey = null; - // do - // { - // var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest - // { - // TableName = _fixture.TableName, - // KeyConditionExpression = "pk = :pk", - // ExpressionAttributeValues = new Dictionary - // { - // [":pk"] = "query-100" - // }, - // Limit = 25, - // ExclusiveStartKey = lastKey - // }); - // count += response.Items.Count; - // lastKey = response.LastEvaluatedKey; - // } while (lastKey != null); - // return count; - // } - // - // [Benchmark, BenchmarkCategory("100 Items")] - // public async Task Efficient_Query_100Items_Typed() - // { - // var count = 0; - // string? paginationToken = null; - // do - // { - // var page = await _fixture.EfficientContext.Query() - // .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-100")) - // .WithLimit(25) - // .WithPaginationToken(paginationToken) - // .ToPageAsync(); - // count += page.Items.Count; - // paginationToken = page.PaginationToken; - // } while (paginationToken != null); - // return count; - // } - // + + [Benchmark, BenchmarkCategory("100 Items")] + public async Task Efficient_Query_100Items() + { + var count = 0; + IReadOnlyDictionary? lastKey = null; + do + { + var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest + { + TableName = _fixture.TableName, + KeyConditionExpression = "pk = :pk", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = "query-100" + }, + Limit = 25, + ExclusiveStartKey = lastKey + }); + count += response.Items.Count; + lastKey = response.LastEvaluatedKey; + } while (lastKey != null); + return count; + } + + [Benchmark, BenchmarkCategory("100 Items")] + public async Task Efficient_Query_100Items_Typed() + { + var count = 0; + string? paginationToken = null; + do + { + var page = await _fixture.EfficientContext.Query() + .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-100")) + .WithLimit(25) + .WithPaginationToken(paginationToken) + .ToPageAsync(); + count += page.Items.Count; + paginationToken = page.PaginationToken; + } while (paginationToken != null); + return count; + } + // // --- Empty query --- // // [Benchmark(Baseline = true), BenchmarkCategory("No Results")] From 6c3530a252149b07554c05611ae9f7fc5539b05d Mon Sep 17 00:00:00 2001 From: "Stuart Blackler (@im5tu)" Date: Thu, 12 Mar 2026 10:44:09 +0000 Subject: [PATCH 18/18] chore: Comment out 1-item query benchmarks Only 100-item benchmarks active for meaningful comparison. --- .../Benchmarks/QueryAsyncBenchmarks.cs | 158 +++++++++--------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs index 3934ae93..827b094c 100644 --- a/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs +++ b/tests/Clients/Goa.Clients.Dynamo.Benchmarks/Benchmarks/QueryAsyncBenchmarks.cs @@ -53,85 +53,85 @@ public void Cleanup() _fixture.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - // --- Single item query --- - - [Benchmark(Baseline = true), BenchmarkCategory("1 Item")] - public async Task AwsSdk_Query_1Item() - { - var count = 0; - Dictionary? lastKey = null; - do - { - var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest - { - TableName = _fixture.TableName, - KeyConditionExpression = "pk = :pk", - ExpressionAttributeValues = new Dictionary - { - [":pk"] = new AttributeValue("query-1") - }, - ExclusiveStartKey = lastKey - }); - count += response.Items.Count; - lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; - } while (lastKey != null); - return count; - } - - [Benchmark, BenchmarkCategory("1 Item")] - public async Task Goa_Query_1Item_DynamoRecord() - { - var count = 0; - Dictionary? lastKey = null; - do - { - var response = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest - { - TableName = _fixture.TableName, - KeyConditionExpression = "pk = :pk", - ExpressionAttributeValues = new Dictionary - { - [":pk"] = GoaModels.AttributeValue.String("query-1") - }, - ExclusiveStartKey = lastKey - }); - - count += response.Value.Items.Count; - lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; - } while (lastKey != null); - return count; - } - - [Benchmark, BenchmarkCategory("1 Item")] - public async Task Efficient_Query_1Item() - { - var count = 0; - IReadOnlyDictionary? lastKey = null; - do - { - var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest - { - TableName = _fixture.TableName, - KeyConditionExpression = "pk = :pk", - ExpressionAttributeValues = new Dictionary - { - [":pk"] = "query-1" - }, - ExclusiveStartKey = lastKey - }); - count += response.Items.Count; - lastKey = response.LastEvaluatedKey; - } while (lastKey != null); - return count; - } - - [Benchmark, BenchmarkCategory("1 Item")] - public async Task Efficient_Query_1Item_Typed() - { - return (await _fixture.EfficientContext.Query() - .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-1")) - .ToListAsync()).Count; - } + // // --- Single item query --- + // + // [Benchmark(Baseline = true), BenchmarkCategory("1 Item")] + // public async Task AwsSdk_Query_1Item() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.AwsSdkClient.QueryAsync(new Amazon.DynamoDBv2.Model.QueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = new AttributeValue("query-1") + // }, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey?.Count > 0 ? response.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("1 Item")] + // public async Task Goa_Query_1Item_DynamoRecord() + // { + // var count = 0; + // Dictionary? lastKey = null; + // do + // { + // var response = await _fixture.GoaClient.QueryAsync(new GoaQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = GoaModels.AttributeValue.String("query-1") + // }, + // ExclusiveStartKey = lastKey + // }); + // + // count += response.Value.Items.Count; + // lastKey = response.Value.HasMoreResults ? response.Value.LastEvaluatedKey : null; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("1 Item")] + // public async Task Efficient_Query_1Item() + // { + // var count = 0; + // IReadOnlyDictionary? lastKey = null; + // do + // { + // var response = await _fixture.EfficientClient.QueryAsync(new EfficientQueryRequest + // { + // TableName = _fixture.TableName, + // KeyConditionExpression = "pk = :pk", + // ExpressionAttributeValues = new Dictionary + // { + // [":pk"] = "query-1" + // }, + // ExclusiveStartKey = lastKey + // }); + // count += response.Items.Count; + // lastKey = response.LastEvaluatedKey; + // } while (lastKey != null); + // return count; + // } + // + // [Benchmark, BenchmarkCategory("1 Item")] + // public async Task Efficient_Query_1Item_Typed() + // { + // return (await _fixture.EfficientContext.Query() + // .WithKeyExpression(Condition.On(x => x.Pk).EqualTo("query-1")) + // .ToListAsync()).Count; + // } // --- 10 item query (Limit=5, forces 2 pages) ---