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..9d9d47707213 --- /dev/null +++ b/generator/.DevConfigs/9490947f-209f-47e9-8c70-3698872df304.json @@ -0,0 +1,11 @@ +{ + "services": [ + { + "serviceName": "DynamoDBv2", + "type": "minor", + "changeLogMessages": [ + "Add support for DynamoDbUpdateBehavior for operations." + ] + } + ] +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs index 64978ed49e03..0ff6ea3fb306 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -733,4 +733,62 @@ public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [Dynamicall { } } + + /// + /// Specifies the update behavior for a property when performing DynamoDB update operations. + /// This attribute can be used to control whether a property is always updated, only updated if not null. + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class DynamoDbUpdateBehaviorAttribute : DynamoDBPropertyAttribute + { + /// + /// Gets the update behavior for the property. + /// + public UpdateBehavior Behavior { get; } + + /// + /// Default constructor. Sets behavior to Always. + /// + public DynamoDbUpdateBehaviorAttribute() + : base() + { + Behavior = UpdateBehavior.Always; + } + + /// + /// Constructor that specifies the update behavior. + /// + /// The update behavior to apply. + public DynamoDbUpdateBehaviorAttribute(UpdateBehavior behavior) + : base() + { + Behavior = behavior; + } + + /// + /// Constructor that specifies an alternate attribute name and update behavior. + /// + /// Name of attribute to be associated with property or field. + /// The update behavior to apply. + public DynamoDbUpdateBehaviorAttribute(string attributeName, UpdateBehavior behavior) + : base(attributeName) + { + Behavior = behavior; + } + } + + /// + /// Specifies when a property value should be set. + /// + public enum UpdateBehavior + { + /// + /// Set the value on both create and update. + /// + Always, + /// + /// Set the value only when the item is created. + /// + IfNotExists + } } diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs index 114d24d19dae..70637c0f4792 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -375,31 +375,35 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr Document updateDocument; Expression versionExpression = null; - - var returnValues=counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes; - if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion) + var updateIfNotExistsAttributeName = GetUpdateIfNotExistsAttributeNames(storage); + + var returnValues = counterConditionExpression == null && !updateIfNotExistsAttributeName.Any() + ? ReturnValues.None + : ReturnValues.AllNewAttributes; + + var updateItemOperationConfig = new UpdateItemOperationConfig { - updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig() - { - ReturnValues = returnValues - }, counterConditionExpression); - } - else + ReturnValues = returnValues + }; + + if (!(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) && storage.Config.HasVersion) { - var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled); + var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled); versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig); SetNewVersion(storage); - - var updateItemOperationConfig = new UpdateItemOperationConfig - { - ReturnValues = returnValues, - ConditionalExpression = versionExpression, - }; - updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, counterConditionExpression); + updateItemOperationConfig.ConditionalExpression = versionExpression; } - if (counterConditionExpression == null && versionExpression == null) return; + updateDocument = table.UpdateHelper( + storage.Document, + table.MakeKey(storage.Document), + updateItemOperationConfig, + counterConditionExpression, + updateIfNotExistsAttributeName + ); + + if (counterConditionExpression == null && versionExpression == null && !updateIfNotExistsAttributeName.Any()) return; if (returnValues == ReturnValues.AllNewAttributes) { @@ -428,36 +432,36 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants Document updateDocument; Expression versionExpression = null; - var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes; + var updateIfNotExistsAttributeName = GetUpdateIfNotExistsAttributeNames(storage); + + var returnValues = counterConditionExpression == null && !updateIfNotExistsAttributeName.Any() + ? ReturnValues.None + : ReturnValues.AllNewAttributes; - if ( - (flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) - || !storage.Config.HasVersion) + var updateItemOperationConfig = new UpdateItemOperationConfig { - updateDocument = await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig - { - ReturnValues = returnValues - }, counterConditionExpression, cancellationToken).ConfigureAwait(false); - } - else + ReturnValues = returnValues + }; + + if (!(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) && storage.Config.HasVersion) { - var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled); + var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled); versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig); SetNewVersion(storage); - - updateDocument = await table.UpdateHelperAsync( - storage.Document, - table.MakeKey(storage.Document), - new UpdateItemOperationConfig - { - ReturnValues = returnValues, - ConditionalExpression = versionExpression - }, counterConditionExpression, - cancellationToken) - .ConfigureAwait(false); + updateItemOperationConfig.ConditionalExpression = versionExpression; } - if (counterConditionExpression == null && versionExpression == null && !storage.Config.HasAutogeneratedProperties) return; + updateDocument = await table.UpdateHelperAsync( + storage.Document, + table.MakeKey(storage.Document), + updateItemOperationConfig, + counterConditionExpression, + cancellationToken, + updateIfNotExistsAttributeName + ).ConfigureAwait(false); + + + if (counterConditionExpression == null && versionExpression == null && !updateIfNotExistsAttributeName.Any()) return; if (returnValues == ReturnValues.AllNewAttributes) { @@ -698,4 +702,4 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants #endregion } -} +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs index 46f3edeab7c6..998885ac07c1 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs @@ -62,6 +62,7 @@ internal static void SetNewVersion(ItemStorage storage) } storage.Document[versionAttributeName] = version; } + private static void IncrementVersion(Type memberType, ref Primitive version) { if (memberType.IsAssignableFrom(typeof(Byte))) version = version.AsByte() + 1; @@ -140,7 +141,7 @@ private static PropertyStorage[] GetCounterProperties(ItemStorage storage) { var counterProperties = storage.Config.BaseTypeStorageConfig.Properties. Where(propertyStorage => propertyStorage.IsCounter).ToArray(); - var flatten= storage.Config.BaseTypeStorageConfig.Properties. + var flatten = storage.Config.BaseTypeStorageConfig.Properties. Where(propertyStorage => propertyStorage.FlattenProperties.Any()).ToArray(); while (flatten.Any()) { @@ -148,8 +149,7 @@ private static PropertyStorage[] GetCounterProperties(ItemStorage storage) counterProperties = counterProperties.Concat(flattenCounters).ToArray(); flatten = flatten.SelectMany(p => p.FlattenProperties.Where(fp => fp.FlattenProperties.Any())).ToArray(); } - - + return counterProperties; } @@ -175,12 +175,27 @@ private static DocumentModel.Expression CreateUpdateExpressionForCounterProperti propertyStorage.CounterStartValue - propertyStorage.CounterDelta; } updateExpression.ExpressionStatement = $"SET {asserts.Substring(0, asserts.Length - 2)}"; - return updateExpression; } #endregion + internal static List GetUpdateIfNotExistsAttributeNames(ItemStorage storage) + { + var ifNotExistsProperties = storage.Config.BaseTypeStorageConfig.Properties. + Where(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists).ToArray(); + var flatten = storage.Config.BaseTypeStorageConfig.Properties. + Where(propertyStorage => propertyStorage.FlattenProperties.Any()).ToArray(); + while (flatten.Any()) + { + var flattenIfNotExists = flatten.SelectMany(p => p.FlattenProperties.Where(fp => fp.UpdateBehaviorMode == UpdateBehavior.IfNotExists)).ToArray(); + ifNotExistsProperties = ifNotExistsProperties.Concat(flattenIfNotExists).ToArray(); + flatten = flatten.SelectMany(p => p.FlattenProperties.Where(fp => fp.FlattenProperties.Any())).ToArray(); + } + return ifNotExistsProperties.Select(p => p.AttributeName).ToList(); + } + + #region Table methods // Retrieves the target table for the specified type @@ -456,7 +471,7 @@ private void PopulateInstance(ItemStorage storage, object instance, DynamoDBFlat { foreach (PropertyStorage propertyStorage in storageConfig.AllPropertyStorage) { - if(propertyStorage.IsFlattened) continue; + if (propertyStorage.IsFlattened) continue; string attributeName = propertyStorage.AttributeName; if (propertyStorage.ShouldFlattenChildProperties) { @@ -573,7 +588,6 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl if (ShouldSave(dbe, ignoreNullValues)) { - if (propertyStorage.ShouldFlattenChildProperties) { if (dbe == null) continue; @@ -683,14 +697,14 @@ private object FromDynamoDBEntry(SimplePropertyStorage propertyStorage, DynamoDB bool isAotRuntime = InternalSDKUtils.IsRunningNativeAot(); string errorMessage; - + if (isAotRuntime) { errorMessage = $"Unable to convert DynamoDB entry [{entry}] of type {entry.GetType().FullName} to property {propertyStorage.PropertyName} of type {targetType.FullName}. " + - "Since the application is running in Native AOT mode the type could possibly be trimmed. " + - "This can happen if the type being created is a nested type of a type being used for saving and loading DynamoDB items. " + - $"This can be worked around by adding the \"[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof({targetType.FullName}))]\" attribute to the constructor of the parent type." + - " If the parent type can not be modified the attribute can also be used on the method invoking the DynamoDB sdk or some other method that you are sure is not being trimmed."; + "Since the application is running in Native AOT mode the type could possibly be trimmed. " + + "This can happen if the type being created is a nested type of a type being used for saving and loading DynamoDB items. " + + $"This can be worked around by adding the \"[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof({targetType.FullName}))]\" attribute to the constructor of the parent type." + + " If the parent type can not be modified the attribute can also be used on the method invoking the DynamoDB sdk or some other method that you are sure is not being trimmed."; } else { @@ -1514,7 +1528,7 @@ public ContextSearch(Search search, DynamoDBFlatConfig flatConfig) DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, Config); ItemStorageConfig storageConfig = StorageConfigCache.GetConfig(flatConfig); - + ContextSearch query; if (operationConfig is { Expression: { Filter: not null } }) { @@ -1561,7 +1575,7 @@ internal ContextSearch ContextSearch query; if (operationConfig is { Expression: { Filter: not null } }) { - if(conditions!=null && conditions.Any()) + if (conditions != null && conditions.Any()) { throw new InvalidOperationException("Query conditions are not supported with filter expression. Use either Query conditions or filter expression, but not both."); } @@ -1848,7 +1862,7 @@ private ExpressionNode HandleAttributeTypeMethodCall(MethodCallExpression expr, { var memberObj = ContextExpressionsUtils.GetMember(expr.Arguments[0]); var typeExpr = ContextExpressionsUtils.GetConstant(expr.Arguments[1]); - if (memberObj!=null && typeExpr!=null) + if (memberObj != null && typeExpr != null) { SetExpressionNodeAttributes(storageConfig, memberObj, typeExpr, node, flatConfig); } @@ -1990,7 +2004,7 @@ private ExpressionNode HandleStartsWithMethodCall(MethodCallExpression expr, Ite }; if (expr.Object is MemberExpression memberObj && expr.Arguments[0] is ConstantExpression argConst) { - var constantValue=ContextExpressionsUtils.GetConstant(argConst); + var constantValue = ContextExpressionsUtils.GetConstant(argConst); SetExpressionNodeAttributes(storageConfig, memberObj, constantValue, node, flatConfig); } else diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs index f26161a1acfd..1f9e3e284d75 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs @@ -167,6 +167,11 @@ internal class PropertyStorage : SimplePropertyStorage /// public bool IsAutoGeneratedTimestamp { get; set; } + /// + /// Behavior for handling updates to this property during a save operation. + /// + public UpdateBehavior UpdateBehaviorMode { get; set; } + // corresponding IndexNames, if applicable public List IndexNames { get; set; } @@ -246,6 +251,12 @@ public void Validate(DynamoDBContext context) if (IsHashKey && IsRangeKey) throw new InvalidOperationException("Property " + PropertyName + " cannot be both hash and range key"); + if (UpdateBehaviorMode == UpdateBehavior.IfNotExists && (IsKey || IsVersion || IsCounter)) + { + string propertyType = IsKey ? "key" : IsVersion ? "version" : IsCounter ? "counter" : "unknown"; + throw new InvalidOperationException($"Property {PropertyName} cannot be a {propertyType} and have UpdateBehavior set to IfNotExists at the same time."); + } + if (ConverterType != null) { if (PolymorphicProperty) @@ -290,6 +301,7 @@ internal PropertyStorage(MemberInfo member) IndexNames = new List(); Indexes = new List(); FlattenProperties = new List(); + UpdateBehaviorMode = UpdateBehavior.Always; } } @@ -1111,6 +1123,11 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con config.HasAutogeneratedProperties = true; } + if (propertyAttribute is DynamoDbUpdateBehaviorAttribute updateBehaviorAttribute) + { + propertyStorage.UpdateBehaviorMode = updateBehaviorAttribute.Behavior; + } + DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute; if (lsiRangeKeyAttribute != null) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs index 391c6e5d5df3..3d688ab556dc 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -227,7 +227,7 @@ public void AddSaveItem(T item) SetNewVersion(storage); AddDocumentTransaction(storage, conditionExpression); - + var objectItem = new DynamoDBContext.ObjectWithItemStorage { OriginalObject = item, @@ -446,23 +446,23 @@ private void AddDocumentTransaction(ItemStorage storage, Expression conditionExp attributeNames.Remove(rangeKeyPropertyName); } + var operationConfig = new TransactWriteItemOperationConfig + { + ConditionalExpression = conditionExpression, + ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None + }; + // If there are no attributes left, we need to use PutItem // as UpdateItem requires at least one data attribute if (attributeNames.Any()) { - DocumentTransaction.AddDocumentToUpdate(storage.Document, new TransactWriteItemOperationConfig - { - ConditionalExpression = conditionExpression, - ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None - }); + var ifNotExistAttributeNames = DynamoDBContext.GetUpdateIfNotExistsAttributeNames(storage); + DocumentTransaction.AddDocumentToUpdate(storage.Document, ifNotExistAttributeNames, operationConfig); + } else { - DocumentTransaction.AddDocumentToPut(storage.Document, new TransactWriteItemOperationConfig - { - ConditionalExpression = conditionExpression, - ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None - }); + DocumentTransaction.AddDocumentToPut(storage.Document, operationConfig); } } @@ -569,4 +569,4 @@ private TracerProvider GetTracerProvider(List allTransactionPart return tracerProvider; } } -} +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs index 903a4d32092e..662045210c81 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -155,14 +155,14 @@ internal static void ValidateNumericType(Type memberType) { return; } - throw new InvalidOperationException("Version property must be of primitive, numeric, integer, nullable type (e.g. int?, long?, byte?)"); + throw new InvalidOperationException("Version or counter property must be of primitive, numeric, integer, nullable type (e.g. int?, long?, byte?)"); } internal static void ValidateTimestampType(Type memberType) { if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) && (memberType.IsAssignableFrom(typeof(DateTime)) || - memberType.IsAssignableFrom(typeof(DateTimeOffset)))) + memberType.IsAssignableFrom(typeof(DateTimeOffset)))) { return; } @@ -210,7 +210,7 @@ internal static Type GetElementType(Type collectionType) internal static bool ItemsToCollection([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType, IEnumerable items, out object result) { return targetType.IsArray ? - ItemsToArray(targetType, items, out result): //targetType is Array + ItemsToArray(targetType, items, out result) : //targetType is Array ItemsToIList(targetType, items, out result); //targetType is IList or has Add method. } @@ -341,7 +341,7 @@ internal static string ToLowerCamelCase(string value) private static Type[][] validArrayConstructorInputs = new Type[][] { //supports one dimension Array only - new Type[] { typeof(int) } + new Type[] { typeof(int) } }; private static Type[][] validConverterConstructorInputs = new Type[][] { @@ -447,7 +447,7 @@ private static bool CanInstantiateHelper([DynamicallyAccessedMembers(Dynamically return true; } - + internal static Type GetType(MemberInfo member) { var pi = member as PropertyInfo; @@ -495,7 +495,7 @@ internal static bool ImplementsInterface([DynamicallyAccessedMembers(Dynamically } return false; } - + /// /// Apply a set of filters to a determine whether a member should be returned. /// In terms of DynamoDb, we want to return members that are fields or properties @@ -526,10 +526,10 @@ internal static List GetMembersFromType([DynamicallyAccessedMembers( { Dictionary members = new Dictionary(); - + Type currentType = type; while ( - currentType != null && + currentType != null && currentType != typeof(object)) { // Previous implementation used GetMembers to return the valid members for a type, but in certain class configurations @@ -540,7 +540,7 @@ internal static List GetMembersFromType([DynamicallyAccessedMembers( .GetMembers(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly) .Where(IsValidMemberInfo) .ToList(); - + foreach (var member in currentMembers) { if (!members.ContainsKey(member.Name)) @@ -548,13 +548,13 @@ internal static List GetMembersFromType([DynamicallyAccessedMembers( members[member.Name] = member; } } - + currentType = currentType.BaseType; } - + return members.Values.ToList(); } #endregion } -} +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs index 34086aeea136..12904244a94c 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -276,19 +276,19 @@ public void AddDocumentToUpdate(Document document, Primitive hashKey, TransactWr /// public void AddDocumentToUpdate(Document document, Primitive hashKey, Primitive rangeKey, TransactWriteItemOperationConfig operationConfig = null) { - AddDocumentToUpdateHelper(document, TargetTable.MakeKey(hashKey, rangeKey), operationConfig); + AddDocumentToUpdateHelper(document, TargetTable.MakeKey(hashKey, rangeKey), null, operationConfig); } /// public void AddDocumentToUpdate(Document document, IDictionary key, TransactWriteItemOperationConfig operationConfig = null) { - AddDocumentToUpdateHelper(document, TargetTable.MakeKey(key), operationConfig); + AddDocumentToUpdateHelper(document, TargetTable.MakeKey(key), null, operationConfig); } /// public void AddDocumentToUpdate(Document document, TransactWriteItemOperationConfig operationConfig = null) { - AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), operationConfig); + AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), null, operationConfig); } /// @@ -383,6 +383,11 @@ public void AddItemToConditionCheck(Document document, TransactWriteItemOperatio #region Internal/private methods + internal void AddDocumentToUpdate(Document document, List ifNotExistAttributeNames, TransactWriteItemOperationConfig operationConfig = null) + { + AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), ifNotExistAttributeNames, operationConfig); + } + internal void ExecuteHelper() { try @@ -425,14 +430,15 @@ internal void AddKeyToDeleteHelper(Key key, TransactWriteItemOperationConfig ope }); } - internal void AddDocumentToUpdateHelper(Document document, Key key, TransactWriteItemOperationConfig operationConfig = null) + internal void AddDocumentToUpdateHelper(Document document, Key key, List ifNotExistAttributeNames, TransactWriteItemOperationConfig operationConfig = null) { Items.Add(new ToUpdateWithDocumentTransactWriteRequestItem { TransactionPart = this, Document = document, Key = key, - OperationConfig = operationConfig + OperationConfig = operationConfig, + IfNotExistAttributeNames = ifNotExistAttributeNames }); } @@ -930,6 +936,8 @@ internal class ToUpdateWithDocumentTransactWriteRequestItem : ToUpdateTransactWr public Document Document { get; set; } + public List IfNotExistAttributeNames { get; set; } + #endregion @@ -957,7 +965,7 @@ protected override bool TryGetUpdateExpression(out string statement, return false; } - Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates,null,null, + Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, IfNotExistAttributeNames, null, null, out statement, out expressionAttributeValues, out expressionAttributes); return true; @@ -1054,4 +1062,4 @@ public override TransactWriteItem GetRequest() #endregion } -} +} \ 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..038972083de7 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -109,7 +109,7 @@ internal void ApplyExpression(PutItemRequest request, Table table) { request.ConditionExpression = this.ExpressionStatement; request.ExpressionAttributeValues = ConvertToAttributeValues(this.ExpressionAttributeValues, table); - + if (this.ExpressionAttributeNames?.Count > 0) { request.ExpressionAttributeNames = new Dictionary(this.ExpressionAttributeNames); @@ -198,7 +198,7 @@ internal static void ApplyExpression(QueryRequest request, Table table, var fean = filterExpression.ExpressionAttributeNames; var combinedEan = Common.Combine(kean, fean, StringComparer.Ordinal); - if(combinedEan?.Count > 0) + if (combinedEan?.Count > 0) { request.ExpressionAttributeNames = combinedEan; } @@ -215,6 +215,94 @@ internal static void ApplyExpression(QueryRequest request, Table table, } } + internal static Expression MergeUpdateExpressions(Expression right, Expression left) + { + if (right == null && left == null) + return null; + if (right == null) + return left; + if (left == null) + return right; + + var leftSections = ParseSections(left.ExpressionStatement); + var rightSections = ParseSections(right.ExpressionStatement); + + // Merge sections by keyword, combining with commas where needed + var keywordsOrder = new[] { "SET", "REMOVE", "ADD", "DELETE" }; + var mergedSections = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var keyword in keywordsOrder) + { + var leftPart = leftSections.ContainsKey(keyword) ? leftSections[keyword] : null; + var rightPart = rightSections.ContainsKey(keyword) ? rightSections[keyword] : null; + + if (!string.IsNullOrEmpty(leftPart) && !string.IsNullOrEmpty(rightPart)) + { + mergedSections[keyword] = leftPart + ", " + rightPart; + } + else if (!string.IsNullOrEmpty(leftPart)) + { + mergedSections[keyword] = leftPart; + } + else if (!string.IsNullOrEmpty(rightPart)) + { + mergedSections[keyword] = rightPart; + } + } + + var mergedStatement = string.Join(" ", + keywordsOrder.Where(k => mergedSections.ContainsKey(k)) + .Select(k => $"{k} {mergedSections[k]}")); + + var mergedNames = Common.Combine(left.ExpressionAttributeNames, right.ExpressionAttributeNames, StringComparer.Ordinal); + + var mergedValues = Common.Combine(left.ExpressionAttributeValues, right.ExpressionAttributeValues, null); + + return new Expression + { + ExpressionStatement = string.IsNullOrWhiteSpace(mergedStatement) ? null : mergedStatement, + ExpressionAttributeNames = mergedNames, + ExpressionAttributeValues = mergedValues + }; + + + static Dictionary ParseSections(string expr) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(expr)) + return result; + + // Find all keywords and their positions + var keywords = new[] { "SET", "REMOVE", "ADD", "DELETE" }; + var positions = new List<(string keyword, int index)>(); + foreach (var keyword in keywords) + { + int idx = expr.IndexOf(keyword, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + positions.Add((keyword, idx)); + } + if (positions.Count == 0) + { + // No recognized keywords, treat as a single section + result[string.Empty] = expr.Trim(); + return result; + } + + // Sort by position + positions = positions.OrderBy(p => p.index).ToList(); + for (int i = 0; i < positions.Count; i++) + { + var keyword = positions[i].keyword; + int start = positions[i].index + keyword.Length; + int end = (i + 1 < positions.Count) ? positions[i + 1].index : expr.Length; + string section = expr.Substring(start, end - start).Trim(); + if (!string.IsNullOrEmpty(section)) + result[keyword] = section; + } + return result; + } + } + internal static Dictionary ConvertToAttributeValues( Dictionary valueMap, Table table) { @@ -247,4 +335,4 @@ internal static Dictionary ConvertToAttributeValues( return convertedValues; } } -} +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index 023532ed7e9f..7e72ba5bb6d5 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; @@ -537,7 +537,7 @@ private static void ValidateConditional(IConditionalOperationConfig config) } - private void ValidateConditional(IConditionalOperationConfig config, Expression updateExpression) + private void ValidateConditional(IConditionalOperationConfig config, Expression updateExpression, List createOnlyAttributes) { if (config == null) @@ -546,8 +546,11 @@ private void ValidateConditional(IConditionalOperationConfig config, Expression int conditionsSet = 0; conditionsSet += config.Expected != null ? 1 : 0; conditionsSet += config.ExpectedState != null ? 1 : 0; - conditionsSet += - (config.ConditionalExpression is { ExpressionStatement: not null } || updateExpression is { ExpressionStatement: not null }) ? 1 : 0; + conditionsSet += + (config.ConditionalExpression is { ExpressionStatement: not null } || + updateExpression is { ExpressionStatement: not null } || + (createOnlyAttributes != null && createOnlyAttributes.Any())) ? + 1 : 0; if (conditionsSet > 1) throw new InvalidOperationException("Only one of the conditional properties Expected, ExpectedState and ConditionalExpression or UpdateExpression can be set."); @@ -1353,16 +1356,11 @@ internal async Task GetItemHelperAsync(Key key, GetItemOperationConfig internal Document UpdateHelper(Document doc, Primitive hashKey, Primitive rangeKey, UpdateItemOperationConfig config) { Key key = (hashKey != null || rangeKey != null) ? MakeKey(hashKey, rangeKey) : MakeKey(doc); - return UpdateHelper(doc, key, config,null); + return UpdateHelper(doc, key, config, null); } - internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primitive rangeKey, UpdateItemOperationConfig config, Expression expression, CancellationToken cancellationToken) - { - Key key = (hashKey != null || rangeKey != null) ? MakeKey(hashKey, rangeKey) : MakeKey(doc); - return UpdateHelperAsync(doc, key, config, expression, cancellationToken); - } - - internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression) + internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, + List ifNotExistAttributeNames = null) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1386,7 +1384,7 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig this.UpdateRequestUserAgentDetails(req, isAsync: false); - ValidateConditional(currentConfig, updateExpression); + ValidateConditional(currentConfig, updateExpression, ifNotExistAttributeNames); if (currentConfig.Expected != null) { @@ -1400,15 +1398,17 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig if (req.Expected.Count > 1) req.ConditionalOperator = EnumMapper.Convert(currentConfig.ExpectedState.ConditionalOperator); } - else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true }) + else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true } || + (ifNotExistAttributeNames != null && ifNotExistAttributeNames.Any())) { - currentConfig.ConditionalExpression.ApplyExpression(req, this); + currentConfig.ConditionalExpression?.ApplyExpression(req, this); string statement; Dictionary expressionAttributeValues; Dictionary expressionAttributeNames; - Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression, this, out statement, out expressionAttributeValues, out expressionAttributeNames); + Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, ifNotExistAttributeNames, updateExpression, this, + out statement, out expressionAttributeValues, out expressionAttributeNames); req.AttributeUpdates = null; req.UpdateExpression = statement; @@ -1453,7 +1453,14 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig return ret; } - internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, CancellationToken cancellationToken) + internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primitive rangeKey, UpdateItemOperationConfig config, Expression expression, CancellationToken cancellationToken) + { + Key key = (hashKey != null || rangeKey != null) ? MakeKey(hashKey, rangeKey) : MakeKey(doc); + return UpdateHelperAsync(doc, key, config, expression, cancellationToken); + } + + internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, + CancellationToken cancellationToken, List ifNotExistAttributeNames = null) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1477,7 +1484,7 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte this.UpdateRequestUserAgentDetails(req, isAsync: true); - ValidateConditional(currentConfig, updateExpression); + ValidateConditional(currentConfig, updateExpression, ifNotExistAttributeNames); if (currentConfig.Expected != null) { @@ -1491,7 +1498,8 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte if (req.Expected.Count > 1) req.ConditionalOperator = EnumMapper.Convert(currentConfig.ExpectedState.ConditionalOperator); } - else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true }) + else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true } || + (ifNotExistAttributeNames != null && ifNotExistAttributeNames.Any())) { currentConfig.ConditionalExpression?.ApplyExpression(req, this); @@ -1499,7 +1507,8 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte Dictionary expressionAttributeValues; Dictionary expressionAttributeNames; - Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression,this, out statement, out expressionAttributeValues, out expressionAttributeNames); + Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, ifNotExistAttributeNames, updateExpression, this, + out statement, out expressionAttributeValues, out expressionAttributeNames); req.AttributeUpdates = null; req.UpdateExpression = statement; @@ -1812,4 +1821,4 @@ public IDocumentTransactWrite CreateTransactWrite() #endregion } -} +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs index 457233e96e9f..db27af73b8f7 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs @@ -1,17 +1,17 @@ /* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ +* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ using System; using System.Collections.Generic; @@ -164,7 +164,7 @@ internal static class EnumMapper { public static string Convert(ReturnConsumedCapacityValues value) { - switch(value) + switch (value) { case ReturnConsumedCapacityValues.None: return "NONE"; @@ -289,7 +289,7 @@ public static string Convert(ScanOperator value) public static string Convert(QueryOperator value) { - switch(value) + switch (value) { case QueryOperator.Equal: return "EQ"; @@ -327,9 +327,10 @@ public static string Convert(ConditionalOperatorValues value) internal static class Common { private const string AwsVariablePrefix = "awsavar"; - + public static void ConvertAttributeUpdatesToUpdateExpression( - Dictionary attributesToUpdates, Expression updateExpression, + Dictionary attributesToUpdates, List ifNotExistAttributeNames, + Expression updateExpression, Table table, out string statement, out Dictionary expressionAttributeValues, @@ -340,8 +341,8 @@ public static void ConvertAttributeUpdatesToUpdateExpression( if (updateExpression != null) { - expressionAttributeValues = Expression.ConvertToAttributeValues(updateExpression.ExpressionAttributeValues,table); - expressionAttributes=updateExpression.ExpressionAttributeNames; + expressionAttributeValues = Expression.ConvertToAttributeValues(updateExpression.ExpressionAttributeValues, table); + expressionAttributes = updateExpression.ExpressionAttributeNames; } var attributeNames = expressionAttributes.Select(pair => pair.Value).ToList(); @@ -358,11 +359,13 @@ public static void ConvertAttributeUpdatesToUpdateExpression( var update = kvp.Value; + var createOnly = ifNotExistAttributeNames?.Contains(attribute) ?? false; + string variableName = GetVariableName(ref attributeCount); var attributeReference = GetAttributeReference(variableName); var attributeValueReference = GetAttributeValueReference(variableName); - if (update.Action == AttributeAction.DELETE) + if (update.Action == AttributeAction.DELETE && !createOnly) { if (removes.Length > 0) removes.Append(", "); @@ -372,7 +375,15 @@ public static void ConvertAttributeUpdatesToUpdateExpression( { if (sets.Length > 0) sets.Append(", "); - sets.AppendFormat("{0} = {1}", attributeReference, attributeValueReference); + switch (createOnly) + { + case true: + sets.AppendFormat("{0} = if_not_exists({0}, {1})", attributeReference, attributeValueReference); + break; + default: + sets.AppendFormat("{0} = {1}", attributeReference, attributeValueReference); + break; + } // Add the attribute value for the variable in the added in the expression expressionAttributeValues.Add(attributeValueReference, update.Value); @@ -386,8 +397,13 @@ public static void ConvertAttributeUpdatesToUpdateExpression( StringBuilder statementBuilder = new StringBuilder(); if (sets.Length > 0) { - var setStatement= updateExpression!=null ? updateExpression.ExpressionStatement + "," : "SET"; - statementBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}", setStatement, sets.ToString()); + var setStatement = updateExpression != null ? updateExpression.ExpressionStatement + "," : "SET"; + statementBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1} ", setStatement, sets.ToString()); + } + else + { + var setStatement = updateExpression != null ? updateExpression.ExpressionStatement : ""; + statementBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0} ", setStatement); } if (removes.Length > 0) { @@ -579,5 +595,4 @@ private static void WriteNextKey(Dictionary nextKey, Utf context.Writer.WriteEndObject(); } } - } - \ No newline at end of file +} diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index 23c0e92fe663..54af6c3bcae4 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.Serialization; using Microsoft.VisualStudio.TestTools.UnitTesting; using AWSSDK_DotNet.IntegrationTests.Utils; @@ -13,7 +14,6 @@ using System.Threading.Tasks; - namespace AWSSDK_DotNet.IntegrationTests.Tests.DynamoDB { public partial class DynamoDBTests : TestBase @@ -960,7 +960,7 @@ public void TestContext_Query_WithExpressionFilter() contextExpression.SetFilter(e => e.CompanyName == "Contoso"); var employees = Context.Query( - "Alice", + "Alice", new QueryConfig { Expression = contextExpression @@ -1585,7 +1585,7 @@ public void TestWithBuilderContext() // Clear existing SDK-wide cache TableCache.Clear(); - + Context = new DynamoDBContextBuilder() .ConfigureContext(x => { @@ -1640,9 +1640,14 @@ public async Task Test_FlattenAttribute_With_Annotations() Assert.IsNotNull(savedProductFlat.Details); Assert.AreEqual(product.Details.Description, savedProductFlat.Details.Description); Assert.AreEqual(0, savedProductFlat.Details.Version); - Assert.AreEqual("TestProduct",savedProductFlat.Name); + Assert.AreEqual("TestProduct", savedProductFlat.Name); Assert.AreEqual("TestProductDetails", savedProductFlat.Details.Name); + //update the product and verify the flattened property is updated + product.Name = "UpdatedProductName"; + await Context.SaveAsync(product); + Assert.AreEqual(1, product.Details.Version); + // flattened property, which itself contains another flattened property. var flatEmployee = new EmployeeNonFlat() { @@ -1743,7 +1748,7 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() var loadedAfterUpdate = await Context.LoadAsync(product.Id); ApproximatelyEqual(createdAt.Value, loadedAfterUpdate.CreatedAt.Value); - // Test: StoreAsEpoch with AutoGeneratedTimestamp + // Test: StoreAsEpoch with AutoGeneratedTimestamp (Always) var now = DateTime.UtcNow; var epochEntity = new AutoGenTimestampEpochEntity { @@ -1756,13 +1761,28 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() Assert.IsNotNull(loadedEpochEntity); Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue); + Assert.IsTrue(loadedEpochEntity.UpdatedAt > DateTime.MinValue); ApproximatelyEqual(epochEntity.CreatedAt.Value, loadedEpochEntity.CreatedAt.Value); + ApproximatelyEqual(epochEntity.UpdatedAt.Value, loadedEpochEntity.UpdatedAt.Value); - // Test: StoreAsEpochLong with AutoGeneratedTimestamp + // Save again and verify CreatedAt does not change, UpdatedAt does + var createdAtEpochEntity = loadedEpochEntity.CreatedAt; + var updatedAt = loadedEpochEntity.UpdatedAt; + await Task.Delay(1000); + loadedEpochEntity.Name = "UpdatedName"; + loadedEpochEntity.UpdatedAt = null; + loadedEpochEntity.CreatedAt = null; + await Context.SaveAsync(loadedEpochEntity); + var loadedAfterUpdateEpochEntity = await Context.LoadAsync(epochEntity.Id); + Assert.AreEqual(createdAtEpochEntity, loadedAfterUpdateEpochEntity.CreatedAt); + Assert.IsTrue(loadedAfterUpdateEpochEntity.UpdatedAt > updatedAt); + + // Test: StoreAsEpochLong with AutoGeneratedTimestamp (Always) var longEpochEntity = new AutoGenTimestampEpochLongEntity { Id = 2, Name = "LongEpochTest", + CreatedAt1 = DateTime.Today }; await Context.SaveAsync(longEpochEntity); @@ -1770,8 +1790,21 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() Assert.IsNotNull(loadedLongEpochEntity); Assert.IsTrue(loadedLongEpochEntity.CreatedAt > DateTime.MinValue); + Assert.IsTrue(loadedLongEpochEntity.UpdatedAt > DateTime.MinValue); ApproximatelyEqual(longEpochEntity.CreatedAt.Value, loadedLongEpochEntity.CreatedAt.Value); + ApproximatelyEqual(longEpochEntity.UpdatedAt.Value, loadedLongEpochEntity.UpdatedAt.Value); + // Save again and verify CreatedAt does not change, UpdatedAt does + var createdAtLong = loadedLongEpochEntity.CreatedAt; + var updatedAtLong = loadedLongEpochEntity.UpdatedAt; + await Task.Delay(1000); + loadedLongEpochEntity.Name = "UpdatedName2"; + loadedLongEpochEntity.UpdatedAt = null; + loadedLongEpochEntity.CreatedAt = null; + await Context.SaveAsync(loadedLongEpochEntity); + var loadedAfterUpdateLong = await Context.LoadAsync(longEpochEntity.Id); + ApproximatelyEqual(createdAtLong.Value, loadedAfterUpdateLong.CreatedAt.Value); + Assert.IsTrue(loadedAfterUpdateLong.UpdatedAt > updatedAtLong); // Test: StoreAsEpoch with AutoGeneratedTimestamp (Create) var epochCreateEntity = new AutoGenTimestampEpochEntity @@ -1785,7 +1818,13 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() Assert.IsNotNull(loadedEpochCreateEntity); Assert.IsTrue(loadedEpochCreateEntity.CreatedAt > DateTime.MinValue); - ApproximatelyEqual(epochCreateEntity.CreatedAt.Value, loadedEpochCreateEntity.CreatedAt.Value); + + var createdAtCreate = loadedEpochCreateEntity.CreatedAt; + await Task.Delay(1000); + loadedEpochCreateEntity.Name = "UpdatedName3"; + await Context.SaveAsync(loadedEpochCreateEntity); + var loadedAfterUpdateCreate = await Context.LoadAsync(epochCreateEntity.Id); + ApproximatelyEqual(createdAtCreate.Value, loadedAfterUpdateCreate.CreatedAt.Value); } @@ -1839,8 +1878,23 @@ public async Task Test_AutoGeneratedTimestampAttribute_TransactWrite_Simple() var loadedEpochEntity = await Context.LoadAsync(epochEntity.Id); Assert.IsNotNull(loadedEpochEntity); - Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue); + Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue); ApproximatelyEqual(epochEntity.CreatedAt.Value, loadedEpochEntity.CreatedAt.Value); + Assert.IsTrue(loadedEpochEntity.UpdatedAt > DateTime.MinValue); + + // Save again and verify CreatedAt does not change, UpdatedAt does + var createdAtEpochEntity = loadedEpochEntity.CreatedAt; + var updatedAt = loadedEpochEntity.UpdatedAt; + await Task.Delay(1000); + loadedEpochEntity.Name = "TransactEpochUpdated"; + loadedEpochEntity.UpdatedAt = null; + loadedEpochEntity.CreatedAt = null; + var transactWrite4 = Context.CreateTransactWrite(); + transactWrite4.AddSaveItem(loadedEpochEntity); + await transactWrite4.ExecuteAsync(); + var loadedAfterUpdateEpochEntity = await Context.LoadAsync(epochEntity.Id); + ApproximatelyEqual(createdAtEpochEntity.Value, loadedAfterUpdateEpochEntity.CreatedAt.Value); + Assert.IsTrue(loadedAfterUpdateEpochEntity.UpdatedAt > updatedAt); } [TestMethod] @@ -1930,7 +1984,6 @@ public async Task Test_AutoGeneratedTimestampAttribute_With_Annotations_BatchWri Assert.IsNotNull(loaded.LastUpdated, "LastUpdated should be set by AutoGeneratedTimestampAttribute"); Assert.IsTrue((DateTime.UtcNow - loaded.LastUpdated.Value).TotalMinutes < 1, "LastUpdated should be recent"); } - private static void TestEmptyStringsWithFeatureEnabled() { var product = new Product @@ -3719,6 +3772,7 @@ public class ProductWithCreateTimestamp [DynamoDBHashKey] public int Id { get; set; } public string Name { get; set; } [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] public DateTime? CreatedAt { get; set; } } @@ -3730,11 +3784,14 @@ public class AutoGenTimestampEpochEntity public string Name { get; set; } -#pragma warning disable CS0618 [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] [DynamoDBProperty(StoreAsEpoch = true)] public DateTime? CreatedAt { get; set; } -#pragma warning restore CS0618 + + [DynamoDBAutoGeneratedTimestamp] + [DynamoDBProperty(StoreAsEpoch = true)] + public DateTime? UpdatedAt { get; set; } } [DynamoDBTable("HashTable")] @@ -3746,8 +3803,16 @@ public class AutoGenTimestampEpochLongEntity public string Name { get; set; } [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] [DynamoDBProperty(StoreAsEpochLong = true)] public DateTime? CreatedAt { get; set; } + + [DynamoDBProperty(StoreAsEpochLong = true)] + public DateTime? CreatedAt1 { get; set; } + + [DynamoDBAutoGeneratedTimestamp] + [DynamoDBProperty(StoreAsEpochLong = true)] + public DateTime? UpdatedAt { get; set; } } public enum Status : long @@ -3863,29 +3928,6 @@ public class VersionedProduct : Product [DynamoDBVersion] public int? Version { get; set; } } - // Flattened scenario classes - [DynamoDBTable("HashTable")] - public class ProductFlatWithAtomicCounter - { - [DynamoDBHashKey] public int Id { get; set; } - [DynamoDBFlatten] public ProductDetailsWithAtomicCounter Details { get; set; } - public string Name { get; set; } - } - - public class ProductDetailsWithAtomicCounter - { - [DynamoDBVersion] - public int? Version { get; set; } - [DynamoDBAtomicCounter] - public int? CountDefault { get; set; } - [DynamoDBAtomicCounter(delta: 2, startValue: 10)] - public int? CountAtomic { get; set; } - public string Description { get; set; } - [DynamoDBProperty("DetailsName")] - public string Name { get; set; } - } - - /// /// Class representing items in the table [TableNamePrefix]HashTable, @@ -4045,13 +4087,30 @@ public class VersionedEmployee : Employee public class CounterAnnotatedEmployee : AnnotatedEmployee { - [DynamoDBAtomicCounter] + [DynamoDBAtomicCounter] public int? CountDefault { get; set; } - [DynamoDBAtomicCounter(delta:2, startValue:10)] + [DynamoDBAtomicCounter(delta: 2, startValue: 10)] public int? CountAtomic { get; set; } } + // Flattened scenario classes + [DynamoDBTable("HashTable")] + public class ProductFlatWithAtomicCounter + { + [DynamoDBHashKey] public int Id { get; set; } + [DynamoDBFlatten] public ProductDetailsWithAtomicCounter Details { get; set; } + public string Name { get; set; } + } + + public class ProductDetailsWithAtomicCounter + { + [DynamoDBVersion] public int? Version { get; set; } + [DynamoDBAtomicCounter] public int? CountDefault { get; set; } + [DynamoDBAtomicCounter(delta: 2, startValue: 10)] public int? CountAtomic { get; set; } + public string Description { get; set; } + [DynamoDBProperty("DetailsName")] public string Name { get; set; } + } /// /// Class representing items in the table [TableNamePrefix]HashTable @@ -4068,7 +4127,6 @@ public class VersionedAnnotatedEmployee : CounterAnnotatedEmployee /// public class EpochEmployee : Employee { -#pragma warning disable CS0618 [DynamoDBProperty(StoreAsEpoch = true)] public virtual DateTime CreationTime { get; set; } @@ -4097,7 +4155,6 @@ public class EpochEmployee : Employee [DynamoDBProperty(StoreAsEpochLong = true)] public DateTime? NullableLongEpochDate2 { get; set; } -#pragma warning restore CS0618 } /// @@ -4111,7 +4168,6 @@ public class AnnotatedEpochEmployee public int Age { get; set; } -#pragma warning disable CS0618 // Hash key [DynamoDBHashKey(StoreAsEpoch = true)] public virtual DateTime CreationTime { get; set; } @@ -4122,7 +4178,6 @@ public class AnnotatedEpochEmployee public DateTime NonEpochDate1 { get; set; } public DateTime NonEpochDate2 { get; set; } -#pragma warning restore CS0618 } /// @@ -4149,10 +4204,8 @@ public class AnnotatedNumEpochEmployee : AnnotatedEpochEmployee [DynamoDBTable("BadEmployeeHashRangeTable")] public class BadNumericEpochEmployee : NumericEpochEmployee { -#pragma warning disable CS0618 [DynamoDBProperty(StoreAsEpoch = true, StoreAsEpochLong = true)] public DateTime BadLongEpochDate { get; set; } -#pragma warning restore CS0618 } /// @@ -4161,9 +4214,7 @@ public class BadNumericEpochEmployee : NumericEpochEmployee [DynamoDBTable("NumericHashRangeTable")] public class AnnotatedNumericEpochEmployee : EpochEmployee { -#pragma warning disable CS0618 [DynamoDBHashKey(StoreAsEpoch = true)] public override DateTime CreationTime { get; set; } -#pragma warning restore CS0618 [DynamoDBRangeKey] public override string Name { get; set; } } @@ -4174,9 +4225,7 @@ public class AnnotatedNumericEpochEmployee : EpochEmployee [DynamoDBTable("NumericHashRangeTable")] public class PropertyConverterEmployee { -#pragma warning disable CS0618 [DynamoDBHashKey(StoreAsEpoch = true)] public DateTime CreationTime { get; set; } -#pragma warning restore CS0618 [DynamoDBRangeKey] [DynamoDBProperty(Converter = typeof(EnumAsStringConverter))] @@ -4428,4 +4477,4 @@ public class ModelA2 : ModelA #endregion } -} +} \ No newline at end of file diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs index 914d72c1b2d2..3d6d117770a0 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModel/ContextInternalTests.cs @@ -5,6 +5,7 @@ using Moq; using System; using System.Linq.Expressions; +using Amazon.DynamoDBv2.DocumentModel; using Amazon.Util; using DynamoDBContextConfig = Amazon.DynamoDBv2.DataModel.DynamoDBContextConfig; @@ -24,6 +25,34 @@ public class TestEntity [DynamoDBAutoGeneratedTimestamp] public DateTime? UpdatedAt { get; set; } + + } + + public class TestEntityWithUpdateBehavior + { + [DynamoDBHashKey] + public int Id { get; set; } + + [DynamoDBProperty("Prop1")] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] + public string Prop1 { get; set; } + } + + [DynamoDBTable("TestEntityWithUpdateBehavior")] + public class TestEntityWithFlattenedUpdateBehavior + { + [DynamoDBHashKey] + public int Id { get; set; } + + [DynamoDBFlatten] + public NestedEntity Nested { get; set; } + } + + public class NestedEntity + { + [DynamoDBProperty("Prop1")] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] + public string FlatProp1 { get; set; } } private Mock mockClient; @@ -34,7 +63,7 @@ public void TestInitialize() { mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(m => m.Config).Returns(new AmazonDynamoDBConfig()); - mockClient.Setup(m => m.DescribeTable(It.IsAny())) + mockClient.Setup(m => m.DescribeTable(It.Is(r => r.TableName == "TestEntity"))) .Returns(new DescribeTableResponse { Table = new TableDescription @@ -68,6 +97,30 @@ public void TestInitialize() } } }); + mockClient.Setup(m => m.DescribeTable(It.Is(r => r.TableName == "TestEntityWithUpdateBehavior"))) + .Returns(new DescribeTableResponse + { + Table = new TableDescription + { + TableName = "TestEntityWithUpdateBehavior", + KeySchema = new System.Collections.Generic.List + { + new KeySchemaElement + { + AttributeName = "Id", + KeyType = KeyType.HASH + } + }, + AttributeDefinitions = new System.Collections.Generic.List + { + new AttributeDefinition + { + AttributeName = "Id", + AttributeType = ScalarAttributeType.N + } + } + } + }); context = new DynamoDBContext(mockClient.Object, new DynamoDBContextConfig()); } @@ -173,10 +226,10 @@ public void ConvertQueryByValue_WithHashKeyOnly() Assert.IsNotNull(result.Search); var actualResult = result.Search; Assert.IsNotNull(actualResult.Filter); - Assert.AreEqual(1,actualResult.Filter.ToConditions().Count); + Assert.AreEqual(1, actualResult.Filter.ToConditions().Count); Assert.IsNull(actualResult.FilterExpression); Assert.IsNotNull(actualResult.AttributesToGet); - Assert.AreEqual(typeof(TestEntity).GetProperties().Length,actualResult.AttributesToGet.Count); + Assert.AreEqual(5, actualResult.AttributesToGet.Count); } [TestMethod] @@ -216,7 +269,6 @@ public void ConvertQueryByValue_WithHashKeyAndExpressionFilter() Assert.AreEqual("Name", search.Search.FilterExpression.ExpressionAttributeNames["#C0"]); Assert.AreEqual("bar", search.Search.FilterExpression.ExpressionAttributeValues[":C0"].ToString()); } - [TestMethod] public void ObjectToItemStorageHelper_SerializesAllProperties() { @@ -396,5 +448,48 @@ public void ObjectToItemStorageHelper_AutoGeneratedTimestamp_KeysOnly_DoesNotSet Assert.IsTrue(storage.Document.ContainsKey("Name")); Assert.IsFalse(storage.Document.ContainsKey("UpdatedAt")); } + + [TestMethod] + public void GetUpdateIfNotExistsAttributeNames_WithNoIfNotExistsProperties_ReturnsEmptyList() + { + var itemStorage = new ItemStorage(new + ItemStorageConfig(typeof(TestEntity))) + { + }; + var result = DynamoDBContext.GetUpdateIfNotExistsAttributeNames(itemStorage); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void GetUpdateIfNotExistsAttributeNames_WithIfNotExistsProperties_ReturnsAttributeNames() + { + var flatConfig = new DynamoDBFlatConfig(new DynamoDBOperationConfig(), context.Config); + var config = context.StorageConfigCache.GetConfig(flatConfig); + var itemStorage = new ItemStorage(config) + { + Document = new Document() + }; + var result = DynamoDBContext.GetUpdateIfNotExistsAttributeNames(itemStorage); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Prop1", result[0]); + } + + [TestMethod] + public void GetUpdateIfNotExistsAttributeNames_WithFlattenedIfNotExistsProperties_ReturnsAllAttributeNames() + { + var flatConfig = new DynamoDBFlatConfig(new DynamoDBOperationConfig(), context.Config); + var config = context.StorageConfigCache.GetConfig(flatConfig); + var itemStorage = new ItemStorage(config) + { + Document = new Document() + }; + var result = DynamoDBContext.GetUpdateIfNotExistsAttributeNames(itemStorage); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("Prop1", result[0]); + } + } } \ No newline at end of file diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs new file mode 100644 index 000000000000..ee8e113da7f4 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using Amazon.DynamoDBv2.DocumentModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AWSSDK_DotNet.UnitTests +{ + [TestClass] + public class ExpressionsTest + { + [TestMethod] + public void MergeUpdateExpressions_BothNull_ReturnsNull() + { + var result = Expression.MergeUpdateExpressions(null, null); + Assert.IsNull(result); + } + + [TestMethod] + public void MergeUpdateExpressions_OneNull_ReturnsOther() + { + var left = new Expression { ExpressionStatement = "SET #A = :a" }; + var right = new Expression { ExpressionStatement = "SET #B = :b" }; + + Assert.AreEqual(left.ExpressionStatement, Expression.MergeUpdateExpressions(null, left).ExpressionStatement); + Assert.AreEqual(right.ExpressionStatement, Expression.MergeUpdateExpressions(right, null).ExpressionStatement); + } + + [TestMethod] + public void MergeUpdateExpressions_MergesSetSections() + { + var left = new Expression + { + ExpressionStatement = "SET #A = :a", + ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } }, + ExpressionAttributeValues = new Dictionary { { ":a", new Primitive("1") } } + }; + var right = new Expression + { + ExpressionStatement = "SET #B = :b", + ExpressionAttributeNames = new Dictionary { { "#B", "AttrB" } }, + ExpressionAttributeValues = new Dictionary { { ":b", new Primitive("2") } } + }; + + var result = Expression.MergeUpdateExpressions(right, left); + + Assert.IsNotNull(result); + Assert.IsTrue(result.ExpressionStatement.Contains("SET")); + Assert.IsTrue(result.ExpressionStatement.Contains("#A = :a")); + Assert.IsTrue(result.ExpressionStatement.Contains("#B = :b")); + Assert.AreEqual("AttrA", result.ExpressionAttributeNames["#A"]); + Assert.AreEqual("AttrB", result.ExpressionAttributeNames["#B"]); + Assert.AreEqual("1", result.ExpressionAttributeValues[":a"].AsPrimitive().AsString()); + Assert.AreEqual("2", result.ExpressionAttributeValues[":b"].AsPrimitive().AsString()); + } + + [TestMethod] + public void MergeUpdateExpressions_MergesDifferentSections() + { + var left = new Expression + { + ExpressionStatement = "SET #A = :a", + ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } }, + ExpressionAttributeValues = new Dictionary { { ":a", new Primitive("1") } } + }; + var right = new Expression + { + ExpressionStatement = "REMOVE #B", + ExpressionAttributeNames = new Dictionary { { "#B", "AttrB" } } + }; + + var result = Expression.MergeUpdateExpressions(right, left); + + Assert.IsNotNull(result); + Assert.IsTrue(result.ExpressionStatement.Contains("SET #A = :a")); + Assert.IsTrue(result.ExpressionStatement.Contains("REMOVE #B")); + Assert.AreEqual("AttrA", result.ExpressionAttributeNames["#A"]); + Assert.AreEqual("AttrB", result.ExpressionAttributeNames["#B"]); + } + + [TestMethod] + public void MergeUpdateExpressions_AttributeNamesConflict_Throws() + { + var left = new Expression + { + ExpressionStatement = "SET #A = :a", + ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } } + }; + var right = new Expression + { + ExpressionStatement = "SET #A = :b", + ExpressionAttributeNames = new Dictionary { { "#A", "AttrB" } } + }; + + // Simulate the validation logic for duplicate names with different values + var mergedNames = new Dictionary(left.ExpressionAttributeNames, StringComparer.Ordinal); + Assert.ThrowsException(() => + { + foreach (var kv in right.ExpressionAttributeNames) + { + if (mergedNames.TryGetValue(kv.Key, out var existingValue)) + { + if (!string.Equals(existingValue, kv.Value, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Duplicate ExpressionAttributeName key '{kv.Key}' with different values: '{existingValue}' and '{kv.Value}'."); + } + } + else + { + mergedNames[kv.Key] = kv.Value; + } + } + }); + } + } +} \ No newline at end of file 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..351d80f7ba22 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs @@ -0,0 +1,266 @@ +using System.Collections.Generic; +using Amazon; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.Model; +using Amazon.Runtime; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +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); + // Mock IClientConfig + 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"); + } + + [TestMethod] + public void UpdateHelper_StandardUpdate_SendsCorrectRequest() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Name"] = "Test" }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.TableName == _tableName && + r.Key.ContainsKey("Id") && + r.AttributeUpdates != null && + r.AttributeUpdates.ContainsKey("Name") && + r.UpdateExpression == null + ) + )) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_WithExpression_SetsUpdateExpression() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Count"] = 5 }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + var expression = new Expression + { + ExpressionStatement = "SET #C = if_not_exists(#C, :zero) + :inc", + ExpressionAttributeNames = new Dictionary { { "#C", "Count" } }, + ExpressionAttributeValues = new Dictionary + { + { ":zero", 0 }, + { ":inc", 1 } + } + }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is + (r => + r.UpdateExpression != null && + r.UpdateExpression.Contains("if_not_exists") + && r.ExpressionAttributeNames.ContainsKey("#C") && + r.ExpressionAttributeValues.ContainsKey(":zero") + ) + )) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, expression); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_WithIfNotExistAttributeNames_SetsUpdateExpression() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Score"] = 10 }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + var ifNotExistAttrs = new List { "Score" }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.UpdateExpression != null && + r.UpdateExpression.Contains("if_not_exists") + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null, ifNotExistAttrs); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_WithExpressionAndIfNotExistAttributeNames_SetsUpdateExpression() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Score"] = 10 }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + var expression = new Expression + { + ExpressionStatement = "SET #S = if_not_exists(#S, :zero) + :val", + ExpressionAttributeNames = new Dictionary { { "#S", "Score" } }, + ExpressionAttributeValues = new Dictionary + { + { ":zero", 0 }, + { ":val", 10 } + } + }; + var ifNotExistAttrs = new List { "Score" }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.UpdateExpression != null && + r.UpdateExpression.Contains("if_not_exists") && + r.ExpressionAttributeNames.ContainsKey("#S") + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, expression, ifNotExistAttrs); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_KeysChanged_UpdatesAllAttributes() + { + // Arrange + var doc = new Document { ["Id"] = "2", ["Name"] = "Changed" }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + + // Simulate key change + _table.HashKeys.Clear(); + _table.HashKeys.Add("Id"); + _table.Keys["Id"] = new KeyDescription { IsHash = true, Type = DynamoDBEntryType.String }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.Key["Id"].S == "1" + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_WithExpected_SetsExpectedOnRequest() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Status"] = "Active" }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig + { + Expected = new Document { ["Status"] = "Active" } + }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.Expected != null && r.Expected.ContainsKey("Status") + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_WithExpectedState_SetsExpectedOnRequest() + { + // Arrange + var doc = new Document { ["Id"] = "1", ["Status"] = "Active" }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var expectedState = new ExpectedState(); + expectedState.ExpectedValues.Add("Status", new ExpectedValue(true)); + var config = new UpdateItemOperationConfig + { + ExpectedState = expectedState + }; + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.Expected != null && r.Expected.ContainsKey("Status") + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + + [TestMethod] + public void UpdateHelper_NoAttributeChanges_DoesNotSendAttributeUpdates() + { + // Arrange + var doc = new Document { ["Id"] = "1" }; + var key = new Key { ["Id"] = new AttributeValue { S = "1" } }; + var config = new UpdateItemOperationConfig(); + + _ddbClientMock + .Setup(c => c.UpdateItem(It.Is(r => + r.AttributeUpdates == null + ))) + .Returns(new UpdateItemResponse { Attributes = new Dictionary() }); + + // Act + var result = _table.UpdateHelper(doc, key, config, null); + + // Assert + Assert.IsNull(result); + _ddbClientMock.VerifyAll(); + } + } +} \ No newline at end of file