Lesson 6.6: Create Table Storage Repository

Now that we have our game data in Azure Table Storage, we need a mechanism for loading that data into our services. To support this, we will implement a new instance IReadableRepository that encapsulates all of the logic for fetching data from Table Storage. The TableStorageReadRepository will follow the pattern that we developed for InMemoryReadRepository and then extended for BlobStorageReadRepository.

To work with Table Storage, we must install the Microsoft.Azure.Cosmos.Table package into our SimpleRPG.Game.Services project. Please launch the NuGet Package Manager for this project, and install the Microsoft.Azure.Cosmos.Table package version 1.0.8 or above.

ITableStorageAdapter to Encapsulate Table Storage API

We are going to use the adapter design pattern to encapsulate the specific Table Storage API calls and not surface it all of the way into the repository. We used this pattern before with the Blob Storage repository.

We start by creating the ITableStorageAdapter interface in the SimpleRPG.Game.Services project and Helpers folder.

using Microsoft.Azure.Cosmos.Table;
using SimpleRPG.Game.Services.DTO;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Services.Helpers
{
    public interface ITableStorageAdapter
    {
        public Task<CloudTable> OpenStorageTable(
            string connection,
            string tableName);

        public TableQuery CreateQueryFromOptions(
            IEnumerable<NameValuePair<string>> filters,
            IEnumerable<NameValuePair<string>> sorts);

        public TableQuery CreateQueryFromIds<TId>(
            IEnumerable<TId> ids);

        public List<T> ExecuteQuery<T>(
            CloudTable table,
            TableQuery query,
            int offset,
            int limit,
            Func<DynamicTableEntity, T> _entityResolver);

        public Task<T> ExecuteOperation<T>(
            CloudTable table,
            TableOperation operation,
            Func<DynamicTableEntity, T> _entityResolver);
    }
}

This interface defines 5 main operations performed on Azure Table Storage:

  1. The OpenStorageTable method retrieves the object that represents a particular Table based on the account connection string and the table’s name.
  2. The CreateQueryFromOptions method uses the filters and sorting options to build a specific TableQuery that represent those options in Table Storage format.
  3. The CreateQueryFromIds method builds a TableQuery to find multiple rows based on their id/RowKey.
  4. The ExecuteQuery method executes any TableQuery and returns a list of results. It takes an EntityResolver function which transforms results into the specified type (this type is explained later in this article).
  5. The ExecuteOperation method runs a single TableOperation and returns a single result. It also uses an EntityResolver for the conversion.

Then we create the TableStorageAdapter class to implement this interface in the same folder.

