diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs index 4101648ecd2d..f87807db6755 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -53,6 +53,61 @@ public partial class DynamoDBContext : IDynamoDBContext #endregion + #region Public methods + /// + /// Adds a to this context's internal cache, which + /// will avoid the need to fetch table metadata automatically from DynamoDB. + /// This may be used in conjunction with an . + /// + /// + /// Using this method can avoid latency and potential deadlocks due to the internal + /// call that is used to populate + /// the SDK's cache of table metadata. It requires that the table's index schema described accurately, + /// otherwise exceptions may be thrown and/or the results of certain DynamoDB operations may change. + /// It is recommended to test your application prior to using this in production code. + /// + /// Table to add to the internal cache + public void RegisterTableDefinition(Table table) + { + try + { + _readerWriterLockSlim.EnterReadLock(); + + if (tablesMap.ContainsKey(table.TableName)) + { + return; + } + } + finally + { + if (_readerWriterLockSlim.IsReadLockHeld) + { + _readerWriterLockSlim.ExitReadLock(); + } + } + + try + { + _readerWriterLockSlim.EnterWriteLock(); + + // Check to see if another thread go the write lock before this thread and filled the cache. + if (tablesMap.ContainsKey(table.TableName)) + { + return; + } + + tablesMap[table.TableName] = table; + } + finally + { + if (_readerWriterLockSlim.IsWriteLockHeld) + { + _readerWriterLockSlim.ExitWriteLock(); + } + } + } + #endregion + #region Constructors #if !NETSTANDARD diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ITableBuilder.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ITableBuilder.cs new file mode 100644 index 000000000000..8b1cb615930e --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ITableBuilder.cs @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +namespace Amazon.DynamoDBv2.DocumentModel +{ + /// + /// Interface for a builder that constructs a + /// + public interface ITableBuilder + { + /// + /// Call at the end to retrieve the new + /// + /// Built table + Table Build(); + + /// + /// Adds the primary key definition to the table + /// + /// Name of the attribute used as the partition key + /// Type of that attribute + ITableBuilder AddHashKey(string hashKeyAttribute, DynamoDBEntryType type); + + /// + /// Adds a sort key definition to the table + /// + /// Name of the attribute used as the sort key + /// Type of that attribute + ITableBuilder AddRangeKey(string rangeKeyAttribute, DynamoDBEntryType type); + + /// + /// Adds a local secondary index definition to the table + /// + /// Name of the local secondary index + /// Name of the attribute used as the sort key in the local secondary index + /// Type of that attribute + ITableBuilder AddLocalSecondaryIndex(string indexName, string rangeKeyAttribute, DynamoDBEntryType type); + + /// + /// Adds a global secondary index definition to the table + /// + /// Name of the global secondary index + /// Name of the attribute used as the partition key in the GSI + /// Type of the hash key attribute + /// Name of the attribute used as the sort key in the GSI + /// Type of the sort key attribute + ITableBuilder AddGlobalSecondaryIndex(string indexName, string hashkeyAttribute, DynamoDBEntryType hashKeyType, string rangeKeyAttribute, DynamoDBEntryType rangeKeyType); + } +} diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs index 72125f8bc4f2..af6da4768dfb 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/Table.cs @@ -340,7 +340,7 @@ private static void ValidateConditional(IConditionalOperationConfig config) throw new InvalidOperationException("Only one of the conditonal properties Expected, ExpectedState and ConditionalExpression can be set."); } - private void ClearTableData() + internal void ClearTableData() { Keys = new Dictionary(); HashKeys = new List(); @@ -399,7 +399,7 @@ internal Dictionary ToAttributeMap(Document doc, DynamoD #region Constructor/factory - private Table(IAmazonDynamoDB ddbClient, TableConfig config) + internal Table(IAmazonDynamoDB ddbClient, TableConfig config) { if (config == null) throw new ArgumentNullException("config"); diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/TableBuilder.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/TableBuilder.cs new file mode 100644 index 000000000000..a94dd76bca1a --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/TableBuilder.cs @@ -0,0 +1,260 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +using Amazon.DynamoDBv2.Model; +using System; +using System.Collections.Generic; + +namespace Amazon.DynamoDBv2.DocumentModel +{ + /// + /// Builder that constructs a + /// + public class TableBuilder : ITableBuilder + { + /// + /// The object that is built and then returned from + /// + private Table _table; + + /// + /// Keeps track internally of attributes that have already been saved in , + /// since they can be shared by different indices. + /// + private HashSet _attributesAlreadyProcessed; + + /// + /// Creates a builder object to construct a + /// + /// Client to use to access DynamoDB. + /// Table name + public TableBuilder(IAmazonDynamoDB ddbClient, string tableName) : + this(ddbClient, tableName, DynamoDBEntryConversion.CurrentConversion, false, null) + { + } + + /// + /// Creates a builder object to construct a + /// + /// Client to use to access DynamoDB. + /// Table name + /// Conversion to use for converting .NET values to DynamoDB values. + /// If the property is false, empty string values will be interpreted as null values. + /// The document API relies on an internal cache of the DynamoDB table's metadata to construct and validate + /// requests. This controls how the cache key is derived, which influences when the SDK will call + /// internally to populate the cache. + public TableBuilder(IAmazonDynamoDB ddbClient, string tableName, DynamoDBEntryConversion conversion, bool isEmptyStringValueEnabled, MetadataCachingMode? metadataCachingMode) + : this (ddbClient, new TableConfig(tableName, conversion, Table.DynamoDBConsumer.DocumentModel, null, isEmptyStringValueEnabled, metadataCachingMode)) + { + } + + /// + /// Creates a builder object to construct a + /// + /// Client to use to access DynamoDB. + /// Configuration to use for the table. + public TableBuilder(IAmazonDynamoDB ddbClient, TableConfig config) + { + _table = new Table(ddbClient, config); + _table.ClearTableData(); // initializes internal collections + _attributesAlreadyProcessed = new HashSet(); + } + + /// + public Table Build() + { + if (_table.HashKeys.Count == 0) + { + throw new ArgumentOutOfRangeException("A partition key definition is required, call AddHashKey before Build."); + } + + return _table; + } + + /// + public ITableBuilder AddHashKey(string hashKeyAttribute, DynamoDBEntryType type) + { + if (string.IsNullOrEmpty(hashKeyAttribute)) + { + throw new ArgumentNullException(nameof(hashKeyAttribute), "The name of the partition key attribute is required."); + } + + if (_table.HashKeys.Count != 0) + { + throw new ArgumentOutOfRangeException("Only a single partition key is supported, and one has already been added."); + } + + _table.HashKeys.Add(hashKeyAttribute); + + _table.Keys.Add(hashKeyAttribute, new KeyDescription + { + IsHash = true, + Type = type + }); + + if (!_attributesAlreadyProcessed.Contains(hashKeyAttribute)) + { + _table.Attributes.Add(new AttributeDefinition(hashKeyAttribute, dynamoDBEntryTypeToScalarAttributeType(type))); + _attributesAlreadyProcessed.Add(hashKeyAttribute); + } + + return this; + } + + /// + public ITableBuilder AddRangeKey(string rangeKeyAttribute, DynamoDBEntryType type) + { + if (string.IsNullOrEmpty(rangeKeyAttribute)) + { + throw new ArgumentNullException(nameof(rangeKeyAttribute), "The name of the sort key attribute is required."); + } + + if (_table.RangeKeys.Count != 0) + { + throw new ArgumentOutOfRangeException("Only a single sort key is supported, and one has already been added."); + } + + _table.RangeKeys.Add(rangeKeyAttribute); + + _table.Keys.Add(rangeKeyAttribute, new KeyDescription + { + IsHash = false, + Type = type + }); + + if (!_attributesAlreadyProcessed.Contains(rangeKeyAttribute)) + { + _table.Attributes.Add(new AttributeDefinition(rangeKeyAttribute, dynamoDBEntryTypeToScalarAttributeType(type))); + _attributesAlreadyProcessed.Add(rangeKeyAttribute); + } + + return this; + } + + /// + public ITableBuilder AddLocalSecondaryIndex(string indexName, string rangeKeyAttribute, DynamoDBEntryType type) + { + if (string.IsNullOrEmpty(indexName)) + { + throw new ArgumentNullException(nameof(indexName), "The name of the local secondary index is required"); + } + + if (_table.LocalSecondaryIndexes.ContainsKey(indexName)) + { + throw new ArgumentException($"An local secondary index with name {indexName} has already been defined."); + } + + if (string.IsNullOrEmpty(rangeKeyAttribute)) + { + throw new ArgumentNullException(nameof(rangeKeyAttribute), "The attribute name of the range key within the local secondary index is required."); + } + + if (_table.HashKeys.Count == 0) + { + throw new ArgumentException("A local secondary index uses the table's partition key, which was not provided. Call AddHashKey prior to AddLocalSecondaryIndex."); + + } + + _table.LocalSecondaryIndexNames.Add(indexName); + + _table.LocalSecondaryIndexes.Add(indexName, new LocalSecondaryIndexDescription + { + IndexName = indexName, + KeySchema = new List() + { + new KeySchemaElement { AttributeName = _table.HashKeys[0], KeyType = KeyType.HASH }, + new KeySchemaElement { AttributeName = rangeKeyAttribute, KeyType = KeyType.RANGE } + } + }); + + if (!_attributesAlreadyProcessed.Contains(rangeKeyAttribute)) + { + _table.Attributes.Add(new AttributeDefinition(rangeKeyAttribute, dynamoDBEntryTypeToScalarAttributeType(type))); + _attributesAlreadyProcessed.Add(rangeKeyAttribute); + } + + return this; + } + + /// + public ITableBuilder AddGlobalSecondaryIndex(string indexName, string hashkeyAttribute, DynamoDBEntryType hashKeyType, string rangeKeyAttribute, DynamoDBEntryType rangeKeyType) + { + if (string.IsNullOrEmpty(indexName)) + { + throw new ArgumentNullException(nameof(indexName), "The name of the global secondary index is required"); + } + + if (_table.GlobalSecondaryIndexes.ContainsKey(indexName)) + { + throw new ArgumentException($"An global secondary index with name {indexName} has already been defined."); + } + + if (string.IsNullOrEmpty(hashkeyAttribute)) + { + throw new ArgumentNullException(nameof(hashkeyAttribute), "The attribute name of the partition key within the global secondary index is required."); + } + + if (string.IsNullOrEmpty(rangeKeyAttribute)) + { + throw new ArgumentNullException(nameof(rangeKeyAttribute), "The attribute name of the range key within the global secondary index is required."); + } + + _table.GlobalSecondaryIndexNames.Add(indexName); + + _table.GlobalSecondaryIndexes.Add(indexName, new GlobalSecondaryIndexDescription + { + IndexName = indexName, + KeySchema = new List() + { + new KeySchemaElement { AttributeName = hashkeyAttribute, KeyType = KeyType.HASH }, + new KeySchemaElement { AttributeName = rangeKeyAttribute, KeyType = KeyType.RANGE } + } + }); + + if (!_attributesAlreadyProcessed.Contains(hashkeyAttribute)) + { + _table.Attributes.Add(new AttributeDefinition(hashkeyAttribute, dynamoDBEntryTypeToScalarAttributeType(hashKeyType))); + _attributesAlreadyProcessed.Add(hashkeyAttribute); + } + + if (!_attributesAlreadyProcessed.Contains(rangeKeyAttribute)) + { + _table.Attributes.Add(new AttributeDefinition(rangeKeyAttribute, dynamoDBEntryTypeToScalarAttributeType(rangeKeyType))); + _attributesAlreadyProcessed.Add(rangeKeyAttribute); + } + + return this; + } + + /// + /// Maps the document model's to the corresponding, low-level + /// + /// Document model attribute type + /// Corresponding low-level attribute type + private static ScalarAttributeType dynamoDBEntryTypeToScalarAttributeType(DynamoDBEntryType entryType) + { + switch (entryType) + { + case DynamoDBEntryType.String: + return ScalarAttributeType.S; + case DynamoDBEntryType.Numeric: + return ScalarAttributeType.N; + case DynamoDBEntryType.Binary: + return ScalarAttributeType.B; + default: + throw new ArgumentOutOfRangeException(nameof(entryType), "Not a valid ScalarAttributeType"); + } + } + } +} diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index f4f2c0c17411..c0e792e82b24 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -159,6 +159,66 @@ public void TestContext_DisableFetchingTableMetadata_DateTimeAsHashKey() } + /// + /// Runs the same object-mapper integration tests as , + /// but using table definitions created by instead of the internal call + /// + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestWithBuilderTables() + { + foreach (var conversion in new DynamoDBEntryConversion[] { DynamoDBEntryConversion.V1, DynamoDBEntryConversion.V2 }) + { + // Cleanup existing data in the tables + CleanupTables(); + + // Clear existing SDK-wide cache + TableCache.Clear(); + + // Redeclare Context, which will start with empty caches + Context = new DynamoDBContext(Client, new DynamoDBContextConfig + { + IsEmptyStringValueEnabled = true, + Conversion = conversion + }); + + Context.RegisterTableDefinition(new TableBuilder(Client, "DotNetTests-HashRangeTable") + .AddHashKey("Name", DynamoDBEntryType.String) + .AddRangeKey("Age", DynamoDBEntryType.Numeric) + .AddGlobalSecondaryIndex("GlobalIndex", "Company", DynamoDBEntryType.String, "Score", DynamoDBEntryType.Numeric) + .AddLocalSecondaryIndex("LocalIndex", "Manager", DynamoDBEntryType.String) + .Build()); + + Context.RegisterTableDefinition(new TableBuilder(Client, "DotNetTests-HashTable") + .AddHashKey("Id", DynamoDBEntryType.Numeric) + .AddGlobalSecondaryIndex("GlobalIndex", "Company", DynamoDBEntryType.String, "Price", DynamoDBEntryType.Numeric) + .Build()); + + Context.RegisterTableDefinition(new TableBuilder(Client, "DotNetTests-NumericHashRangeTable") + .AddHashKey("CreationTime", DynamoDBEntryType.Numeric) + .AddRangeKey("Name", DynamoDBEntryType.String) + .Build()); + + TestEmptyStringsWithFeatureEnabled(); + + TestEnumHashKeyObjects(); + + TestEmptyCollections(conversion); + + TestUnsupportedTypes(); + TestEnums(conversion); + + TestHashObjects(); + TestHashRangeObjects(); + TestOtherContextOperations(); + TestBatchOperations(); + TestTransactionOperations(); + TestMultiTableTransactionOperations(); + + TestStoreAsEpoch(); + } + } + private static void TestEmptyStringsWithFeatureEnabled() { var product = new Product diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs index ed0e2cd52ab9..a7173f61ca13 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DocumentTests.cs @@ -81,6 +81,82 @@ public void TestTableOperations() } } + /// + /// Runs the same tests as , but with + /// static table definitions that avoid the internal call to populate the cache + /// + [TestMethod] + public void TestTableOperationsViaBuilder() + { + foreach (var conversion in new DynamoDBEntryConversion[] { DynamoDBEntryConversion.V1, DynamoDBEntryConversion.V2 }) + { + // Clear tables + CleanupTables(); + + var hashTable = new TableBuilder(Client, "DotNetTests-HashTable", conversion, true, null) + .AddHashKey("Id", DynamoDBEntryType.Numeric) + .AddGlobalSecondaryIndex("GlobalIndex", "Company", DynamoDBEntryType.String, "Price", DynamoDBEntryType.Numeric) + .Build(); + + + var hashRangeTable = new TableBuilder(Client, "DotNetTests-HashRangeTable", conversion, true, null) + .AddHashKey("Name", DynamoDBEntryType.String) + .AddRangeKey("Age", DynamoDBEntryType.Numeric) + .AddGlobalSecondaryIndex("GlobalIndex", "Company", DynamoDBEntryType.String, "Score", DynamoDBEntryType.Numeric) + .AddLocalSecondaryIndex("LocalIndex", "Manager", DynamoDBEntryType.String) + .Build(); + + var numericHashRangeTable = new TableBuilder(Client, "DotNetTests-NumericHashRangeTable", conversion, true, null) + .AddHashKey("CreationTime", DynamoDBEntryType.Numeric) + .AddRangeKey("Name", DynamoDBEntryType.String) + .Build(); + + TestEmptyString(hashTable); + + // Test saving and loading empty lists and maps + TestEmptyCollections(hashTable); + + // Test operations on hash-key table + TestHashTable(hashTable, conversion); + + // Test operations on hash-and-range-key table + TestHashRangeTable(hashRangeTable, conversion); + + // Test using multiple test batch writer + TestMultiTableDocumentBatchWrite(hashTable, hashRangeTable); + + // Test multi-table transactional operations + TestMultiTableDocumentTransactWrite(hashTable, hashRangeTable, conversion); + + // Test large batch writes and gets + TestLargeBatchOperations(hashTable); + + // Test expressions for update + TestExpressionUpdate(hashTable); + + // Test expressions for put + TestExpressionPut(hashTable); + + // Test expressions for delete + TestExpressionsOnDelete(hashTable); + + // Test expressions for transactional operations + TestExpressionsOnTransactWrite(hashTable, conversion); + + // Test expressions for query + TestExpressionsOnQuery(hashRangeTable); + + // Test expressions for scan + TestExpressionsOnScan(hashRangeTable); + + // Test Query and Scan manual pagination + TestPagination(hashRangeTable); + + // Test storing some attributes as epoch seconds + TestStoreAsEpoch(hashRangeTable, numericHashRangeTable); + } + } + private void TestEmptyString(Table hashTable) { var companyInfo = new DynamoDBList(); diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/TableBuilderTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/TableBuilderTests.cs new file mode 100644 index 000000000000..0ecb0a19b154 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/TableBuilderTests.cs @@ -0,0 +1,189 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.DocumentModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace AWSSDK_DotNet35.UnitTests +{ + /// + /// Tests for + /// + [TestClass] + public class TableBuilderTests + { + private IAmazonDynamoDB _amazonDynamoDBClient = new AmazonDynamoDBClient(); + + /// + /// Asserts that the table requires a partition key definition + /// + [TestMethod] + public void MissingPrimaryKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + + Assert.ThrowsException(() => builder.Build()); + } + + /// + /// Asserts that the partition key requires a non-null attribute + /// + [TestMethod] + public void NullPrimaryKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + + Assert.ThrowsException(() => builder.AddHashKey("", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when more that one partition key is defined + /// + [TestMethod] + public void MoreThanOnePrimaryKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddHashKey("Id2", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the partition key requires a non-null attribute + /// + [TestMethod] + public void NullRangeKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + + Assert.ThrowsException(() => builder.AddRangeKey("", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when more that one sort key is defined + /// + [TestMethod] + public void MoreThanOneRangeKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddRangeKey("Date", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddRangeKey("Date2", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when a LSI is defined before the partition key is, + /// since the LSI reuses the partition key + /// + [TestMethod] + public void LSIWithoutPrimaryKey_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + + Assert.ThrowsException(() => builder.AddLocalSecondaryIndex("LSI", "Date", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when a LSI is defined without an index name + /// + [TestMethod] + public void MissingLSIIndexName_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddLocalSecondaryIndex("", "Date", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when a LSI is defined without an attribute name + /// + [TestMethod] + public void MissingLSIAttribute_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddLocalSecondaryIndex("LSI", "", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when a duplicate LSI is defined + /// + [TestMethod] + public void DuplicateLSIName_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + builder.AddLocalSecondaryIndex("LSI", "Date", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddLocalSecondaryIndex("LSI", "Date2", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder only stores key attributes once when reused in an LSI + /// + [TestMethod] + public void AttributeReusedViaLSI_OnlyStoredOnce() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + + // Add a GSI with two new attributes + builder.AddGlobalSecondaryIndex("GSI", "Date", DynamoDBEntryType.String, "OrderNumber", DynamoDBEntryType.Numeric); + + // Add a LSI that reuses one of those, which shouldn't be stored in table.Attributes again + builder.AddLocalSecondaryIndex("LSI", "OrderNumber", DynamoDBEntryType.Numeric); + + var table = builder.Build(); + + Assert.AreEqual(3, table.Attributes.Count); + } + + /// + /// Asserts that the builder throws an exception when any of the required GSI identifiers are null + /// + public void GSINullNames_ThrowException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + + // Null GSI index name + Assert.ThrowsException(() => builder.AddGlobalSecondaryIndex("", "Id", DynamoDBEntryType.String, "Date", DynamoDBEntryType.String)); + + // Null partition key + Assert.ThrowsException(() => builder.AddGlobalSecondaryIndex("GSI", "", DynamoDBEntryType.String, "Date", DynamoDBEntryType.String)); + + // Null sort key + Assert.ThrowsException(() => builder.AddGlobalSecondaryIndex("GSI", "Id", DynamoDBEntryType.String, "", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder throws an exception when a duplicate GSI is defined + /// + [TestMethod] + public void DuplicateGSIName_ThrowsException() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddGlobalSecondaryIndex("GSI", "Id", DynamoDBEntryType.String, "Date", DynamoDBEntryType.String); + + Assert.ThrowsException(() => builder.AddGlobalSecondaryIndex("GSI", "Id", DynamoDBEntryType.String, "Date", DynamoDBEntryType.String)); + } + + /// + /// Asserts that the builder only stores key attributes once when they are reused in a GSI + /// + [TestMethod] + public void AttributesResuedViaGSI_OnlyStoredOnce() + { + var builder = new TableBuilder(_amazonDynamoDBClient, "TestTable"); + builder.AddHashKey("Id", DynamoDBEntryType.String); + builder.AddRangeKey("Date", DynamoDBEntryType.String); + + // Add a GSI that uses those same two attributes + builder.AddGlobalSecondaryIndex("GSI", "Date", DynamoDBEntryType.String, "Id", DynamoDBEntryType.String); + + var table = builder.Build(); + + Assert.AreEqual(2, table.Attributes.Count); + } + } +}