From 2a011640f2e56f2fbbf7be5eb7843d170ec9c4ff Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 21 Jul 2025 10:04:41 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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 e4f3710f5020c4d3d70d80e87e0e847b3db6be9a Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 24 Nov 2025 12:45:42 +0200 Subject: [PATCH 4/5] 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 5/5] 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 } }