using Microsoft.Azure.Cosmos.Table;
using SimpleRPG.Game.Services.DTO;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Services.Helpers
{
    public class TableStorageAdapter : ITableStorageAdapter
    {
        private const string _rowKeyToken = "RowKey";
        private const string _equalityToken = "eq";
        private const string _conditionJoinToken = "and";
        private const string _conditionOrToken = "or";
        private const string _descendingToken = "dsc";
        private readonly char[] _conditionTrimChars = new char[] { ' ', 'a', 'n', 'd' };
        private readonly char[] _conditionOrTrimChars = new char[] { ' ', 'o', 'r' };

        public async Task<CloudTable> OpenStorageTable(
            string connection,
            string tableName)
        {
            if (string.IsNullOrEmpty(connection))
                throw new ArgumentNullException(nameof(connection));
            if (string.IsNullOrEmpty(tableName))
                throw new ArgumentNullException(nameof(tableName));

            var account = CloudStorageAccount.Parse(connection);

            var tableClient = account.CreateCloudTableClient(
                new TableClientConfiguration());

            var table = tableClient.GetTableReference(tableName);
            await table.CreateIfNotExistsAsync();

            return table;
        }

        public TableQuery CreateQueryFromOptions(
            IEnumerable<NameValuePair<string>> filters,
            IEnumerable<NameValuePair<string>> sorts)
        {
            var query = new TableQuery();
            query = ApplyFilters(query, filters);
            query = ApplySorting(query, sorts);

            return query;
        }

        public TableQuery CreateQueryFromIds<TId>(
            IEnumerable<TId> ids)
        {
            _ = ids ?? throw new ArgumentNullException(nameof(ids));

            var query = new TableQuery();
            string conditions = string.Empty;

            foreach (var id in ids)
            {
                var condition = TableQuery.GenerateFilterCondition(
                    _rowKeyToken, _equalityToken, id.ToString());

                conditions += $"{condition} {_conditionOrToken} ";
            }

            return query.Where(conditions.TrimEnd(_conditionOrTrimChars));
        }


        public List<T> ExecuteQuery<T>(
            CloudTable table,
            TableQuery query,
            int offset,
            int limit,
            Func<DynamicTableEntity, T> entityResolver)
        {
            _ = table ?? throw new ArgumentNullException(nameof(table));
            _ = query ?? throw new ArgumentNullException(nameof(query));
            _ = entityResolver ?? throw new ArgumentNullException(nameof(entityResolver));

            var entities = table.ExecuteQuery(query).Skip(offset).Take(limit);
            var results = new List<T>();

            foreach (var entity in entities)
            {
                var converted = entityResolver.Invoke(entity);
                results.Add(converted);
            }

            return results;
        }

        public async Task<T> ExecuteOperation<T>(
            CloudTable table,
            TableOperation operation,
            Func<DynamicTableEntity, T> entityResolver)
        {
            _ = table ?? throw new ArgumentNullException(nameof(table));
            _ = operation ?? throw new ArgumentNullException(nameof(operation));
            _ = entityResolver ?? throw new ArgumentNullException(nameof(entityResolver));

            TableResult result = await table.ExecuteAsync(operation)
                                            .ConfigureAwait(false);
            if (result.HttpStatusCode != (int)HttpStatusCode.OK)
            {
                throw new InvalidOperationException();
            }

            var tableEntity = result.Result as DynamicTableEntity;
            return entityResolver(tableEntity);
        }

        private TableQuery ApplyFilters(
            TableQuery query,
            IEnumerable<NameValuePair<string>> filters)
        {
            string conditions = string.Empty;

            if (filters != null)
            {
                foreach (var filter in filters)
                {
                    var condition = GenerateFilterCondition(filter);
                    conditions += $"{condition} {_conditionJoinToken} ";
                }
            }

            return query.Where(conditions.TrimEnd(_conditionTrimChars));
        }

        private TableQuery ApplySorting(
            TableQuery query,
            IEnumerable<NameValuePair<string>> sorts)
        {
            if (sorts != null)
            {
                foreach (var sort in sorts)
                {
                    if (sort.Value == _descendingToken)
                    {
                        query = query.OrderByDesc(sort.Name);
                    }
                    else
                    {
                        query = query.OrderBy(sort.Name);
                    }
                }
            }

            return query;
        }

        private string GenerateFilterCondition(NameValuePair<string> filter)
        {
            if (int.TryParse(filter.Value, out int valInt))
            {
                return TableQuery.GenerateFilterConditionForInt(
                    filter.Name, _equalityToken, valInt);
            }

            if (double.TryParse(filter.Value, out double valDouble))
            {
                return TableQuery.GenerateFilterConditionForDouble(
                    filter.Name, _equalityToken, valDouble);
            }

            if (bool.TryParse(filter.Value, out bool valBool))
            {
                return TableQuery.GenerateFilterConditionForBool(
                    filter.Name, _equalityToken, valBool);
            }

            if (Guid.TryParse(filter.Value, out Guid valGuid))
            {
                return TableQuery.GenerateFilterConditionForGuid(
                    filter.Name, _equalityToken, valGuid);
            }

            if (DateTime.TryParse(filter.Value, out DateTime valDT))
            {
                return TableQuery.GenerateFilterConditionForDate(
                    filter.Name, _equalityToken, valDT);
            }

            return TableQuery.GenerateFilterCondition(
                filter.Name, _equalityToken, filter.Value);
        }
    }
}

