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..2af8608a843e 100644
--- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
+++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
@@ -688,4 +688,123 @@ 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 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.
+ ///
+ 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.
+ ///
+ /// Name of attribute to be associated with property or field.
+ public DynamoDBAutoGeneratedTimestampAttribute(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 DynamoDBAutoGeneratedTimestampAttribute(string attributeName, TimestampMode mode)
+ : base(attributeName)
+ {
+ Mode = mode;
+ }
+
+ ///
+ /// Constructor that specifies a custom converter.
+ ///
+ /// Custom converter type.
+ public DynamoDBAutoGeneratedTimestampAttribute([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 DynamoDBAutoGeneratedTimestampAttribute([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 DynamoDBAutoGeneratedTimestampAttribute(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 DynamoDBAutoGeneratedTimestampAttribute(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 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