diff --git a/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json b/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json
new file mode 100644
index 000000000000..69a580771fac
--- /dev/null
+++ b/generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json
@@ -0,0 +1,11 @@
+{
+ "services": [
+ {
+ "serviceName": "DynamoDBv2",
+ "type": "patch",
+ "changeLogMessages": [
+ "Add support for DynamoDBAutoGeneratedTimestampAttribute that sets current timestamp during persistence operations."
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
index e3b879e5b9e2..ad34d37765d4 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
@@ -688,4 +688,108 @@ public DynamoDBLocalSecondaryIndexRangeKeyAttribute(params string[] indexNames)
IndexNames = indexNames.Distinct(StringComparer.Ordinal).ToArray();
}
}
+
+ ///
+ /// Specifies that the decorated property or field should have its value automatically
+ /// set to the current timestamp during persistence operations.
+ ///
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
+ public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute
+ {
+
+ ///
+ /// Default constructor. Timestamp is set on both create and update.
+ ///
+ public DynamoDBAutoGeneratedTimestampAttribute()
+ : base()
+ {
+ }
+
+
+ ///
+ /// Constructor that specifies an alternate attribute name.
+ ///
+ /// Name of attribute to be associated with property or field.
+ public DynamoDBAutoGeneratedTimestampAttribute(string attributeName)
+ : base(attributeName)
+ {
+ }
+ ///
+ /// Constructor that specifies a custom converter.
+ ///
+ /// Custom converter type.
+ public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter)
+ : base(converter)
+ {
+ }
+
+ ///
+ /// Constructor that specifies an alternate attribute name and a custom converter.
+ ///
+ /// Name of attribute to be associated with property or field.
+ /// Custom converter type.
+ public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter)
+ : base(attributeName, converter)
+ {
+ }
+ }
+
+ ///
+ /// Specifies the update behavior for a property when performing DynamoDB update operations.
+ /// This attribute can be used to control whether a property is always updated, only updated if not null,
+ /// or ignored during update operations.
+ ///
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
+ public sealed class DynamoDbUpdateBehaviorAttribute : DynamoDBPropertyAttribute
+ {
+ ///
+ /// Gets the update behavior for the property.
+ ///
+ public UpdateBehavior Behavior { get; }
+
+ ///
+ /// Default constructor. Sets behavior to Always.
+ ///
+ public DynamoDbUpdateBehaviorAttribute()
+ : base()
+ {
+ Behavior = UpdateBehavior.Always;
+ }
+
+ ///
+ /// Constructor that specifies the update behavior.
+ ///
+ /// The update behavior to apply.
+ public DynamoDbUpdateBehaviorAttribute(UpdateBehavior behavior)
+ : base()
+ {
+ Behavior = behavior;
+ }
+
+ ///
+ /// Constructor that specifies an alternate attribute name and update behavior.
+ ///
+ /// Name of attribute to be associated with property or field.
+ /// The update behavior to apply.
+ public DynamoDbUpdateBehaviorAttribute(string attributeName, UpdateBehavior behavior)
+ : base(attributeName)
+ {
+ Behavior = behavior;
+ }
+ }
+
+ ///
+ /// Specifies when a property value should be set.
+ ///
+ public enum UpdateBehavior
+ {
+ ///
+ /// Set the value on both create and update.
+ ///
+ Always,
+ ///
+ /// Set the value only when the item is created.
+ ///
+ IfNotExists
+ }
}
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs
index 57a43bdba112..1077f988d2cd 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs
@@ -377,31 +377,37 @@ public IMultiTableTransactWrite CreateMultiTableTransactWrite(params ITransactWr
Document updateDocument;
Expression versionExpression = null;
-
- var returnValues=counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;
- if ((flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) || !storage.Config.HasVersion)
+ SetNewTimestamps(storage);
+
+ var updateIfNotExists = GetUpdateIfNotExistsAttributeNames(storage);
+
+ var returnValues = counterConditionExpression == null && !updateIfNotExists.Any()
+ ? ReturnValues.None
+ : ReturnValues.AllNewAttributes;
+
+ var updateItemOperationConfig = new UpdateItemOperationConfig
{
- updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig()
- {
- ReturnValues = returnValues
- }, counterConditionExpression);
- }
- else
+ ReturnValues = returnValues
+ };
+
+ if (!(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) && storage.Config.HasVersion)
{
- var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
+ var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
SetNewVersion(storage);
-
- var updateItemOperationConfig = new UpdateItemOperationConfig
- {
- ReturnValues = returnValues,
- ConditionalExpression = versionExpression,
- };
- updateDocument = table.UpdateHelper(storage.Document, table.MakeKey(storage.Document), updateItemOperationConfig, counterConditionExpression);
+ updateItemOperationConfig.ConditionalExpression = versionExpression;
}
- if (counterConditionExpression == null && versionExpression == null) return;
+ updateDocument = table.UpdateHelper(
+ storage.Document,
+ table.MakeKey(storage.Document),
+ updateItemOperationConfig,
+ counterConditionExpression,
+ updateIfNotExists
+ );
+
+ if (counterConditionExpression == null && versionExpression == null && !updateIfNotExists.Any()) return;
if (returnValues == ReturnValues.AllNewAttributes)
{
@@ -431,36 +437,38 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants
Document updateDocument;
Expression versionExpression = null;
- var returnValues = counterConditionExpression == null ? ReturnValues.None : ReturnValues.AllNewAttributes;
+ SetNewTimestamps(storage);
+
+ var updateIfNotExistsAttributeName = GetUpdateIfNotExistsAttributeNames(storage);
- if (
- (flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value)
- || !storage.Config.HasVersion)
+ var returnValues = counterConditionExpression == null && !updateIfNotExistsAttributeName.Any()
+ ? ReturnValues.None
+ : ReturnValues.AllNewAttributes;
+
+ var updateItemOperationConfig = new UpdateItemOperationConfig
{
- updateDocument = await table.UpdateHelperAsync(storage.Document, table.MakeKey(storage.Document), new UpdateItemOperationConfig
- {
- ReturnValues = returnValues
- }, counterConditionExpression, cancellationToken).ConfigureAwait(false);
- }
- else
+ ReturnValues = returnValues
+ };
+
+ if (!(flatConfig.SkipVersionCheck.HasValue && flatConfig.SkipVersionCheck.Value) && storage.Config.HasVersion)
{
- var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
+ var conversionConfig = new DynamoDBEntry.AttributeConversionConfig(table.Conversion, table.IsEmptyStringValueEnabled);
versionExpression = CreateConditionExpressionForVersion(storage, conversionConfig);
SetNewVersion(storage);
-
- updateDocument = await table.UpdateHelperAsync(
- storage.Document,
- table.MakeKey(storage.Document),
- new UpdateItemOperationConfig
- {
- ReturnValues = returnValues,
- ConditionalExpression = versionExpression
- }, counterConditionExpression,
- cancellationToken)
- .ConfigureAwait(false);
+ updateItemOperationConfig.ConditionalExpression = versionExpression;
}
- if (counterConditionExpression == null && versionExpression == null) return;
+ updateDocument = await table.UpdateHelperAsync(
+ storage.Document,
+ table.MakeKey(storage.Document),
+ updateItemOperationConfig,
+ counterConditionExpression,
+ cancellationToken,
+ updateIfNotExistsAttributeName
+ ).ConfigureAwait(false);
+
+
+ if (counterConditionExpression == null && versionExpression == null && !updateIfNotExistsAttributeName.Any()) return;
if (returnValues == ReturnValues.AllNewAttributes)
{
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs
index 4beb34fd68cb..3515fbd6af7e 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs
@@ -26,6 +26,7 @@
using Amazon.Util.Internal;
using System.Globalization;
using System.Diagnostics.CodeAnalysis;
+using Amazon.Util;
using ThirdParty.RuntimeBackports;
namespace Amazon.DynamoDBv2.DataModel
@@ -59,6 +60,7 @@ internal static void SetNewVersion(ItemStorage storage)
}
storage.Document[versionAttributeName] = version;
}
+
private static void IncrementVersion(Type memberType, ref Primitive version)
{
if (memberType.IsAssignableFrom(typeof(Byte))) version = version.AsByte() + 1;
@@ -118,6 +120,40 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora
#endregion
+ #region Autogenerated Timestamp
+
+ internal static void SetNewTimestamps(ItemStorage storage)
+ {
+ var timestampProperties = GetTimestampProperties(storage);
+ if (timestampProperties.Length == 0) return;
+
+ var now = AWSSDKUtils.CorrectedUtcNow;
+
+ foreach (var timestampProperty in timestampProperties)
+ {
+ var attributeName = timestampProperty.AttributeName;
+
+ storage.Document[attributeName] = new Primitive(now.ToString("o"));
+ }
+ }
+
+ internal static bool HasCreateOnlyProperties(ItemStorage storage)
+ {
+ return storage.Config.BaseTypeStorageConfig.AllPropertyStorage
+ .Any(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists);
+ }
+
+ private static PropertyStorage[] GetTimestampProperties(ItemStorage storage)
+ {
+ //todo : adapt this to work with polymorphic types
+ var counterProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage.
+ Where(propertyStorage => propertyStorage.IsAutoGeneratedTimestamp).ToArray();
+
+ return counterProperties;
+ }
+
+ #endregion
+
#region Atomic counters
internal static Expression BuildCounterConditionExpression(ItemStorage storage)
@@ -135,7 +171,7 @@ internal static Expression BuildCounterConditionExpression(ItemStorage storage)
private static PropertyStorage[] GetCounterProperties(ItemStorage storage)
{
- var counterProperties = storage.Config.BaseTypeStorageConfig.Properties.
+ var counterProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage.
Where(propertyStorage => propertyStorage.IsCounter).ToArray();
return counterProperties;
@@ -163,10 +199,16 @@ private static Expression CreateUpdateExpressionForCounterProperties(PropertySto
propertyStorage.CounterStartValue - propertyStorage.CounterDelta;
}
updateExpression.ExpressionStatement = $"SET {asserts.Substring(0, asserts.Length - 2)}";
-
return updateExpression;
}
+ internal static List GetUpdateIfNotExistsAttributeNames(ItemStorage storage)
+ {
+ var timestampProperties = storage.Config.BaseTypeStorageConfig.AllPropertyStorage
+ .Where(propertyStorage => propertyStorage.UpdateBehaviorMode == UpdateBehavior.IfNotExists).ToArray();
+ return timestampProperties.Select(p => p.AttributeName).ToList();
+ }
+
#endregion
#region Table methods
@@ -570,6 +612,14 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
{
document[pair.Key] = pair.Value;
}
+
+ if (propertyStorage.FlattenProperties.Any(p => p.IsVersion))
+ {
+ var innerVersionProperty =
+ propertyStorage.FlattenProperties.First(p => p.IsVersion);
+ storage.CurrentVersion =
+ innerDocument[innerVersionProperty.AttributeName] as Primitive;
+ }
}
else
{
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs
index 65eefb12aebb..bc34ad841f9c 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs
@@ -152,6 +152,12 @@ internal class PropertyStorage : SimplePropertyStorage
// whether to store property at parent level
public bool IsFlattened { get; set; }
+ // whether to store the property as a timestamp that is automatically generated
+ public bool IsAutoGeneratedTimestamp { get; set; }
+
+ // Update behavior for the property,
+ public UpdateBehavior UpdateBehaviorMode { get; set; }
+
// corresponding IndexNames, if applicable
public List IndexNames { get; set; }
@@ -231,6 +237,11 @@ public void Validate(DynamoDBContext context)
if (IsHashKey && IsRangeKey)
throw new InvalidOperationException("Property " + PropertyName + " cannot be both hash and range key");
+ if (UpdateBehaviorMode == UpdateBehavior.IfNotExists && (IsKey || IsVersion || IsCounter))
+ {
+ throw new InvalidOperationException("Property " + PropertyName + " cannot be a key and have UpdateBehavior set to IfNotExists at the same time.");
+ }
+
if (ConverterType != null)
{
if (PolymorphicProperty)
@@ -241,12 +252,17 @@ public void Validate(DynamoDBContext context)
if (StoreAsEpoch || StoreAsEpochLong)
throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as StoreAsEpoch or StoreAsEpochLong is set to true");
-
+
+ if (IsAutoGeneratedTimestamp)
+ throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as AutoGeneratedTimestamp is set to true.");
+
if (!Utils.CanInstantiateConverter(ConverterType) || !Utils.ImplementsInterface(ConverterType, typeof(IPropertyConverter)))
throw new InvalidOperationException("Converter for " + PropertyName + " must be instantiable with no parameters and must implement IPropertyConverter");
this.Converter = Utils.InstantiateConverter(ConverterType, context) as IPropertyConverter;
}
+ if (IsAutoGeneratedTimestamp)
+ Utils.ValidateTimestampType(MemberType);
if (StoreAsEpoch && StoreAsEpochLong)
throw new InvalidOperationException(PropertyName + " must not set both StoreAsEpoch and StoreAsEpochLong as true at the same time.");
@@ -269,6 +285,7 @@ internal PropertyStorage(MemberInfo member)
IndexNames = new List();
Indexes = new List();
FlattenProperties = new List();
+ UpdateBehaviorMode = UpdateBehavior.Always;
}
}
@@ -1061,8 +1078,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
if (propertyAttribute is DynamoDBHashKeyAttribute)
{
- var gsiHashAttribute = propertyAttribute as DynamoDBGlobalSecondaryIndexHashKeyAttribute;
- if (gsiHashAttribute != null)
+ if (propertyAttribute is DynamoDBGlobalSecondaryIndexHashKeyAttribute gsiHashAttribute)
{
propertyStorage.IsGSIHashKey = true;
propertyStorage.AddIndex(gsiHashAttribute);
@@ -1072,8 +1088,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
}
if (propertyAttribute is DynamoDBRangeKeyAttribute)
{
- var gsiRangeAttribute = propertyAttribute as DynamoDBGlobalSecondaryIndexRangeKeyAttribute;
- if (gsiRangeAttribute != null)
+ if (propertyAttribute is DynamoDBGlobalSecondaryIndexRangeKeyAttribute gsiRangeAttribute)
{
propertyStorage.IsGSIRangeKey = true;
propertyStorage.AddIndex(gsiRangeAttribute);
@@ -1082,6 +1097,16 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
propertyStorage.IsRangeKey = true;
}
+ if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute)
+ {
+ propertyStorage.IsAutoGeneratedTimestamp = true;
+ }
+
+ if(propertyAttribute is DynamoDbUpdateBehaviorAttribute updateBehaviorAttribute)
+ {
+ propertyStorage.UpdateBehaviorMode = updateBehaviorAttribute.Behavior;
+ }
+
DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute;
if (lsiRangeKeyAttribute != null)
{
@@ -1090,8 +1115,7 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
}
}
- DynamoDBPolymorphicTypeAttribute polymorphicAttribute = attribute as DynamoDBPolymorphicTypeAttribute;
- if (polymorphicAttribute != null)
+ if (attribute is DynamoDBPolymorphicTypeAttribute polymorphicAttribute)
{
propertyStorage.PolymorphicProperty = true;
propertyStorage.AddDerivedType(polymorphicAttribute.TypeDiscriminator, polymorphicAttribute.DerivedType);
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs
index eaa5f5ba06a0..cb2fe7c7b64f 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs
@@ -229,6 +229,8 @@ public void AddSaveItem(T item)
Expression conditionExpression = CreateConditionExpressionForVersion(storage);
SetNewVersion(storage);
+ SetNewTimestamps(storage);
+
AddDocumentTransaction(storage, conditionExpression);
var objectItem = new DynamoDBContext.ObjectWithItemStorage
@@ -451,23 +453,23 @@ private void AddDocumentTransaction(ItemStorage storage, Expression conditionExp
attributeNames.Remove(rangeKeyPropertyName);
}
+ var operationConfig = new TransactWriteItemOperationConfig
+ {
+ ConditionalExpression = conditionExpression,
+ ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None
+ };
+
// If there are no attributes left, we need to use PutItem
// as UpdateItem requires at least one data attribute
if (attributeNames.Any())
{
- DocumentTransaction.AddDocumentToUpdate(storage.Document, new TransactWriteItemOperationConfig
- {
- ConditionalExpression = conditionExpression,
- ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None
- });
+ var ifNotExistAttributeNames = DynamoDBContext.GetUpdateIfNotExistsAttributeNames(storage);
+ DocumentTransaction.AddDocumentToUpdate(storage.Document, ifNotExistAttributeNames, operationConfig);
+
}
else
{
- DocumentTransaction.AddDocumentToPut(storage.Document, new TransactWriteItemOperationConfig
- {
- ConditionalExpression = conditionExpression,
- ReturnValuesOnConditionCheckFailure = DocumentModel.ReturnValuesOnConditionCheckFailure.None
- });
+ DocumentTransaction.AddDocumentToPut(storage.Document, operationConfig);
}
}
@@ -476,6 +478,11 @@ private void SetNewVersion(ItemStorage storage)
if (!ShouldUseVersioning()) return;
DynamoDBContext.SetNewVersion(storage);
}
+
+ private void SetNewTimestamps(ItemStorage storage)
+ {
+ DynamoDBContext.SetNewTimestamps(storage);
+ }
}
///
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs
index 5d72b6dd6d3e..6c8349dd4873 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs
@@ -155,7 +155,22 @@ internal static void ValidateNumericType(Type memberType)
{
return;
}
- throw new InvalidOperationException("Version property must be of primitive, numeric, integer, nullable type (e.g. int?, long?, byte?)");
+ throw new InvalidOperationException("Version or counter property must be of primitive, numeric, integer, nullable type (e.g. int?, long?, byte?)");
+ }
+
+ internal static void ValidateTimestampType(Type memberType)
+ {
+ if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
+ (memberType.IsAssignableFrom(typeof(DateTime)) ||
+ memberType.IsAssignableFrom(typeof(DateTimeOffset))))
+ {
+ return;
+ }
+ throw new InvalidOperationException(
+ $"Timestamp properties must be of type Nullable (DateTime?) or Nullable (DateTimeOffset?). " +
+ $"Invalid type: {memberType.FullName}. " +
+ "Please ensure your property is declared as 'DateTime?' or 'DateTimeOffset?'."
+ );
}
[return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)]
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs
index cfe4de17166b..b48f202739e3 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/DocumentTransactWrite.cs
@@ -278,19 +278,19 @@ public void AddDocumentToUpdate(Document document, Primitive hashKey, TransactWr
///
public void AddDocumentToUpdate(Document document, Primitive hashKey, Primitive rangeKey, TransactWriteItemOperationConfig operationConfig = null)
{
- AddDocumentToUpdateHelper(document, TargetTable.MakeKey(hashKey, rangeKey), operationConfig);
+ AddDocumentToUpdateHelper(document, TargetTable.MakeKey(hashKey, rangeKey),null,operationConfig);
}
///
public void AddDocumentToUpdate(Document document, IDictionary key, TransactWriteItemOperationConfig operationConfig = null)
{
- AddDocumentToUpdateHelper(document, TargetTable.MakeKey(key), operationConfig);
+ AddDocumentToUpdateHelper(document, TargetTable.MakeKey(key), null, operationConfig);
}
///
public void AddDocumentToUpdate(Document document, TransactWriteItemOperationConfig operationConfig = null)
{
- AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), operationConfig);
+ AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), null, operationConfig);
}
///
@@ -385,6 +385,11 @@ public void AddItemToConditionCheck(Document document, TransactWriteItemOperatio
#region Internal/private methods
+ internal void AddDocumentToUpdate(Document document, List ifNotExistAttributeNames, TransactWriteItemOperationConfig operationConfig = null)
+ {
+ AddDocumentToUpdateHelper(document, TargetTable.MakeKey(document), ifNotExistAttributeNames, operationConfig);
+ }
+
internal void ExecuteHelper()
{
try
@@ -429,14 +434,15 @@ internal void AddKeyToDeleteHelper(Key key, TransactWriteItemOperationConfig ope
});
}
- internal void AddDocumentToUpdateHelper(Document document, Key key, TransactWriteItemOperationConfig operationConfig = null)
+ internal void AddDocumentToUpdateHelper(Document document, Key key, List ifNotExistAttributeNames, TransactWriteItemOperationConfig operationConfig = null)
{
Items.Add(new ToUpdateWithDocumentTransactWriteRequestItem
{
TransactionPart = this,
Document = document,
Key = key,
- OperationConfig = operationConfig
+ OperationConfig = operationConfig,
+ IfNotExistAttributeNames = ifNotExistAttributeNames
});
}
@@ -926,7 +932,7 @@ public sealed override TransactWriteItem GetRequest()
return new TransactWriteItem { Update = update };
}
- protected abstract bool TryGetUpdateExpression(out string statement,
+ protected abstract bool TryGetUpdateExpression( out string statement,
out Dictionary expressionAttributeValues,
out Dictionary expressionAttributes);
@@ -939,6 +945,8 @@ internal class ToUpdateWithDocumentTransactWriteRequestItem : ToUpdateTransactWr
public Document Document { get; set; }
+ public List IfNotExistAttributeNames { get; set; }
+
#endregion
@@ -966,7 +974,7 @@ protected override bool TryGetUpdateExpression(out string statement,
return false;
}
- Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates,null,null,
+ Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, IfNotExistAttributeNames, null, null,
out statement, out expressionAttributeValues, out expressionAttributes);
return true;
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs
index 3278dbc0015c..f613f7a3b643 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Expression.cs
@@ -215,6 +215,94 @@ internal static void ApplyExpression(QueryRequest request, Table table,
}
}
+ internal static Expression MergeUpdateExpressions(Expression right, Expression left)
+ {
+ if (right == null && left == null)
+ return null;
+ if (right == null)
+ return left;
+ if (left == null)
+ return right;
+
+ var leftSections = ParseSections(left.ExpressionStatement);
+ var rightSections = ParseSections(right.ExpressionStatement);
+
+ // Merge sections by keyword, combining with commas where needed
+ var keywordsOrder = new[] { "SET", "REMOVE", "ADD", "DELETE" };
+ var mergedSections = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var keyword in keywordsOrder)
+ {
+ var leftPart = leftSections.ContainsKey(keyword) ? leftSections[keyword] : null;
+ var rightPart = rightSections.ContainsKey(keyword) ? rightSections[keyword] : null;
+
+ if (!string.IsNullOrEmpty(leftPart) && !string.IsNullOrEmpty(rightPart))
+ {
+ mergedSections[keyword] = leftPart + ", " + rightPart;
+ }
+ else if (!string.IsNullOrEmpty(leftPart))
+ {
+ mergedSections[keyword] = leftPart;
+ }
+ else if (!string.IsNullOrEmpty(rightPart))
+ {
+ mergedSections[keyword] = rightPart;
+ }
+ }
+
+ var mergedStatement = string.Join(" ",
+ keywordsOrder.Where(k => mergedSections.ContainsKey(k))
+ .Select(k => $"{k} {mergedSections[k]}"));
+
+ var mergedNames = Common.Combine(left.ExpressionAttributeNames, right.ExpressionAttributeNames, StringComparer.Ordinal); ;
+
+ var mergedValues = Common.Combine(left.ExpressionAttributeValues, right.ExpressionAttributeValues, null);
+
+ return new Expression
+ {
+ ExpressionStatement = string.IsNullOrWhiteSpace(mergedStatement) ? null : mergedStatement,
+ ExpressionAttributeNames = mergedNames,
+ ExpressionAttributeValues = mergedValues
+ };
+
+
+ static Dictionary ParseSections(string expr)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(expr))
+ return result;
+
+ // Find all keywords and their positions
+ var keywords = new[] { "SET", "REMOVE", "ADD", "DELETE" };
+ var positions = new List<(string keyword, int index)>();
+ foreach (var keyword in keywords)
+ {
+ int idx = expr.IndexOf(keyword, StringComparison.OrdinalIgnoreCase);
+ if (idx >= 0)
+ positions.Add((keyword, idx));
+ }
+ if (positions.Count == 0)
+ {
+ // No recognized keywords, treat as a single section
+ result[string.Empty] = expr.Trim();
+ return result;
+ }
+
+ // Sort by position
+ positions = positions.OrderBy(p => p.index).ToList();
+ for (int i = 0; i < positions.Count; i++)
+ {
+ var keyword = positions[i].keyword;
+ int start = positions[i].index + keyword.Length;
+ int end = (i + 1 < positions.Count) ? positions[i + 1].index : expr.Length;
+ string section = expr.Substring(start, end - start).Trim();
+ if (!string.IsNullOrEmpty(section))
+ result[keyword] = section;
+ }
+ return result;
+ }
+ }
+
internal static Dictionary ConvertToAttributeValues(
Dictionary valueMap, Table table)
{
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs
index 84034ca03af3..b6dd85d7b5a5 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs
@@ -539,7 +539,7 @@ private static void ValidateConditional(IConditionalOperationConfig config)
}
- private void ValidateConditional(IConditionalOperationConfig config, Expression updateExpression)
+ private void ValidateConditional(IConditionalOperationConfig config, Expression updateExpression, List createOnlyAttributes)
{
if (config == null)
@@ -549,7 +549,10 @@ private void ValidateConditional(IConditionalOperationConfig config, Expression
conditionsSet += config.Expected != null ? 1 : 0;
conditionsSet += config.ExpectedState != null ? 1 : 0;
conditionsSet +=
- (config.ConditionalExpression is { ExpressionStatement: not null } || updateExpression is { ExpressionStatement: not null }) ? 1 : 0;
+ (config.ConditionalExpression is { ExpressionStatement: not null } ||
+ updateExpression is { ExpressionStatement: not null } ||
+ (createOnlyAttributes!=null && createOnlyAttributes.Any())) ?
+ 1 : 0;
if (conditionsSet > 1)
throw new InvalidOperationException("Only one of the conditional properties Expected, ExpectedState and ConditionalExpression or UpdateExpression can be set.");
@@ -1369,8 +1372,9 @@ internal Task UpdateHelperAsync(Document doc, Primitive hashKey, Primi
return UpdateHelperAsync(doc, key, config, expression, cancellationToken);
}
#endif
-
- internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression)
+ //todo unit tests for UpdateHelper with Expression and ifNotExistAttributeNames
+ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression,
+ List ifNotExistAttributeNames = null)
{
var currentConfig = config ?? new UpdateItemOperationConfig();
@@ -1394,7 +1398,7 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig
this.UpdateRequestUserAgentDetails(req, isAsync: false);
- ValidateConditional(currentConfig, updateExpression);
+ ValidateConditional(currentConfig, updateExpression, ifNotExistAttributeNames);
if (currentConfig.Expected != null)
{
@@ -1408,15 +1412,17 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig
if (req.Expected.Count > 1)
req.ConditionalOperator = EnumMapper.Convert(currentConfig.ExpectedState.ConditionalOperator);
}
- else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true })
+ else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true } ||
+ (ifNotExistAttributeNames != null && ifNotExistAttributeNames.Any()))
{
- currentConfig.ConditionalExpression.ApplyExpression(req, this);
+ currentConfig.ConditionalExpression?.ApplyExpression(req, this);
string statement;
Dictionary expressionAttributeValues;
Dictionary expressionAttributeNames;
- Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression, this, out statement, out expressionAttributeValues, out expressionAttributeNames);
+ Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, ifNotExistAttributeNames, updateExpression, this,
+ out statement, out expressionAttributeValues, out expressionAttributeNames);
req.AttributeUpdates = null;
req.UpdateExpression = statement;
@@ -1462,7 +1468,8 @@ internal Document UpdateHelper(Document doc, Key key, UpdateItemOperationConfig
}
#if AWS_ASYNC_API
- internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression, CancellationToken cancellationToken)
+ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateItemOperationConfig config, Expression updateExpression,
+ CancellationToken cancellationToken, List ifNotExistAttributeNames = null)
{
var currentConfig = config ?? new UpdateItemOperationConfig();
@@ -1486,7 +1493,7 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte
this.UpdateRequestUserAgentDetails(req, isAsync: true);
- ValidateConditional(currentConfig, updateExpression);
+ ValidateConditional(currentConfig, updateExpression, ifNotExistAttributeNames);
if (currentConfig.Expected != null)
{
@@ -1500,7 +1507,8 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte
if (req.Expected.Count > 1)
req.ConditionalOperator = EnumMapper.Convert(currentConfig.ExpectedState.ConditionalOperator);
}
- else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true })
+ else if (currentConfig.ConditionalExpression is { IsSet: true } || updateExpression is { IsSet: true }||
+ (ifNotExistAttributeNames != null && ifNotExistAttributeNames.Any()))
{
currentConfig.ConditionalExpression?.ApplyExpression(req, this);
@@ -1508,7 +1516,8 @@ internal async Task UpdateHelperAsync(Document doc, Key key, UpdateIte
Dictionary expressionAttributeValues;
Dictionary expressionAttributeNames;
- Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, updateExpression,this, out statement, out expressionAttributeValues, out expressionAttributeNames);
+ Common.ConvertAttributeUpdatesToUpdateExpression(attributeUpdates, ifNotExistAttributeNames, updateExpression, this,
+ out statement, out expressionAttributeValues, out expressionAttributeNames);
req.AttributeUpdates = null;
req.UpdateExpression = statement;
diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs
index 457233e96e9f..e2b3a6607222 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Util.cs
@@ -329,7 +329,8 @@ internal static class Common
private const string AwsVariablePrefix = "awsavar";
public static void ConvertAttributeUpdatesToUpdateExpression(
- Dictionary attributesToUpdates, Expression updateExpression,
+ Dictionary attributesToUpdates, List ifNotExistAttributeNames,
+ Expression updateExpression,
Table table,
out string statement,
out Dictionary expressionAttributeValues,
@@ -358,11 +359,13 @@ public static void ConvertAttributeUpdatesToUpdateExpression(
var update = kvp.Value;
+ var createOnly = ifNotExistAttributeNames?.Contains(attribute) ?? false;
+
string variableName = GetVariableName(ref attributeCount);
var attributeReference = GetAttributeReference(variableName);
var attributeValueReference = GetAttributeValueReference(variableName);
- if (update.Action == AttributeAction.DELETE)
+ if (update.Action == AttributeAction.DELETE && !createOnly)
{
if (removes.Length > 0)
removes.Append(", ");
@@ -372,7 +375,15 @@ public static void ConvertAttributeUpdatesToUpdateExpression(
{
if (sets.Length > 0)
sets.Append(", ");
- sets.AppendFormat("{0} = {1}", attributeReference, attributeValueReference);
+ switch (createOnly)
+ {
+ case true:
+ sets.AppendFormat("{0} = if_not_exists({0}, {1})", attributeReference, attributeValueReference);
+ break;
+ default:
+ sets.AppendFormat("{0} = {1}", attributeReference, attributeValueReference);
+ break;
+ }
// Add the attribute value for the variable in the added in the expression
expressionAttributeValues.Add(attributeValueReference, update.Value);
diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs
index b5389a4dae68..ded7fe486a33 100644
--- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs
+++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Runtime.Serialization;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using AWSSDK_DotNet.IntegrationTests.Utils;
@@ -739,6 +740,61 @@ public async Task TestContext_AtomicCounterAnnotation()
Assert.AreEqual(1, storedUpdatedEmployee.CountDefault);
Assert.AreEqual(12, storedUpdatedEmployee.CountAtomic);
+ // --- Flatten scenario with atomic counter and version ---
+ var product = new ProductFlatWithAtomicCounter
+ {
+ Id = 500,
+ Name = "FlatAtomic",
+ Details = new ProductDetailsWithAtomicCounter
+ {
+ Description = "Flat details",
+ Name = "FlatName"
+ }
+ };
+
+ await Context.SaveAsync(product);
+ var loadedProduct = await Context.LoadAsync(product.Id);
+ Assert.IsNotNull(loadedProduct);
+ Assert.IsNotNull(loadedProduct.Details);
+ Assert.AreEqual(0, loadedProduct.Details.CountDefault);
+ Assert.AreEqual(10, loadedProduct.Details.CountAtomic);
+ Assert.AreEqual(0, loadedProduct.Details.Version);
+
+ // Increment counters via null assignment
+ loadedProduct.Details.CountDefault = null;
+ loadedProduct.Details.CountAtomic = null;
+ await Context.SaveAsync(loadedProduct);
+
+ var loadedProductAfterIncrement = await Context.LoadAsync(product.Id);
+ Assert.AreEqual(1, loadedProductAfterIncrement.Details.CountDefault);
+ Assert.AreEqual(12, loadedProductAfterIncrement.Details.CountAtomic);
+ Assert.AreEqual(1, loadedProductAfterIncrement.Details.Version);
+
+ // Simulate a stale POCO for flattened details
+ var staleFlat = new ProductFlatWithAtomicCounter
+ {
+ Id = 500,
+ Name = "FlatAtomic",
+ Details = new ProductDetailsWithAtomicCounter
+ {
+ Description = "Flat details",
+ Name = "FlatName",
+ CountDefault = 0,
+ CountAtomic = 10,
+ Version = 1
+ }
+ };
+ await Context.SaveAsync(staleFlat);
+
+ Assert.AreEqual(2, staleFlat.Details.CountDefault);
+ Assert.AreEqual(14, staleFlat.Details.CountAtomic);
+ Assert.AreEqual(2, staleFlat.Details.Version);
+
+ var loadedFlatLatest = await Context.LoadAsync(product.Id);
+ Assert.AreEqual(2, loadedFlatLatest.Details.CountDefault);
+ Assert.AreEqual(14, loadedFlatLatest.Details.CountAtomic);
+ Assert.AreEqual(2, loadedFlatLatest.Details.Version);
+
}
[TestMethod]
@@ -1038,6 +1094,11 @@ public async Task Test_FlattenAttribute_With_Annotations()
Assert.AreEqual("TestProduct",savedProductFlat.Name);
Assert.AreEqual("TestProductDetails", savedProductFlat.Details.Name);
+ //update the product and verify the flattened property is updated
+ product.Name= "UpdatedProductName";
+ await Context.SaveAsync(product);
+ Assert.AreEqual(1,product.Details.Version);
+
// flattened property, which itself contains another flattened property.
var flatEmployee = new EmployeeNonFlat()
{
@@ -1107,7 +1168,240 @@ public async Task Test_FlattenAttribute_With_Annotations()
}
+ [TestMethod]
+ [TestCategory("DynamoDBv2")]
+ public async Task Test_AutoGeneratedTimestampAttribute_CreateMode_Simple()
+ {
+ CleanupTables();
+ TableCache.Clear();
+
+ var product = new ProductWithCreateTimestamp
+ {
+ Id = 999,
+ Name = "SimpleCreate"
+ };
+
+ await Context.SaveAsync(product);
+ var loaded = await Context.LoadAsync(product.Id);
+
+ Assert.IsNotNull(loaded);
+ Assert.AreEqual(product.Id, loaded.Id);
+ Assert.AreEqual("SimpleCreate", loaded.Name);
+ Assert.IsNotNull(loaded.CreatedAt);
+ Assert.IsTrue(loaded.CreatedAt > DateTime.MinValue);
+ Assert.AreEqual(product.CreatedAt, loaded.CreatedAt);
+
+ // Save again and verify CreatedAt does not change
+ var createdAt = loaded.CreatedAt;
+ await Task.Delay(1000);
+ loaded.Name = "UpdatedName";
+ await Context.SaveAsync(loaded);
+ var loadedAfterUpdate = await Context.LoadAsync(product.Id);
+ Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt);
+
+ // Test: StoreAsEpoch with AutoGeneratedTimestamp (Always)
+ var now = DateTime.UtcNow;
+ var epochEntity = new AutoGenTimestampEpochEntity
+ {
+ Id = 1,
+ Name = "EpochTest"
+ };
+
+ await Context.SaveAsync(epochEntity);
+ var loadedEpochEntity = await Context.LoadAsync(epochEntity.Id);
+
+ Assert.IsNotNull(loadedEpochEntity);
+ Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue);
+ Assert.IsTrue(loadedEpochEntity.UpdatedAt > DateTime.MinValue);
+ Assert.AreEqual(epochEntity.CreatedAt, loadedEpochEntity.CreatedAt);
+ Assert.AreEqual(epochEntity.UpdatedAt, loadedEpochEntity.UpdatedAt);
+
+ // Save again and verify CreatedAt does not change, UpdatedAt does
+ var createdAtEpochEntity = loadedEpochEntity.CreatedAt;
+ var updatedAt = loadedEpochEntity.UpdatedAt;
+ await Task.Delay(1000);
+ loadedEpochEntity.Name = "UpdatedName";
+ await Context.SaveAsync(loadedEpochEntity);
+ var loadedAfterUpdateEpochEntity = await Context.LoadAsync(epochEntity.Id);
+ Assert.AreEqual(createdAtEpochEntity, loadedAfterUpdateEpochEntity.CreatedAt);
+ Assert.IsTrue(loadedAfterUpdateEpochEntity.UpdatedAt > updatedAt);
+
+ // Test: StoreAsEpochLong with AutoGeneratedTimestamp (Always)
+ var longEpochEntity = new AutoGenTimestampEpochLongEntity
+ {
+ Id = 2,
+ Name = "LongEpochTest",
+ CreatedAt1 = DateTime.Today
+ };
+
+ await Context.SaveAsync(longEpochEntity);
+ var loadedLongEpochEntity = await Context.LoadAsync(longEpochEntity.Id);
+
+ Assert.IsNotNull(loadedLongEpochEntity);
+ Assert.IsTrue(loadedLongEpochEntity.CreatedAt > DateTime.MinValue);
+ Assert.IsTrue(loadedLongEpochEntity.UpdatedAt > DateTime.MinValue);
+ Assert.AreEqual(longEpochEntity.CreatedAt, loadedLongEpochEntity.CreatedAt);
+ Assert.AreEqual(longEpochEntity.UpdatedAt, loadedLongEpochEntity.UpdatedAt);
+
+ // Save again and verify CreatedAt does not change, UpdatedAt does
+ var createdAtLong = loadedLongEpochEntity.CreatedAt;
+ var updatedAtLong = loadedLongEpochEntity.UpdatedAt;
+ await Task.Delay(1000);
+ loadedLongEpochEntity.Name = "UpdatedName2";
+ await Context.SaveAsync(loadedLongEpochEntity);
+ var loadedAfterUpdateLong = await Context.LoadAsync(longEpochEntity.Id);
+ Assert.AreEqual(createdAtLong, loadedAfterUpdateLong.CreatedAt);
+ Assert.IsTrue(loadedAfterUpdateLong.UpdatedAt > updatedAtLong);
+
+ // Test: StoreAsEpoch with AutoGeneratedTimestamp (Create)
+ var epochCreateEntity = new AutoGenTimestampEpochEntity
+ {
+ Id = 3,
+ Name = "EpochCreateTest"
+ };
+
+ await Context.SaveAsync(epochCreateEntity);
+ var loadedEpochCreateEntity = await Context.LoadAsync(epochCreateEntity.Id);
+
+ Assert.IsNotNull(loadedEpochCreateEntity);
+ Assert.IsTrue(loadedEpochCreateEntity.CreatedAt > DateTime.MinValue);
+
+ var createdAtCreate = loadedEpochCreateEntity.CreatedAt;
+ await Task.Delay(1000);
+ loadedEpochCreateEntity.Name = "UpdatedName3";
+ await Context.SaveAsync(loadedEpochCreateEntity);
+ var loadedAfterUpdateCreate = await Context.LoadAsync(epochCreateEntity.Id);
+ Assert.AreEqual(createdAtCreate, loadedAfterUpdateCreate.CreatedAt);
+
+ }
+
+ [TestMethod]
+ [TestCategory("DynamoDBv2")]
+ public async Task Test_AutoGeneratedTimestampAttribute_TransactWrite_Simple()
+ {
+ CleanupTables();
+ TableCache.Clear();
+
+ var product = new ProductWithCreateTimestamp
+ {
+ Id = 1001,
+ Name = "TransactCreate"
+ };
+
+ // Save using TransactWrite
+ var transactWrite = Context.CreateTransactWrite();
+ transactWrite.AddSaveItem(product);
+ await transactWrite.ExecuteAsync();
+
+ var loaded = await Context.LoadAsync(product.Id);
+
+ Assert.IsNotNull(loaded);
+ Assert.AreEqual(product.Id, loaded.Id);
+ Assert.AreEqual("TransactCreate", loaded.Name);
+ Assert.IsNotNull(loaded.CreatedAt);
+ Assert.IsTrue(loaded.CreatedAt > DateTime.MinValue);
+ Assert.AreEqual(product.CreatedAt, loaded.CreatedAt);
+
+ // Save again using TransactWrite and verify CreatedAt does not change
+ var createdAt = loaded.CreatedAt;
+ await Task.Delay(1000);
+ loaded.Name = "TransactUpdated";
+ var transactWrite2 = Context.CreateTransactWrite();
+ transactWrite2.AddSaveItem(loaded);
+ await transactWrite2.ExecuteAsync();
+ var loadedAfterUpdate = await Context.LoadAsync(product.Id);
+ Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt);
+
+ // Test: StoreAsEpoch with AutoGeneratedTimestamp (Always) using TransactWrite
+ var epochEntity = new AutoGenTimestampEpochEntity
+ {
+ Id = 1002,
+ Name = "TransactEpoch"
+ };
+
+ var transactWrite3 = Context.CreateTransactWrite();
+ transactWrite3.AddSaveItem(epochEntity);
+ await transactWrite3.ExecuteAsync();
+ var loadedEpochEntity = await Context.LoadAsync(epochEntity.Id);
+
+ Assert.IsNotNull(loadedEpochEntity);
+ Assert.IsTrue(loadedEpochEntity.CreatedAt > DateTime.MinValue);
+ Assert.IsTrue(loadedEpochEntity.UpdatedAt > DateTime.MinValue);
+
+ // Save again and verify CreatedAt does not change, UpdatedAt does
+ var createdAtEpochEntity = loadedEpochEntity.CreatedAt;
+ var updatedAt = loadedEpochEntity.UpdatedAt;
+ await Task.Delay(1000);
+ loadedEpochEntity.Name = "TransactEpochUpdated";
+ var transactWrite4 = Context.CreateTransactWrite();
+ transactWrite4.AddSaveItem(loadedEpochEntity);
+ await transactWrite4.ExecuteAsync();
+ var loadedAfterUpdateEpochEntity = await Context.LoadAsync(epochEntity.Id);
+ Assert.AreEqual(createdAtEpochEntity, loadedAfterUpdateEpochEntity.CreatedAt);
+ Assert.IsTrue(loadedAfterUpdateEpochEntity.UpdatedAt > updatedAt);
+ }
+
+ [TestMethod]
+ [TestCategory("DynamoDBv2")]
+ public async Task Test_AutoGeneratedTimestampAttribute_With_Annotations()
+ {
+ CleanupTables();
+ TableCache.Clear();
+
+ // 1. Test: AutoGeneratedTimestamp combined with Version and Flatten
+ var now = DateTime.UtcNow;
+ var product = new ProductFlatWithTimestamp
+ {
+ Id = 100,
+ Name = "TimestampedProduct",
+ Details = new ProductDetailsWithTimestamp
+ {
+ Description = "Timestamped details",
+ Name = "DetailsName",
+ }
+ };
+
+ await Context.SaveAsync(product);
+ var savedProduct = await Context.LoadAsync(product.Id);
+ Assert.IsNotNull(savedProduct);
+ Assert.IsNotNull(savedProduct.Details);
+ Assert.IsTrue(savedProduct.Details.CreatedAt > DateTime.MinValue);
+ Assert.AreEqual(0, savedProduct.Details.Version);
+
+ // 2. Test: AutoGeneratedTimestamp combined with AtomicCounter and GSI
+ var employee = new EmployeeWithTimestampAndCounter
+ {
+ Name = "Alice",
+ Age = 25,
+ CompanyName = "TestCompany",
+ Score = 10,
+ ManagerName = "Bob"
+ };
+ await Context.SaveAsync(employee);
+ var loadedEmployee = await Context.LoadAsync(employee.Name, employee.Age);
+ Assert.IsNotNull(loadedEmployee);
+ Assert.IsTrue(loadedEmployee.LastUpdated > DateTime.MinValue);
+ Assert.AreEqual(0, loadedEmployee.CountDefault);
+ // 3. Test: AutoGeneratedTimestamp with TimestampMode.Create
+ var productCreateOnly = new ProductWithCreateTimestamp
+ {
+ Id = 200,
+ Name = "CreateOnly"
+ };
+ await Context.SaveAsync(productCreateOnly);
+ var loadedCreateOnly = await Context.LoadAsync(productCreateOnly.Id);
+ Assert.IsNotNull(loadedCreateOnly);
+ var createdAt = loadedCreateOnly.CreatedAt;
+ Assert.IsTrue(createdAt > DateTime.MinValue);
+
+ // Update and verify CreatedAt does not change
+ await Task.Delay(1000);
+ loadedCreateOnly.Name = "UpdatedName";
+ await Context.SaveAsync(loadedCreateOnly);
+ var loadedAfterUpdate = await Context.LoadAsync(productCreateOnly.Id);
+ Assert.AreEqual(createdAt, loadedAfterUpdate.CreatedAt);
+ }
private static void TestEmptyStringsWithFeatureEnabled()
{
var product = new Product
@@ -2865,6 +3159,80 @@ private ModelA CreateNestedTypeItem(out Guid id)
#region OPM definitions
+ // Helper classes for the integration test
+
+ [DynamoDBTable("HashTable")]
+ public class ProductFlatWithTimestamp
+ {
+ [DynamoDBHashKey] public int Id { get; set; }
+ [DynamoDBFlatten] public ProductDetailsWithTimestamp Details { get; set; }
+ public string Name { get; set; }
+ }
+
+ public class ProductDetailsWithTimestamp
+ {
+ [DynamoDBVersion] public int? Version { get; set; }
+ [DynamoDBAutoGeneratedTimestamp] public DateTime? CreatedAt { get; set; }
+ public string Description { get; set; }
+ [DynamoDBProperty("DetailsName")] public string Name { get; set; }
+ }
+
+ [DynamoDBTable("HashRangeTable")]
+ public class EmployeeWithTimestampAndCounter : AnnotatedEmployee
+ {
+ [DynamoDBAutoGeneratedTimestamp] public DateTime? LastUpdated { get; set; }
+ [DynamoDBAtomicCounter] public int? CountDefault { get; set; }
+ }
+
+ [DynamoDBTable("HashTable")]
+ public class ProductWithCreateTimestamp
+ {
+ [DynamoDBHashKey] public int Id { get; set; }
+ public string Name { get; set; }
+ [DynamoDBAutoGeneratedTimestamp]
+ [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)]
+ public DateTime? CreatedAt { get; set; }
+ }
+
+ [DynamoDBTable("HashTable")]
+ public class AutoGenTimestampEpochEntity
+ {
+ [DynamoDBHashKey]
+ public int Id { get; set; }
+
+ public string Name { get; set; }
+
+ [DynamoDBAutoGeneratedTimestamp]
+ [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)]
+ [DynamoDBProperty(StoreAsEpoch = true)]
+ public DateTime? CreatedAt { get; set; }
+
+ [DynamoDBAutoGeneratedTimestamp]
+ [DynamoDBProperty(StoreAsEpoch = true)]
+ public DateTime? UpdatedAt { get; set; }
+ }
+
+ [DynamoDBTable("HashTable")]
+ public class AutoGenTimestampEpochLongEntity
+ {
+ [DynamoDBHashKey]
+ public int Id { get; set; }
+
+ public string Name { get; set; }
+
+ [DynamoDBAutoGeneratedTimestamp]
+ [DynamoDbUpdateBehavior(UpdateBehavior.IfNotExists)]
+ [DynamoDBProperty(StoreAsEpochLong = true)]
+ public DateTime? CreatedAt { get; set; }
+
+ [DynamoDBProperty(StoreAsEpochLong = true)]
+ public DateTime? CreatedAt1 { get; set; }
+
+ [DynamoDBAutoGeneratedTimestamp]
+ [DynamoDBProperty(StoreAsEpochLong = true)]
+ public DateTime? UpdatedAt { get; set; }
+ }
+
public enum Status : long
{
Active = 256,
@@ -3144,6 +3512,23 @@ public class CounterAnnotatedEmployee : AnnotatedEmployee
public int? CountAtomic { get; set; }
}
+ // Flattened scenario classes
+ [DynamoDBTable("HashTable")]
+ public class ProductFlatWithAtomicCounter
+ {
+ [DynamoDBHashKey] public int Id { get; set; }
+ [DynamoDBFlatten] public ProductDetailsWithAtomicCounter Details { get; set; }
+ public string Name { get; set; }
+ }
+
+ public class ProductDetailsWithAtomicCounter
+ {
+ [DynamoDBVersion] public int? Version { get; set; }
+ [DynamoDBAtomicCounter] public int? CountDefault { get; set; }
+ [DynamoDBAtomicCounter(delta: 2, startValue: 10)] public int? CountAtomic { get; set; }
+ public string Description { get; set; }
+ [DynamoDBProperty("DetailsName")] public string Name { get; set; }
+ }
///
/// Class representing items in the table [TableNamePrefix]HashTable
diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs
new file mode 100644
index 000000000000..6a59a43a35b7
--- /dev/null
+++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/ExpressionsTest.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using Amazon.DynamoDBv2.DocumentModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace AWSSDK.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel
+{
+ [TestClass]
+ public class ExpressionsTest
+ {
+ [TestMethod]
+ public void MergeUpdateExpressions_BothNull_ReturnsNull()
+ {
+ var result = Expression.MergeUpdateExpressions(null, null);
+ Assert.IsNull(result);
+ }
+
+ [TestMethod]
+ public void MergeUpdateExpressions_OneNull_ReturnsOther()
+ {
+ var left = new Expression { ExpressionStatement = "SET #A = :a" };
+ var right = new Expression { ExpressionStatement = "SET #B = :b" };
+
+ Assert.AreEqual(left.ExpressionStatement, Expression.MergeUpdateExpressions(null, left).ExpressionStatement);
+ Assert.AreEqual(right.ExpressionStatement, Expression.MergeUpdateExpressions(right, null).ExpressionStatement);
+ }
+
+ [TestMethod]
+ public void MergeUpdateExpressions_MergesSetSections()
+ {
+ var left = new Expression
+ {
+ ExpressionStatement = "SET #A = :a",
+ ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } },
+ ExpressionAttributeValues = new Dictionary { { ":a", new Primitive("1") } }
+ };
+ var right = new Expression
+ {
+ ExpressionStatement = "SET #B = :b",
+ ExpressionAttributeNames = new Dictionary { { "#B", "AttrB" } },
+ ExpressionAttributeValues = new Dictionary { { ":b", new Primitive("2") } }
+ };
+
+ var result = Expression.MergeUpdateExpressions(right, left);
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.ExpressionStatement.Contains("SET"));
+ Assert.IsTrue(result.ExpressionStatement.Contains("#A = :a"));
+ Assert.IsTrue(result.ExpressionStatement.Contains("#B = :b"));
+ Assert.AreEqual("AttrA", result.ExpressionAttributeNames["#A"]);
+ Assert.AreEqual("AttrB", result.ExpressionAttributeNames["#B"]);
+ Assert.AreEqual("1", result.ExpressionAttributeValues[":a"].AsPrimitive().AsString());
+ Assert.AreEqual("2", result.ExpressionAttributeValues[":b"].AsPrimitive().AsString());
+ }
+
+ [TestMethod]
+ public void MergeUpdateExpressions_MergesDifferentSections()
+ {
+ var left = new Expression
+ {
+ ExpressionStatement = "SET #A = :a",
+ ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } },
+ ExpressionAttributeValues = new Dictionary { { ":a", new Primitive("1") } }
+ };
+ var right = new Expression
+ {
+ ExpressionStatement = "REMOVE #B",
+ ExpressionAttributeNames = new Dictionary { { "#B", "AttrB" } }
+ };
+
+ var result = Expression.MergeUpdateExpressions(right, left);
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.ExpressionStatement.Contains("SET #A = :a"));
+ Assert.IsTrue(result.ExpressionStatement.Contains("REMOVE #B"));
+ Assert.AreEqual("AttrA", result.ExpressionAttributeNames["#A"]);
+ Assert.AreEqual("AttrB", result.ExpressionAttributeNames["#B"]);
+ }
+
+ [TestMethod]
+ public void MergeUpdateExpressions_AttributeNamesConflict_Throws()
+ {
+ var left = new Expression
+ {
+ ExpressionStatement = "SET #A = :a",
+ ExpressionAttributeNames = new Dictionary { { "#A", "AttrA" } }
+ };
+ var right = new Expression
+ {
+ ExpressionStatement = "SET #A = :b",
+ ExpressionAttributeNames = new Dictionary { { "#A", "AttrB" } }
+ };
+
+ // Simulate the validation logic for duplicate names with different values
+ var mergedNames = new Dictionary(left.ExpressionAttributeNames, StringComparer.Ordinal);
+ Assert.ThrowsException(() =>
+ {
+ foreach (var kv in right.ExpressionAttributeNames)
+ {
+ if (mergedNames.TryGetValue(kv.Key, out var existingValue))
+ {
+ if (!string.Equals(existingValue, kv.Value, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Duplicate ExpressionAttributeName key '{kv.Key}' with different values: '{existingValue}' and '{kv.Value}'.");
+ }
+ }
+ else
+ {
+ mergedNames[kv.Key] = kv.Value;
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs
new file mode 100644
index 000000000000..a91bb7244c4d
--- /dev/null
+++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DocumentModel/PropertyStorageTests.cs
@@ -0,0 +1,278 @@
+using Amazon.DynamoDBv2.DataModel;
+using Amazon.DynamoDBv2.DocumentModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using System;
+using System.Collections.Generic;
+using Amazon.DynamoDBv2;
+
+namespace AWSSDK.UnitTests.DynamoDBv2.NetFramework.Custom.DocumentModel
+{
+ [TestClass]
+ public class PropertyStorageTests
+ {
+ private class TestClass
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ public int? Counter { get; set; }
+ public int? Version { get; set; }
+ public DateTime? Timestamp { get; set; }
+ }
+
+ private PropertyStorage CreatePropertyStorage(string propertyName = "Id")
+ {
+ var member = typeof(TestClass).GetProperty(propertyName);
+ return new PropertyStorage(member);
+ }
+
+ private class DummyContext : DynamoDBContext
+ {
+
+ public DummyContext(IAmazonDynamoDB client) : base(client, false, null)
+ {
+ }
+
+ }
+
+ private class FakePropertyConverter : IPropertyConverter
+ {
+ public object FromEntry(DynamoDBEntry entry) => null;
+ public DynamoDBEntry ToEntry(object value) => null;
+ }
+
+
+ [TestMethod]
+ public void AddIndex_AddsIndexToIndexesList()
+ {
+ var storage = CreatePropertyStorage();
+ var gsi = new PropertyStorage.GSI(true, "Attr", "Index1");
+ storage.AddIndex(gsi);
+
+ Assert.AreEqual(1, storage.Indexes.Count);
+ Assert.AreSame(gsi, storage.Indexes[0]);
+ }
+
+ [TestMethod]
+ public void AddGsiIndex_AddsGSIIndex()
+ {
+ var storage = CreatePropertyStorage();
+ storage.AddGsiIndex(true, "Attr", "Index1", "Index2");
+
+ Assert.AreEqual(1, storage.Indexes.Count);
+ var gsi = storage.Indexes[0] as PropertyStorage.GSI;
+ Assert.IsNotNull(gsi);
+ Assert.IsTrue(gsi.IsHashKey);
+ CollectionAssert.AreEquivalent(new List { "Index1", "Index2" }, gsi.IndexNames);
+ }
+
+ [TestMethod]
+ public void AddLsiIndex_AddsLSIIndex()
+ {
+ var storage = CreatePropertyStorage();
+ storage.AddLsiIndex("Attr", "Index1");
+
+ Assert.AreEqual(1, storage.Indexes.Count);
+ var lsi = storage.Indexes[0] as PropertyStorage.LSI;
+ Assert.IsNotNull(lsi);
+ Assert.AreEqual("Attr", lsi.AttributeName);
+ CollectionAssert.AreEquivalent(new List { "Index1" }, lsi.IndexNames);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfBothHashAndRangeKey()
+ {
+ var storage = CreatePropertyStorage("Name");
+ storage.IsHashKey = true;
+ storage.IsRangeKey = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfStoreAsEpochAndStoreAsEpochLong()
+ {
+ var storage = CreatePropertyStorage("Timestamp");
+ storage.StoreAsEpoch = true;
+ storage.StoreAsEpochLong = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfConverterTypeAndPolymorphicProperty()
+ {
+ var storage = CreatePropertyStorage("Name");
+ storage.ConverterType = typeof(object); // Not a real converter, but triggers the check
+ storage.PolymorphicProperty = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfConverterTypeAndShouldFlattenChildProperties()
+ {
+ var storage = CreatePropertyStorage("Name");
+ storage.ConverterType = typeof(object);
+ storage.ShouldFlattenChildProperties = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfConverterTypeAndStoreAsEpoch()
+ {
+ var storage = CreatePropertyStorage("Timestamp");
+ storage.ConverterType = typeof(object);
+ storage.StoreAsEpoch = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIfConverterTypeAndIsAutoGeneratedTimestamp()
+ {
+ var storage = CreatePropertyStorage("Timestamp");
+ storage.ConverterType = typeof(object);
+ storage.IsAutoGeneratedTimestamp = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ storage.Validate(null);
+ }
+ [TestMethod]
+ public void Validate_AllowsIsVersionOnNumericType()
+ {
+ var mockClient = new Mock();
+ var context = new DummyContext(mockClient.Object);
+
+ var storage = CreatePropertyStorage("Version");
+ storage.IsVersion = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should not throw for int property
+ storage.Validate(context);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIsVersionOnNonNumericType()
+ {
+ var storage = CreatePropertyStorage("Name");
+ storage.IsVersion = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should throw for string property
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ public void Validate_AllowsIsCounterOnNumericType()
+ {
+ var mockClient = new Mock();
+ var context = new DummyContext(mockClient.Object);
+
+ var storage = CreatePropertyStorage("Counter");
+ storage.IsCounter = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should not throw for int property
+ storage.Validate(context);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIsCounterOnNonNumericType()
+ {
+ var storage = CreatePropertyStorage("Name");
+ storage.IsCounter = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should throw for string property
+ storage.Validate(null);
+ }
+
+
+ [TestMethod]
+ public void Validate_AllowsIsAutoGeneratedTimestampOnDateTime()
+ {
+ var mockClient = new Mock();
+ var context = new DummyContext(mockClient.Object);
+
+ var storage = CreatePropertyStorage("Timestamp");
+ storage.IsAutoGeneratedTimestamp = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should not throw for DateTime property
+ storage.Validate(context);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(InvalidOperationException))]
+ public void Validate_ThrowsIsAutoGeneratedTimestampOnNonDateTime()
+ {
+ var storage = CreatePropertyStorage("Id");
+ storage.IsAutoGeneratedTimestamp = true;
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ // Should throw for int property
+ storage.Validate(null);
+ }
+
+ [TestMethod]
+ public void Validate_UsesConverterFromContextCache()
+ {
+ var mockClient = new Mock();
+ var context = new DummyContext(mockClient.Object);
+
+ var storage = CreatePropertyStorage("Id");
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+
+ var fakeConverter = new FakePropertyConverter();
+ context.ConverterCache[typeof(int)] = fakeConverter;
+
+ storage.Validate(context);
+
+ Assert.AreSame(fakeConverter, storage.Converter);
+ }
+
+ [TestMethod]
+ public void Validate_PopulatesIndexNamesFromIndexes()
+ {
+ var mockClient = new Mock();
+ var context = new DummyContext(mockClient.Object);
+
+ var storage = CreatePropertyStorage("Id");
+ storage.IndexNames = new List();
+ storage.FlattenProperties = new List();
+ storage.AddGsiIndex(true, "Attr", "IndexA", "IndexB");
+
+ storage.Validate(context);
+
+ CollectionAssert.Contains(storage.IndexNames, "IndexA");
+ CollectionAssert.Contains(storage.IndexNames, "IndexB");
+ }
+ }
+}
\ No newline at end of file