The TableStorageAdapter class implements all of the functionality defined by the ITableStorageAdapter interface. This class is responsible for calling the Table Storage APIs.

  • This class starts by defining a set of tokens used by its methods to build queries and parse any text (lines #13-19).
  • The OpenStorageTable method (lines #21-39) calls all of the setup methods to parse the connection string into a valid storage account, then get the TableClient API for that connection, and finally the requested table in that storage account. If the connection or table requests fail for any reason, this method bubbles those exceptions up the call-stack.
  • The CreateQueryFromOptions method (lines 41-50) just calls helper methods to adding a filtering query and sort order to the TableQuery object.
  • The CreateQueryFromIds method (lines #52-69) loops through all of the specified Ids and creates the corresponding conditions string. Each id is mapped to the RowKey in the query. And multiple conditions are ORed together. Finally the conditions are applied to the TableQuery.Where clause and returned.
  • The ExecuteQuery method (lines #72-93) uses the TableQuery object in the CloudTable.Query method. And uses the offset and limit parameters to get just the specified amount of results. Then, we loop through those results and use the entityResolver to convert them to our expected results type. Finally, we return the converted list.
  • The ExecuteOperation method (lines #95-113) uses the TableOperation object in the CloudTable.ExecuteAsync method. We ensure that the operations succeeded; otherwise we throw an exception to inform the caller of the failure. Then, we convert a single result using the entityResolver. Finally, we return that object.
  • The ApplyFilters helper method (lines #115-131) ensures that we have filters. For each filter, we concatenate together the conditions. Finally we use the conditions query string to call the TableQuery.Where clause.
  • The ApplySorting helper method (lines #133-153) ensures that we have sorting options. While it is possible to have multiple sorting options, only the last one takes effect (the Table Storage API only supports one OrderBy operation). Then, we return the TableQuery object.
  • The GenerateFilterCondition helper method (lines #155-189) is needed to ensure the TableQuery uses the right type for its data. The filters are just string name value pairs, but when the table data is an integer value, we must build the query with quotes around the data… strings do have quotes. Other types like Guid and DateTime prepend their type to the data, so the Table Storage knows how to interpret them. Unfortunately, this adds some complexity in trying to see if a value should be a particular type. So this method hides all of that ugliness from our query building code.

This completes the TableStorageAdapter. As we can see there is a lot of very Table Storage engine specific code with details about queries, operations, and types. So it is good to keep all of those details away from the rest of our repository code.

Mapping Types with TableEntityResolver

Table Storage works with rows that derive from TableEntity. However, our data transfer objects work across various different data storage technologies, so we don’t want to add that class derivation to our data classes. So, we are going to implement the TableEntityResolver class to manage the mismatch between these types. The methods in this class will know how to convert DynamicTableEntity objects into their corresponding data transfer objects. This mapping layer will allow the rest of the code outside of the repository to remain unchanged.

Let’s create the TableEntityResolver class in the SimpleRPG.Game.Services project and Repositories folder.

using Microsoft.Azure.Cosmos.Table;
using SimpleRPG.Game.Services.DTO;
using System;
using System.Collections.Generic;
using System.Text.Json;

namespace SimpleRPG.Game.Services.Repositories
{
    public static class TableEntityResolver
    {
        public static ItemTemplate ToItemTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new ItemTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    Category = entity.Properties["Category"].Int32Value.Value,
                    Price = entity.Properties["Price"].Int32Value.Value,
                    Damage = GetOptionalStringProperty(entity.Properties, "Damage"),
                    Heals = GetOptionalIntProperty(entity.Properties, "Heals")
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for ItemTemplate.",
                    ex);
            }
        }

        public static MonsterTemplate ToMonsterTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new MonsterTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    Dex = entity.Properties["Dex"].Int32Value.Value,
                    Str = entity.Properties["Str"].Int32Value.Value,
                    AC = entity.Properties["AC"].Int32Value.Value,
                    MaxHP = entity.Properties["MaxHP"].Int32Value.Value,
                    WeaponId = entity.Properties["WeaponId"].Int32Value.Value,
                    RewardXP = entity.Properties["RewardXP"].Int32Value.Value,
                    Gold = entity.Properties["Gold"].Int32Value.Value,
                    Image = entity.Properties["Image"].StringValue,
                    LootItems = ConvertJsonProperty<LootItem>(entity.Properties, "LootItems")
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for MonsterTemplate.",
                    ex);
            }
        }

        public static LocationTemplate ToLocationTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new LocationTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    X = entity.Properties["X"].Int32Value.Value,
                    Y = entity.Properties["Y"].Int32Value.Value,
                    ImageName = entity.Properties["ImageName"].StringValue,
                    Description = entity.Properties["Description"].StringValue,
                    TraderId = GetOptionalNullableIntProperty(entity.Properties, "TraderId"),
                    Quests = ConvertJsonProperty<int>(entity.Properties, "Quests"),
                    Monsters = ConvertJsonProperty<MonsterEncounterItem>(entity.Properties, "Monsters")
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for LocationTemplate.",
                    ex);
            }
        }

        public static QuestTemplate ToQuestTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new QuestTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    Description = entity.Properties["Description"].StringValue,
                    RewardGold = entity.Properties["RewardGold"].Int32Value.Value,
                    RewardXP = entity.Properties["RewardXP"].Int32Value.Value,
                    RewardItems = ConvertJsonProperty<IdQuantityItem>(entity.Properties, "RewardItems"),
                    Requirements = ConvertJsonProperty<IdQuantityItem>(entity.Properties, "Requirements")
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for QuestTemplate.",
                    ex);
            }
        }

        public static RecipeTemplate ToRecipeTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new RecipeTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    Ingredients = ConvertJsonProperty<IdQuantityItem>(entity.Properties, "Ingredients"),
                    OutputItems = ConvertJsonProperty<IdQuantityItem>(entity.Properties, "OutputItems")
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for RecipeTemplate.",
                    ex);
            }
        }

        public static TraderTemplate ToTraderTemplate(DynamicTableEntity entity)
        {
            _ = entity ?? throw new ArgumentNullException(nameof(entity));

            try
            {
                return new TraderTemplate
                {
                    Id = Convert.ToInt32(entity.RowKey),
                    Name = entity.Properties["Name"].StringValue,
                    Inventory = ConvertJsonProperty<IdQuantityItem>(entity.Properties, "Inventory"),
                };
            }
            catch (Exception ex)
            {
                throw new FormatException(
                    $"Entity (Id={entity.RowKey}) does not contain required properties for TraderTemplate.",
                    ex);
            }
        }

        private static string GetOptionalStringProperty(
            IDictionary<string, EntityProperty> properties,
            string propertyName) =>
                GetOptionalProperty(properties, propertyName)?.StringValue;

        private static int GetOptionalIntProperty(
            IDictionary<string, EntityProperty> properties,
            string propertyName)
        {
            EntityProperty prop = GetOptionalProperty(properties, propertyName);
            return (prop != null) ? Convert.ToInt32(prop.StringValue) : 0;
        }

        private static int? GetOptionalNullableIntProperty(
            IDictionary<string, EntityProperty> properties,
            string propertyName)
        {
            EntityProperty prop = GetOptionalProperty(properties, propertyName);
            return prop?.Int32Value;
        }

        private static EntityProperty GetOptionalProperty(
            IDictionary<string, EntityProperty> properties,
            string propertyName)
        {
            EntityProperty result = null;
            if (properties.ContainsKey(propertyName))
            {
                result = properties[propertyName];
            }

            return result;
        }

        private static IEnumerable<T> ConvertJsonProperty<T>(
            IDictionary<string, EntityProperty> properties,
            string propertyName)
        {
            IList<T> results = new List<T>();
            EntityProperty prop = GetOptionalProperty(properties, propertyName);
            if (prop != null)
            {
                var jsonProperty = prop.StringValue;
                if (!string.IsNullOrEmpty(jsonProperty))
                {
                    results = JsonSerializer.Deserialize<IList<T>>(jsonProperty);
                }
            }

            return results;
        }
    }
}

