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..5565c81319d5 --- /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/DocumentModel/DocumentOperationPipeline.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentOperationPipeline.cs new file mode 100644 index 000000000000..0d188ac281be --- /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 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); +#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("GetItemDocumentOperationRequest.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 Task InvokeAsync(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 967e2beed87d..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. @@ -83,4 +109,161 @@ 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 + { + /// + /// 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; } + + /// + /// Gets or sets the conditional expression evaluated before the update is performed. + /// If false, a ConditionalCheckFailedException is 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. + /// 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 + { + /// + /// 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. + /// 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 + { + /// + /// 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 3844463f4d54..745ea740e54f 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -157,6 +157,47 @@ 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(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 c66b09649997..ec167d855d40 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 { @@ -151,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 @@ -1302,6 +1309,18 @@ internal async Task PutItemHelperAsync(Document doc, PutItemOperationC return ret; } + internal async Task PutItemHelperAsync(PutItemDocumentOperationRequest request, CancellationToken cancellationToken) + { + var pipeline = new PutItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + + internal Document PutItemHelper(PutItemDocumentOperationRequest request) + { + var pipeline = new PutItemPipeline(this); + return pipeline.ExecuteSync(request); + } + #endregion @@ -1363,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 @@ -1380,6 +1411,19 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi return UpdateHelperAsync(doc, key, config, expression, cancellationToken); } + internal Document UpdateHelper(UpdateItemDocumentOperationRequest request) + { + var pipeline = new UpdateItemPipeline(this); + return pipeline.ExecuteSync(request); + } + + internal async Task UpdateHelperAsync(UpdateItemDocumentOperationRequest request, + CancellationToken cancellationToken) + { + var pipeline = new UpdateItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1622,6 +1666,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(); @@ -1665,6 +1710,19 @@ internal async Task DeleteHelperAsync(Key key, DeleteItemOperationConf return ret; } + + internal Document DeleteHelper(DeleteItemDocumentOperationRequest request) + { + var pipeline = new DeleteItemPipeline(this); + return pipeline.ExecuteSync(request); + } + + internal async Task DeleteHelperAsync(DeleteItemDocumentOperationRequest request, CancellationToken cancellationToken) + { + var pipeline = new DeleteItemPipeline(this); + return await pipeline.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + #endregion @@ -1718,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 @@ -1852,7 +1939,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 f4b8ecccc379..206713f5ca25 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs @@ -330,7 +330,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 8b8abf7110ad..9ed96daf7d9a 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; @@ -35,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. @@ -44,7 +45,7 @@ public partial interface ITable /// /// Initiates the asynchronous execution of the PutItem operation. - /// + /// /// /// Document to save. /// Configuration to use. @@ -52,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 @@ -109,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 @@ -189,6 +210,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 @@ -264,6 +294,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 } @@ -292,6 +330,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 @@ -357,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 @@ -441,6 +499,16 @@ 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)) + { + return await UpdateHelperAsync(request, cancellationToken).ConfigureAwait(false); + + } + } #endregion @@ -516,15 +584,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..560b9d834dd9 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 @@ -69,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 @@ -159,6 +182,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 +278,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 +328,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 @@ -308,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 @@ -425,6 +521,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 +666,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 433b7c8b4ea0..8d4f6d853dac 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -1122,6 +1122,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 f3d230d8fac4..c99b9cf0dc47 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs @@ -87,6 +87,11 @@ public void TestTableOperations() // Test Count on Query TestSelectCountOnQuery(hashTable); + + TestExpressionPutWithDocumentOperationRequest(hashTable); + TestExpressionUpdateWithDocumentOperationRequest(hashTable); + TestExpressionsOnDeleteWithDocumentOperationRequest(hashTable); + } } @@ -167,6 +172,11 @@ public void TestTableOperationsViaBuilder() // Test that attributes stored as Datetimes can be retrieved in UTC. TestAsDateTimeUtc(numericHashRangeTable); + + TestExpressionPutWithDocumentOperationRequest(hashTable); + TestExpressionUpdateWithDocumentOperationRequest(hashTable); + TestExpressionsOnDeleteWithDocumentOperationRequest(hashTable); + } } @@ -2324,6 +2334,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..972149439ee1 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs @@ -0,0 +1,936 @@ +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 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); + 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 (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; + 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 = "DeleteItemDocumentOperationRequest.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 + + #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 + } +}