From 2a011640f2e56f2fbbf7be5eb7843d170ec9c4ff Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 21 Jul 2025 10:04:41 +0300 Subject: [PATCH 1/7] wip request objects --- .../DataModel/DocumentOperationRequest.cs | 195 ++++++++++++++++++ .../DocumentModel/_async/Table.Async.cs | 19 ++ 2 files changed, 214 insertions(+) create mode 100644 sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs new file mode 100644 index 000000000000..cbd5ce513169 --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs @@ -0,0 +1,195 @@ +using System.Collections.Generic; +using Amazon.DynamoDBv2.DocumentModel; + +namespace Amazon.DynamoDBv2.DataModel +{ + /// + /// Base class for requests that perform operations on a document in DynamoDB. + /// + public abstract class DocumentOperationRequest + { + //"ReturnConsumedCapacity" + } + + /// + /// Represents a request to update an item in a DynamoDB table using the Document Model. + /// + public class UpdateItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the attributes to be updated in the item. + /// + public Document Document { get; set; } + + /// + /// Gets or sets the update expression specifying how attributes should be updated. + /// + public Expression UpdateExpression { get; set; } + + /// + /// The expression that is evaluated before the update is performed. If the expression evaluates to false the update + /// will fail and a ConditionalCheckFailedException exception will be thrown. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + } + + /// + /// Represents a request to get an item from a DynamoDB table using the Document Model. + /// + public class GetItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the projection expression specifying which attributes should be retrieved. + /// + public Expression ProjectionExpression { get; set; } + + /// + /// Gets or sets the consistent read flag. + /// + public bool ConsistentRead { get; set; } + } + + /// + /// Represents a request to delete an item from a DynamoDB table using the Document Model. + /// + public class DeleteItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the conditional expression specifying when the item should be deleted. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + } + + /// + /// Represents a request to put (create or replace) an item in a DynamoDB table using the Document Model. + /// + public class PutItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the document to be put in the table. + /// + public Document Document { get; set; } + + /// + /// Gets or sets the conditional expression specifying when the item should be put. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + } + + /// + /// Represents a request to scan items in a DynamoDB table using the Document Model. + /// + public class ScanDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the filter expression specifying which items should be returned. + /// + public Expression FilterExpression { get; set; } + + /// + /// Gets or sets the projection expression specifying which attributes should be retrieved. + /// + public Expression ProjectionExpression { get; set; } + + /// + /// Gets or sets the maximum number of items to return. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets the exclusive start key for paginated scans. + /// + public IDictionary ExclusiveStartKey { get; set; } + + /// + /// Gets or sets the consistent read flag. + /// + public bool ConsistentRead { get; set; } + + /// + /// Gets or sets the segment number for parallel scans. + /// + public int? Segment { get; set; } + + /// + /// Gets or sets the total number of segments for parallel scans. + /// + public int? TotalSegments { get; set; } + } + + /// + /// Represents a request to query items in a DynamoDB table using the Document Model. + /// + public class QueryDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key condition expression specifying which items should be returned. + /// + public Expression KeyConditionExpression { get; set; } + + /// + /// Gets or sets the filter expression specifying which items should be returned. + /// + public Expression FilterExpression { get; set; } + + /// + /// Gets or sets the projection expression specifying which attributes should be retrieved. + /// + public Expression ProjectionExpression { get; set; } + + /// + /// Gets or sets the exclusive start key for paginated queries. + /// + public IDictionary ExclusiveStartKey { get; set; } + + /// + /// Gets or sets the maximum number of items to return. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets the consistent read flag. + /// + public bool ConsistentRead { get; set; } + + /// + /// Gets or sets the index name to query against. + /// + public string IndexName { get; set; } + + /// + /// Gets or sets the scan direction. If true, the scan is performed in descending order. + /// + public bool ScanIndexForward { get; set; } + } +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs index 8b8abf7110ad..80bf2b67d782 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs @@ -24,6 +24,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; +using Amazon.DynamoDBv2.DataModel; using Amazon.Runtime.Internal; using Amazon.Runtime.Telemetry.Tracing; @@ -189,6 +190,15 @@ public partial interface ITable /// A Task that can be used to poll or wait for results, or both. Task UpdateItemAsync(Document doc, Primitive hashKey, Primitive rangeKey, UpdateItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Initiates the asynchronous execution of the UpdateItem operation using a DocumentUpdateRequest object. + /// + /// The UpdateItemDocumentOperationRequest object containing all parameters for the update. + /// Token which can be used to cancel the task. + /// A Task that can be used to poll or wait for results, or both. + Task UpdateItemAsync(UpdateItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)); + + #endregion @@ -441,6 +451,15 @@ public partial class Table : ITable } } + /// + public async Task UpdateItemAsync(UpdateItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(UpdateItemAsync)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + throw new NotImplementedException(); + } + } #endregion From fc6c71fc720ad8502c6f38b0e8e7d9f50542448c Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 4 Aug 2025 18:33:20 +0300 Subject: [PATCH 2/7] DeleteItemDocumentOperationRequest implementation wip wip implement sync update method and unit tests PutItemOperationRequest integration tests --- .../9490947f-209f-47e9-8c70-3698872df304.json | 11 + .../DataModel/DocumentOperationRequest.cs | 195 ----- .../DocumentModel/DocumentOperationRequest.cs | 87 ++ .../Custom/DocumentModel/Expression.cs | 35 + .../DynamoDBv2/Custom/DocumentModel/Table.cs | 275 +++++- .../DynamoDBv2/Custom/DocumentModel/Util.cs | 2 +- .../DocumentModel/_async/Table.Async.cs | 59 +- .../Custom/DocumentModel/_bcl/Table.Sync.cs | 132 +++ .../IntegrationTests/DataModelTests.cs | 51 ++ .../IntegrationTests/DocumentTests.cs | 152 ++++ .../Custom/DocumentModel/TableTests.cs | 790 ++++++++++++++++++ 11 files changed, 1572 insertions(+), 217 deletions(-) create mode 100644 generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json delete mode 100644 sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs create mode 100644 sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs create mode 100644 sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs diff --git a/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json b/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json new file mode 100644 index 000000000000..da0de01eecab --- /dev/null +++ b/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json @@ -0,0 +1,11 @@ +{ + "services": [ + { + "serviceName": "DynamoDBv2", + "type": "minor", + "changeLogMessages": [ + "Add Request Object Pattern and Expression-Based for DynamoDB Document Model " + ] + } + ] +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs deleted file mode 100644 index cbd5ce513169..000000000000 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/DocumentOperationRequest.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Collections.Generic; -using Amazon.DynamoDBv2.DocumentModel; - -namespace Amazon.DynamoDBv2.DataModel -{ - /// - /// Base class for requests that perform operations on a document in DynamoDB. - /// - public abstract class DocumentOperationRequest - { - //"ReturnConsumedCapacity" - } - - /// - /// Represents a request to update an item in a DynamoDB table using the Document Model. - /// - public class UpdateItemDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the key identifying the item in the table. - /// - public IDictionary Key { get; set; } - - /// - /// Gets or sets the attributes to be updated in the item. - /// - public Document Document { get; set; } - - /// - /// Gets or sets the update expression specifying how attributes should be updated. - /// - public Expression UpdateExpression { get; set; } - - /// - /// The expression that is evaluated before the update is performed. If the expression evaluates to false the update - /// will fail and a ConditionalCheckFailedException exception will be thrown. - /// - public Expression ConditionalExpression { get; set; } - - /// - /// Flag specifying what values should be returned. - /// - public ReturnValues ReturnValues { get; set; } - } - - /// - /// Represents a request to get an item from a DynamoDB table using the Document Model. - /// - public class GetItemDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the key identifying the item in the table. - /// - public IDictionary Key { get; set; } - - /// - /// Gets or sets the projection expression specifying which attributes should be retrieved. - /// - public Expression ProjectionExpression { get; set; } - - /// - /// Gets or sets the consistent read flag. - /// - public bool ConsistentRead { get; set; } - } - - /// - /// Represents a request to delete an item from a DynamoDB table using the Document Model. - /// - public class DeleteItemDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the key identifying the item in the table. - /// - public IDictionary Key { get; set; } - - /// - /// Gets or sets the conditional expression specifying when the item should be deleted. - /// - public Expression ConditionalExpression { get; set; } - - /// - /// Flag specifying what values should be returned. - /// - public ReturnValues ReturnValues { get; set; } - } - - /// - /// Represents a request to put (create or replace) an item in a DynamoDB table using the Document Model. - /// - public class PutItemDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the document to be put in the table. - /// - public Document Document { get; set; } - - /// - /// Gets or sets the conditional expression specifying when the item should be put. - /// - public Expression ConditionalExpression { get; set; } - - /// - /// Flag specifying what values should be returned. - /// - public ReturnValues ReturnValues { get; set; } - } - - /// - /// Represents a request to scan items in a DynamoDB table using the Document Model. - /// - public class ScanDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the filter expression specifying which items should be returned. - /// - public Expression FilterExpression { get; set; } - - /// - /// Gets or sets the projection expression specifying which attributes should be retrieved. - /// - public Expression ProjectionExpression { get; set; } - - /// - /// Gets or sets the maximum number of items to return. - /// - public int? Limit { get; set; } - - /// - /// Gets or sets the exclusive start key for paginated scans. - /// - public IDictionary ExclusiveStartKey { get; set; } - - /// - /// Gets or sets the consistent read flag. - /// - public bool ConsistentRead { get; set; } - - /// - /// Gets or sets the segment number for parallel scans. - /// - public int? Segment { get; set; } - - /// - /// Gets or sets the total number of segments for parallel scans. - /// - public int? TotalSegments { get; set; } - } - - /// - /// Represents a request to query items in a DynamoDB table using the Document Model. - /// - public class QueryDocumentOperationRequest : DocumentOperationRequest - { - /// - /// Gets or sets the key condition expression specifying which items should be returned. - /// - public Expression KeyConditionExpression { get; set; } - - /// - /// Gets or sets the filter expression specifying which items should be returned. - /// - public Expression FilterExpression { get; set; } - - /// - /// Gets or sets the projection expression specifying which attributes should be retrieved. - /// - public Expression ProjectionExpression { get; set; } - - /// - /// Gets or sets the exclusive start key for paginated queries. - /// - public IDictionary ExclusiveStartKey { get; set; } - - /// - /// Gets or sets the maximum number of items to return. - /// - public int? Limit { get; set; } - - /// - /// Gets or sets the consistent read flag. - /// - public bool ConsistentRead { get; set; } - - /// - /// Gets or sets the index name to query against. - /// - public string IndexName { get; set; } - - /// - /// Gets or sets the scan direction. If true, the scan is performed in descending order. - /// - public bool ScanIndexForward { get; set; } - } -} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs new file mode 100644 index 000000000000..8842898feb04 --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +namespace Amazon.DynamoDBv2.DocumentModel +{ + /// + /// Base class for requests that perform operations on a document in DynamoDB. + /// + public abstract class DocumentOperationRequest + { + } + + /// + /// Represents a request to update an item in a DynamoDB table using the Document Model. + /// + public class UpdateItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the attributes to be updated in the item. + /// + public Document Document { get; set; } + + /// + /// Gets or sets the update expression specifying how attributes should be updated. + /// + public Expression UpdateExpression { get; set; } + + /// + /// The expression that is evaluated before the update is performed. If the expression evaluates to false the update + /// will fail and a ConditionalCheckFailedException exception will be thrown. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + } + + /// + /// Represents a request to delete an item from a DynamoDB table using the Document Model. + /// + public class DeleteItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the conditional expression specifying when the item should be deleted. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + + } + + /// + /// Represents a request to put (create or replace) an item in a DynamoDB table using the Document Model. + /// + public class PutItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the document to be put in the table. + /// + public Document Document { get; set; } + + /// + /// Gets or sets the conditional expression specifying when the item should be put. + /// + public Expression ConditionalExpression { get; set; } + + /// + /// Flag specifying what values should be returned. + /// + public ReturnValues ReturnValues { get; set; } + } + +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs index 3278dbc0015c..6c8d216e6321 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -127,6 +127,41 @@ internal void ApplyExpression(UpdateItemRequest request, Table table) } } + internal void ApplyConditionalExpression(UpdateItemRequest request, Table table) + { + request.ConditionExpression = this.ExpressionStatement; + MergeAttributes(request, table); + } + + internal void ApplyUpdateExpression(UpdateItemRequest request, Table table) + { + request.UpdateExpression = this.ExpressionStatement; + MergeAttributes(request, table); + } + + private void MergeAttributes(UpdateItemRequest request, Table table) + { + var convertToAttributeValues = ConvertToAttributeValues(this.ExpressionAttributeValues, table); + request.ExpressionAttributeValues ??= new Dictionary(StringComparer.Ordinal); + foreach (var kvp in convertToAttributeValues) + { + request.ExpressionAttributeValues[kvp.Key] = kvp.Value; + } + + + if (this.ExpressionAttributeNames?.Count > 0) + { + request.ExpressionAttributeNames ??= new Dictionary(StringComparer.Ordinal); + foreach (var kvp in this.ExpressionAttributeNames) + { + if (!request.ExpressionAttributeNames.ContainsKey(kvp.Key)) + { + request.ExpressionAttributeNames[kvp.Key] = kvp.Value; + } + } + } + } + internal void ApplyExpression(Get request, Table table) { request.ProjectionExpression = ExpressionStatement; diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index 023532ed7e9f..3936ddc69955 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -13,20 +13,18 @@ * permissions and limitations under the License. */ -using System; - +using Amazon.DynamoDBv2.DataModel; using Amazon.DynamoDBv2.Model; -using Amazon.Runtime; -using Amazon.Util; +using Amazon.Runtime.Internal.UserAgent; +using Amazon.Runtime.Internal.Util; +using Amazon.Runtime.Telemetry.Tracing; +using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; -using System.Collections.Generic; -using System.Globalization; -using Amazon.DynamoDBv2.DataModel; -using Amazon.Runtime.Internal.Util; -using Amazon.Runtime.Telemetry.Tracing; -using Amazon.Runtime.Internal.UserAgent; namespace Amazon.DynamoDBv2.DocumentModel { @@ -1239,6 +1237,47 @@ internal Document PutItemHelper(Document doc, PutItemOperationConfig config) return ret; } + internal async Task PutItemHelperAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken) + { + var req = MapPutItemDocumentOperationRequestToPutItemRequest(request); + this.UpdateRequestUserAgentDetails(req, isAsync: true); + var resp = await DDBClient.PutItemAsync(req, cancellationToken).ConfigureAwait(false); + request.Document.CommitChanges(); + Document ret = null; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + { + ret = this.FromAttributeMap(resp.Attributes); + } + return ret; + } + internal Document PutItemHelper(PutItemDocumentOperationRequest request) + { + var req = MapPutItemDocumentOperationRequestToPutItemRequest(request); + +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous PutItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via PutItemAsync instead."); + } +#else + var client = DDBClient; +#endif + + var resp = client.PutItem(req); + + request.Document.CommitChanges(); + Document ret = null; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + { + ret = this.FromAttributeMap(resp.Attributes); + } + return ret; + } + + internal async Task PutItemHelperAsync(Document doc, PutItemOperationConfig config, CancellationToken cancellationToken) { var currentConfig = config ?? new PutItemOperationConfig(); @@ -1284,6 +1323,31 @@ internal async Task PutItemHelperAsync(Document doc, PutItemOperationC return ret; } + private PutItemRequest MapPutItemDocumentOperationRequestToPutItemRequest(PutItemDocumentOperationRequest request) + { + if(request==null) + throw new ArgumentNullException(nameof(request)); + + if (request.Document == null) + throw new InvalidOperationException("The Document property of the PutItemDocumentOperationRequest cannot be null."); + + PutItemRequest req = new PutItemRequest + { + TableName = TableName, + Item = this.ToAttributeMap(request.Document) + }; + + if (request.ReturnValues == ReturnValues.AllOldAttributes) + req.ReturnValues = EnumMapper.Convert(request.ReturnValues); + + if (request.ConditionalExpression is { IsSet: true }) + { + request.ConditionalExpression.ApplyExpression(req, this); + } + + return req; + } + #endregion @@ -1362,6 +1426,122 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi return UpdateHelperAsync(doc, key, config, expression, cancellationToken); } + internal Document UpdateHelper(UpdateItemDocumentOperationRequest request) + { + var req = MapUpdateItemOperationToUpdateItemRequest(request, out var doc); + +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous UpdateItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via UpdateItemAsync instead."); + } +#else + var client = DDBClient; +#endif + + var resp = client.UpdateItem(req); + var returnedAttributes = resp.Attributes; + + // If the document was provided, commit the changes to it + doc?.CommitChanges(); + + Document ret = null; + if (request.ReturnValues != ReturnValues.None) + { + ret = this.FromAttributeMap(returnedAttributes); + } + return ret; + + } + + internal async Task UpdateHelperAsync(UpdateItemDocumentOperationRequest request, + CancellationToken cancellationToken) + { + var req = MapUpdateItemOperationToUpdateItemRequest(request, out var doc); + + this.UpdateRequestUserAgentDetails(req, isAsync: true); + + var resp = await DDBClient.UpdateItemAsync(req, cancellationToken).ConfigureAwait(false); + var returnedAttributes = resp.Attributes; + + // If the document was provided, commit the changes to it + doc?.CommitChanges(); + + Document ret = null; + if (request.ReturnValues != ReturnValues.None) + { + ret = this.FromAttributeMap(returnedAttributes); + } + + return ret; + } + + private UpdateItemRequest MapUpdateItemOperationToUpdateItemRequest(UpdateItemDocumentOperationRequest request, out Document doc) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + doc = request.Document; + + Expression updateExpression = request.UpdateExpression; + + // Validate that either doc or updateExpression is set, but not both null or both set to non-meaningful values + if ((doc == null && updateExpression is not { IsSet: true }) || + (doc != null && updateExpression is { IsSet: true })) + { + throw new InvalidOperationException("Either Document or UpdateExpression must be set in the request."); + } + + UpdateItemRequest req = new UpdateItemRequest + { + TableName = TableName, + ReturnValues = EnumMapper.Convert(request.ReturnValues) + }; + + Key key = request.Key != null ? MakeKey(request.Key) : null; + + if (doc != null) + { + key ??= MakeKey(doc); + bool haveKeysChanged = HaveKeysChanged(doc); + bool updateChangedAttributesOnly = !haveKeysChanged; + var attributeUpdates = this.ToAttributeUpdateMap(doc, updateChangedAttributesOnly); + foreach (var keyName in this.KeyNames) + { + attributeUpdates.Remove(keyName); + } + + Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression, this, + out var statement, out var expressionAttributeValues, out var expressionAttributeNames); + req.UpdateExpression = statement; + req.ExpressionAttributeValues = expressionAttributeValues; + req.ExpressionAttributeNames = expressionAttributeNames; + } + + if (key == null || key.Count == 0) + { + throw new InvalidOperationException( + "UpdateItem requires a key to be specified either in the request or in the Document."); + } + + req.Key = key; + + if(request.UpdateExpression is { IsSet: true }) + { + request.UpdateExpression.ApplyUpdateExpression(req, this); + } + + if(request.ConditionalExpression is { IsSet: true }) + { + request.ConditionalExpression.ApplyConditionalExpression(req, this); + } + + return req; + } + internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1604,6 +1784,7 @@ internal Document DeleteHelper(Key key, DeleteItemOperationConfig config) return ret; } + internal async Task DeleteHelperAsync(Key key, DeleteItemOperationConfig config, CancellationToken cancellationToken) { var currentConfig = config ?? new DeleteItemOperationConfig(); @@ -1647,6 +1828,78 @@ internal async Task DeleteHelperAsync(Key key, DeleteItemOperationConf return ret; } + + internal Document DeleteHelper(DeleteItemDocumentOperationRequest request) + { + var req = MapDeleteItemOperationRequestToDeleteItemRequest(request); + +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException( + "Calling the synchronous DeleteItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when calling DeleteItemAsync instead."); + } +#else + var client = DDBClient; +#endif + + var attributes = client.DeleteItem(req).Attributes; + + Document ret = null; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + { + ret = this.FromAttributeMap(attributes); + } + + return ret; + } + + internal async Task DeleteHelperAsync(DeleteItemDocumentOperationRequest request, CancellationToken cancellationToken) + { + var req = MapDeleteItemOperationRequestToDeleteItemRequest(request); + + this.UpdateRequestUserAgentDetails(req, isAsync: true); + + var attributes = (await DDBClient.DeleteItemAsync(req, cancellationToken).ConfigureAwait(false)).Attributes; + + Document ret = null; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + { + ret = this.FromAttributeMap(attributes); + } + return ret; + } + + private DeleteItemRequest MapDeleteItemOperationRequestToDeleteItemRequest(DeleteItemDocumentOperationRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (request.Key == null || request.Key.Count == 0) + { + throw new InvalidOperationException("Key cannot be null or empty"); + } + + var req = new DeleteItemRequest + { + TableName = TableName, + Key = MakeKey(request.Key) + }; + + if (request.ReturnValues == ReturnValues.AllOldAttributes) + req.ReturnValues = EnumMapper.Convert(request.ReturnValues); + + if(request.ConditionalExpression is { IsSet: true }) + { + request.ConditionalExpression.ApplyExpression(req, this); + } + + return req; + } + #endregion @@ -1809,7 +2062,7 @@ public IDocumentTransactWrite CreateTransactWrite() { return new DocumentTransactWrite(this); } - #endregion + } } diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs index 457233e96e9f..a9276d0abd49 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs @@ -327,7 +327,7 @@ public static string Convert(ConditionalOperatorValues value) internal static class Common { private const string AwsVariablePrefix = "awsavar"; - + public static void ConvertAttributeUpdatesToUpdateExpression( Dictionary attributesToUpdates, Expression updateExpression, Table table, diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs index 80bf2b67d782..370b5ce0850d 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs @@ -36,7 +36,7 @@ public partial interface ITable /// /// Initiates the asynchronous execution of the PutItem operation. - /// + /// /// /// Document to save. /// Token which can be used to cancel the task. @@ -45,7 +45,7 @@ public partial interface ITable /// /// Initiates the asynchronous execution of the PutItem operation. - /// + /// /// /// Document to save. /// Configuration to use. @@ -53,6 +53,17 @@ public partial interface ITable /// A Task that can be used to poll or wait for results, or both. Task PutItemAsync(Document doc, PutItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Initiates the asynchronous execution of the PutItem operation. + /// + /// + /// The PutItemDocumentOperationRequest object containing all parameters for the PutItem operation. + /// Token which can be used to cancel the task. + /// A Task that can be used to poll or wait for results, or both. + Task PutItemAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)); + + + #endregion #region GetItemAsync @@ -274,6 +285,14 @@ public partial interface ITable /// A Task that can be used to poll or wait for results, or both. Task DeleteItemAsync(IDictionary key, DeleteItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Initiates the asynchronous execution of the DeleteItem operation. + /// + /// The DeleteItemDocumentOperationRequest object containing all parameters for the delete operation. + /// Token which can be used to cancel the task. + /// A Task that can be used to poll or wait for results, or both. + Task DeleteItemAsync(DeleteItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)); + #endregion } @@ -302,6 +321,16 @@ public partial class Table : ITable } } + /// + public async Task PutItemAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(PutItemAsync)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return await PutItemHelperAsync(request, cancellationToken).ConfigureAwait(false); + } + } + #endregion #region GetItemAsync @@ -457,7 +486,8 @@ public partial class Table : ITable var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(UpdateItemAsync)); using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) { - throw new NotImplementedException(); + return await UpdateHelperAsync(request, cancellationToken).ConfigureAwait(false); + } } @@ -535,15 +565,24 @@ public partial class Table : ITable } } - /// - public async Task DeleteItemAsync(IDictionary key, DeleteItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)) - { - var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(DeleteItemAsync)); - using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + /// + public async Task DeleteItemAsync(IDictionary key, DeleteItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)) { - return await DeleteHelperAsync(MakeKey(key), config, cancellationToken).ConfigureAwait(false); + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(DeleteItemAsync)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return await DeleteHelperAsync(MakeKey(key), config, cancellationToken).ConfigureAwait(false); + } + } + /// + public async Task DeleteItemAsync(DeleteItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(DeleteItemAsync)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return await DeleteHelperAsync(request, cancellationToken).ConfigureAwait(false); + } } - } #endregion diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs index 2f170790dc5b..e0c96c863454 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs @@ -39,6 +39,20 @@ public partial interface ITable /// True if put is successful or false if the condition in the config was not met. bool TryPutItem(Document doc, PutItemOperationConfig config = null); + /// + /// Puts a document into DynamoDB, using optional configs. + /// + /// The PutItemDocumentOperationRequest object containing all parameters for the PutItem operation. + /// Null or updated attributes, depending on request config. + Document PutItem(PutItemDocumentOperationRequest request); + + /// + /// Puts a document into DynamoDB, using optional configs. + /// + /// The PutItemDocumentOperationRequest object containing all parameters for the PutItem operation. + /// True if put is successful or false if the condition was not met. + bool TryPutItem(PutItemDocumentOperationRequest request); + #endregion #region GetItem @@ -159,6 +173,24 @@ public partial interface ITable /// bool TryUpdateItem(Document doc, Primitive hashKey, Primitive rangeKey, UpdateItemOperationConfig config = null); + + /// + /// Initiates the execution of the UpdateItem operation using a DocumentUpdateRequest object. + /// + /// The UpdateItemDocumentOperationRequest object containing all parameters for the update. + /// Null or updated attributes, depending on request. + Document UpdateItem(UpdateItemDocumentOperationRequest request); + + + /// + /// Update a document in DynamoDB, with a hash-and-range primary key to identify + /// the document, and using the specified config. + /// + /// The UpdateItemDocumentOperationRequest object containing all parameters for the update. + /// True if updated or false if the condition in the config was not met. + /// + bool TryUpdateItem(UpdateItemDocumentOperationRequest request); + #endregion #region DeleteItem @@ -237,6 +269,21 @@ public partial interface ITable /// True if deleted or false if the condition in the config was not met. bool TryDeleteItem(IDictionary key, DeleteItemOperationConfig config = null); + /// + /// Delete a document in DynamoDB, identified by a key, using specified configs. + /// + /// If the condition set on the config fails. + /// The DeleteItemDocumentOperationRequest object containing all parameters for the delete operation. + /// Null or old attributes, depending on config. + Document DeleteItem(DeleteItemDocumentOperationRequest request); + + /// + /// Delete a document in DynamoDB, identified by a key, using specified configs. + /// + /// The DeleteItemDocumentOperationRequest object containing all parameters for the delete operation. + /// True if deleted or false if the condition in the config was not met. + bool TryDeleteItem(DeleteItemDocumentOperationRequest request); + #endregion } @@ -272,6 +319,35 @@ public bool TryPutItem(Document doc, PutItemOperationConfig config = null) } } + /// + public Document PutItem(PutItemDocumentOperationRequest request) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(PutItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return PutItemHelper(request); + } + } + + /// + public bool TryPutItem(PutItemDocumentOperationRequest request) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(TryPutItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + try + { + PutItemHelper(request); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + } + + #endregion @@ -425,6 +501,34 @@ public bool TryUpdateItem(Document doc, Primitive hashKey, Primitive rangeKey, U } } + /// + public Document UpdateItem(UpdateItemDocumentOperationRequest request) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(UpdateItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return UpdateHelper(request); + } + } + + /// + public bool TryUpdateItem(UpdateItemDocumentOperationRequest request) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(TryUpdateItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + try + { + UpdateHelper(request); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + } + #endregion @@ -542,6 +646,34 @@ public bool TryDeleteItem(IDictionary key, DeleteItemOper } } + /// + public Document DeleteItem(DeleteItemDocumentOperationRequest request) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(DeleteItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return DeleteHelper(request); + } + } + + /// + public bool TryDeleteItem(DeleteItemDocumentOperationRequest request) + { + + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(TryDeleteItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + try + { + DeleteHelper(request); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + } #endregion } diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index 8d855f4b9e4d..f8de4685acbf 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -912,6 +912,57 @@ public void TestContext_Scan_WithExpressionFilter() Assert.AreEqual("Sam", upcomingEnumResult[0].Name); } + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestContext_SaveItem_WithTTL() + { + TableCache.Clear(); + CleanupTables(); + TableCache.Clear(); + + // Create context and table if needed + CreateContext(DynamoDBEntryConversion.V2, true); + + // Define TTL to be 2 minutes in the future + var ttlEpoch = DateTimeOffset.UtcNow.AddMinutes(2).ToUnixTimeSeconds(); + + var item = new TtlTestItem + { + Id = 1, + Data = "Test with TTL", + Ttl = ttlEpoch + }; + + Context.Save(item); + + // Load immediately, should exist + var loaded = Context.Load(item.Id); + Assert.IsNotNull(loaded); + Assert.AreEqual(item.Id, loaded.Id); + Assert.AreEqual(item.Data, loaded.Data); + Assert.AreEqual(item.Ttl, loaded.Ttl); + + item.Ttl = DateTimeOffset.UtcNow.AddMinutes(3).ToUnixTimeSeconds(); + Context.Save(item); + var loaded2 = Context.Load(item.Id); + Assert.IsNotNull(loaded2); + Assert.AreEqual(item.Id, loaded2.Id); + Assert.AreEqual(item.Data, loaded2.Data); + Assert.AreEqual(item.Ttl, loaded2.Ttl); + } + + // Example model for TTL + [DynamoDBTable("HashTable")] + public class TtlTestItem + { + [DynamoDBHashKey] + public int Id { get; set; } + + public string Data { get; set; } + + [DynamoDBProperty("TTL")] + public long Ttl { get; set; } + } [TestMethod] [TestCategory("DynamoDBv2")] diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs index 2936e491d5f1..2466a6cfc16e 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs @@ -83,6 +83,11 @@ public void TestTableOperations() // Test Count on Query TestSelectCountOnQuery(hashTable); + + TestExpressionPutWithDocumentOperationRequest(hashTable); + TestExpressionUpdateWithDocumentOperationRequest(hashTable); + TestExpressionsOnDeleteWithDocumentOperationRequest(hashTable); + } } @@ -163,6 +168,11 @@ public void TestTableOperationsViaBuilder() // Test that attributes stored as Datetimes can be retrieved in UTC. TestAsDateTimeUtc(numericHashRangeTable); + + TestExpressionPutWithDocumentOperationRequest(hashTable); + TestExpressionUpdateWithDocumentOperationRequest(hashTable); + TestExpressionsOnDeleteWithDocumentOperationRequest(hashTable); + } } @@ -1852,6 +1862,148 @@ private void TestSelectCountOnQuery(ITable hashTable) Assert.AreEqual(0, docs.Count); } + private void TestExpressionPutWithDocumentOperationRequest(ITable table) + { + var doc = new Document + { + ["Id"] = DateTime.UtcNow.Ticks, + ["name"] = "docop-conditional-form" + }; + + table.PutItem(doc); + + var conditionalExpression = new Expression + { + ExpressionStatement = "attribute_not_exists(referencecounter) OR referencecounter = :zero", + ExpressionAttributeValues = { [":zero"] = 0 } + }; + + var putRequest = new PutItemDocumentOperationRequest + { + Document = doc, + ConditionalExpression = conditionalExpression + }; + + doc["update-test"] = 1; + Assert.IsTrue(table.TryPutItem(putRequest)); + + doc["referencecounter"] = 0; + table.UpdateItem(doc); + + doc["update-test"] = null; + Assert.IsTrue(table.TryPutItem(new PutItemDocumentOperationRequest { Document = doc, ConditionalExpression = conditionalExpression })); + + var reloaded = table.GetItem(doc); + Assert.IsFalse(reloaded.Contains("update-test")); + + doc["referencecounter"] = 1; + table.UpdateItem(doc); + + doc["update-test"] = 3; + Assert.IsFalse(table.TryPutItem(new PutItemDocumentOperationRequest { Document = doc, ConditionalExpression = conditionalExpression })); + + table.DeleteItem(doc); + } + + private void TestExpressionUpdateWithDocumentOperationRequest(ITable table) + { + var doc = new Document + { + ["Id"] = DateTime.UtcNow.Ticks, + ["name"] = "docop-update-conditional" + }; + table.PutItem(doc); + + var conditionalExpression = new Expression + { + ExpressionStatement = "attribute_not_exists(referencecounter) OR referencecounter = :zero", + ExpressionAttributeValues = { [":zero"] = 0 } + }; + + var config = new UpdateItemOperationConfig + { + ConditionalExpression = conditionalExpression + }; + + doc["update-test"] = 1; + Assert.IsTrue(table.TryUpdateItem(new UpdateItemDocumentOperationRequest + { + Document = doc, + ConditionalExpression = conditionalExpression + })); + + doc["referencecounter"] = 0; + table.UpdateItem(doc); + + doc["update-test"] = null; + Assert.IsTrue(table.TryUpdateItem(new UpdateItemDocumentOperationRequest + { + Document = doc, + ConditionalExpression = conditionalExpression + })); + + var reloaded = table.GetItem(doc); + Assert.IsFalse(reloaded.Contains("update-test")); + + doc["referencecounter"] = 1; + table.UpdateItem(doc); + + doc["update-test"] = 3; + Assert.IsFalse(table.TryUpdateItem(new UpdateItemDocumentOperationRequest + { + Document = doc, + ConditionalExpression = conditionalExpression + })); + + table.DeleteItem(doc); + } + + private void TestExpressionsOnDeleteWithDocumentOperationRequest(ITable table) + { + var doc = new Document + { + ["Id"] = 9001, + ["Price"] = 6 + }; + table.PutItem(doc); + + var key = new Dictionary + { + { "Id", doc["Id"] } + }; + + var expression = new Expression + { + ExpressionStatement = "Price > :price", + ExpressionAttributeValues = { [":price"] = 7 } + }; + + var failingRequest = new DeleteItemDocumentOperationRequest + { + Key = key, + ConditionalExpression = expression, + ReturnValues = ReturnValues.AllOldAttributes + }; + + Assert.IsFalse(table.TryDeleteItem(failingRequest)); + Assert.IsNotNull(table.GetItem(doc)); + + expression.ExpressionAttributeValues[":price"] = 4; + + var succeedingRequest = new DeleteItemDocumentOperationRequest + { + Key = key, + ConditionalExpression = expression, + ReturnValues = ReturnValues.AllOldAttributes + }; + + var oldAttributes = table.DeleteItem(succeedingRequest); + Assert.IsNotNull(oldAttributes); + Assert.AreEqual(6, oldAttributes["Price"].AsInt()); + + Assert.IsNull(table.GetItem(doc)); + } + private bool AreValuesEqual(Document docA, Document docB, DynamoDBEntryConversion conversion = null) { if (conversion != null) diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs new file mode 100644 index 000000000000..a2c67a722862 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs @@ -0,0 +1,790 @@ +using Amazon; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AWSSDK_DotNet.UnitTests +{ + [TestClass] + public class TableTests + { + private Mock _ddbClientMock; + private Table _table; + private string _tableName = "TestTable"; + + [TestInitialize] + public void Setup() + { + _ddbClientMock = new Mock(MockBehavior.Strict); + + var clientConfigMock = new Mock(); + // Setup any properties/methods you expect to be used, e.g.: + clientConfigMock.SetupGet(c => c.RegionEndpoint).Returns((RegionEndpoint)null); + clientConfigMock.SetupGet(c => c.ServiceURL).Returns((string)null); + // Add more setups as needed for your tests + + // Setup the Config property on the IAmazonDynamoDB mock + _ddbClientMock.SetupGet(c => c.Config).Returns(clientConfigMock.Object); + + var config = new TableConfig(_tableName); + + _table = new Table(_ddbClientMock.Object, config); + _table.ClearTableData(); + _table.Keys.Add("Id", new KeyDescription { IsHash = true, Type = DynamoDBEntryType.String }); + _table.HashKeys.Add("Id"); + } + + #region Helpers + + private Task InvokeUpdateAsync(UpdateItemDocumentOperationRequest request) + => _table.UpdateHelperAsync(request, CancellationToken.None); + + private Document InvokeUpdateSync(UpdateItemDocumentOperationRequest request) + => _table.UpdateHelper(request); + private Task InvokeDeleteAsync(DeleteItemDocumentOperationRequest request) + => _table.DeleteHelperAsync(request, CancellationToken.None); + + private Document InvokeDeleteSync(DeleteItemDocumentOperationRequest request) + => _table.DeleteHelper(request); + + private Task InvokePutAsync(PutItemDocumentOperationRequest request) + => _table.PutItemAsync(request, CancellationToken.None); + + private Document InvokePutSync(PutItemDocumentOperationRequest request) + => _table.PutItem(request); + + private async Task AssertThrowsAsync(Func act, string expectedMessage = null) where T : Exception + { + var ex = await Assert.ThrowsExceptionAsync(act); + if (expectedMessage != null) + Assert.AreEqual(expectedMessage, ex.Message); + } + + private void AssertThrowsSync(Action act, string expectedMessage = null) where T : Exception + { + var ex = Assert.ThrowsException(act); + if (expectedMessage != null) + Assert.AreEqual(expectedMessage, ex.Message); + } + + #endregion + + #region UpdateHelper Tests + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_RequestNull_ThrowsArgumentNullException(bool isAsync) + { + if (isAsync) + await AssertThrowsAsync(() => InvokeUpdateAsync(null)); + else + AssertThrowsSync(() => InvokeUpdateSync(null)); + } + + [DataTestMethod] + [DataRow(null, null, "Either Document or UpdateExpression must be set in the request.", true)] + [DataRow("doc", "expr", "Either Document or UpdateExpression must be set in the request.", true)] + [DataRow(null, null, "Either Document or UpdateExpression must be set in the request.", false)] + [DataRow("doc", "expr", "Either Document or UpdateExpression must be set in the request.", false)] + public async Task UpdateHelper_RequestInvalidDocExprCombination_ThrowsInvalidOperationException(object docObj, object exprObj, string expectedMessage, bool isAsync) + { + Document doc = docObj as Document; + Expression expr = exprObj as Expression; + + if (docObj is string && (string)docObj == "doc") + doc = new Document { ["Id"] = "1", ["Count"] = 5 }; + if (exprObj is string && (string)exprObj == "expr") + expr = new Expression { ExpressionStatement = "test" }; + + var request = new UpdateItemDocumentOperationRequest + { + Document = doc, + UpdateExpression = expr + }; + + if (isAsync) + await AssertThrowsAsync(() => InvokeUpdateAsync(request), expectedMessage); + else + AssertThrowsSync(() => InvokeUpdateSync(request), expectedMessage); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_KeyMissing_ThrowsInvalidOperationException(bool isAsync) + { + var request = new UpdateItemDocumentOperationRequest + { + UpdateExpression = new Expression { ExpressionStatement = "test" }, + Key = null + }; + _table.HashKeys.Clear(); + _table.Keys.Clear(); + + if (isAsync) + await AssertThrowsAsync(() => InvokeUpdateAsync(request)); + else + AssertThrowsSync(() => InvokeUpdateSync(request)); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_ReturnsDocument_WhenReturnValuesNotNone(bool isAsync) + { + var docMock = new Document { ["Id"] = "1", ["Count"] = 5 }; + + var request = new UpdateItemDocumentOperationRequest + { + Document = docMock, + UpdateExpression = null, + Key = new Dictionary { { "Id", new Primitive("abc") } }, + ReturnValues = ReturnValues.AllOldAttributes + }; + + var updateItemResponse = new UpdateItemResponse + { + Attributes = new Dictionary { { "Id", new AttributeValue { S = "abc" } } } + }; + + if (isAsync) + _ddbClientMock.Setup(c => c.UpdateItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(updateItemResponse); + else + _ddbClientMock.Setup(c => c.UpdateItem(It.IsAny())) + .Returns(updateItemResponse); + + var result = isAsync + ? await InvokeUpdateAsync(request) + : InvokeUpdateSync(request); + + Assert.IsNotNull(result); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_SetsUpdateExpression_WhenDocumentProvided(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Count"] = 5 }; + + var request = new UpdateItemDocumentOperationRequest + { + Document = doc, + UpdateExpression = null, + Key = null, + ReturnValues = ReturnValues.AllNewAttributes + }; + + var updateItemResponse = new UpdateItemResponse + { + Attributes = new Dictionary { { "Id", new AttributeValue { S = "1" } }, { "Count", new AttributeValue { N = "5" } } } + }; + + if (isAsync) + { + _ddbClientMock.Setup(c => c.UpdateItemAsync( + It.Is(r => r.Key.ContainsKey("Id") + && r.UpdateExpression != null + && r.ExpressionAttributeValues != null + && r.ExpressionAttributeNames != null), It.IsAny())) + .ReturnsAsync(updateItemResponse) + .Verifiable(); + } + else + { + _ddbClientMock.Setup(c => c.UpdateItem( + It.Is(r => r.Key.ContainsKey("Id") + && r.UpdateExpression != null + && r.ExpressionAttributeValues != null + && r.ExpressionAttributeNames != null))) + .Returns(updateItemResponse) + .Verifiable(); + } + + var result = isAsync ? await InvokeUpdateAsync(request) : InvokeUpdateSync(request); + + Assert.IsNotNull(result); + + if (isAsync) + _ddbClientMock.Verify(c => c.UpdateItemAsync( + It.Is(r => r.UpdateExpression != null + && r.ExpressionAttributeValues != null + && r.ExpressionAttributeNames != null), + It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.UpdateItem( + It.Is(r => r.UpdateExpression != null + && r.ExpressionAttributeValues != null + && r.ExpressionAttributeNames != null)), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_UsesOnlyChangedAttributes_WhenKeysUnchanged(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Count"] = 5, ["Prop"] = "test" }; + doc.CommitChanges(); + doc["Count"] = 10; + doc["Prop2"] = "test"; + + var request = new UpdateItemDocumentOperationRequest + { + Document = doc, + UpdateExpression = null, + Key = null, + ReturnValues = ReturnValues.AllNewAttributes + }; + + var updateItemResponse = new UpdateItemResponse + { + Attributes = new Dictionary + { { "Id", new AttributeValue { S = "1" } }, { "Count", new AttributeValue { N = "5" } } } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.UpdateExpression != null + && r.ExpressionAttributeValues.Count == 2 + && r.ExpressionAttributeNames.ContainsValue("Count") + && r.ExpressionAttributeNames.ContainsValue("Prop2") + && !r.ExpressionAttributeNames.ContainsValue("Prop"); + + if (isAsync) + _ddbClientMock.Setup(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(updateItemResponse) + .Verifiable(); + else + _ddbClientMock.Setup(c => c.UpdateItem(It.Is(r => predicate(r)))) + .Returns(updateItemResponse) + .Verifiable(); + + var result = isAsync ? await InvokeUpdateAsync(request) : InvokeUpdateSync(request); + + Assert.IsNotNull(result); + + if (isAsync) + _ddbClientMock.Verify(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.UpdateItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_AppliesUpdateExpression(bool isAsync) + { + var updateExpr = new Expression() + { + ExpressionStatement = "SET #N = :v", + ExpressionAttributeNames = new Dictionary { { "#N", "test" } }, + ExpressionAttributeValues = new Dictionary { { ":v", "test" } }, + }; + var request = new UpdateItemDocumentOperationRequest + { + Document = null, + UpdateExpression = updateExpr, + ConditionalExpression = null, + Key = new Dictionary { { "Id", new Primitive("abc") } } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.UpdateExpression == "SET #N = :v" + && r.ExpressionAttributeValues.Count == 1; + + if (isAsync) + _ddbClientMock.Setup(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(new UpdateItemResponse()); + else + _ddbClientMock.Setup(c => c.UpdateItem(It.Is(r => predicate(r)))) + .Returns(new UpdateItemResponse()); + + if (isAsync) + await InvokeUpdateAsync(request); + else + InvokeUpdateSync(request); + + if (isAsync) + _ddbClientMock.Verify(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.UpdateItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_AppliesConditionalExpression(bool isAsync) + { + var condExpr = new Expression() + { + ExpressionStatement = "#N = :v", + ExpressionAttributeNames = new Dictionary { { "#N", "test" } }, + ExpressionAttributeValues = new Dictionary { { ":v", "test" } }, + }; + var doc = new Document { ["Id"] = "1", ["Count"] = 5, ["Prop"] = "test" }; + var request = new UpdateItemDocumentOperationRequest + { + Document = doc, + UpdateExpression = null, + ConditionalExpression = condExpr, + Key = new Dictionary { { "Id", new Primitive("abc") } } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.ConditionExpression == "#N = :v" + && r.ExpressionAttributeValues.Count == 3; // :v + converted Count + Prop + + if (isAsync) + _ddbClientMock.Setup(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(new UpdateItemResponse()); + else + _ddbClientMock.Setup(c => c.UpdateItem(It.Is(r => predicate(r)))) + .Returns(new UpdateItemResponse()); + + if (isAsync) + await InvokeUpdateAsync(request); + else + InvokeUpdateSync(request); + + if (isAsync) + _ddbClientMock.Verify(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.UpdateItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task UpdateHelper_AppliesBothExpressions(bool isAsync) + { + var updateExpr = new Expression() + { + ExpressionStatement = "SET #Nu = :vu", + ExpressionAttributeNames = new Dictionary { { "#Nu", "test" } }, + ExpressionAttributeValues = new Dictionary { { ":vu", "test" } }, + }; + var condExpr = new Expression() + { + ExpressionStatement = "#Nc = :vc", + ExpressionAttributeNames = new Dictionary { { "#Nc", "test" } }, + ExpressionAttributeValues = new Dictionary { { ":vc", "test" } }, + }; + var request = new UpdateItemDocumentOperationRequest + { + Document = null, + UpdateExpression = updateExpr, + ConditionalExpression = condExpr, + Key = new Dictionary { { "Id", new Primitive("abc") } } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.ConditionExpression == "#Nc = :vc" + && r.UpdateExpression == "SET #Nu = :vu" + && r.ExpressionAttributeValues.Count == 2; + + if (isAsync) + _ddbClientMock.Setup(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(new UpdateItemResponse()); + else + _ddbClientMock.Setup(c => c.UpdateItem(It.Is(r => predicate(r)))) + .Returns(new UpdateItemResponse()); + + if (isAsync) + await InvokeUpdateAsync(request); + else + InvokeUpdateSync(request); + + if (isAsync) + _ddbClientMock.Verify(c => c.UpdateItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.UpdateItem(It.Is(r => predicate(r))), Times.Once); + } + + #endregion + + #region DeleteHelper Tests + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task DeleteHelper_RequestNull_ThrowsArgumentNullException(bool isAsync) + { + if (isAsync) + await AssertThrowsAsync(() => InvokeDeleteAsync(null)); + else + AssertThrowsSync(() => InvokeDeleteSync(null)); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task DeleteHelper_ReturnsDocument_WhenReturnValuesAllOldAttributes(bool isAsync) + { + var request = new DeleteItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("abc") } }, + ReturnValues = ReturnValues.AllOldAttributes + }; + + var deleteResponse = new DeleteItemResponse + { + Attributes = new Dictionary { { "Id", new AttributeValue { S = "abc" } } } + }; + + if (isAsync) + _ddbClientMock.Setup(c => c.DeleteItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(deleteResponse); + else + _ddbClientMock.Setup(c => c.DeleteItem(It.IsAny())) + .Returns(deleteResponse); + + var result = isAsync ? await InvokeDeleteAsync(request) : InvokeDeleteSync(request); + + Assert.IsNotNull(result); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task DeleteHelper_ReturnsNull_WhenReturnValuesNone(bool isAsync) + { + var request = new DeleteItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("xyz") } }, + ReturnValues = ReturnValues.None + }; + + var deleteResponse = new DeleteItemResponse + { + Attributes = new Dictionary { { "Id", new AttributeValue { S = "xyz" } } } + }; + + if (isAsync) + _ddbClientMock.Setup(c => c.DeleteItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(deleteResponse); + else + _ddbClientMock.Setup(c => c.DeleteItem(It.IsAny())) + .Returns(deleteResponse); + + var result = isAsync ? await InvokeDeleteAsync(request) : InvokeDeleteSync(request); + + Assert.IsNull(result); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task DeleteHelper_AppliesConditionalExpression(bool isAsync) + { + var condExpr = new Expression + { + ExpressionStatement = "#A = :v", + ExpressionAttributeNames = new Dictionary { { "#A", "Attr" } }, + ExpressionAttributeValues = new Dictionary { { ":v", "val" } } + }; + + var request = new DeleteItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("abc") } }, + ConditionalExpression = condExpr, + ReturnValues = ReturnValues.None + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.ConditionExpression == "#A = :v" + && r.ExpressionAttributeValues.Count == 1; + + var deleteResponse = new DeleteItemResponse(); + + if (isAsync) + _ddbClientMock.Setup(c => c.DeleteItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(deleteResponse) + .Verifiable(); + else + _ddbClientMock.Setup(c => c.DeleteItem(It.Is(r => predicate(r)))) + .Returns(deleteResponse) + .Verifiable(); + + var result = isAsync ? await InvokeDeleteAsync(request) : InvokeDeleteSync(request); + + Assert.IsNull(result); // ReturnValues.None + + if (isAsync) + _ddbClientMock.Verify(c => c.DeleteItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.DeleteItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task DeleteHelper_AppliesConditionalExpression_WithAllOldAttributes(bool isAsync) + { + var condExpr = new Expression + { + ExpressionStatement = "#A = :v", + ExpressionAttributeNames = new Dictionary { { "#A", "Attr" } }, + ExpressionAttributeValues = new Dictionary { { ":v", "val" } } + }; + + var request = new DeleteItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("abc") } }, + ConditionalExpression = condExpr, + ReturnValues = ReturnValues.AllOldAttributes + }; + + var deleteResponse = new DeleteItemResponse + { + Attributes = new Dictionary + { + { "Id", new AttributeValue { S = "abc" } }, + { "Attr", new AttributeValue { S = "val" } } + } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.ConditionExpression == "#A = :v" + && r.ExpressionAttributeValues.Count == 1; + + if (isAsync) + _ddbClientMock.Setup(c => c.DeleteItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(deleteResponse) + .Verifiable(); + else + _ddbClientMock.Setup(c => c.DeleteItem(It.Is(r => predicate(r)))) + .Returns(deleteResponse) + .Verifiable(); + + var result = isAsync ? await InvokeDeleteAsync(request) : InvokeDeleteSync(request); + + Assert.IsNotNull(result); + Assert.AreEqual("abc", result["Id"].AsPrimitive().Value); + + if (isAsync) + _ddbClientMock.Verify(c => c.DeleteItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.DeleteItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true, true)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(false, false)] + public async Task DeleteHelper_KeyNullOrEmpty_ThrowsInvalidOperationException(bool isAsync, bool keyIsNull) + { + var request = new DeleteItemDocumentOperationRequest + { + Key = keyIsNull ? null : new Dictionary(), + ReturnValues = ReturnValues.None + }; + + const string expectedMessage = "Key cannot be null or empty"; + + if (isAsync) + { + var ex = await Assert.ThrowsExceptionAsync(() => InvokeDeleteAsync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + else + { + var ex = Assert.ThrowsException(() => InvokeDeleteSync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + } + + #endregion + + #region PutItemHelper Tests + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_RequestNull_ThrowsArgumentNullException(bool isAsync) + { + if (isAsync) + await AssertThrowsAsync(() => InvokePutAsync(null)); + else + AssertThrowsSync(() => InvokePutSync(null)); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_ReturnsNull_WhenReturnValuesNone(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Value"] = "A" }; + var request = new PutItemDocumentOperationRequest + { + Document = doc, + ReturnValues = ReturnValues.None + }; + + var putResponse = new PutItemResponse + { + Attributes = new Dictionary { { "Id", new AttributeValue { S = "1" } }, { "Value", new AttributeValue { S = "A" } } } + }; + + if (isAsync) + _ddbClientMock.Setup(c => c.PutItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(putResponse); + else + _ddbClientMock.Setup(c => c.PutItem(It.IsAny())) + .Returns(putResponse); + + var result = isAsync ? await InvokePutAsync(request) : InvokePutSync(request); + + Assert.IsNull(result); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_ReturnsOldAttributes_WhenReturnValuesAllOld(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Value"] = "A" }; + var request = new PutItemDocumentOperationRequest + { + Document = doc, + ReturnValues = ReturnValues.AllOldAttributes + }; + + var oldAttributes = new Dictionary + { + { "Id", new AttributeValue { S = "1" } }, + { "Value", new AttributeValue { S = "OLD" } } + }; + + var putResponse = new PutItemResponse { Attributes = oldAttributes }; + + if (isAsync) + _ddbClientMock.Setup(c => c.PutItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(putResponse); + else + _ddbClientMock.Setup(c => c.PutItem(It.IsAny())) + .Returns(putResponse); + + var result = isAsync ? await InvokePutAsync(request) : InvokePutSync(request); + + Assert.IsNotNull(result); + Assert.AreEqual("OLD", result["Value"].AsPrimitive().Value); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_CommitsDocumentChanges(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Value"] = "A" }; + // Initially document has changes + Assert.IsTrue(doc.IsDirty()); + + var request = new PutItemDocumentOperationRequest + { + Document = doc, + ReturnValues = ReturnValues.None + }; + + var putResponse = new PutItemResponse { Attributes = new Dictionary() }; + + if (isAsync) + _ddbClientMock.Setup(c => c.PutItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(putResponse); + else + _ddbClientMock.Setup(c => c.PutItem(It.IsAny())) + .Returns(putResponse); + + if (isAsync) + await InvokePutAsync(request); + else + InvokePutSync(request); + + Assert.IsFalse(doc.IsDirty(), "Document should have committed changes after PutItem."); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_PassesConditionalExpression(bool isAsync) + { + var doc = new Document { ["Id"] = "1", ["Value"] = "A" }; + var condExpr = new Expression + { + ExpressionStatement = "attribute_not_exists(#PK)", + ExpressionAttributeNames = new Dictionary { { "#PK", "Id" } } + }; + + var request = new PutItemDocumentOperationRequest + { + Document = doc, + ConditionalExpression = condExpr, + ReturnValues = ReturnValues.None + }; + + Predicate predicate = r => + r.Item.ContainsKey("Id") && + r.ConditionExpression == "attribute_not_exists(#PK)" && + r.ExpressionAttributeNames.ContainsKey("#PK"); + + var putResponse = new PutItemResponse(); + + if (isAsync) + _ddbClientMock.Setup(c => c.PutItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(putResponse); + else + _ddbClientMock.Setup(c => c.PutItem(It.Is(r => predicate(r)))) + .Returns(putResponse); + + if (isAsync) + await InvokePutAsync(request); + else + InvokePutSync(request); + + if (isAsync) + _ddbClientMock.Verify(c => c.PutItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + else + _ddbClientMock.Verify(c => c.PutItem(It.Is(r => predicate(r))), Times.Once); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task PutItemHelper_DocumentNull_ThrowsInvalidOperationException(bool isAsync) + { + var request = new PutItemDocumentOperationRequest + { + Document = null, + ReturnValues = ReturnValues.None + }; + + const string expectedMessage = "The Document property of the PutItemDocumentOperationRequest cannot be null."; + + if (isAsync) + { + var ex = await Assert.ThrowsExceptionAsync(() => InvokePutAsync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + else + { + var ex = Assert.ThrowsException(() => InvokePutSync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + } + #endregion + } +} From da1adc3a8cad71346bef77b22df7d1e63cff3cb4 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Fri, 21 Nov 2025 14:53:26 +0200 Subject: [PATCH 3/7] implement DocumentOperationPipeline --- .../DocumentOperationPipeline.cs | 434 ++++++++++++++++++ .../DocumentModel/DocumentOperationRequest.cs | 114 ++++- .../Custom/DocumentModel/Expression.cs | 6 + .../DynamoDBv2/Custom/DocumentModel/Table.cs | 298 +++--------- .../DocumentModel/_async/Table.Async.cs | 19 + .../Custom/DocumentModel/_bcl/Table.Sync.cs | 20 + .../Custom/DocumentModel/TableTests.cs | 10 +- 7 files changed, 661 insertions(+), 240 deletions(-) create mode 100644 sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs new file mode 100644 index 000000000000..e99c43d801bc --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs @@ -0,0 +1,434 @@ +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.DynamoDBv2.DocumentModel +{ + /// + /// Provides an abstract pipeline for executing document operations against a DynamoDB table, supporting both + /// synchronous and asynchronous execution patterns. + /// + /// This class defines the structure for mapping high-level document requests to low-level + /// DynamoDB requests, validating input, applying expressions, invoking the service, and post-processing results. + /// Derived classes must implement the abstract methods to provide specific operation logic. The pipeline ensures + /// that requests are executed with the appropriate user agent details and supports cancellation for asynchronous + /// operations. + /// The type representing the high-level request for the document operation. + /// The type representing the low-level DynamoDB request. Must inherit from AmazonDynamoDBRequest. + /// The type representing the response from the DynamoDB service. Must inherit from AmazonWebServiceResponse. + /// The type representing the final result produced by the pipeline after processing the service response. + internal abstract class DocumentOperationPipeline + where TLowLevelRequest : AmazonDynamoDBRequest + where TServiceResponse : AmazonWebServiceResponse + { + protected readonly Table Table; + + protected DocumentOperationPipeline(Table table) + { + Table = table ?? throw new ArgumentNullException(nameof(table)); + } + + /// + /// Executes the specified high-level request synchronously and returns the processed result. + /// + /// The high-level request to execute. Cannot be null. + /// The result of processing the request, of type . + public TResult ExecuteSync(THighLevelRequest request) + { + Validate(request); + var low = Map(request); + ApplyExpressions(request, low); + var resp = InvokeSync(low); + return PostProcess(request, resp); + } + + /// + /// Executes the specified high-level request asynchronously and returns the processed result. + /// + /// The high-level request to execute. Cannot be null. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the processed response for the + /// specified request. + public async Task ExecuteAsync(THighLevelRequest request, CancellationToken ct) + { + Validate(request); + var low = Map(request); + Table.UpdateRequestUserAgentDetails(low, isAsync: true); + ApplyExpressions(request, low); + var resp = await InvokeAsync(low, ct).ConfigureAwait(false); + return PostProcess(request, resp); + } + + /// + /// Validates the specified high-level request to ensure it meets all required criteria before processing. + /// + /// The high-level request to validate. Cannot be null. + protected abstract void Validate(THighLevelRequest request); + + /// + /// Converts a high-level request object to its corresponding low-level request representation. + /// + /// The high-level request to be mapped. Cannot be null. + /// The low-level request object that represents the mapped version of the specified high-level request. + protected abstract TLowLevelRequest Map(THighLevelRequest request); + + /// + /// Applies high-level request parameters to the specified low-level request object. + /// + /// Implementations should ensure that all relevant parameters from the high-level + /// request are correctly mapped to the low-level request. This method does not perform validation; callers are + /// responsible for providing valid request objects. + /// The high-level request containing parameters and options to be applied. + /// The low-level request object that will be modified based on the high-level request. + protected abstract void ApplyExpressions(THighLevelRequest request, TLowLevelRequest lowLevel); + + /// + /// Invokes the low-level request synchronously against the DynamoDB service. + /// + /// The low-level request object to be sent to the service. Cannot be null. + /// The service response returned by the service. + protected abstract TServiceResponse InvokeSync(TLowLevelRequest lowLevel); + + /// + /// Asynchronously sends a low-level request and returns the corresponding service response. + /// + /// The low-level request object to be sent to the service. Cannot be null. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the service response returned by + /// the service. + protected abstract Task InvokeAsync(TLowLevelRequest lowLevel, CancellationToken ct); + + /// + /// Performs the final transformation of the raw DynamoDB service response into the high-level + /// result type returned to the caller. + /// + /// + /// The original high-level request that initiated the operation. Guaranteed non-null (already validated + /// by ). + /// + /// + /// The low-level response object returned by the DynamoDB service invocation. Contains the raw data + /// (e.g., attribute maps) needed to construct the final result. + /// + /// + /// The processed result derived from . Implementations may return + /// null for operations whose semantics do not yield a material result (e.g., PutItem without requesting + /// return values). + /// + /// + /// Typical responsibilities of an implementation include: + /// 1. Committing or reconciling state changes on the request (e.g., persisting Document mutations). + /// 2. Converting attribute dictionaries to instances or other domain types. + /// 3. Extracting and shaping operation metadata when required by higher-level abstractions. + /// + /// Implementations should avoid throwing unless an unexpected serialization or mapping issue occurs. + /// Side effects should be limited to intentional state commits on objects referenced by . + /// + protected abstract TResult PostProcess(THighLevelRequest request, TServiceResponse serviceResponse); + } + + /// + /// Provides a pipeline for executing document-based PutItem operations against a DynamoDB table. + /// + /// This class maps high-level document requests to low-level PutItem requests, applies + /// conditional expressions, and processes responses to support document-oriented workflows. It is intended for + /// internal use within the document API infrastructure and is not designed for direct consumption by application + /// code. + internal sealed class PutItemPipeline : DocumentOperationPipeline + { + public PutItemPipeline(Table table) : base(table) + { + } + + protected override void Validate(PutItemDocumentOperationRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (request.Document == null) + throw new InvalidOperationException( + "The Document property of the PutItemDocumentOperationRequest cannot be null."); + } + + protected override PutItemRequest Map(PutItemDocumentOperationRequest request) + { + var req = new PutItemRequest + { + TableName = Table.TableName, + Item = Table.ToAttributeMap(request.Document) + }; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + req.ReturnValues = EnumMapper.Convert(request.ReturnValues); + return req; + } + + protected override void ApplyExpressions(PutItemDocumentOperationRequest request, PutItemRequest lowLevel) + { + if (request.ConditionalExpression is { IsSet: true }) + request.ConditionalExpression.ApplyExpression(lowLevel, Table); + } + + protected override PutItemResponse InvokeSync(PutItemRequest lowLevel) + { +#if NETSTANDARD + + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = Table.DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous GetItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via PutItemAsync instead."); + } + return client.PutItem(lowLevel); +#else + return Table.DDBClient.PutItem(lowLevel); +#endif + } + + + protected override Task InvokeAsync(PutItemRequest lowLevel, CancellationToken ct) + => Table.DDBClient.PutItemAsync(lowLevel, ct); + + protected override Document PostProcess(PutItemDocumentOperationRequest request, + PutItemResponse serviceResponse) + { + request.Document.CommitChanges(); + if (request.ReturnValues == ReturnValues.AllOldAttributes && serviceResponse.Attributes != null) + return Table.FromAttributeMap(serviceResponse.Attributes); + return null; + } + } + + /// + /// Provides a pipeline for executing a DynamoDB GetItem operation using document-based requests and responses. + /// + /// This class maps high-level document operation requests to low-level GetItem requests, applies + /// projection expressions, and processes responses into document objects. It is intended for internal use within + /// the document operation framework. + internal sealed class GetItemPipeline + : DocumentOperationPipeline + { + public GetItemPipeline(Table table) : base(table) + { + } + + protected override void Validate(GetItemDocumentOperationRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + if (request.Key == null || request.Key.Count == 0) + throw new InvalidOperationException("GetDocumentOperationRequest.Key cannot be null or empty."); + } + + protected override GetItemRequest Map(GetItemDocumentOperationRequest request) + { + return new GetItemRequest + { + TableName = Table.TableName, + Key = Table.MakeKey(request.Key), + ConsistentRead = request.ConsistentRead + }; + } + + protected override void ApplyExpressions(GetItemDocumentOperationRequest request, GetItemRequest lowLevel) + { + if (request.ProjectionExpression is { IsSet: true }) + request.ProjectionExpression.ApplyExpression(lowLevel, Table); + } + + protected override GetItemResponse InvokeSync(GetItemRequest lowLevel) + { +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = Table.DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous GetItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via GetItemAsync instead."); + } + return client.GetItem(lowLevel); +#else + return Table.DDBClient.GetItem(lowLevel); +#endif + } + + + protected override async Task InvokeAsync(GetItemRequest lowLevel, CancellationToken ct) => + await Table.DDBClient.GetItemAsync(lowLevel, ct).ConfigureAwait(false); + + protected override Document PostProcess(GetItemDocumentOperationRequest request, GetItemResponse serviceResponse) + { + var map = serviceResponse.Item; + return (map == null || map.Count == 0) ? null : Table.FromAttributeMap(map); + } + } + + /// + /// Provides a pipeline for processing update operations on DynamoDB items, handling validation, mapping, and + /// execution of update requests. + /// + /// This class is intended for internal use within the document model infrastructure and + /// coordinates the conversion of high-level update requests into low-level DynamoDB update operations. It ensures + /// that either a document or an update expression is provided exclusively, and manages the mapping of keys and + /// update expressions. + internal sealed class UpdateItemPipeline : + DocumentOperationPipeline + { + public UpdateItemPipeline(Table table) : base(table) + { + } + + protected override void Validate(UpdateItemDocumentOperationRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + bool docSet = request.Document != null; + bool exprSet = request.UpdateExpression is { IsSet: true }; + if ((docSet && exprSet) || (!docSet && !exprSet)) + throw new InvalidOperationException("Either Document or UpdateExpression must be set (exclusively)."); + } + + protected override UpdateItemRequest Map(UpdateItemDocumentOperationRequest request) + { + var req = new UpdateItemRequest + { + TableName = Table.TableName, + ReturnValues = EnumMapper.Convert(request.ReturnValues) + }; + + Key key = request.Key != null ? Table.MakeKey(request.Key) : null; + + if (request.Document != null) + { + key ??= Table.MakeKey(request.Document); + bool haveKeysChanged = Table.HaveKeysChanged(request.Document); + var attrUpdates = Table.ToAttributeUpdateMap(request.Document, !haveKeysChanged); + foreach (var k in Table.KeyNames) + attrUpdates.Remove(k); + + Common.ConvertAttributeUpdatesToUpdateExpression( + attrUpdates, + request.UpdateExpression, + Table, + out var statement, + out var exprValues, + out var exprNames); + + req.UpdateExpression = statement; + req.ExpressionAttributeValues = exprValues; + req.ExpressionAttributeNames = exprNames; + } + + if (key == null || key.Count == 0) + throw new InvalidOperationException("UpdateItem requires a key either via request.Key or Document."); + + req.Key = key; + + return req; + } + + protected override void ApplyExpressions(UpdateItemDocumentOperationRequest request, UpdateItemRequest lowLevel) + { + if (request.UpdateExpression is { IsSet: true }) + request.UpdateExpression.ApplyUpdateExpression(lowLevel, Table); + if (request.ConditionalExpression is { IsSet: true }) + request.ConditionalExpression.ApplyConditionalExpression(lowLevel, Table); + } + + protected override UpdateItemResponse InvokeSync(UpdateItemRequest lowLevel) + { +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = Table.DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous GetItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via UpdateItemAsync instead."); + } + return client.UpdateItem(lowLevel); +#else + return Table.DDBClient.UpdateItem(lowLevel); +#endif + } + + protected override async TaskInvokeAsync(UpdateItemRequest lowLevel, CancellationToken ct) => + await Table.DDBClient.UpdateItemAsync(lowLevel, ct).ConfigureAwait(false); + + protected override Document PostProcess(UpdateItemDocumentOperationRequest request, + UpdateItemResponse serviceResponse) + { + var resp = (UpdateItemResponse)serviceResponse; + request.Document?.CommitChanges(); + if (request.ReturnValues != ReturnValues.None && resp.Attributes != null) + return Table.FromAttributeMap(resp.Attributes); + return null; + } + } + + /// + /// Provides a pipeline for executing a DynamoDB DeleteItem operation using document model requests and responses. + /// + /// This class maps high-level document-based delete requests to low-level DeleteItem operations + /// against a specific DynamoDB table. It handles validation, expression application, synchronous and asynchronous + /// invocation, and post-processing of results. The pipeline is intended for internal use within the document model. + /// + internal sealed class DeleteItemPipeline : + DocumentOperationPipeline + { + public DeleteItemPipeline(Table table) : base(table) { } + + protected override void Validate(DeleteItemDocumentOperationRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (request.Key == null || request.Key.Count == 0) + throw new InvalidOperationException("DeleteItemDocumentOperationRequest.Key cannot be null or empty."); + } + + protected override DeleteItemRequest Map(DeleteItemDocumentOperationRequest request) + { + var req = new DeleteItemRequest + { + TableName = Table.TableName, + Key = Table.MakeKey(request.Key) + }; + if (request.ReturnValues == ReturnValues.AllOldAttributes) + req.ReturnValues = EnumMapper.Convert(request.ReturnValues); + return req; + } + + protected override void ApplyExpressions(DeleteItemDocumentOperationRequest request, DeleteItemRequest lowLevel) + { + if (request.ConditionalExpression is { IsSet: true }) + request.ConditionalExpression.ApplyExpression(lowLevel, Table); + } + + protected override DeleteItemResponse InvokeSync(DeleteItemRequest lowLevel) + { +#if NETSTANDARD + // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods + var client = Table.DDBClient as AmazonDynamoDBClient; + if (client == null) + { + throw new InvalidOperationException("Calling the synchronous GetItem from .NET or .NET Core requires initializing the Table " + + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via DeleteItemAsync instead."); + } + return client.DeleteItem(lowLevel); +#else + return Table.DDBClient.DeleteItem(lowLevel); +#endif + } + + protected override async Task InvokeAsync(DeleteItemRequest lowLevel, CancellationToken ct) => + await Table.DDBClient.DeleteItemAsync(lowLevel, ct).ConfigureAwait(false); + + + protected override Document PostProcess(DeleteItemDocumentOperationRequest request, DeleteItemResponse serviceResponse) + { + var resp = (DeleteItemResponse)serviceResponse; + if (request.ReturnValues == ReturnValues.AllOldAttributes && resp.Attributes != null) + return Table.FromAttributeMap(resp.Attributes); + return null; + } + } +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs index 92974eea34d2..b0f587e5556f 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs @@ -12,6 +12,32 @@ public abstract class DocumentOperationRequest { } + /// + /// Represents a request to get a single item from a DynamoDB table using the Document Model. + /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. + /// Legacy parameters such as AttributesToGet are not supported. Use ProjectionExpression instead. + /// + public class GetItemDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Gets or sets the key identifying the item in the table. + /// All key components (partition and sort key, if the table has one) must be provided. + /// + public IDictionary Key { get; set; } + + /// + /// Gets or sets the projection expression specifying which attributes should be retrieved. + /// If null, all attributes are returned. + /// + public Expression ProjectionExpression { get; set; } + + /// + /// Gets or sets the consistent read flag. + /// Strongly consistent reads are only valid for tables and local secondary indexes. + /// + public bool ConsistentRead { get; set; } + } + /// /// Represents a request to query items in a DynamoDB table using the Document Model. /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. @@ -84,8 +110,85 @@ public QueryDocumentOperationRequest() public string PaginationToken { get; set; } } + /// + /// Represents a request to scan items in a DynamoDB table or index using the Document Model. + /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. + /// Legacy parameters such as ScanFilter, AttributesToGet are not supported. + /// Use FilterExpression and ProjectionExpression instead. + /// + public class ScanDocumentOperationRequest : DocumentOperationRequest + { + /// + /// Initializes a new instance of the class. + /// + public ScanDocumentOperationRequest() + { + Limit = Int32.MaxValue; + Select = SelectValues.AllAttributes; + TotalSegments = 1; + } + + /// + /// Gets or sets the filter expression specifying which items should be returned after scanning. + /// Applied after DynamoDB reads the items. + /// + public Expression FilterExpression { get; set; } + + /// + /// Gets or sets the projection expression specifying which attributes should be retrieved. + /// + public Expression ProjectionExpression { get; set; } + + /// + /// Enum specifying what data to return from the scan (e.g., attributes or count). + /// + public SelectValues Select { get; set; } + + /// + /// Gets or sets the maximum number of items DynamoDB should process before returning a page of results. + /// Defaults to int.MaxValue (service default paging behavior). + /// + public int Limit { get; set; } + + /// + /// Gets or sets the consistent read flag. Strongly consistent reads are only supported for tables and local secondary indexes. + /// + public bool ConsistentRead { get; set; } + + /// + /// Gets or sets the index name to scan against. + /// + public string IndexName { get; set; } + + /// + /// Whether to collect GetNextSet and GetRemaining results in Matches property. + /// Default is true. If set to false, Matches will always be empty. + /// + public bool CollectResults { get; set; } + + /// + /// Pagination token corresponding to the item where the last Scan operation stopped. + /// Set this value to resume the scan from the next item. Retrieved from a Search object. + /// + public string PaginationToken { get; set; } + + /// + /// The segment number to scan in a parallel scan. Must be between 0 and TotalSegments - 1 when specified. + /// If null, a non-parallel (single segment) scan is performed. + /// + public int Segment { get; set; } + + /// + /// Total number of segments for a parallel scan. Defaults to 1 (no parallelism). + /// + public int TotalSegments { get; set; } + } + /// /// Represents a request to update an item in a DynamoDB table using the Document Model. + /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. + /// Legacy parameters such as AttributeUpdates, Expected are not supported. + /// Use UpdateExpression and ConditionalExpression instead. /// public class UpdateItemDocumentOperationRequest : DocumentOperationRequest { @@ -105,8 +208,8 @@ public class UpdateItemDocumentOperationRequest : DocumentOperationRequest public Expression UpdateExpression { get; set; } /// - /// The expression that is evaluated before the update is performed. If the expression evaluates to false the update - /// will fail and a ConditionalCheckFailedException exception will be thrown. + /// Gets or sets the conditional expression evaluated before the update is performed. + /// If false, a ConditionalCheckFailedException is thrown. /// public Expression ConditionalExpression { get; set; } @@ -118,6 +221,9 @@ public class UpdateItemDocumentOperationRequest : DocumentOperationRequest /// /// Represents a request to delete an item from a DynamoDB table using the Document Model. + /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. + /// Legacy parameters such as Expected are not supported. + /// Use ConditionalExpression instead. /// public class DeleteItemDocumentOperationRequest : DocumentOperationRequest { @@ -135,11 +241,13 @@ public class DeleteItemDocumentOperationRequest : DocumentOperationRequest /// Flag specifying what values should be returned. /// public ReturnValues ReturnValues { get; set; } - } /// /// Represents a request to put (create or replace) an item in a DynamoDB table using the Document Model. + /// This class introduces a modern expression-based API that replaces legacy parameter-based approaches. + /// Legacy parameters such as Expected are not supported. + /// Use ConditionalExpression instead. /// public class PutItemDocumentOperationRequest : DocumentOperationRequest { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs index 6208177b564d..745ea740e54f 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -192,6 +192,12 @@ private void MergeAttributes(UpdateItemRequest request, Table table) } } + internal void ApplyExpression(GetItemRequest request, Table table) + { + request.ProjectionExpression = ExpressionStatement; + request.ExpressionAttributeNames = new Dictionary(this.ExpressionAttributeNames); + } + internal void ApplyExpression(Get request, Table table) { request.ProjectionExpression = ExpressionStatement; diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index 247d58612391..ec167d855d40 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -149,6 +149,15 @@ public partial interface ITable /// Resultant Search container. ISearch Scan(ScanOperationConfig config); + /// + /// Initiates a scan operation on the specified document and returns a search result representing the scan + /// outcome. + /// + /// An object containing the parameters and data required to perform the scan operation. Cannot be null. + /// An ISearch instance representing the result of the scan operation. The returned object contains information + /// about matches found in the document. + ISearch Scan(ScanDocumentOperationRequest operationRequest); + #endregion #region Query @@ -1255,47 +1264,6 @@ internal Document PutItemHelper(Document doc, PutItemOperationConfig config) return ret; } - internal async Task PutItemHelperAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken) - { - var req = MapPutItemDocumentOperationRequestToPutItemRequest(request); - this.UpdateRequestUserAgentDetails(req, isAsync: true); - var resp = await DDBClient.PutItemAsync(req, cancellationToken).ConfigureAwait(false); - request.Document.CommitChanges(); - Document ret = null; - if (request.ReturnValues == ReturnValues.AllOldAttributes) - { - ret = this.FromAttributeMap(resp.Attributes); - } - return ret; - } - internal Document PutItemHelper(PutItemDocumentOperationRequest request) - { - var req = MapPutItemDocumentOperationRequestToPutItemRequest(request); - -#if NETSTANDARD - // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods - var client = DDBClient as AmazonDynamoDBClient; - if (client == null) - { - throw new InvalidOperationException("Calling the synchronous PutItem from .NET or .NET Core requires initializing the Table " + - "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via PutItemAsync instead."); - } -#else - var client = DDBClient; -#endif - - var resp = client.PutItem(req); - - request.Document.CommitChanges(); - Document ret = null; - if (request.ReturnValues == ReturnValues.AllOldAttributes) - { - ret = this.FromAttributeMap(resp.Attributes); - } - return ret; - } - - internal async Task PutItemHelperAsync(Document doc, PutItemOperationConfig config, CancellationToken cancellationToken) { var currentConfig = config ?? new PutItemOperationConfig(); @@ -1341,29 +1309,16 @@ internal async Task PutItemHelperAsync(Document doc, PutItemOperationC return ret; } - private PutItemRequest MapPutItemDocumentOperationRequestToPutItemRequest(PutItemDocumentOperationRequest request) + internal async Task PutItemHelperAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken) { - if(request==null) - throw new ArgumentNullException(nameof(request)); - - if (request.Document == null) - throw new InvalidOperationException("The Document property of the PutItemDocumentOperationRequest cannot be null."); - - PutItemRequest req = new PutItemRequest - { - TableName = TableName, - Item = this.ToAttributeMap(request.Document) - }; - - if (request.ReturnValues == ReturnValues.AllOldAttributes) - req.ReturnValues = EnumMapper.Convert(request.ReturnValues); - - if (request.ConditionalExpression is { IsSet: true }) - { - request.ConditionalExpression.ApplyExpression(req, this); - } + var pipeline = new PutItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } - return req; + internal Document PutItemHelper(PutItemDocumentOperationRequest request) + { + var pipeline = new PutItemPipeline(this); + return pipeline.ExecuteSync(request); } #endregion @@ -1427,6 +1382,18 @@ internal async Task GetItemHelperAsync(Key key, GetItemOperationConfig return this.FromAttributeMap(attributeMap); } + internal Document GetItemHelper(GetItemDocumentOperationRequest request) + { + var pipeline = new GetItemPipeline(this); + return pipeline.ExecuteSync(request); + } + + internal async Task GetItemHelperAsync(GetItemDocumentOperationRequest request, CancellationToken cancellationToken) + { + var pipeline = new GetItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + #endregion @@ -1446,118 +1413,15 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi internal Document UpdateHelper(UpdateItemDocumentOperationRequest request) { - var req = MapUpdateItemOperationToUpdateItemRequest(request, out var doc); - -#if NETSTANDARD - // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods - var client = DDBClient as AmazonDynamoDBClient; - if (client == null) - { - throw new InvalidOperationException("Calling the synchronous UpdateItem from .NET or .NET Core requires initializing the Table " + - "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via UpdateItemAsync instead."); - } -#else - var client = DDBClient; -#endif - - var resp = client.UpdateItem(req); - var returnedAttributes = resp.Attributes; - - // If the document was provided, commit the changes to it - doc?.CommitChanges(); - - Document ret = null; - if (request.ReturnValues != ReturnValues.None) - { - ret = this.FromAttributeMap(returnedAttributes); - } - return ret; - + var pipeline = new UpdateItemPipeline(this); + return pipeline.ExecuteSync(request); } internal async Task UpdateHelperAsync(UpdateItemDocumentOperationRequest request, CancellationToken cancellationToken) { - var req = MapUpdateItemOperationToUpdateItemRequest(request, out var doc); - - this.UpdateRequestUserAgentDetails(req, isAsync: true); - - var resp = await DDBClient.UpdateItemAsync(req, cancellationToken).ConfigureAwait(false); - var returnedAttributes = resp.Attributes; - - // If the document was provided, commit the changes to it - doc?.CommitChanges(); - - Document ret = null; - if (request.ReturnValues != ReturnValues.None) - { - ret = this.FromAttributeMap(returnedAttributes); - } - - return ret; - } - - private UpdateItemRequest MapUpdateItemOperationToUpdateItemRequest(UpdateItemDocumentOperationRequest request, out Document doc) - { - if (request == null) - throw new ArgumentNullException(nameof(request)); - - doc = request.Document; - - Expression updateExpression = request.UpdateExpression; - - // Validate that either doc or updateExpression is set, but not both null or both set to non-meaningful values - if ((doc == null && updateExpression is not { IsSet: true }) || - (doc != null && updateExpression is { IsSet: true })) - { - throw new InvalidOperationException("Either Document or UpdateExpression must be set in the request."); - } - - UpdateItemRequest req = new UpdateItemRequest - { - TableName = TableName, - ReturnValues = EnumMapper.Convert(request.ReturnValues) - }; - - Key key = request.Key != null ? MakeKey(request.Key) : null; - - if (doc != null) - { - key ??= MakeKey(doc); - bool haveKeysChanged = HaveKeysChanged(doc); - bool updateChangedAttributesOnly = !haveKeysChanged; - var attributeUpdates = this.ToAttributeUpdateMap(doc, updateChangedAttributesOnly); - foreach (var keyName in this.KeyNames) - { - attributeUpdates.Remove(keyName); - } - - Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression, this, - out var statement, out var expressionAttributeValues, out var expressionAttributeNames); - req.UpdateExpression = statement; - req.ExpressionAttributeValues = expressionAttributeValues; - req.ExpressionAttributeNames = expressionAttributeNames; - } - - if (key == null || key.Count == 0) - { - throw new InvalidOperationException( - "UpdateItem requires a key to be specified either in the request or in the Document."); - } - - req.Key = key; - - if(request.UpdateExpression is { IsSet: true }) - { - request.UpdateExpression.ApplyUpdateExpression(req, this); - } - - if(request.ConditionalExpression is { IsSet: true }) - { - request.ConditionalExpression.ApplyConditionalExpression(req, this); - } - - return req; + var pipeline = new UpdateItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); } internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression) @@ -1849,73 +1713,14 @@ internal async Task DeleteHelperAsync(Key key, DeleteItemOperationConf internal Document DeleteHelper(DeleteItemDocumentOperationRequest request) { - var req = MapDeleteItemOperationRequestToDeleteItemRequest(request); - -#if NETSTANDARD - // Cast the IAmazonDynamoDB to the concrete client instead, so we can access the internal sync-over-async methods - var client = DDBClient as AmazonDynamoDBClient; - if (client == null) - { - throw new InvalidOperationException( - "Calling the synchronous DeleteItem from .NET or .NET Core requires initializing the Table " + - "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when calling DeleteItemAsync instead."); - } -#else - var client = DDBClient; -#endif - - var attributes = client.DeleteItem(req).Attributes; - - Document ret = null; - if (request.ReturnValues == ReturnValues.AllOldAttributes) - { - ret = this.FromAttributeMap(attributes); - } - - return ret; + var pipeline = new DeleteItemPipeline(this); + return pipeline.ExecuteSync(request); } internal async Task DeleteHelperAsync(DeleteItemDocumentOperationRequest request, CancellationToken cancellationToken) { - var req = MapDeleteItemOperationRequestToDeleteItemRequest(request); - - this.UpdateRequestUserAgentDetails(req, isAsync: true); - - var attributes = (await DDBClient.DeleteItemAsync(req, cancellationToken).ConfigureAwait(false)).Attributes; - - Document ret = null; - if (request.ReturnValues == ReturnValues.AllOldAttributes) - { - ret = this.FromAttributeMap(attributes); - } - return ret; - } - - private DeleteItemRequest MapDeleteItemOperationRequestToDeleteItemRequest(DeleteItemDocumentOperationRequest request) - { - if (request == null) - throw new ArgumentNullException(nameof(request)); - - if (request.Key == null || request.Key.Count == 0) - { - throw new InvalidOperationException("Key cannot be null or empty"); - } - - var req = new DeleteItemRequest - { - TableName = TableName, - Key = MakeKey(request.Key) - }; - - if (request.ReturnValues == ReturnValues.AllOldAttributes) - req.ReturnValues = EnumMapper.Convert(request.ReturnValues); - - if(request.ConditionalExpression is { IsSet: true }) - { - request.ConditionalExpression.ApplyExpression(req, this); - } - - return req; + var pipeline = new DeleteItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); } #endregion @@ -1971,6 +1776,35 @@ public ISearch Scan(ScanOperationConfig config) return ret; } + /// + public ISearch Scan(ScanDocumentOperationRequest operationRequest) + { + if (operationRequest == null) + throw new ArgumentNullException("operationRequest"); + + Search ret = new Search(SearchType.Scan) + { + SourceTable = this, + TableName = TableName, + Limit = operationRequest.Limit, + FilterExpression = operationRequest.FilterExpression, + ProjectionExpression = operationRequest.ProjectionExpression, + Select = operationRequest.Select, + CollectResults = operationRequest.CollectResults, + IndexName = operationRequest.IndexName, + IsConsistentRead = operationRequest.ConsistentRead, + PaginationToken = operationRequest.PaginationToken + }; + + if (operationRequest.TotalSegments != 0) + { + ret.TotalSegments = operationRequest.TotalSegments; + ret.Segment = operationRequest.Segment; + } + + return ret; + } + #endregion diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs index 370b5ce0850d..9ed96daf7d9a 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_async/Table.Async.cs @@ -121,6 +121,15 @@ public partial interface ITable /// A Task that can be used to poll or wait for results, or both. Task GetItemAsync(IDictionary key, GetItemOperationConfig config, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Initiates the asynchronous execution of the GetItem operation. + /// + /// The GetDocumentOperationRequest object containing all parameters for the GetItem operation. + /// Token which can be used to cancel the task. + /// A Task that can be used to poll or wait for results, or both. + Task GetItemAsync(GetItemDocumentOperationRequest request, CancellationToken cancellationToken = default); + + #endregion #region UpdateItemAsync @@ -396,6 +405,16 @@ public partial class Table : ITable } } + /// + public async Task GetItemAsync(GetItemDocumentOperationRequest request, CancellationToken cancellationToken = default(CancellationToken)) + { + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(GetItemAsync)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return await GetItemHelperAsync(request, cancellationToken).ConfigureAwait(false); + } + } + #endregion #region UpdateItemAsync diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs index e0c96c863454..e733e09aa9a7 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs @@ -83,6 +83,15 @@ public partial interface ITable /// Document from DynamoDB. Document GetItem(IDictionary key, GetItemOperationConfig config = null); + /// + /// Gets a document from DynamoDB using a request object. + /// + /// The GetDocumentOperationRequest object containing all parameters for the GetItem operation. + /// >Document from DynamoDB. + Document GetItem(GetItemDocumentOperationRequest request); + + + #endregion #region UpdateItem @@ -384,6 +393,17 @@ public Document GetItem(IDictionary key, GetItemOperation } + /// + public Document GetItem(GetItemDocumentOperationRequest request) + { + + var operationName = DynamoDBTelemetry.ExtractOperationName(nameof(Table), nameof(GetItem)); + using (DynamoDBTelemetry.CreateSpan(TracerProvider, operationName, spanKind: SpanKind.CLIENT)) + { + return GetItemHelper(request); + } + } + #endregion diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs index a2c67a722862..a837835479c1 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs @@ -90,10 +90,10 @@ public async Task UpdateHelper_RequestNull_ThrowsArgumentNullException(bool isAs } [DataTestMethod] - [DataRow(null, null, "Either Document or UpdateExpression must be set in the request.", true)] - [DataRow("doc", "expr", "Either Document or UpdateExpression must be set in the request.", true)] - [DataRow(null, null, "Either Document or UpdateExpression must be set in the request.", false)] - [DataRow("doc", "expr", "Either Document or UpdateExpression must be set in the request.", false)] + [DataRow(null, null, "Either Document or UpdateExpression must be set (exclusively).", true)] + [DataRow("doc", "expr", "Either Document or UpdateExpression must be set (exclusively).", true)] + [DataRow(null, null, "Either Document or UpdateExpression must be set (exclusively).", false)] + [DataRow("doc", "expr", "Either Document or UpdateExpression must be set (exclusively).", false)] public async Task UpdateHelper_RequestInvalidDocExprCombination_ThrowsInvalidOperationException(object docObj, object exprObj, string expectedMessage, bool isAsync) { Document doc = docObj as Document; @@ -594,7 +594,7 @@ public async Task DeleteHelper_KeyNullOrEmpty_ThrowsInvalidOperationException(bo ReturnValues = ReturnValues.None }; - const string expectedMessage = "Key cannot be null or empty"; + const string expectedMessage = "DeleteItemDocumentOperationRequest.Key cannot be null or empty."; if (isAsync) { From a8ff489c5b9adec11e26ea18db60260afacd051b Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Fri, 21 Nov 2025 16:18:25 +0200 Subject: [PATCH 4/7] implement SearchMetrics on query and scan --- .../DocumentModel/DocumentOperationRequest.cs | 16 +- .../Custom/DocumentModel/Expression.cs | 8 +- .../DynamoDBv2/Custom/DocumentModel/Search.cs | 183 +++++++++++++++--- .../DynamoDBv2/Custom/DocumentModel/Table.cs | 6 +- 4 files changed, 183 insertions(+), 30 deletions(-) diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs index b0f587e5556f..ba86d0d542bb 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationRequest.cs @@ -54,6 +54,7 @@ public QueryDocumentOperationRequest() Limit = Int32.MaxValue; Select = SelectValues.AllAttributes; + ReturnConsumedCapacity = ReturnConsumedCapacity.NONE; } /// /// Gets or sets the key condition expression specifying which items should be returned. @@ -108,6 +109,12 @@ public QueryDocumentOperationRequest() /// This token should be retrieved from a Search object. /// public string PaginationToken { get; set; } + + /// + /// Controls whether DynamoDB returns capacity consumption details for each Scan request. + /// Defaults to NONE. Set to TOTAL or INDEXES to capture consumed capacity metrics in Search.Metrics. + /// + public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } } /// @@ -124,8 +131,9 @@ public class ScanDocumentOperationRequest : DocumentOperationRequest public ScanDocumentOperationRequest() { Limit = Int32.MaxValue; + ConsistentRead = false; Select = SelectValues.AllAttributes; - TotalSegments = 1; + ReturnConsumedCapacity = ReturnConsumedCapacity.NONE; } /// @@ -182,6 +190,12 @@ public ScanDocumentOperationRequest() /// Total number of segments for a parallel scan. Defaults to 1 (no parallelism). /// public int TotalSegments { get; set; } + + /// + /// Controls whether DynamoDB returns capacity consumption details for each Scan request. + /// Defaults to NONE. Set to TOTAL or INDEXES to capture consumed capacity metrics in Search.Metrics. + /// + public ReturnConsumedCapacity ReturnConsumedCapacity { get; set; } } /// diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs index 745ea740e54f..0d6824938cb6 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -13,14 +13,13 @@ * permissions and limitations under the License. */ +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime.Internal; +using Amazon.Util; using System; using System.Collections.Generic; using System.IO; using System.Linq; - -using Amazon.DynamoDBv2.Model; -using Amazon.Util; - namespace Amazon.DynamoDBv2.DocumentModel { /// @@ -248,7 +247,6 @@ internal void ApplyExpression(ConditionCheck request, Table table) } } - internal static void ApplyExpression(QueryRequest request, Table table, Expression keyExpression, Expression filterExpression) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Search.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Search.cs index 67b558d6a4a3..6e6be12efed3 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Search.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Search.cs @@ -13,16 +13,15 @@ * permissions and limitations under the License. */ +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime; +using Amazon.Runtime.Telemetry.Tracing; using System; using System.Collections.Generic; -using System.Linq; - -using Amazon.DynamoDBv2.Model; using System.Globalization; -using Amazon.Runtime.Telemetry.Tracing; - -using System.Threading.Tasks; +using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace Amazon.DynamoDBv2.DocumentModel { @@ -211,9 +210,10 @@ internal Search() internal Search(SearchType searchMethod) { SearchMethod = searchMethod; + ReturnConsumedCapacity = ReturnConsumedCapacity.NONE; Reset(); TracerProvider = SourceTable?.DDBClient?.Config?.TelemetryProvider?.TracerProvider - ?? AWSConfigs.TelemetryProvider.TracerProvider; + ?? AWSConfigs.TelemetryProvider.TracerProvider; } #endregion @@ -260,17 +260,21 @@ internal Search(SearchType searchMethod) /// public Dictionary NextKey { get; private set; } + /// + /// Aggregated per-call and accumulated metrics for this search operation. + /// + public SearchMetrics Metrics => _metrics; + + /// + /// The ReturnConsumedCapacity setting used for this search (NONE, TOTAL, INDEXES). + /// + public ReturnConsumedCapacity ReturnConsumedCapacity { get; internal set; } + /// public string PaginationToken { - get - { - return Common.ToPaginationToken(NextKey); - } - internal set - { - NextKey = Common.FromPaginationToken(value); - } + get { return Common.ToPaginationToken(NextKey); } + internal set { NextKey = Common.FromPaginationToken(value); } } /// @@ -361,6 +365,9 @@ internal List GetNextSetHelper() scanReq.Segment = this.Segment; } + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + scanReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(scanReq, isAsync: false); var scanResult = internalClient.Scan(scanReq); @@ -379,10 +386,13 @@ internal List GetNextSetHelper() NextKey = scanResult.LastEvaluatedKey; scannedCount = scanResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(scanResult.ConsumedCapacity, scanResult.ScannedCount.GetValueOrDefault(), + ret.Count); if (NextKey == null || NextKey.Count == 0) { IsDone = true; } + return ret; case SearchType.Query: QueryRequest queryReq = new QueryRequest @@ -401,7 +411,8 @@ internal List GetNextSetHelper() if (Filter != null) { - SplitQueryFilter(Filter, SourceTable, queryReq.IndexName, out var keyConditions, out var filterConditions); + SplitQueryFilter(Filter, SourceTable, queryReq.IndexName, out var keyConditions, + out var filterConditions); queryReq.KeyConditions = keyConditions?.Count > 0 ? keyConditions : null; queryReq.QueryFilter = filterConditions?.Count > 0 ? filterConditions : null; } @@ -423,6 +434,9 @@ internal List GetNextSetHelper() if (queryReq.QueryFilter != null && queryReq.QueryFilter.Count > 1) queryReq.ConditionalOperator = EnumMapper.Convert(ConditionalOperator); + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + queryReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(queryReq, isAsync: false); var queryResult = internalClient.Query(queryReq); @@ -441,10 +455,13 @@ internal List GetNextSetHelper() NextKey = queryResult.LastEvaluatedKey; scannedCount = queryResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(queryResult.ConsumedCapacity, + queryResult.ScannedCount.GetValueOrDefault(), ret.Count); if (NextKey == null || NextKey.Count == 0) { IsDone = true; } + return ret; default: throw new InvalidOperationException("Unknown Search Method"); @@ -502,9 +519,13 @@ internal async Task> GetNextSetHelperAsync(CancellationToken canc scanReq.Segment = this.Segment; } + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + scanReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(scanReq, isAsync: true); - var scanResult = await SourceTable.DDBClient.ScanAsync(scanReq, cancellationToken).ConfigureAwait(false); + var scanResult = await SourceTable.DDBClient.ScanAsync(scanReq, cancellationToken) + .ConfigureAwait(false); if (scanResult.Items != null) { foreach (var item in scanResult.Items) @@ -520,11 +541,14 @@ internal async Task> GetNextSetHelperAsync(CancellationToken canc NextKey = scanResult.LastEvaluatedKey; scannedCount = scanResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(scanResult.ConsumedCapacity, scanResult.ScannedCount.GetValueOrDefault(), + ret.Count); if (NextKey == null || NextKey.Count == 0) { IsDone = true; } + return ret; case SearchType.Query: QueryRequest queryReq = new QueryRequest @@ -557,19 +581,23 @@ internal async Task> GetNextSetHelperAsync(CancellationToken canc if (this.ProjectionExpression != null && this.ProjectionExpression.IsSet) { - queryReq.ProjectionExpression= this.ProjectionExpression.ExpressionStatement; + queryReq.ProjectionExpression = this.ProjectionExpression.ExpressionStatement; foreach (var ean in this.ProjectionExpression.ExpressionAttributeNames) { - queryReq.ExpressionAttributeNames.Add(ean.Key,ean.Value); + queryReq.ExpressionAttributeNames.Add(ean.Key, ean.Value); } } if (queryReq.QueryFilter != null && queryReq.QueryFilter.Count > 1) queryReq.ConditionalOperator = EnumMapper.Convert(ConditionalOperator); + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + queryReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(queryReq, isAsync: true); - var queryResult = await SourceTable.DDBClient.QueryAsync(queryReq, cancellationToken).ConfigureAwait(false); + var queryResult = await SourceTable.DDBClient.QueryAsync(queryReq, cancellationToken) + .ConfigureAwait(false); if (queryResult.Items != null) { foreach (var item in queryResult.Items) @@ -582,13 +610,17 @@ internal async Task> GetNextSetHelperAsync(CancellationToken canc } } } + NextKey = queryResult.LastEvaluatedKey; scannedCount = queryResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(queryResult.ConsumedCapacity, + queryResult.ScannedCount.GetValueOrDefault(), ret.Count); if (NextKey == null || NextKey.Count == 0) { IsDone = true; } + return ret; default: throw new InvalidOperationException("Unknown Search Method"); @@ -609,6 +641,7 @@ internal List GetRemainingHelper() { ret.Add(doc); } + scannedCount += previousScannedCount; } @@ -626,6 +659,7 @@ internal async Task> GetRemainingHelperAsync(CancellationToken ca { ret.Add(doc); } + scannedCount += previousScannedCount; } @@ -640,7 +674,8 @@ internal async Task> GetRemainingHelperAsync(CancellationToken ca internal Table SourceTable { get; set; } - private static void SplitQueryFilter(Filter filter, Table targetTable, string indexName, out Dictionary keyConditions, out Dictionary filterConditions) + private static void SplitQueryFilter(Filter filter, Table targetTable, string indexName, + out Dictionary keyConditions, out Dictionary filterConditions) { QueryFilter queryFilter = filter as QueryFilter; if (queryFilter == null) throw new InvalidOperationException("Filter is not of type QueryFilter"); @@ -724,7 +759,8 @@ private int GetCount() TableName = TableName, Select = EnumMapper.Convert(SelectValues.Count), ExclusiveStartKey = NextKey, - ScanFilter = Filter.ToConditions(SourceTable.Conversion, SourceTable.IsEmptyStringValueEnabled), + ScanFilter = Filter.ToConditions(SourceTable.Conversion, + SourceTable.IsEmptyStringValueEnabled), ConsistentRead = IsConsistentRead }; if (!string.IsNullOrEmpty(this.IndexName)) @@ -740,12 +776,18 @@ private int GetCount() scanReq.Segment = this.Segment; } + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + scanReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(scanReq, isAsync: false); var scanResult = internalClient.Scan(scanReq); count = Matches.Count + scanResult.Count.GetValueOrDefault(); scannedCount = scanResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(scanResult.ConsumedCapacity, + scanResult.ScannedCount.GetValueOrDefault(), 0); + return count; case SearchType.Query: QueryRequest queryReq = new QueryRequest @@ -772,12 +814,18 @@ private int GetCount() if (queryReq.QueryFilter != null && queryReq.QueryFilter.Count > 1) queryReq.ConditionalOperator = EnumMapper.Convert(ConditionalOperator); + if (ReturnConsumedCapacity != ReturnConsumedCapacity.NONE) + queryReq.ReturnConsumedCapacity = ReturnConsumedCapacity; + SourceTable.UpdateRequestUserAgentDetails(queryReq, isAsync: false); var queryResult = internalClient.Query(queryReq); count = Matches.Count + queryResult.Count.GetValueOrDefault(); scannedCount = queryResult.ScannedCount.GetValueOrDefault(); + UpdateMetricsAfterPage(queryResult.ConsumedCapacity, + queryResult.ScannedCount.GetValueOrDefault(), 0); + return count; default: throw new InvalidOperationException("Unknown Search Method"); @@ -797,8 +845,99 @@ internal void Reset() NextKey = null; Matches = new List(); CollectResults = true; + _metrics = new SearchMetrics(); + } + + private SearchMetrics _metrics; + + private void UpdateMetricsAfterPage(ConsumedCapacity consumed, int scannedCountPage, int itemsReturnedPage) + { + _metrics.ScannedCountLast = scannedCountPage; + _metrics.ScannedCountAccumulated += scannedCountPage; + _metrics.ItemsReturnedLast = itemsReturnedPage; + _metrics.TotalItemsReturned += itemsReturnedPage; + if (consumed != null) + { + _metrics.LastConsumedCapacity = consumed; + _metrics._history.Add(consumed); + if (consumed.CapacityUnits.HasValue) + _metrics.TotalCapacityUnits = (_metrics.TotalCapacityUnits ?? 0) + consumed.CapacityUnits.Value; + if (consumed.ReadCapacityUnits.HasValue) + _metrics.TotalReadCapacityUnits = + (_metrics.TotalReadCapacityUnits ?? 0) + consumed.ReadCapacityUnits.Value; + if (consumed.WriteCapacityUnits.HasValue) + _metrics.TotalWriteCapacityUnits = + (_metrics.TotalWriteCapacityUnits ?? 0) + consumed.WriteCapacityUnits.Value; + } } #endregion } + + /// + /// Provides aggregated metrics and capacity usage information for a multi-page search or query operation. + /// + /// This class exposes read-only properties that summarize capacity consumption, item counts, and + /// scan statistics across all pages retrieved during a search or query. Instances are typically returned by + /// operations that support capacity reporting, such as paginated database queries. All properties reflect the + /// cumulative or most recent values as appropriate, and are updated as additional pages are processed. This type is + /// not intended to be instantiated directly. + public sealed class SearchMetrics + { + internal SearchMetrics() + { + _history = new List(); + } + + internal List _history; + + /// + /// Gets the details of the capacity units consumed by the most recent operation. + /// + /// This property is typically populated after a request to a data service that tracks + /// consumed capacity, such as a database or storage operation. The value may be null if capacity information is + /// not available for the last operation. + public ConsumedCapacity LastConsumedCapacity { get; internal set; } + + /// + /// Gets the history of consumed capacity details for all operations performed during the search. + /// + public IReadOnlyList ConsumedCapacityHistory => _history; + + /// + /// Total capacity units accumulated. + /// + public double? TotalCapacityUnits { get; internal set; } + + /// + /// Gets the total provisioned read capacity units for all tables in the collection. + /// + public double? TotalReadCapacityUnits { get; internal set; } + + /// + /// Gets the total provisioned write capacity units for all tables in the current context. + /// + public double? TotalWriteCapacityUnits { get; internal set; } + + /// + /// Gets the number of items scanned during the most recent operation. + /// + public int ScannedCountLast { get; internal set; } + + /// + /// Gets the total number of items scanned across all operations. + /// + /// This property is updated internally and reflects the cumulative count of scanned items. + public int ScannedCountAccumulated { get; internal set; } + + /// + /// Number of items returned in the last operation. + /// + public int ItemsReturnedLast { get; internal set; } + + /// + /// Total number of items returned across all operations. + /// + public int TotalItemsReturned { get; internal set; } + } } diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index ec167d855d40..29a97fb9b5e7 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -1793,7 +1793,8 @@ public ISearch Scan(ScanDocumentOperationRequest operationRequest) CollectResults = operationRequest.CollectResults, IndexName = operationRequest.IndexName, IsConsistentRead = operationRequest.ConsistentRead, - PaginationToken = operationRequest.PaginationToken + PaginationToken = operationRequest.PaginationToken, + ReturnConsumedCapacity = operationRequest.ReturnConsumedCapacity }; if (operationRequest.TotalSegments != 0) @@ -1890,7 +1891,8 @@ public ISearch Query(QueryDocumentOperationRequest operationRequest) IndexName = operationRequest.IndexName, Select = operationRequest.Select, CollectResults = operationRequest.CollectResults, - PaginationToken = operationRequest.PaginationToken + PaginationToken = operationRequest.PaginationToken, + ReturnConsumedCapacity = operationRequest.ReturnConsumedCapacity }; return ret; From e4f3710f5020c4d3d70d80e87e0e847b3db6be9a Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 24 Nov 2025 12:45:42 +0200 Subject: [PATCH 5/7] address feedback --- .../.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json | 2 +- .../Custom/DocumentModel/DocumentOperationPipeline.cs | 4 ++-- .../DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json b/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json index da0de01eecab..5565c81319d5 100644 --- a/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json +++ b/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json @@ -4,7 +4,7 @@ "serviceName": "DynamoDBv2", "type": "minor", "changeLogMessages": [ - "Add Request Object Pattern and Expression-Based for DynamoDB Document Model " + "Add Request Object Pattern and Expression-Based for DynamoDB Document Model." ] } ] diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs index e99c43d801bc..3e646c150ea7 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs @@ -178,7 +178,7 @@ protected override PutItemResponse InvokeSync(PutItemRequest lowLevel) var client = Table.DDBClient as AmazonDynamoDBClient; if (client == null) { - throw new InvalidOperationException("Calling the synchronous GetItem from .NET or .NET Core requires initializing the Table " + + throw new InvalidOperationException("Calling the synchronous PutItem from .NET or .NET Core requires initializing the Table " + "with an actual AmazonDynamoDBClient. You can use a mocked or substitute IAmazonDynamoDB when creating a Table via PutItemAsync instead."); } return client.PutItem(lowLevel); @@ -352,7 +352,7 @@ protected override UpdateItemResponse InvokeSync(UpdateItemRequest lowLevel) #endif } - protected override async TaskInvokeAsync(UpdateItemRequest lowLevel, CancellationToken ct) => + protected override async Task InvokeAsync(UpdateItemRequest lowLevel, CancellationToken ct) => await Table.DDBClient.UpdateItemAsync(lowLevel, ct).ConfigureAwait(false); protected override Document PostProcess(UpdateItemDocumentOperationRequest request, diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs index e733e09aa9a7..560b9d834dd9 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/_bcl/Table.Sync.cs @@ -87,7 +87,7 @@ public partial interface ITable /// Gets a document from DynamoDB using a request object. /// /// The GetDocumentOperationRequest object containing all parameters for the GetItem operation. - /// >Document from DynamoDB. + /// Document from DynamoDB. Document GetItem(GetItemDocumentOperationRequest request); From f7b919f1af101528f9a66c9111654b7c3d0b81c5 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Wed, 26 Nov 2025 19:20:44 +0200 Subject: [PATCH 6/7] increase test coverage --- .../DocumentOperationPipeline.cs | 2 +- .../Custom/DocumentModel/TableTests.cs | 146 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs index 3e646c150ea7..0d188ac281be 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs @@ -219,7 +219,7 @@ protected override void Validate(GetItemDocumentOperationRequest request) if (request == null) throw new ArgumentNullException(nameof(request)); if (request.Key == null || request.Key.Count == 0) - throw new InvalidOperationException("GetDocumentOperationRequest.Key cannot be null or empty."); + throw new InvalidOperationException("GetItemDocumentOperationRequest.Key cannot be null or empty."); } protected override GetItemRequest Map(GetItemDocumentOperationRequest request) diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs index a837835479c1..972149439ee1 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs @@ -60,6 +60,12 @@ private Task InvokePutAsync(PutItemDocumentOperationRequest request) private Document InvokePutSync(PutItemDocumentOperationRequest request) => _table.PutItem(request); + private Task InvokeGetAsync(GetItemDocumentOperationRequest request) + => _table.GetItemAsync(request, CancellationToken.None); + + private Document InvokeGetSync(GetItemDocumentOperationRequest request) + => _table.GetItem(request); + private async Task AssertThrowsAsync(Func act, string expectedMessage = null) where T : Exception { var ex = await Assert.ThrowsExceptionAsync(act); @@ -786,5 +792,145 @@ public async Task PutItemHelper_DocumentNull_ThrowsInvalidOperationException(boo } } #endregion + + #region GetItemHelper Tests + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task GetItemHelper_RequestNull_ThrowsArgumentNullException(bool isAsync) + { + if (isAsync) + { + await AssertThrowsAsync(() => InvokeGetAsync(null)); + } + else + { + AssertThrowsSync(() => InvokeGetSync(null)); + } + } + + [DataTestMethod] + [DataRow(true, true)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(false, false)] + public async Task GetItemHelper_KeyNullOrEmpty_ThrowsInvalidOperationException(bool isAsync, bool keyIsNull) + { + var request = new GetItemDocumentOperationRequest + { + Key = keyIsNull ? null : new Dictionary(), + ConsistentRead = false + }; + + const string expectedMessage = "GetItemDocumentOperationRequest.Key cannot be null or empty."; + + if (isAsync) + { + var ex = await Assert.ThrowsExceptionAsync(() => InvokeGetAsync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + else + { + var ex = Assert.ThrowsException(() => InvokeGetSync(request)); + Assert.AreEqual(expectedMessage, ex.Message); + } + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task GetItemHelper_AppliesProjectionExpression(bool isAsync) + { + var request = new GetItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("abc") } }, + ConsistentRead = true, + ProjectionExpression = new Expression + { + ExpressionStatement = "#A, #B", + ExpressionAttributeNames = new Dictionary + { + { "#A", "AttrA" }, + { "#B", "AttrB" } + } + } + }; + + Predicate predicate = r => + r.Key.ContainsKey("Id") + && r.ConsistentRead == true + && r.ProjectionExpression == "#A, #B" + && r.ExpressionAttributeNames.ContainsKey("#A") + && r.ExpressionAttributeNames.ContainsKey("#B"); + + var getResponse = new GetItemResponse { Item = new Dictionary { { "AttrA", new AttributeValue { S = "valA" } } } }; + + if (isAsync) + { + _ddbClientMock + .Setup(c => c.GetItemAsync(It.Is(r => predicate(r)), It.IsAny())) + .ReturnsAsync(getResponse) + .Verifiable(); + + var result = await InvokeGetAsync(request); + Assert.IsNotNull(result); + _ddbClientMock.Verify(c => c.GetItemAsync(It.Is(r => predicate(r)), It.IsAny()), Times.Once); + } + else + { + _ddbClientMock + .Setup(c => c.GetItem(It.Is(r => predicate(r)))) + .Returns(getResponse) + .Verifiable(); + + var result = InvokeGetSync(request); + Assert.IsNotNull(result); + _ddbClientMock.Verify(c => c.GetItem(It.Is(r => predicate(r))), Times.Once); + } + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task GetItemHelper_ReturnsDocument(bool isAsync) + { + var request = new GetItemDocumentOperationRequest + { + Key = new Dictionary { { "Id", new Primitive("123") } }, + ConsistentRead = false + }; + + var getResponse = new GetItemResponse + { + Item = new Dictionary + { + { "Id", new AttributeValue { S = "123" } }, + { "Name", new AttributeValue { S = "John" } } + } + }; + + if (isAsync) + { + _ddbClientMock.Setup(c => c.GetItemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(getResponse); + + var result = await InvokeGetAsync(request); + Assert.IsNotNull(result); + Assert.AreEqual("123", result["Id"].AsPrimitive().Value); + Assert.AreEqual("John", result["Name"].AsPrimitive().Value); + } + else + { + _ddbClientMock.Setup(c => c.GetItem(It.IsAny())) + .Returns(getResponse); + + var result = InvokeGetSync(request); + Assert.IsNotNull(result); + Assert.AreEqual("123", result["Id"].AsPrimitive().Value); + Assert.AreEqual("John", result["Name"].AsPrimitive().Value); + } + } + #endregion } } From ab851119f1a735013e6de633da50ad9400dbe478 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Thu, 27 Nov 2025 09:24:17 +0200 Subject: [PATCH 7/7] unit tests --- .../Custom/DataModel/ContextInternalTests.cs | 72 ++++++------ .../DataModel/ItemStorageConfigCacheTests.cs | 4 +- .../UnitTests/Custom/SearchTests.cs | 108 ++++++++++++++++++ 3 files changed, 150 insertions(+), 34 deletions(-) diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs index 4d82ec188266..d66f98f464bf 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs @@ -15,7 +15,7 @@ namespace AWSSDK_DotNet.UnitTests [TestClass] public class ContextInternalTests { - public class TestEntity + public class ContextTestEntity { [DynamoDBHashKey] public int Id { get; set; } @@ -140,7 +140,7 @@ public void TestInitialize() { Table = new TableDescription { - TableName = "TestEntity", + TableName = "ContextTestEntity", KeySchema = new System.Collections.Generic.List { new KeySchemaElement @@ -179,11 +179,11 @@ public void TestInitialize() public void ConvertScan_WithFilterExpression_ReturnsMappedFilterExpression() { // Create a filter expression (e => e.Id == 1) - Expression> expr = e => e.Id == 1; + Expression> expr = e => e.Id == 1; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); - var result = context.ConvertScan(filterExpr, null); + var result = context.ConvertScan(filterExpr, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search.FilterExpression); @@ -202,11 +202,11 @@ public void ConvertScan_WithFilterExpression_ReturnsMappedFilterExpression() public void ConvertScan_WithNameFilterExpression_ReturnsMappedFilterExpression() { // Filter: e => e.Name == "foo" - Expression> expr = e => e.Name == "foo"; + Expression> expr = e => e.Name == "foo"; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); - var result = context.ConvertScan(filterExpr, null); + var result = context.ConvertScan(filterExpr, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search.FilterExpression); @@ -224,11 +224,11 @@ public void ConvertScan_WithNameFilterExpression_ReturnsMappedFilterExpression() public void ConvertScan_WithGreaterThanFilterExpression_ReturnsMappedFilterExpression() { // Filter: e => e.Id > 10 - Expression> expr = e => e.Id > 10; + Expression> expr = e => e.Id > 10; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); - var result = context.ConvertScan(filterExpr, null); + var result = context.ConvertScan(filterExpr, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search.FilterExpression); @@ -246,11 +246,11 @@ public void ConvertScan_WithGreaterThanFilterExpression_ReturnsMappedFilterExpre public void ConvertScan_WithAndFilterExpression_ReturnsMappedFilterExpression() { // Filter: e => e.Id == 1 && e.Name == "foo" - Expression> expr = e => e.Id == 1 && e.Name == "foo"; + Expression> expr = e => e.Id == 1 && e.Name == "foo"; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); - var result = context.ConvertScan(filterExpr, null); + var result = context.ConvertScan(filterExpr, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search.FilterExpression); @@ -268,7 +268,7 @@ public void ConvertScan_WithAndFilterExpression_ReturnsMappedFilterExpression() public void ConvertQueryByValue_WithHashKeyOnly() { // Act - var result = context.ConvertQueryByValue(1, null, null); + var result = context.ConvertQueryByValue(1, null, null); // Assert Assert.IsNotNull(result); @@ -278,14 +278,14 @@ public void ConvertQueryByValue_WithHashKeyOnly() Assert.AreEqual(1, actualResult.Filter.ToConditions().Count); Assert.IsNull(actualResult.FilterExpression); Assert.IsNotNull(actualResult.AttributesToGet); - Assert.AreEqual(typeof(TestEntity).GetProperties().Length, actualResult.AttributesToGet.Count); + Assert.AreEqual(typeof(ContextTestEntity).GetProperties().Length, actualResult.AttributesToGet.Count); } [TestMethod] public void ConvertQueryByValue_WithHashKeyAndExpressionFilter() { // Arrange - Expression> expr = e => e.Name == "bar"; + Expression> expr = e => e.Name == "bar"; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); @@ -295,7 +295,7 @@ public void ConvertQueryByValue_WithHashKeyAndExpressionFilter() }; // Act - var result = context.ConvertQueryByValue(1, operationConfig, null); + var result = context.ConvertQueryByValue(1, operationConfig, null); // Assert Assert.IsNotNull(result); @@ -326,7 +326,7 @@ public void ConvertQueryConditional_WithHashKeyOnly_ReturnsValidSearch() var queryConditional = QueryConditional.HashKeyEqualTo("Id", 1); // Act - var result = context.ConvertQueryConditional(queryConditional, null); + var result = context.ConvertQueryConditional(queryConditional, null); // Assert Assert.IsNotNull(result); @@ -360,7 +360,7 @@ public void ConvertQueryConditional_WithHashKeyAndRangeKey_ReturnsValidSearch() .AndRangeKeyEqualTo("Name", "test"); // Act - var result = context.ConvertQueryConditional(queryConditional, null); + var result = context.ConvertQueryConditional(queryConditional, null); // Assert Assert.IsNotNull(result); @@ -431,7 +431,7 @@ public void ConvertQueryConditional_WithGSIIndexNameAndOrder_ReturnsValidSearch( public void ConvertQueryByValue_WithExpressionAndQueryFilter_ThrowsInvalidOperationException() { // Arrange: provide both an expression filter and a QueryFilter which should be incompatible - Expression> expr = e => e.Name == "foo"; + Expression> expr = e => e.Name == "foo"; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); @@ -446,7 +446,7 @@ public void ConvertQueryByValue_WithExpressionAndQueryFilter_ThrowsInvalidOperat // Act & Assert var ex = Assert.ThrowsException(() => - context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, operationConfig)); + context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, operationConfig)); Assert.IsTrue(ex.Message.Contains("Cannot specify both QueryFilter and ExpressionFilter in the same operation configuration. Please use one or the other."), "Unexpected exception message: " + ex.Message); } @@ -456,7 +456,7 @@ public void ConvertQueryByValue_WithExpressionAndQueryFilter_ThrowsInvalidOperat public void ConvertQueryConditional_WithNullQueryConditional_ThrowsArgumentNullException() { // Act - context.ConvertQueryConditional(null, null); + context.ConvertQueryConditional(null, null); } [TestMethod] @@ -467,7 +467,7 @@ public void ConvertQueryConditional_WithNullHashKeys_ThrowsInvalidOperationExcep var queryConditional = QueryConditional.HashKeysEqual(null); // Act - context.ConvertQueryConditional(queryConditional, null); + context.ConvertQueryConditional(queryConditional, null); } [TestMethod] @@ -485,7 +485,7 @@ public void ConvertQueryConditional_WithQueryFilter_ReturnsValidSearch() var queryConditional = QueryConditional.HashKeyEqualTo("Id", 1); // Act - var result = context.ConvertQueryConditional(queryConditional, operationConfig); + var result = context.ConvertQueryConditional(queryConditional, operationConfig); // Assert Assert.IsNotNull(result); @@ -527,7 +527,7 @@ public void ConvertQueryByValues_WithRangeKeyPropertiesNotInModel_ThrowsInvalidO try { // Act - context.ConvertQueryByValue(hashKeys, operationConfig, null); + context.ConvertQueryByValue(hashKeys, operationConfig, null); } catch (InvalidOperationException ex) { @@ -573,7 +573,7 @@ public void ConvertFromQuery_WithQueryOperationConfig_ReturnsValidContextSearch( }; // Act - var result = context.ConvertFromQuery(queryConfig, null); + var result = context.ConvertFromQuery(queryConfig, null); // Assert Assert.IsNotNull(result); @@ -598,7 +598,7 @@ public void ConvertFromQuery_WithQueryDocumentOperationRequest_ReturnsValidConte }; // Act - var result = context.ConvertFromQuery(request, null); + var result = context.ConvertFromQuery(request, null); // Assert Assert.IsNotNull(result); @@ -613,7 +613,7 @@ public void ConvertFromQuery_WithQueryDocumentOperationRequest_ReturnsValidConte [TestMethod] public void ConvertQueryByValue_WithRangeEqualCondition_UsesQueryFilterNotKeyExpression() { - var result = context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, null); + var result = context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search); @@ -641,7 +641,7 @@ public void ConvertQueryByValue_WithRangeEqualCondition_UsesQueryFilterNotKeyExp [TestMethod] public void ConvertQueryByValue_WithBetweenOperator_UsesQueryFilterNotKeyExpression() { - var result = context.ConvertQueryByValue(1, QueryOperator.Between, new object[] { "a", "z" }, null); + var result = context.ConvertQueryByValue(1, QueryOperator.Between, new object[] { "a", "z" }, null); Assert.IsNotNull(result); Assert.IsNotNull(result.Search); @@ -650,7 +650,15 @@ public void ConvertQueryByValue_WithBetweenOperator_UsesQueryFilterNotKeyExpress Assert.IsNotNull(search.Filter, "Expected Query.Filter to be set for ConvertQueryByValue with BETWEEN range condition."); var conditions = search.Filter.ToConditions(); - + if (!conditions.ContainsKey("Id")) + { + var s = ""; + foreach (var kvp in conditions) + { + s += $"{kvp.Key}: {kvp.Value}\n"; + } + throw new Exception(s); + } Assert.IsTrue(conditions.ContainsKey("Id")); var idCondition = conditions["Id"]; Assert.AreEqual(ComparisonOperator.EQ, idCondition.ComparisonOperator); @@ -670,7 +678,7 @@ public void ConvertQueryByValue_WithBetweenOperator_UsesQueryFilterNotKeyExpress [TestMethod] public void ConvertQueryByValue_WithRangeEqualCondition_AndExpressionFilter_BuildsKeyAndFilterExpressions() { - Expression> expr = e => e.Name == "bar"; + Expression> expr = e => e.Name == "bar"; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); var operationConfig = new DynamoDBOperationConfig @@ -678,7 +686,7 @@ public void ConvertQueryByValue_WithRangeEqualCondition_AndExpressionFilter_Buil Expression = filterExpr }; - var result = context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, operationConfig); + var result = context.ConvertQueryByValue(1, QueryOperator.Equal, new object[] { "test" }, operationConfig); Assert.IsNotNull(result); Assert.IsNotNull(result.Search); @@ -707,7 +715,7 @@ public void ConvertQueryByValue_WithRangeEqualCondition_AndExpressionFilter_Buil [TestMethod] public void ConvertQueryByValue_WithBetweenOperator_AndExpressionFilter_BuildsKeyAndFilterExpressions() { - Expression> expr = e => e.Id == 1; + Expression> expr = e => e.Id == 1; var filterExpr = new ContextExpression(); filterExpr.SetFilter(expr); var operationConfig = new DynamoDBOperationConfig @@ -715,7 +723,7 @@ public void ConvertQueryByValue_WithBetweenOperator_AndExpressionFilter_BuildsKe Expression = filterExpr }; - var result = context.ConvertQueryByValue(1, QueryOperator.Between, new object[] { "a", "z" }, operationConfig); + var result = context.ConvertQueryByValue(1, QueryOperator.Between, new object[] { "a", "z" }, operationConfig); Assert.IsNotNull(result); Assert.IsNotNull(result.Search); @@ -960,7 +968,7 @@ public void ConvertQueryConditional_WithMissingHashKeyProperty_ThrowsInvalidOper try { // Act - context.ConvertQueryConditional(queryConditional, null); + context.ConvertQueryConditional(queryConditional, null); } catch (InvalidOperationException ex) { diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ItemStorageConfigCacheTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ItemStorageConfigCacheTests.cs index 06d0e5eeb704..be20b78f6926 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ItemStorageConfigCacheTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ItemStorageConfigCacheTests.cs @@ -267,7 +267,7 @@ public void GetConfig_ConversionOnly_WhenTableCacheExists_ReturnsBaseTypeConfig( [TestMethod] public void GetConfig_WithTypeMappings_PopulatesConfigFromMappings() { - var typeMapping = new TypeMapping(typeof(ContextInternalTests.TestEntity), "CustomTestTable"); + var typeMapping = new TypeMapping(typeof(ContextInternalTests.ContextTestEntity), "CustomTestTable"); typeMapping.AddProperty(new PropertyConfig("Id") { Attribute = "CustomId", @@ -285,7 +285,7 @@ public void GetConfig_WithTypeMappings_PopulatesConfigFromMappings() var flatConfig = new DynamoDBFlatConfig(null, context.Config); - var config = context.StorageConfigCache.GetConfig(typeof(ContextInternalTests.TestEntity), flatConfig, + var config = context.StorageConfigCache.GetConfig(typeof(ContextInternalTests.ContextTestEntity), flatConfig, conversionOnly: false); Assert.IsNotNull(config); diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/SearchTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/SearchTests.cs index 2bb2b21254da..373771d66a1d 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/SearchTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/SearchTests.cs @@ -140,6 +140,114 @@ public void GetCount_ShouldReturnCorrectCount() Assert.AreEqual(2, count); } + [TestMethod] + [TestCategory("DynamoDBv2")] + public void GetNextSetHelper_ShouldUpdateMetricsAfterPage() + { + // Arrange + var consumed = new ConsumedCapacity + { + CapacityUnits = 3.5, + ReadCapacityUnits = 2.0, + WriteCapacityUnits = 1.5 + }; + + var mockScanResponse = new ScanResponse + { + Items = new List> + { + new Dictionary(), + new Dictionary() + }, + // No more pages -> IsDone = true + LastEvaluatedKey = null, + ScannedCount = 10, + ConsumedCapacity = consumed + }; + + _mockDynamoDBClient + .Setup(client => client.Scan(It.IsAny())) + .Returns(mockScanResponse); + + _search.Reset(); + _search.ReturnConsumedCapacity = ReturnConsumedCapacity.TOTAL; + + // Act + var result = _search.GetNextSetHelper(); + + // Assert item count to ensure we exercised the path + Assert.AreEqual(2, result.Count); + Assert.IsTrue(_search.IsDone); + + // Assert metrics aggregation populated by UpdateMetricsAfterPage + Assert.AreEqual(10, _search.Metrics.ScannedCountLast); + Assert.AreEqual(10, _search.Metrics.ScannedCountAccumulated); + Assert.AreEqual(2, _search.Metrics.ItemsReturnedLast); + Assert.AreEqual(2, _search.Metrics.TotalItemsReturned); + Assert.AreSame(consumed, _search.Metrics.LastConsumedCapacity); + Assert.AreEqual(1, _search.Metrics.ConsumedCapacityHistory.Count); + Assert.AreEqual(3.5, _search.Metrics.TotalCapacityUnits); + Assert.AreEqual(2.0, _search.Metrics.TotalReadCapacityUnits); + Assert.AreEqual(1.5, _search.Metrics.TotalWriteCapacityUnits); + } + + [TestMethod] + [TestCategory("DynamoDBv2")] + public void GetNextSetHelper_SetsReturnConsumedCapacity_WhenNotNone() + { + // Arrange - capture the outgoing ScanRequest + ScanRequest captured = null; + + var mockScanResponse = new ScanResponse + { + Items = new List>(), + LastEvaluatedKey = null, + }; + + _mockDynamoDBClient + .Setup(client => client.Scan(It.IsAny())) + .Callback(req => captured = req) + .Returns(mockScanResponse); + + _search.Reset(); + _search.ReturnConsumedCapacity = ReturnConsumedCapacity.INDEXES; + + // Act + _ = _search.GetNextSetHelper(); + + // Assert: ReturnConsumedCapacity is forwarded to the request + Assert.IsNotNull(captured, "Expected ScanRequest to be captured."); + Assert.AreEqual(ReturnConsumedCapacity.INDEXES, captured.ReturnConsumedCapacity); + } + + [TestMethod] + [TestCategory("DynamoDBv2")] + public void GetNextSetHelper_DoesNotSetReturnConsumedCapacity_WhenNone() + { + // Arrange - capture the outgoing ScanRequest + ScanRequest captured = null; + + var mockScanResponse = new ScanResponse + { + Items = new List>(), + LastEvaluatedKey = null, + }; + + _mockDynamoDBClient + .Setup(client => client.Scan(It.IsAny())) + .Callback(req => captured = req) + .Returns(mockScanResponse); + + _search.Reset(); + _search.ReturnConsumedCapacity = ReturnConsumedCapacity.NONE; + + // Act + _ = _search.GetNextSetHelper(); + + // Assert: when NONE, the property should remain null (not set) + Assert.IsNotNull(captured, "Expected ScanRequest to be captured."); + Assert.IsNull(captured.ReturnConsumedCapacity); + } } } \ No newline at end of file