We will review the ToItemTemplate and ToMonsterTemplate methods to cover all of the conversion types… the remaining conversion methods are just variations of these two (so we won’t discuss them in detail). We will also dive into the helper conversion methods, which are used throughout the class.

  • The ToItemTemplate method (lines #11-33) validate that we have a valid input entity. Then it creates an ItemTemplate instance with the appropriate properties:
    • The RowKey is mapped to the Id property.
    • The Name property is straightforward.
    • The Category and Price properties are converted to integer values.
    • The Damage and Heals properties are optional (meaning they may not have values in the DynamicTableEntry), so we have helper methods to deal with that.
    • Finally, if there are any exceptions during the conversion we rethrow them as FormatExceptions.
  • The ToMonsterTemplate method (lines #35-62) is largely the same code, but creates a MonsterTemplate instance instead. The additional interesting property is LootItems, which is an array of objects. In our Table, this is represented as a snippet of JSON. So, we use the ConvertJsonProperty helper method to parse that text snippet and convert it into an array of LootItem.
  • The GetOptionalStringProperty method (lines #159-162) returns a null string if the property is not defined.
  • The GetOptionalIntProperty method (lines #164-170) converts the property to an int or returns 0 if the property is not defined.
  • The GetOptionalNullableIntProperty method (lines #172-178) returns a null if the property is not defined; otherwise it retrieves the int value.
  • The GetOptionalProperty method (lines #180-191) retrieves an EntityProperty if it is available in the property list. If not, it returns null. But does not throw an exception for a missing property. If we just accessed the property getter directly, then missing properties would throw an exception.
  • Finally, the ConvertJsonProperty method (lines #193-209) retrieves an optional property for the JSON string. If it’s found, we convert the string to a list of objects using the JsonSerializer, and return that list. If the property isn’t available, then we return an empty list.

TableStorageReadRepository Implementation

Finally, we can bring this all together in the TableStorageReadRepository class by creating it in the SimpleRPG.Game.Services project and Repositories folder.

using Microsoft.Azure.Cosmos.Table;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Helpers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SimpleRPG.Game.Services.Repositories
{
    public class TableStorageReadRepository<T, TId> : IReadableRepository<T, TId>
        where T : NamedElement<TId>
        where TId : struct
    {
        private const string _defaultPartition = "";
        private const int _defaultMaxLimit = 1000;
        private readonly string _connectionString;
        private readonly string _tableName;
        private readonly ITableStorageAdapter _tableAdapter;
        private readonly Func<DynamicTableEntity, T> _entityResolver;

        public TableStorageReadRepository(
            string connectionString,
            string tableName,
            ITableStorageAdapter tableAdapter,
            Func<DynamicTableEntity, T> entityResolver)
        {
            if (string.IsNullOrEmpty(connectionString))
                throw new ArgumentNullException(nameof(connectionString));
            _connectionString = connectionString;

            if (string.IsNullOrEmpty(tableName))
                throw new ArgumentNullException(nameof(tableName));
            _tableName = tableName;

            _tableAdapter = tableAdapter ?? throw new ArgumentNullException(nameof(tableAdapter));

            _entityResolver = entityResolver ?? throw new ArgumentNullException(nameof(entityResolver));
        }

        public async Task<IEnumerable<T>> GetEntities(
            int? offset,
            int? limit,
            IEnumerable<NameValuePair<string>> filters,
            IEnumerable<NameValuePair<string>> sorts)
        {
            var table = await _tableAdapter.OpenStorageTable(
                _connectionString, _tableName);

            var o = offset ?? 0;
            var l = limit ?? _defaultMaxLimit;
            var query = _tableAdapter.CreateQueryFromOptions(filters, sorts);

            var results = _tableAdapter.ExecuteQuery<T>(
                table, query, o, l, _entityResolver);
            return results;
        }

        public async Task<int> GetEntityCount(
            IEnumerable<NameValuePair<string>> filters)
        {
            var table = await _tableAdapter.OpenStorageTable(
                _connectionString, _tableName);

            var query = _tableAdapter.CreateQueryFromOptions(filters, null);

            return _tableAdapter.ExecuteQuery<T>(
                table, query, 0, _defaultMaxLimit, _entityResolver).Count();
        }

        public async Task<T> GetEntityById(TId id)
        {
            var table = await _tableAdapter.OpenStorageTable(
                _connectionString, _tableName).ConfigureAwait(false);

            var retrieveOperation =
                TableOperation.Retrieve(_defaultPartition, id.ToString());

            return await _tableAdapter.ExecuteOperation<T>(
                table, retrieveOperation, _entityResolver)
                                      .ConfigureAwait(false);
        }

        public async Task<IEnumerable<T>> GetBulkEntitiesById(
            IEnumerable<TId> ids)
        {
            var table = await _tableAdapter.OpenStorageTable(
                _connectionString, _tableName);

            var query = _tableAdapter.CreateQueryFromIds<TId>(ids);

            return _tableAdapter.ExecuteQuery<T>(
                table, query, 0, _defaultMaxLimit, _entityResolver);
        }
    }
}

With much of the logic implemented in the TableStorageAdapter and the EntityResolver, the code in our repository focuses more of the specific retrieval functionality:

  1. The constructor (lines #22-39) accepts data for the connection string and table name, the ITableAdapter to use, and the function that performs the entity resolution. We will tie all of these dependencies together in our IRepositoryBuilder classes (in the next lesson).
  2. The GetEntities method (lines #41-57) starts by opening the specified table. Then, it uses the ITableAdapter to build the options query. Then, it executes the query. And finally, returns the resulting list. This method supports filtering, sorting, and pagination settings.
  3. The GetEntityCount method (lines #59-69) also starts by opening the specified table. But it only builds the query with filter options (sorting doesn’t matter for getting the count). Finally, it returns the number of items in the list.
  4. The GetEntityById method (lines #71-82) opens the specified table. Then, it creates a retrieval operation with the specified entity id mapped to RowKey. Then, it uses the ITableAdapter to execute that operation. Finally, it returns the result to the caller.
  5. The GetBulkEntitiesById method (lines #84-94) works very much like the GetEntities method. We open the table that we are working on. Then, we use the ITableAdapter to build a query with the specified id list. And, we execute the query to get the list of items represented by those ids. And finally, we return that list. If no items are found, then the list is empty.

Testing the TableStorageReadRepository

With our repository code complete, we need to create a new set of tests that validate this new repository, adapter, and helper methods. This commit has a full set of these tests, but we will review just a couple to see how they are implemented. The other tests follow very similar usage patterns.

using Microsoft.Azure.Cosmos.Table;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Helpers;
using SimpleRPG.Game.Services.Repositories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;

namespace SimpleRPG.Game.Services.Tests.Repositories
{
    public class TableStorageReadRepositoryTests
    {
        private const string _connectionString = "DefaultEndpointsProtocol=https;AccountName=simplerpgzzzdatastorage;AccountKey=rTf770zsTadc12m6r4isF0anseifaVwdTt5G6Pj5zwCOqca3HxCn5VWl5eINbF7sAaBnLIlXU0cFr3UQ4kyB1A==;TableEndpoint=https://simplerpgdatastorage.table.cosmos.azure.com:443/;";
        private const string _tableName = "Items";
        private static readonly ITableStorageAdapter _adapter = new TableStorageAdapter();
        private static readonly Func<DynamicTableEntity, ItemTemplate> _entityResolver =
            TableEntityResolver.ToItemTemplate;

        [Fact]
        public async Task ItemTemplate_GetEntityCount()
        {
            // arrange
            var repo = new TableStorageReadRepository<ItemTemplate, int>(
                _connectionString, _tableName, _adapter, _entityResolver);

            // act
            var count = await repo.GetEntityCount(null)
                                  .ConfigureAwait(false);

            // assert
            Assert.Equal(15, count);
        }

        [Fact]
        public async Task ItemTemplate_GetEntities()
        {
            // arrange
            var repo = new TableStorageReadRepository<ItemTemplate, int>(
                _connectionString, _tableName, _adapter, _entityResolver);

            // act
            var result = await repo.GetEntities(null, null, null, null)
                                   .ConfigureAwait(false);
            var count = await repo.GetEntityCount(null)
                                  .ConfigureAwait(false);

            // assert
            Assert.Equal(count, result.Count());
            Assert.Contains<ItemTemplate>(
                result, f => f.Name == "Pointy stick - Table");
        }
    }
}
  • The test class starts by specifying the required members (lines #15-19) for the connection string, the table name (“Items” in this case), a new instance of the TableStorageAdapter, and the TableEntityResolver for ToItemTemplate.
  • The ItemTemplate_GetEntityCount test method (lines #21-34):
    • Sets up the TableStorageReadRepository for the ItemTemplate type, and the connection string, table name, adapter, and entity resolver defined earlier.
    • Calls the TableStorageReadRepository.GetEntityCount method.
    • Finally, validates we get the expected number of items.
  • The ItemTemplate_GetEntities test method (lines #36-53) does very similar operations but calls TableStorageReadRepository.GetEntities instead. Then we verify that the pointy stick item is coming from the expected Table Storage.

We can run all of these tests to verify that the repository works correctly with our Azure Table Storage account.

In conclusion, we have built a new fully functional repository. We use the Table Storage API to load queries and run operations on demand directly against our Table store. We don’t proxy/cache all data in the repository and then query against the copy (as we did with InMemoryReadRepository). Our tests validate that we can read our game data from our Table Storage account, so this is a good place to commit our code.

In the next lesson, we are going to add repository builders for the TableStorageReadRepository types and surface them through the RepositoryFactory. Then, we will verify our services retrieving data from Table Storage successfully.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s