From 1c95be472d5d87e4288ca2044ce1a412330e7e22 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 16 Jun 2025 13:26:04 +0300 Subject: [PATCH 1/5] add new attribute --- .../DynamoDBv2/Custom/DataModel/Attributes.cs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs index e3b879e5b9e2..adc9c442e00c 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -688,4 +688,128 @@ 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. + /// + /// + /// The property controls when the timestamp is set: + /// + /// : Set only when the item is created. + /// : Set only when the item is updated. + /// : Set on both create and update. + /// + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class AutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute + { + /// + /// Gets or sets when the timestamp should be generated. + /// + public TimestampMode Mode { get; set; } + + /// + /// Default constructor. Timestamp is set on both create and update. + /// + public AutoGeneratedTimestampAttribute() + : base() + { + Mode = TimestampMode.Always; + } + + /// + /// Constructor that specifies when the timestamp should be generated. + /// + /// Specifies when the timestamp should be generated. + public AutoGeneratedTimestampAttribute(TimestampMode mode) + : base() + { + Mode = mode; + } + + /// + /// Constructor that specifies an alternate attribute name. + /// + /// Name of attribute to be associated with property or field. + public AutoGeneratedTimestampAttribute(string attributeName) + : base(attributeName) + { + Mode = TimestampMode.Always; + } + + /// + /// Constructor that specifies an alternate attribute name and when the timestamp should be generated. + /// + /// Name of attribute to be associated with property or field. + /// Specifies when the timestamp should be generated. + public AutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode) + : base(attributeName) + { + Mode = mode; + } + + /// + /// Constructor that specifies a custom converter. + /// + /// Custom converter type. + public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(converter) + { + Mode = TimestampMode.Always; + } + + /// + /// Constructor that specifies a custom converter and when the timestamp should be generated. + /// + /// Custom converter type. + /// Specifies when the timestamp should be generated. + public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) + : base(converter) + { + Mode = mode; + } + + /// + /// 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 AutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(attributeName, converter) + { + Mode = TimestampMode.Always; + } + + /// + /// Constructor that specifies an alternate attribute name, a custom converter, and when the timestamp should be generated. + /// + /// Name of attribute to be associated with property or field. + /// Custom converter type. + /// Specifies when the timestamp should be generated. + public AutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) + : base(attributeName, converter) + { + Mode = mode; + } + } + + /// + /// Specifies when an auto-generated timestamp should be set. + /// + public enum TimestampMode + { + /// + /// Set the timestamp only when the item is created. + /// + Create, + /// + /// Set the timestamp only when the item is updated. + /// + Update, + /// + /// Set the timestamp on both create and update. + /// + Always + } } From 5f4d36232a84d50240d721667f4149cd06c64049 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Wed, 18 Jun 2025 16:29:03 +0300 Subject: [PATCH 2/5] handle autogenerated timestamp --- .../DynamoDBv2/Custom/DataModel/Attributes.cs | 25 +- .../DynamoDBv2/Custom/DataModel/Context.cs | 30 +- .../Custom/DataModel/ContextInternal.cs | 91 ++++- .../Custom/DataModel/InternalModel.cs | 27 +- .../DynamoDBv2/Custom/DataModel/Utils.cs | 17 +- .../Custom/DocumentModel/Expression.cs | 87 +++++ .../DynamoDBv2/Custom/DocumentModel/Table.cs | 2 +- .../IntegrationTests/DataModelTests.cs | 310 ++++++++++++++++++ .../Custom/DocumentModel/ExpressionsTest.cs | 116 +++++++ .../DocumentModel/PropertyStorageTests.cs | 278 ++++++++++++++++ 10 files changed, 949 insertions(+), 34 deletions(-) create mode 100644 sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs create mode 100644 sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs index adc9c442e00c..2af8608a843e 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -697,22 +697,21 @@ public DynamoDBLocalSecondaryIndexRangeKeyAttribute(params string[] indexNames) /// The property controls when the timestamp is set: /// /// : Set only when the item is created. - /// : Set only when the item is updated. /// : Set on both create and update. /// /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] - public sealed class AutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute + public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute { /// /// Gets or sets when the timestamp should be generated. /// - public TimestampMode Mode { get; set; } + public TimestampMode Mode { get; } /// /// Default constructor. Timestamp is set on both create and update. /// - public AutoGeneratedTimestampAttribute() + public DynamoDBAutoGeneratedTimestampAttribute() : base() { Mode = TimestampMode.Always; @@ -722,7 +721,7 @@ public AutoGeneratedTimestampAttribute() /// Constructor that specifies when the timestamp should be generated. /// /// Specifies when the timestamp should be generated. - public AutoGeneratedTimestampAttribute(TimestampMode mode) + public DynamoDBAutoGeneratedTimestampAttribute(TimestampMode mode) : base() { Mode = mode; @@ -732,7 +731,7 @@ public AutoGeneratedTimestampAttribute(TimestampMode mode) /// Constructor that specifies an alternate attribute name. /// /// Name of attribute to be associated with property or field. - public AutoGeneratedTimestampAttribute(string attributeName) + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName) : base(attributeName) { Mode = TimestampMode.Always; @@ -743,7 +742,7 @@ public AutoGeneratedTimestampAttribute(string attributeName) /// /// Name of attribute to be associated with property or field. /// Specifies when the timestamp should be generated. - public AutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode) + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode) : base(attributeName) { Mode = mode; @@ -753,7 +752,7 @@ public AutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode) /// Constructor that specifies a custom converter. /// /// Custom converter type. - public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) : base(converter) { Mode = TimestampMode.Always; @@ -764,7 +763,7 @@ public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAc /// /// Custom converter type. /// Specifies when the timestamp should be generated. - public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) + public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) : base(converter) { Mode = mode; @@ -775,7 +774,7 @@ public AutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAc /// /// Name of attribute to be associated with property or field. /// Custom converter type. - public AutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) : base(attributeName, converter) { Mode = TimestampMode.Always; @@ -787,7 +786,7 @@ public AutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccesse /// Name of attribute to be associated with property or field. /// Custom converter type. /// Specifies when the timestamp should be generated. - public AutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) : base(attributeName, converter) { Mode = mode; @@ -804,10 +803,6 @@ public enum TimestampMode /// Create, /// - /// Set the timestamp only when the item is updated. - /// - Update, - /// /// Set the timestamp on both create and update. /// Always diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs index 57a43bdba112..9abea392bff0 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -375,17 +375,23 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr var counterConditionExpression = BuildCounterConditionExpression(storage); + var autoGeneratedTimestampExpression = BuildTimestampConditionExpression(storage); + Document updateDocument; Expression versionExpression = null; - - var returnValues=counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes; + + var updateExpression = Expression.MergeUpdateExpressions(counterConditionExpression, autoGeneratedTimestampExpression); + + var returnValues = updateExpression == null + ? ReturnValues.None + : ReturnValues.AllNewAttributes; if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion) { updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig() { ReturnValues = returnValues - }, counterConditionExpression); + }, updateExpression); } else { @@ -398,10 +404,10 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr ReturnValues = returnValues, ConditionalExpression = versionExpression, }; - updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, counterConditionExpression); + updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, updateExpression); } - if (counterConditionExpression == null && versionExpression == null) return; + if (updateExpression == null && versionExpression == null) return; if (returnValues == ReturnValues.AllNewAttributes) { @@ -428,10 +434,16 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants var counterConditionExpression = BuildCounterConditionExpression(storage); + var autoGeneratedTimestampExpression = BuildTimestampConditionExpression(storage); + Document updateDocument; Expression versionExpression = null; - var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes; + var updateExpression = Expression.MergeUpdateExpressions(counterConditionExpression, autoGeneratedTimestampExpression); + + var returnValues = updateExpression == null + ? ReturnValues.None + : ReturnValues.AllNewAttributes; if ( (flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) @@ -440,7 +452,7 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants updateDocument = await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig { ReturnValues = returnValues - }, counterConditionExpression, cancellationToken).ConfigureAwait(false); + }, updateExpression, cancellationToken).ConfigureAwait(false); } else { @@ -455,12 +467,12 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants { ReturnValues = returnValues, ConditionalExpression = versionExpression - }, counterConditionExpression, + }, updateExpression, cancellationToken) .ConfigureAwait(false); } - if (counterConditionExpression == null && versionExpression == null) return; + if (updateExpression == null && versionExpression == null) 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..d2a1fe5b49e4 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 @@ -118,6 +119,86 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora #endregion + #region Autogenerated Timestamp + + internal static Expression BuildTimestampConditionExpression(ItemStorage storage) + { + var timestampProperties = GetTimestampProperties(storage); + Expression timestampConditionExpression = null; + + if (timestampProperties.Length != 0) + { + timestampConditionExpression = CreateUpdateExpressionForTimestampProperties(timestampProperties); + } + + return timestampConditionExpression; + } + + private static Expression CreateUpdateExpressionForTimestampProperties(PropertyStorage[] timestampProperties) + { + if (timestampProperties == null || timestampProperties.Length == 0) + return null; + + var updateExpression = new Expression(); + var setClauses = new List(); + + var dateTime = AWSSDKUtils.CorrectedUtcNow; // Use corrected UTC time + foreach (var propertyStorage in timestampProperties) + { + string attributeName = propertyStorage.AttributeName; + string attributeRef = Common.GetAttributeReference(attributeName); + string valueRef = $":{attributeName}Timestamp"; + updateExpression.ExpressionAttributeNames[attributeRef] = attributeName; + + if (propertyStorage.StoreAsEpochLong) + { + string epochSecondsAsString = AWSSDKUtils.ConvertToUnixEpochSecondsString(dateTime); + updateExpression.ExpressionAttributeValues[valueRef] = new Primitive(epochSecondsAsString, saveAsNumeric: true); + } + else if (propertyStorage.StoreAsEpoch) + { + updateExpression.ExpressionAttributeValues[valueRef] = AWSSDKUtils.ConvertToUnixEpochSeconds(dateTime); + } + else + { + updateExpression.ExpressionAttributeValues[valueRef] = dateTime.ToString("o"); + } + + // Determine SET clause based on TimestampMode + string clause; + var mode = propertyStorage.AutoGeneratedTimestampMode; + switch (mode) + { + case TimestampMode.Create: + clause = $"{attributeRef} = if_not_exists({attributeRef}, {valueRef})"; + break; + case TimestampMode.Always: + default: + clause = $"{attributeRef} = {valueRef}"; + break; + } + setClauses.Add(clause); + } + + if (setClauses.Count > 0) + { + updateExpression.ExpressionStatement = "SET " + string.Join(", ", setClauses); + } + + return updateExpression; + } + + 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 +216,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; @@ -570,6 +651,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..ca124cc0e197 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs @@ -152,6 +152,11 @@ 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; } + + public TimestampMode AutoGeneratedTimestampMode { get; set; } + // corresponding IndexNames, if applicable public List IndexNames { get; set; } @@ -241,12 +246,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."); @@ -1061,8 +1071,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 +1081,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 +1090,12 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con propertyStorage.IsRangeKey = true; } + if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute autogeneratedTimestampAttribute) + { + propertyStorage.IsAutoGeneratedTimestamp = true; + propertyStorage.AutoGeneratedTimestampMode = autogeneratedTimestampAttribute.Mode; + } + DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute; if (lsiRangeKeyAttribute != null) { @@ -1090,8 +1104,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/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/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs index 3278dbc0015c..4608dbaa81da 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -214,6 +214,93 @@ internal static void ApplyExpression(QueryRequest request, Table table, request.ExpressionAttributeValues = attributeValues; } } + 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..cb1004eb4380 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -1410,7 +1410,7 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig } else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true }) { - currentConfig.ConditionalExpression.ApplyExpression(req, this); + currentConfig.ConditionalExpression?.ApplyExpression(req, this); string statement; Dictionary expressionAttributeValues; diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index b5389a4dae68..60016431b077 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -739,6 +739,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 +1093,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 +1167,173 @@ 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" + }; + + 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_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 +3091,73 @@ 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(TimestampMode.Create)] public DateTime? CreatedAt { get; set; } + } + + [DynamoDBTable("HashTable")] + public class AutoGenTimestampEpochEntity + { + [DynamoDBHashKey] + public int Id { get; set; } + + public string Name { get; set; } + + [DynamoDBAutoGeneratedTimestamp(TimestampMode.Create)] + [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(TimestampMode.Create)] + [DynamoDBProperty(StoreAsEpochLong = true)] + public DateTime? CreatedAt { get; set; } + + [DynamoDBAutoGeneratedTimestamp] + [DynamoDBProperty(StoreAsEpochLong = true)] + public DateTime? UpdatedAt { get; set; } + } + public enum Status : long { Active = 256, @@ -3144,6 +3437,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 From e6ceb76b7f6bec08503aad1c3b29230b9c0b2561 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 23 Jun 2025 14:44:07 +0300 Subject: [PATCH 3/5] Add changelog --- .../c952ab1e-3056-4598-9d0e-f7f02187e982.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json 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 From 2d61f5c2f2bf4428aa1019b689a41c2649029157 Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Fri, 4 Jul 2025 15:16:23 +0300 Subject: [PATCH 4/5] add DynamoDbUpdateBehaviorAttribute --- .../DynamoDBv2/Custom/DataModel/Attributes.cs | 99 ++++++-------- .../DynamoDBv2/Custom/DataModel/Context.cs | 88 ++++++------ .../Custom/DataModel/ContextInternal.cs | 77 +++-------- .../Custom/DataModel/InternalModel.cs | 17 ++- .../Custom/DataModel/TransactWrite.cs | 27 ++-- .../DocumentModel/DocumentTransactWrite.cs | 22 ++- .../Custom/DocumentModel/Expression.cs | 1 + .../DynamoDBv2/Custom/DocumentModel/Table.cs | 31 +++-- .../DynamoDBv2/Custom/DocumentModel/Util.cs | 17 ++- .../IntegrationTests/DataModelTests.cs | 129 ++++++++++++++---- 10 files changed, 286 insertions(+), 222 deletions(-) diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs index 2af8608a843e..ad34d37765d4 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -693,20 +693,9 @@ public DynamoDBLocalSecondaryIndexRangeKeyAttribute(params string[] indexNames) /// Specifies that the decorated property or field should have its value automatically /// set to the current timestamp during persistence operations. /// - /// - /// The property controls when the timestamp is set: - /// - /// : Set only when the item is created. - /// : Set on both create and update. - /// - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute { - /// - /// Gets or sets when the timestamp should be generated. - /// - public TimestampMode Mode { get; } /// /// Default constructor. Timestamp is set on both create and update. @@ -714,18 +703,8 @@ public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAt public DynamoDBAutoGeneratedTimestampAttribute() : base() { - Mode = TimestampMode.Always; } - /// - /// Constructor that specifies when the timestamp should be generated. - /// - /// Specifies when the timestamp should be generated. - public DynamoDBAutoGeneratedTimestampAttribute(TimestampMode mode) - : base() - { - Mode = mode; - } /// /// Constructor that specifies an alternate attribute name. @@ -734,77 +713,83 @@ public DynamoDBAutoGeneratedTimestampAttribute(TimestampMode mode) public DynamoDBAutoGeneratedTimestampAttribute(string attributeName) : base(attributeName) { - Mode = TimestampMode.Always; } - /// - /// Constructor that specifies an alternate attribute name and when the timestamp should be generated. + /// Constructor that specifies a custom converter. /// - /// Name of attribute to be associated with property or field. - /// Specifies when the timestamp should be generated. - public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode) - : base(attributeName) + /// Custom converter type. + public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(converter) { - Mode = mode; } /// - /// Constructor that specifies a custom 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([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) - : base(converter) + public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter) + : base(attributeName, converter) { - Mode = TimestampMode.Always; } + } + /// + /// 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 + { /// - /// Constructor that specifies a custom converter and when the timestamp should be generated. + /// Gets the update behavior for the property. /// - /// Custom converter type. - /// Specifies when the timestamp should be generated. - public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) - : base(converter) + public UpdateBehavior Behavior { get; } + + /// + /// Default constructor. Sets behavior to Always. + /// + public DynamoDbUpdateBehaviorAttribute() + : base() { - Mode = mode; + Behavior = UpdateBehavior.Always; } /// - /// Constructor that specifies an alternate attribute name and a custom converter. + /// Constructor that specifies the update behavior. /// - /// 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) + /// The update behavior to apply. + public DynamoDbUpdateBehaviorAttribute(UpdateBehavior behavior) + : base() { - Mode = TimestampMode.Always; + Behavior = behavior; } /// - /// Constructor that specifies an alternate attribute name, a custom converter, and when the timestamp should be generated. + /// Constructor that specifies an alternate attribute name and update behavior. /// /// Name of attribute to be associated with property or field. - /// Custom converter type. - /// Specifies when the timestamp should be generated. - public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter, TimestampMode mode) - : base(attributeName, converter) + /// The update behavior to apply. + public DynamoDbUpdateBehaviorAttribute(string attributeName, UpdateBehavior behavior) + : base(attributeName) { - Mode = mode; + Behavior = behavior; } } /// - /// Specifies when an auto-generated timestamp should be set. + /// Specifies when a property value should be set. /// - public enum TimestampMode + public enum UpdateBehavior { /// - /// Set the timestamp only when the item is created. + /// Set the value on both create and update. /// - Create, + Always, /// - /// Set the timestamp on both create and update. + /// Set the value only when the item is created. /// - Always + IfNotExists } } diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs index 9abea392bff0..1077f988d2cd 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -375,39 +375,39 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr var counterConditionExpression = BuildCounterConditionExpression(storage); - var autoGeneratedTimestampExpression = BuildTimestampConditionExpression(storage); - Document updateDocument; Expression versionExpression = null; - var updateExpression = Expression.MergeUpdateExpressions(counterConditionExpression, autoGeneratedTimestampExpression); + SetNewTimestamps(storage); + + var updateIfNotExists = GetUpdateIfNotExistsAttributeNames(storage); - var returnValues = updateExpression == null + var returnValues = counterConditionExpression == null && !updateIfNotExists.Any() ? ReturnValues.None : ReturnValues.AllNewAttributes; - if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion) + var updateItemOperationConfig = new UpdateItemOperationConfig { - updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig() - { - ReturnValues = returnValues - }, updateExpression); - } - 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, updateExpression); + updateItemOperationConfig.ConditionalExpression = versionExpression; } - if (updateExpression == 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) { @@ -434,45 +434,41 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants var counterConditionExpression = BuildCounterConditionExpression(storage); - var autoGeneratedTimestampExpression = BuildTimestampConditionExpression(storage); - Document updateDocument; Expression versionExpression = null; - var updateExpression = Expression.MergeUpdateExpressions(counterConditionExpression, autoGeneratedTimestampExpression); + SetNewTimestamps(storage); - var returnValues = updateExpression == null + 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 - }, updateExpression, 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 - }, updateExpression, - cancellationToken) - .ConfigureAwait(false); + updateItemOperationConfig.ConditionalExpression = versionExpression; } - if (updateExpression == 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 d2a1fe5b49e4..3515fbd6af7e 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs @@ -60,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; @@ -121,71 +122,25 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora #region Autogenerated Timestamp - internal static Expression BuildTimestampConditionExpression(ItemStorage storage) + internal static void SetNewTimestamps(ItemStorage storage) { var timestampProperties = GetTimestampProperties(storage); - Expression timestampConditionExpression = null; - - if (timestampProperties.Length != 0) - { - timestampConditionExpression = CreateUpdateExpressionForTimestampProperties(timestampProperties); - } + if (timestampProperties.Length == 0) return; - return timestampConditionExpression; - } - - private static Expression CreateUpdateExpressionForTimestampProperties(PropertyStorage[] timestampProperties) - { - if (timestampProperties == null || timestampProperties.Length == 0) - return null; + var now = AWSSDKUtils.CorrectedUtcNow; - var updateExpression = new Expression(); - var setClauses = new List(); - - var dateTime = AWSSDKUtils.CorrectedUtcNow; // Use corrected UTC time - foreach (var propertyStorage in timestampProperties) + foreach (var timestampProperty in timestampProperties) { - string attributeName = propertyStorage.AttributeName; - string attributeRef = Common.GetAttributeReference(attributeName); - string valueRef = $":{attributeName}Timestamp"; - updateExpression.ExpressionAttributeNames[attributeRef] = attributeName; + var attributeName = timestampProperty.AttributeName; - if (propertyStorage.StoreAsEpochLong) - { - string epochSecondsAsString = AWSSDKUtils.ConvertToUnixEpochSecondsString(dateTime); - updateExpression.ExpressionAttributeValues[valueRef] = new Primitive(epochSecondsAsString, saveAsNumeric: true); - } - else if (propertyStorage.StoreAsEpoch) - { - updateExpression.ExpressionAttributeValues[valueRef] = AWSSDKUtils.ConvertToUnixEpochSeconds(dateTime); - } - else - { - updateExpression.ExpressionAttributeValues[valueRef] = dateTime.ToString("o"); - } - - // Determine SET clause based on TimestampMode - string clause; - var mode = propertyStorage.AutoGeneratedTimestampMode; - switch (mode) - { - case TimestampMode.Create: - clause = $"{attributeRef} = if_not_exists({attributeRef}, {valueRef})"; - break; - case TimestampMode.Always: - default: - clause = $"{attributeRef} = {valueRef}"; - break; - } - setClauses.Add(clause); - } - - if (setClauses.Count > 0) - { - updateExpression.ExpressionStatement = "SET " + string.Join(", ", setClauses); + storage.Document[attributeName] = new Primitive(now.ToString("o")); } + } - return updateExpression; + internal static bool HasCreateOnlyProperties(ItemStorage storage) + { + return storage.Config.BaseTypeStorageConfig.AllPropertyStorage + .Any(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists); } private static PropertyStorage[] GetTimestampProperties(ItemStorage storage) @@ -244,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 diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs index ca124cc0e197..bc34ad841f9c 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs @@ -155,7 +155,8 @@ internal class PropertyStorage : SimplePropertyStorage // whether to store the property as a timestamp that is automatically generated public bool IsAutoGeneratedTimestamp { get; set; } - public TimestampMode AutoGeneratedTimestampMode { get; set; } + // Update behavior for the property, + public UpdateBehavior UpdateBehaviorMode { get; set; } // corresponding IndexNames, if applicable public List IndexNames { get; set; } @@ -236,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) @@ -279,6 +285,7 @@ internal PropertyStorage(MemberInfo member) IndexNames = new List(); Indexes = new List(); FlattenProperties = new List(); + UpdateBehaviorMode = UpdateBehavior.Always; } } @@ -1090,10 +1097,14 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con propertyStorage.IsRangeKey = true; } - if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute autogeneratedTimestampAttribute) + if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute) { propertyStorage.IsAutoGeneratedTimestamp = true; - propertyStorage.AutoGeneratedTimestampMode = autogeneratedTimestampAttribute.Mode; + } + + if(propertyAttribute is DynamoDbUpdateBehaviorAttribute updateBehaviorAttribute) + { + propertyStorage.UpdateBehaviorMode = updateBehaviorAttribute.Behavior; } DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute; 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/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 4608dbaa81da..f613f7a3b643 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs @@ -214,6 +214,7 @@ internal static void ApplyExpression(QueryRequest request, Table table, request.ExpressionAttributeValues = attributeValues; } } + internal static Expression MergeUpdateExpressions(Expression right, Expression left) { if (right == null && left == null) diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index cb1004eb4380..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,7 +1412,8 @@ 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); @@ -1416,7 +1421,8 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig 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 60016431b077..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; @@ -1174,29 +1175,29 @@ 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); + 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; @@ -1229,7 +1230,8 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() var longEpochEntity = new AutoGenTimestampEpochLongEntity { Id = 2, - Name = "LongEpochTest" + Name = "LongEpochTest", + CreatedAt1 = DateTime.Today }; await Context.SaveAsync(longEpochEntity); @@ -1273,6 +1275,72 @@ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple() } + [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() @@ -3121,7 +3189,9 @@ public class ProductWithCreateTimestamp { [DynamoDBHashKey] public int Id { get; set; } public string Name { get; set; } - [DynamoDBAutoGeneratedTimestamp(TimestampMode.Create)] public DateTime? CreatedAt { get; set; } + [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] + public DateTime? CreatedAt { get; set; } } [DynamoDBTable("HashTable")] @@ -3132,7 +3202,8 @@ public class AutoGenTimestampEpochEntity public string Name { get; set; } - [DynamoDBAutoGeneratedTimestamp(TimestampMode.Create)] + [DynamoDBAutoGeneratedTimestamp] + [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)] [DynamoDBProperty(StoreAsEpoch = true)] public DateTime? CreatedAt { get; set; } @@ -3149,10 +3220,14 @@ public class AutoGenTimestampEpochLongEntity public string Name { get; set; } - [DynamoDBAutoGeneratedTimestamp(TimestampMode.Create)] + [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; } From 610a4414542cb5636632a066eab82b6dc9a0b56a Mon Sep 17 00:00:00 2001 From: irina-herciu Date: Mon, 7 Jul 2025 18:13:31 +0300 Subject: [PATCH 5/5] unit testing --- .../DynamoDBv2/Custom/DataModel/Attributes.cs | 3 +- .../DynamoDBv2/Custom/DocumentModel/Table.cs | 1 - .../DynamoDBv2/Custom/DocumentModel/Util.cs | 7 +- .../Custom/DocumentModel/ExpressionsTest.cs | 2 +- .../DocumentModel/PropertyStorageTests.cs | 2 +- .../Custom/DocumentModel/TableTests.cs | 266 ++++++++++++++++++ 6 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/TableTests.cs diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs index ad34d37765d4..0ff6ea3fb306 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs @@ -736,8 +736,7 @@ 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, - /// or ignored during 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 diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index b6dd85d7b5a5..dcabe8fd3eb2 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -1372,7 +1372,6 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi return UpdateHelperAsync(doc, key, config, expression, cancellationToken); } #endif - //todo unit tests for UpdateHelper with Expression and ifNotExistAttributeNames internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, List ifNotExistAttributeNames = null) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs index e2b3a6607222..b759da7be4a2 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs @@ -398,7 +398,12 @@ public static void ConvertAttributeUpdatesToUpdateExpression( if (sets.Length > 0) { var setStatement= updateExpression!=null ? updateExpression.ExpressionStatement + "," : "SET"; - statementBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0} {1}", setStatement, sets.ToString()); + 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) { diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs index 6a59a43a35b7..e5140898da74 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs @@ -3,7 +3,7 @@ using Amazon.DynamoDBv2.DocumentModel; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace AWSSDK.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel +namespace AWSSDK_DotNet.UnitTests { [TestClass] public class ExpressionsTest diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs index a91bb7244c4d..3a19bba97266 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using Amazon.DynamoDBv2; -namespace AWSSDK.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel +namespace AWSSDK_DotNet.UnitTests { [TestClass] public class PropertyStorageTests 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..fff99f24565f --- /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