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