diff --git a/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json b/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json new file mode 100644 index 000000000000..69a580771fac --- /dev/null +++ b/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json @@ -0,0 +1,11 @@ +{ + "services": [ + { + "serviceName": "DynamoDBv2", + "type": "patch", + "changeLogMessages": [ + "Add support for DynamoDBAutoGeneratedTimestampAttribute that sets current timestamp during persistence 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 e3b879e5b9e2..ad34d37765d4 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -688,4 +688,108 @@ public DynamoDBLocalSecondaryIndexRangeKeyAttribute(params string[] indexNames) IndexNames = indexNames.Distinct(StringComparer.Ordinal).ToArray(); } } + + /// + /// Specifies that the decorated property or field should have its value automatically + /// set to the current timestamp during persistence operations. + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute + { + + /// + /// Default constructor. Timestamp is set on both create and update. + /// + public DynamoDBAutoGeneratedTimestampAttribute() + : base() + { + } + + + /// + /// Constructor that specifies an alternate attribute name. + /// + /// Name of attribute to be associated with property or field. + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName) + : base(attributeName) + { + } + /// + /// Constructor that specifies a custom converter. + /// + /// Custom converter type. + public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(converter) + { + } + + /// + /// Constructor that specifies an alternate attribute name and a custom converter. + /// + /// Name of attribute to be associated with property or field. + /// Custom converter type. + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(attributeName, converter) + { + } + } + + /// + /// 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, + /// or ignored during update operations. + /// + [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 57a43bdba112..1077f988d2cd 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -377,31 +377,37 @@ 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) + SetNewTimestamps(storage); + + var updateIfNotExists = GetUpdateIfNotExistsAttributeNames(storage); + + var returnValues = counterConditionExpression == null && !updateIfNotExists.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, + updateIfNotExists + ); + + if (counterConditionExpression == null && versionExpression == null && !updateIfNotExists.Any()) return; if (returnValues == ReturnValues.AllNewAttributes) { @@ -431,36 +437,38 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants Document updateDocument; Expression versionExpression = null; - var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes; + SetNewTimestamps(storage); + + var updateIfNotExistsAttributeName = GetUpdateIfNotExistsAttributeNames(storage); - if ( - (flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) - || !storage.Config.HasVersion) + var returnValues = counterConditionExpression == null && !updateIfNotExistsAttributeName.Any() + ? ReturnValues.None + : ReturnValues.AllNewAttributes; + + 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) 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) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs index 4beb34fd68cb..3515fbd6af7e 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs @@ -26,6 +26,7 @@ using Amazon.Util.Internal; using System.Globalization; using System.Diagnostics.CodeAnalysis; +using Amazon.Util; using ThirdParty.RuntimeBackports; namespace Amazon.DynamoDBv2.DataModel @@ -59,6 +60,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; @@ -118,6 +120,40 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora #endregion + #region Autogenerated Timestamp + + internal static void SetNewTimestamps(ItemStorage storage) + { + var timestampProperties = GetTimestampProperties(storage); + if (timestampProperties.Length == 0) return; + + var now = AWSSDKUtils.CorrectedUtcNow; + + foreach (var timestampProperty in timestampProperties) + { + var attributeName = timestampProperty.AttributeName; + + storage.Document[attributeName] = new Primitive(now.ToString("o")); + } + } + + internal static bool HasCreateOnlyProperties(ItemStorage storage) + { + return storage.Config.BaseTypeStorageConfig.AllPropertyStorage + .Any(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists); + } + + private static PropertyStorage[] GetTimestampProperties(ItemStorage storage) + { + //todo : adapt this to work with polymorphic types + var counterProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage. + Where(propertyStorage => propertyStorage.IsAutoGeneratedTimestamp).ToArray(); + + return counterProperties; + } + + #endregion + #region Atomic counters internal static Expression BuildCounterConditionExpression(ItemStorage storage) @@ -135,7 +171,7 @@ internal static Expression BuildCounterConditionExpression(ItemStorage storage) private static PropertyStorage[] GetCounterProperties(ItemStorage storage) { - var counterProperties = storage.Config.BaseTypeStorageConfig.Properties. + var counterProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage. Where(propertyStorage => propertyStorage.IsCounter).ToArray(); return counterProperties; @@ -163,10 +199,16 @@ private static Expression CreateUpdateExpressionForCounterProperties(PropertySto propertyStorage.CounterStartValue - propertyStorage.CounterDelta; } updateExpression.ExpressionStatement = $"SET {asserts.Substring(0, asserts.Length - 2)}"; - return updateExpression; } + internal static List GetUpdateIfNotExistsAttributeNames(ItemStorage storage) + { + var timestampProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage + .Where(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists).ToArray(); + return timestampProperties.Select(p => p.AttributeName).ToList(); + } + #endregion #region Table methods @@ -570,6 +612,14 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl { document[pair.Key] = pair.Value; } + + if (propertyStorage.FlattenProperties.Any(p => p.IsVersion)) + { + var innerVersionProperty = + propertyStorage.FlattenProperties.First(p => p.IsVersion); + storage.CurrentVersion = + innerDocument[innerVersionProperty.AttributeName] as Primitive; + } } else { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs index 65eefb12aebb..bc34ad841f9c 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs @@ -152,6 +152,12 @@ internal class PropertyStorage : SimplePropertyStorage // whether to store property at parent level public bool IsFlattened { get; set; } + // whether to store the property as a timestamp that is automatically generated + public bool IsAutoGeneratedTimestamp { get; set; } + + // Update behavior for the property, + public UpdateBehavior UpdateBehaviorMode { get; set; } + // corresponding IndexNames, if applicable public List IndexNames { get; set; } @@ -231,6 +237,11 @@ 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)) + { + throw new InvalidOperationException("Property " + PropertyName + " cannot be a key and have UpdateBehavior set to IfNotExists at the same time."); + } + if (ConverterType != null) { if (PolymorphicProperty) @@ -241,12 +252,17 @@ public void Validate(DynamoDBContext context) if (StoreAsEpoch || StoreAsEpochLong) throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as StoreAsEpoch or StoreAsEpochLong is set to true"); - + + if (IsAutoGeneratedTimestamp) + throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as AutoGeneratedTimestamp is set to true."); + if (!Utils.CanInstantiateConverter(ConverterType) || !Utils.ImplementsInterface(ConverterType, typeof(IPropertyConverter))) throw new InvalidOperationException("Converter for " + PropertyName + " must be instantiable with no parameters and must implement IPropertyConverter"); this.Converter = Utils.InstantiateConverter(ConverterType, context) as IPropertyConverter; } + if (IsAutoGeneratedTimestamp) + Utils.ValidateTimestampType(MemberType); if (StoreAsEpoch && StoreAsEpochLong) throw new InvalidOperationException(PropertyName + " must not set both StoreAsEpoch and StoreAsEpochLong as true at the same time."); @@ -269,6 +285,7 @@ internal PropertyStorage(MemberInfo member) IndexNames = new List(); Indexes = new List(); FlattenProperties = new List(); + UpdateBehaviorMode = UpdateBehavior.Always; } } @@ -1061,8 +1078,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con if (propertyAttribute is DynamoDBHashKeyAttribute) { - var gsiHashAttribute = propertyAttribute as DynamoDBGlobalSecondaryIndexHashKeyAttribute; - if (gsiHashAttribute != null) + if (propertyAttribute is DynamoDBGlobalSecondaryIndexHashKeyAttribute gsiHashAttribute) { propertyStorage.IsGSIHashKey = true; propertyStorage.AddIndex(gsiHashAttribute); @@ -1072,8 +1088,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con } if (propertyAttribute is DynamoDBRangeKeyAttribute) { - var gsiRangeAttribute = propertyAttribute as DynamoDBGlobalSecondaryIndexRangeKeyAttribute; - if (gsiRangeAttribute != null) + if (propertyAttribute is DynamoDBGlobalSecondaryIndexRangeKeyAttribute gsiRangeAttribute) { propertyStorage.IsGSIRangeKey = true; propertyStorage.AddIndex(gsiRangeAttribute); @@ -1082,6 +1097,16 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con propertyStorage.IsRangeKey = true; } + if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute) + { + propertyStorage.IsAutoGeneratedTimestamp = true; + } + + if(propertyAttribute is DynamoDbUpdateBehaviorAttribute updateBehaviorAttribute) + { + propertyStorage.UpdateBehaviorMode = updateBehaviorAttribute.Behavior; + } + DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute; if (lsiRangeKeyAttribute != null) { @@ -1090,8 +1115,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con } } - DynamoDBPolymorphicTypeAttribute polymorphicAttribute = attribute as DynamoDBPolymorphicTypeAttribute; - if (polymorphicAttribute != null) + if (attribute is DynamoDBPolymorphicTypeAttribute polymorphicAttribute) { propertyStorage.PolymorphicProperty = true; propertyStorage.AddDerivedType(polymorphicAttribute.TypeDiscriminator, polymorphicAttribute.DerivedType); diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs index eaa5f5ba06a0..cb2fe7c7b64f 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs @@ -229,6 +229,8 @@ public void AddSaveItem(T item) Expression conditionExpression = CreateConditionExpressionForVersion(storage); SetNewVersion(storage); + SetNewTimestamps(storage); + AddDocumentTransaction(storage, conditionExpression); var objectItem = new DynamoDBContext.ObjectWithItemStorage @@ -451,23 +453,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); } } @@ -476,6 +478,11 @@ private void SetNewVersion(ItemStorage storage) if (!ShouldUseVersioning()) return; DynamoDBContext.SetNewVersion(storage); } + + private void SetNewTimestamps(ItemStorage storage) + { + DynamoDBContext.SetNewTimestamps(storage); + } } /// diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs index 5d72b6dd6d3e..6c8349dd4873 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs @@ -155,7 +155,22 @@ 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)))) + { + return; + } + throw new InvalidOperationException( + $"Timestamp properties must be of type Nullable (DateTime?) or Nullable (DateTimeOffset?). " + + $"Invalid type: {memberType.FullName}. " + + "Please ensure your property is declared as 'DateTime?' or 'DateTimeOffset?'." + ); } [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)] diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs index cfe4de17166b..b48f202739e3 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs @@ -278,19 +278,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); } /// @@ -385,6 +385,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 @@ -429,14 +434,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 }); } @@ -926,7 +932,7 @@ public sealed override TransactWriteItem GetRequest() return new TransactWriteItem { Update = update }; } - protected abstract bool TryGetUpdateExpression(out string statement, + protected abstract bool TryGetUpdateExpression( out string statement, out Dictionary expressionAttributeValues, out Dictionary expressionAttributes); @@ -939,6 +945,8 @@ internal class ToUpdateWithDocumentTransactWriteRequestItem : ToUpdateTransactWr public Document Document { get; set; } + public List IfNotExistAttributeNames { get; set; } + #endregion @@ -966,7 +974,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; diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs index 3278dbc0015c..f613f7a3b643 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -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) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index 84034ca03af3..b6dd85d7b5a5 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -539,7 +539,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) @@ -549,7 +549,10 @@ private void ValidateConditional(IConditionalOperationConfig config, Expression 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; + (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."); @@ -1369,8 +1372,9 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi return UpdateHelperAsync(doc, key, config, expression, cancellationToken); } #endif - - internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression) + //todo unit tests for UpdateHelper with Expression and ifNotExistAttributeNames + internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, + List ifNotExistAttributeNames = null) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1394,7 +1398,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) { @@ -1408,15 +1412,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; @@ -1462,7 +1468,8 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig } #if AWS_ASYNC_API - internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, CancellationToken cancellationToken) + internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, + CancellationToken cancellationToken, List ifNotExistAttributeNames = null) { var currentConfig = config ?? new UpdateItemOperationConfig(); @@ -1486,7 +1493,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) { @@ -1500,7 +1507,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); @@ -1508,7 +1516,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; diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs index 457233e96e9f..e2b3a6607222 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs @@ -329,7 +329,8 @@ 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, @@ -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); diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index b5389a4dae68..ded7fe486a33 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; @@ -739,6 +740,61 @@ public async Task TestContext_AtomicCounterAnnotation() Assert.AreEqual(1, storedUpdatedEmployee.CountDefault); Assert.AreEqual(12, storedUpdatedEmployee.CountAtomic); + // --- Flatten scenario with atomic counter and version --- + var product = new ProductFlatWithAtomicCounter + { + Id = 500, + Name = "FlatAtomic", + Details = new ProductDetailsWithAtomicCounter + { + Description = "Flat details", + Name = "FlatName" + } + }; + + await Context.SaveAsync(product); + var loadedProduct = await Context.LoadAsync(product.Id); + Assert.IsNotNull(loadedProduct); + Assert.IsNotNull(loadedProduct.Details); + Assert.AreEqual(0, loadedProduct.Details.CountDefault); + Assert.AreEqual(10, loadedProduct.Details.CountAtomic); + Assert.AreEqual(0, loadedProduct.Details.Version); + + // Increment counters via null assignment + loadedProduct.Details.CountDefault = null; + loadedProduct.Details.CountAtomic = null; + await Context.SaveAsync(loadedProduct); + + var loadedProductAfterIncrement = await Context.LoadAsync(product.Id); + Assert.AreEqual(1, loadedProductAfterIncrement.Details.CountDefault); + Assert.AreEqual(12, loadedProductAfterIncrement.Details.CountAtomic); + Assert.AreEqual(1, loadedProductAfterIncrement.Details.Version); + + // Simulate a stale POCO for flattened details + var staleFlat = new ProductFlatWithAtomicCounter + { + Id = 500, + Name = "FlatAtomic", + Details = new ProductDetailsWithAtomicCounter + { + Description = "Flat details", + Name = "FlatName", + CountDefault = 0, + CountAtomic = 10, + Version = 1 + } + }; + await Context.SaveAsync(staleFlat); + + Assert.AreEqual(2, staleFlat.Details.CountDefault); + Assert.AreEqual(14, staleFlat.Details.CountAtomic); + Assert.AreEqual(2, staleFlat.Details.Version); + + var loadedFlatLatest = await Context.LoadAsync(product.Id); + Assert.AreEqual(2, loadedFlatLatest.Details.CountDefault); + Assert.AreEqual(14, loadedFlatLatest.Details.CountAtomic); + Assert.AreEqual(2, loadedFlatLatest.Details.Version); + } [TestMethod] @@ -1038,6 +1094,11 @@ public async Task Test_FlattenAttribute_With_Annotations() 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() { @@ -1107,7 +1168,240 @@ public async Task Test_FlattenAttribute_With_Annotations() } + [TestMethod] + [TestCategory("DynamoDBv2")] + public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() + { + CleanupTables(); + TableCache.Clear(); + + var product = new ProductWithCreateTimestamp + { + Id = 999, + Name = "SimpleCreate" + }; + + await Context.SaveAsync(product); + var loaded = await Context.LoadAsync(product.Id); + + Assert.IsNotNull(loaded); + Assert.AreEqual(product.Id, loaded.Id); + Assert.AreEqual("SimpleCreate", loaded.Name); + Assert.IsNotNull(loaded.CreatedAt); + Assert.IsTrue(loaded.CreatedAt > DateTime.MinValue); + Assert.AreEqual(product.CreatedAt, loaded.CreatedAt); + + // Save again and verify CreatedAt does not change + var createdAt = loaded.CreatedAt; + await Task.Delay(1000); + loaded.Name = "UpdatedName"; + await Context.SaveAsync(loaded); + var loadedAfterUpdate = await Context.LoadAsync(product.Id); + Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt); + + // Test: StoreAsEpoch with AutoGeneratedTimestamp (Always) + var now = DateTime.UtcNow; + var epochEntity = new AutoGenTimestampEpochEntity + { + Id = 1, + Name = "EpochTest" + }; + + await Context.SaveAsync(epochEntity); + var loadedEpochEntity = await Context.LoadAsync(epochEntity.Id); + + Assert.IsNotNull(loadedEpochEntity); + Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue); + Assert.IsTrue(loadedEpochEntity.UpdatedAt > DateTime.MinValue); + Assert.AreEqual(epochEntity.CreatedAt, loadedEpochEntity.CreatedAt); + Assert.AreEqual(epochEntity.UpdatedAt, loadedEpochEntity.UpdatedAt); + + // 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"; + 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); + var loadedLongEpochEntity = await Context.LoadAsync(longEpochEntity.Id); + + Assert.IsNotNull(loadedLongEpochEntity); + Assert.IsTrue(loadedLongEpochEntity.CreatedAt > DateTime.MinValue); + Assert.IsTrue(loadedLongEpochEntity.UpdatedAt > DateTime.MinValue); + Assert.AreEqual(longEpochEntity.CreatedAt, loadedLongEpochEntity.CreatedAt); + Assert.AreEqual(longEpochEntity.UpdatedAt, loadedLongEpochEntity.UpdatedAt); + + // 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"; + await Context.SaveAsync(loadedLongEpochEntity); + var loadedAfterUpdateLong = await Context.LoadAsync(longEpochEntity.Id); + Assert.AreEqual(createdAtLong, loadedAfterUpdateLong.CreatedAt); + Assert.IsTrue(loadedAfterUpdateLong.UpdatedAt > updatedAtLong); + + // Test: StoreAsEpoch with AutoGeneratedTimestamp (Create) + var epochCreateEntity = new AutoGenTimestampEpochEntity + { + Id = 3, + Name = "EpochCreateTest" + }; + + await Context.SaveAsync(epochCreateEntity); + var loadedEpochCreateEntity = await Context.LoadAsync(epochCreateEntity.Id); + + Assert.IsNotNull(loadedEpochCreateEntity); + Assert.IsTrue(loadedEpochCreateEntity.CreatedAt > DateTime.MinValue); + + var createdAtCreate = loadedEpochCreateEntity.CreatedAt; + await Task.Delay(1000); + loadedEpochCreateEntity.Name = "UpdatedName3"; + await Context.SaveAsync(loadedEpochCreateEntity); + var loadedAfterUpdateCreate = await Context.LoadAsync(epochCreateEntity.Id); + Assert.AreEqual(createdAtCreate, loadedAfterUpdateCreate.CreatedAt); + + } + + [TestMethod] + [TestCategory("DynamoDBv2")] + public async Task Test_AutoGeneratedTimestampAttribute_TransactWrite_Simple() + { + CleanupTables(); + TableCache.Clear(); + + var product = new ProductWithCreateTimestamp + { + Id = 1001, + Name = "TransactCreate" + }; + + // Save using TransactWrite + var transactWrite = Context.CreateTransactWrite(); + transactWrite.AddSaveItem(product); + await transactWrite.ExecuteAsync(); + + var loaded = await Context.LoadAsync(product.Id); + + Assert.IsNotNull(loaded); + Assert.AreEqual(product.Id, loaded.Id); + Assert.AreEqual("TransactCreate", loaded.Name); + Assert.IsNotNull(loaded.CreatedAt); + Assert.IsTrue(loaded.CreatedAt > DateTime.MinValue); + Assert.AreEqual(product.CreatedAt, loaded.CreatedAt); + + // Save again using TransactWrite and verify CreatedAt does not change + var createdAt = loaded.CreatedAt; + await Task.Delay(1000); + loaded.Name = "TransactUpdated"; + var transactWrite2 = Context.CreateTransactWrite(); + transactWrite2.AddSaveItem(loaded); + await transactWrite2.ExecuteAsync(); + var loadedAfterUpdate = await Context.LoadAsync(product.Id); + Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt); + + // Test: StoreAsEpoch with AutoGeneratedTimestamp (Always) using TransactWrite + var epochEntity = new AutoGenTimestampEpochEntity + { + Id = 1002, + Name = "TransactEpoch" + }; + + var transactWrite3 = Context.CreateTransactWrite(); + transactWrite3.AddSaveItem(epochEntity); + await transactWrite3.ExecuteAsync(); + var loadedEpochEntity = await Context.LoadAsync(epochEntity.Id); + + Assert.IsNotNull(loadedEpochEntity); + Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue); + 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"; + var transactWrite4 = Context.CreateTransactWrite(); + transactWrite4.AddSaveItem(loadedEpochEntity); + await transactWrite4.ExecuteAsync(); + var loadedAfterUpdateEpochEntity = await Context.LoadAsync(epochEntity.Id); + Assert.AreEqual(createdAtEpochEntity, loadedAfterUpdateEpochEntity.CreatedAt); + Assert.IsTrue(loadedAfterUpdateEpochEntity.UpdatedAt > updatedAt); + } + + [TestMethod] + [TestCategory("DynamoDBv2")] + public async Task Test_AutoGeneratedTimestampAttribute_With_Annotations() + { + CleanupTables(); + TableCache.Clear(); + + // 1. Test: AutoGeneratedTimestamp combined with Version and Flatten + var now = DateTime.UtcNow; + var product = new ProductFlatWithTimestamp + { + Id = 100, + Name = "TimestampedProduct", + Details = new ProductDetailsWithTimestamp + { + Description = "Timestamped details", + Name = "DetailsName", + } + }; + + await Context.SaveAsync(product); + var savedProduct = await Context.LoadAsync(product.Id); + Assert.IsNotNull(savedProduct); + Assert.IsNotNull(savedProduct.Details); + Assert.IsTrue(savedProduct.Details.CreatedAt > DateTime.MinValue); + Assert.AreEqual(0, savedProduct.Details.Version); + + // 2. Test: AutoGeneratedTimestamp combined with AtomicCounter and GSI + var employee = new EmployeeWithTimestampAndCounter + { + Name = "Alice", + Age = 25, + CompanyName = "TestCompany", + Score = 10, + ManagerName = "Bob" + }; + await Context.SaveAsync(employee); + var loadedEmployee = await Context.LoadAsync(employee.Name, employee.Age); + Assert.IsNotNull(loadedEmployee); + Assert.IsTrue(loadedEmployee.LastUpdated > DateTime.MinValue); + Assert.AreEqual(0, loadedEmployee.CountDefault); + // 3. Test: AutoGeneratedTimestamp with TimestampMode.Create + var productCreateOnly = new ProductWithCreateTimestamp + { + Id = 200, + Name = "CreateOnly" + }; + await Context.SaveAsync(productCreateOnly); + var loadedCreateOnly = await Context.LoadAsync(productCreateOnly.Id); + Assert.IsNotNull(loadedCreateOnly); + var createdAt = loadedCreateOnly.CreatedAt; + Assert.IsTrue(createdAt > DateTime.MinValue); + + // Update and verify CreatedAt does not change + await Task.Delay(1000); + loadedCreateOnly.Name = "UpdatedName"; + await Context.SaveAsync(loadedCreateOnly); + var loadedAfterUpdate = await Context.LoadAsync(productCreateOnly.Id); + Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt); + } private static void TestEmptyStringsWithFeatureEnabled() { var product = new Product @@ -2865,6 +3159,80 @@ private ModelA CreateNestedTypeItem(out Guid id) #region OPM definitions + // Helper classes for the integration test + + [DynamoDBTable("HashTable")] + public class ProductFlatWithTimestamp + { + [DynamoDBHashKey] public int Id { get; set; } + [DynamoDBFlatten] public ProductDetailsWithTimestamp Details { get; set; } + public string Name { get; set; } + } + + public class ProductDetailsWithTimestamp + { + [DynamoDBVersion] public int? Version { get; set; } + [DynamoDBAutoGeneratedTimestamp] public DateTime? CreatedAt { get; set; } + public string Description { get; set; } + [DynamoDBProperty("DetailsName")] public string Name { get; set; } + } + + [DynamoDBTable("HashRangeTable")] + public class EmployeeWithTimestampAndCounter : AnnotatedEmployee + { + [DynamoDBAutoGeneratedTimestamp] public DateTime? LastUpdated { get; set; } + [DynamoDBAtomicCounter] public int? CountDefault { get; set; } + } + + [DynamoDBTable("HashTable")] + public class ProductWithCreateTimestamp + { + [DynamoDBHashKey] public int Id { get; set; } + public string Name { get; set; } + [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] + public DateTime? CreatedAt { get; set; } + } + + [DynamoDBTable("HashTable")] + public class AutoGenTimestampEpochEntity + { + [DynamoDBHashKey] + public int Id { get; set; } + + public string Name { get; set; } + + [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] + [DynamoDBProperty(StoreAsEpoch = true)] + public DateTime? CreatedAt { get; set; } + + [DynamoDBAutoGeneratedTimestamp] + [DynamoDBProperty(StoreAsEpoch = true)] + public DateTime? UpdatedAt { get; set; } + } + + [DynamoDBTable("HashTable")] + public class AutoGenTimestampEpochLongEntity + { + [DynamoDBHashKey] + public int Id { get; set; } + + 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 { Active = 256, @@ -3144,6 +3512,23 @@ public class CounterAnnotatedEmployee : AnnotatedEmployee 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 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..6a59a43a35b7 --- /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.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel +{ + [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; + } + } + }); + } + } +} diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs new file mode 100644 index 000000000000..a91bb7244c4d --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs @@ -0,0 +1,278 @@ +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; +using System.Collections.Generic; +using Amazon.DynamoDBv2; + +namespace AWSSDK.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel +{ + [TestClass] + public class PropertyStorageTests + { + private class TestClass + { + public int Id { get; set; } + public string Name { get; set; } + public int? Counter { get; set; } + public int? Version { get; set; } + public DateTime? Timestamp { get; set; } + } + + private PropertyStorage CreatePropertyStorage(string propertyName = "Id") + { + var member = typeof(TestClass).GetProperty(propertyName); + return new PropertyStorage(member); + } + + private class DummyContext : DynamoDBContext + { + + public DummyContext(IAmazonDynamoDB client) : base(client, false, null) + { + } + + } + + private class FakePropertyConverter : IPropertyConverter + { + public object FromEntry(DynamoDBEntry entry) => null; + public DynamoDBEntry ToEntry(object value) => null; + } + + + [TestMethod] + public void AddIndex_AddsIndexToIndexesList() + { + var storage = CreatePropertyStorage(); + var gsi = new PropertyStorage.GSI(true, "Attr", "Index1"); + storage.AddIndex(gsi); + + Assert.AreEqual(1, storage.Indexes.Count); + Assert.AreSame(gsi, storage.Indexes[0]); + } + + [TestMethod] + public void AddGsiIndex_AddsGSIIndex() + { + var storage = CreatePropertyStorage(); + storage.AddGsiIndex(true, "Attr", "Index1", "Index2"); + + Assert.AreEqual(1, storage.Indexes.Count); + var gsi = storage.Indexes[0] as PropertyStorage.GSI; + Assert.IsNotNull(gsi); + Assert.IsTrue(gsi.IsHashKey); + CollectionAssert.AreEquivalent(new List { "Index1", "Index2" }, gsi.IndexNames); + } + + [TestMethod] + public void AddLsiIndex_AddsLSIIndex() + { + var storage = CreatePropertyStorage(); + storage.AddLsiIndex("Attr", "Index1"); + + Assert.AreEqual(1, storage.Indexes.Count); + var lsi = storage.Indexes[0] as PropertyStorage.LSI; + Assert.IsNotNull(lsi); + Assert.AreEqual("Attr", lsi.AttributeName); + CollectionAssert.AreEquivalent(new List { "Index1" }, lsi.IndexNames); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfBothHashAndRangeKey() + { + var storage = CreatePropertyStorage("Name"); + storage.IsHashKey = true; + storage.IsRangeKey = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfStoreAsEpochAndStoreAsEpochLong() + { + var storage = CreatePropertyStorage("Timestamp"); + storage.StoreAsEpoch = true; + storage.StoreAsEpochLong = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfConverterTypeAndPolymorphicProperty() + { + var storage = CreatePropertyStorage("Name"); + storage.ConverterType = typeof(object); // Not a real converter, but triggers the check + storage.PolymorphicProperty = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfConverterTypeAndShouldFlattenChildProperties() + { + var storage = CreatePropertyStorage("Name"); + storage.ConverterType = typeof(object); + storage.ShouldFlattenChildProperties = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfConverterTypeAndStoreAsEpoch() + { + var storage = CreatePropertyStorage("Timestamp"); + storage.ConverterType = typeof(object); + storage.StoreAsEpoch = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIfConverterTypeAndIsAutoGeneratedTimestamp() + { + var storage = CreatePropertyStorage("Timestamp"); + storage.ConverterType = typeof(object); + storage.IsAutoGeneratedTimestamp = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + storage.Validate(null); + } + [TestMethod] + public void Validate_AllowsIsVersionOnNumericType() + { + var mockClient = new Mock(); + var context = new DummyContext(mockClient.Object); + + var storage = CreatePropertyStorage("Version"); + storage.IsVersion = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should not throw for int property + storage.Validate(context); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIsVersionOnNonNumericType() + { + var storage = CreatePropertyStorage("Name"); + storage.IsVersion = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should throw for string property + storage.Validate(null); + } + + [TestMethod] + public void Validate_AllowsIsCounterOnNumericType() + { + var mockClient = new Mock(); + var context = new DummyContext(mockClient.Object); + + var storage = CreatePropertyStorage("Counter"); + storage.IsCounter = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should not throw for int property + storage.Validate(context); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIsCounterOnNonNumericType() + { + var storage = CreatePropertyStorage("Name"); + storage.IsCounter = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should throw for string property + storage.Validate(null); + } + + + [TestMethod] + public void Validate_AllowsIsAutoGeneratedTimestampOnDateTime() + { + var mockClient = new Mock(); + var context = new DummyContext(mockClient.Object); + + var storage = CreatePropertyStorage("Timestamp"); + storage.IsAutoGeneratedTimestamp = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should not throw for DateTime property + storage.Validate(context); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Validate_ThrowsIsAutoGeneratedTimestampOnNonDateTime() + { + var storage = CreatePropertyStorage("Id"); + storage.IsAutoGeneratedTimestamp = true; + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + // Should throw for int property + storage.Validate(null); + } + + [TestMethod] + public void Validate_UsesConverterFromContextCache() + { + var mockClient = new Mock(); + var context = new DummyContext(mockClient.Object); + + var storage = CreatePropertyStorage("Id"); + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + + var fakeConverter = new FakePropertyConverter(); + context.ConverterCache[typeof(int)] = fakeConverter; + + storage.Validate(context); + + Assert.AreSame(fakeConverter, storage.Converter); + } + + [TestMethod] + public void Validate_PopulatesIndexNamesFromIndexes() + { + var mockClient = new Mock(); + var context = new DummyContext(mockClient.Object); + + var storage = CreatePropertyStorage("Id"); + storage.IndexNames = new List(); + storage.FlattenProperties = new List(); + storage.AddGsiIndex(true, "Attr", "IndexA", "IndexB"); + + storage.Validate(context); + + CollectionAssert.Contains(storage.IndexNames, "IndexA"); + CollectionAssert.Contains(storage.IndexNames, "IndexB"); + } + } +} \ No newline at end of file