Our Table repository is complete and tested, so now we need to surface this new repository to our game services. We will continue to follow the pattern we used throughout this chapter: create typed repository builders and add mapping data to the RepositoryFactory
to get these new instances. However, because we are starting to get some duplicate code in the RepositoryFactory
, we are going to refactor it to share more of the implementation.
Also, once we built all of the service tests, we started to see some failures due to implementation details mismatched between storage types – like case sensitivity in property names. Therefore, we are also including some fixes the to both the InMemoryReadRepository
and the TableStorageReadRepository
to handle that.
Table Repository Builders
First, we must create a specific builder for each supported repository type. We will do this as we did with the Blob Storage repositories in lesson 6.3
Let’s start by creating the TableRepositoryBuilders.cs file in the SimpleRPG.Game.Service project and the Repositories folder. We will place all six of our table-specific builder classes in this file.
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Helpers;
namespace SimpleRPG.Game.Services.Repositories
{
internal struct TableStorageData
{
public const string TableConnection = "DefaultEndpointsProtocol=https;AccountName=simplerpgdatastorage;AccountKey=rTf770zzzzsTadc12m6r4isF0anseifaVwdTt5G6Pj5zwCOqca3HxCn5VWl5eINbF7sAaBnLIlXU0cFr3UQ4kyB1A==;TableEndpoint=https://simplerpgdatastorage.table.cosmos.azure.com:443/;";
public static ITableStorageAdapter TableStorageAdapter = new TableStorageAdapter();
}
internal class ItemTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Items";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<ItemTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToItemTemplate);
}
}
internal class MonsterTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Monsters";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<MonsterTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToMonsterTemplate);
}
}
internal class LocationTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Locations";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<LocationTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToLocationTemplate);
}
}
internal class QuestTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Quests";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<QuestTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToQuestTemplate);
}
}
internal class RecipeTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Recipes";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<RecipeTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToRecipeTemplate);
}
}
internal class TraderTemplateTableRepositoryBuilder : IRepositoryBuilder
{
private const string _tableName = "Traders";
public IRepository CreateRepository()
{
return new TableStorageReadRepository<TraderTemplate, int>(
TableStorageData.TableConnection,
_tableName,
TableStorageData.TableStorageAdapter,
TableEntityResolver.ToTraderTemplate);
}
}
}
- We define the
TableStorageData
struct (lines #6-10) that holds the connection string and table adapter instance used by all of these builders. - The
ItemTemplateTableRepositoryBuilder
class (lines #12-24) creates aTableStorageReadRepository
instance forItemTemplate
entities. It defines the table name for “Items”. And, provides theTableEntityResolver.ToItemTemplate
entity resolver for use by this repository. - The remaining builder classes work exactly the same way, but with different table names (to match the table names in our storage account) and type specific entity resolver methods.
Refactor RepositoryFactory
Next, we need to use the Table repository builders in RepositoryFactory
. However, we can already see repetitive data structures (like the mapping dictionaries) and code repetition in the CreateResourceRepository
and CreateBlobStorageRepository
methods. If we keep adding repositories to this factory, we will just make this duplication worse. So, this is a good time to refactor the RepositoryFactory
to remove some of this duplication.
We will make two significant changes:
- Rather than having separate mapping dictionaries and repository names as independent member variables, we are going to place all of the type configuration data into single type lookup table.
- Refactor the
CreateRepository
method to use the type lookup table and share the creation code for all supported repository types.
Here are the code changes to enable these refactorings in the RepositoryFactory
class in the Repositories folder:
using SimpleRPG.Game.Services.DTO;
using System;
using System.Collections.Generic;
namespace SimpleRPG.Game.Services.Repositories
{
public static class RepositoryFactory
{
private static readonly Dictionary<string, Dictionary<Type, IRepositoryBuilder>> _repoTypeLookup =
new Dictionary<string, Dictionary<Type, IRepositoryBuilder>>
{
{
"resource",
new Dictionary<Type, IRepositoryBuilder>
{
{ typeof(ItemTemplate), new ItemTemplateMemoryRepositoryBuilder() },
{ typeof(MonsterTemplate), new MonsterTemplateMemoryRepositoryBuilder() },
{ typeof(LocationTemplate), new LocationTemplateMemoryRepositoryBuilder() },
{ typeof(TraderTemplate), new TraderTemplateMemoryRepositoryBuilder() },
{ typeof(QuestTemplate), new QuestTemplateMemoryRepositoryBuilder() },
{ typeof(RecipeTemplate), new RecipeTemplateMemoryRepositoryBuilder() },
}
},
{
"blob",
new Dictionary<Type, IRepositoryBuilder>
{
{ typeof(ItemTemplate), new ItemTemplateBlobRepositoryBuilder() },
{ typeof(MonsterTemplate), new MonsterTemplateBlobRepositoryBuilder() },
{ typeof(LocationTemplate), new LocationTemplateBlobRepositoryBuilder() },
{ typeof(TraderTemplate), new TraderTemplateBlobRepositoryBuilder() },
{ typeof(QuestTemplate), new QuestTemplateBlobRepositoryBuilder() },
{ typeof(RecipeTemplate), new RecipeTemplateBlobRepositoryBuilder() },
}
},
{
"table",
new Dictionary<Type, IRepositoryBuilder>
{
{ typeof(ItemTemplate), new ItemTemplateTableRepositoryBuilder() },
{ typeof(MonsterTemplate), new MonsterTemplateTableRepositoryBuilder() },
{ typeof(LocationTemplate), new LocationTemplateTableRepositoryBuilder() },
{ typeof(TraderTemplate), new TraderTemplateTableRepositoryBuilder() },
{ typeof(QuestTemplate), new QuestTemplateTableRepositoryBuilder() },
{ typeof(RecipeTemplate), new RecipeTemplateTableRepositoryBuilder() },
}
}
};
public static IReadableRepository<T, TId> CreateRepository<T, TId>(string repoSource)
where T : NamedElement<TId>
where TId : struct
{
if (_repoTypeLookup.ContainsKey(repoSource) is false)
throw new ArgumentOutOfRangeException(nameof(repoSource));
var mapping = _repoTypeLookup[repoSource];
return CreateSpecificRepository<T, TId>(mapping);
}
private static IReadableRepository<T, TId> CreateSpecificRepository<T, TId>(
IDictionary<Type, IRepositoryBuilder> typeBuilderMapping)
where T : NamedElement<TId>
where TId : struct
{
var builder = typeBuilderMapping[typeof(T)];
return builder.CreateRepository() as IReadableRepository<T, TId>;
}
}
}
- We created a new dictionary of dictionaries to hold all of the type configuration lookups (lines #9-48). This holds the same data as before, but in a single member variable.
- The top level entry is keyed by the repository type.
- Then the secondary dictionary contains the entity type to builder mappings.
- The
CreateRepository
method (lines #50-59) first checks if the repository source type is supported in the in lookup table. If not, we throw a specific exception. Otherwise, we get that repository’s mappings. Finally we callCreateSpecificRepository
with the appropriate type and mappings. - The
CreateSpecificRepository
method (lines #61-68) finds the appropriateIRepositoryBuilder
for the entity type. Then, it calls that particularIRepositoryBuilder.CreateRepository
method, and returns the result. This method can also throw an exception if the entity type to builder mapping does not exist in the nested dictionary.
Fixes to Repository Implementations
The Table Storage API query class is case dependent when it comes to property names. To this point, we have not had problems with that. But, as we started running some game services tests targeting the Table repository, we quickly found this issue. So to handle it, we changed all of the property name handling for filtering and sorting in the InMemoryReadRepository
to be case-insensitive, so that would be transparent to our consumers.
Here are the changes to the InMemoryReadRepository
class:
using SimpleRPG.Game.Services.DTO;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Repositories
{
public class InMemoryReadRepository<T, TId> : IReadableRepository<T, TId>
where T : NamedElement<TId>
where TId : struct
{
private readonly Dictionary<string, Func<T, string, bool>> _filterChecks = new Dictionary<string, Func<T, string, bool>>();
private readonly Dictionary<string, Func<T, object>> _sortChecks = new Dictionary<string, Func<T, object>>();
public InMemoryReadRepository(
IList<T> entities,
IList<NameValuePair<Func<T, string, bool>>> knownFilters = null,
IList<NameValuePair<Func<T, object>>> knownSorts = null)
: this()
{
Entities = entities ?? new List<T>();
if (knownFilters != null)
{
foreach (var f in knownFilters)
{
_filterChecks.Add(f.Name.ToLower(), f.Value);
}
}
if (knownSorts != null)
{
foreach (var s in knownSorts)
{
_sortChecks.Add(s.Name.ToLower(), s.Value);
}
}
}
public InMemoryReadRepository()
{
_filterChecks.Add("name", (f, v) => f.Name.Contains(v, StringComparison.InvariantCultureIgnoreCase));
_sortChecks.Add("name", p => p.Name);
_sortChecks.Add("id", p => p.Id);
}
protected virtual IList<T> Entities { get; set; } = new List<T>();
public virtual Task<IEnumerable<T>> GetEntities(
int? offset,
int? limit,
IEnumerable<NameValuePair<string>> filters,
IEnumerable<NameValuePair<string>> sorts)
{
var o = offset ?? 0;
var l = limit ?? Entities.Count;
var filtered = ApplyFilters(Entities, filters);
var sorted = GetSortedEntities(filtered, sorts);
return Task.FromResult(sorted.Skip(o).Take(l).ToList().AsEnumerable());
}
public virtual Task<int> GetEntityCount(IEnumerable<NameValuePair<string>> filters)
{
var filtered = ApplyFilters(Entities, filters);
return Task.FromResult(filtered.Count());
}
public virtual Task<T> GetEntityById(TId id)
{
var spell = Entities.FirstOrDefault(t => t.Id.Equals(id));
if (spell == null)
{
throw new EntityNotFoundException(nameof(id), id);
}
return Task.FromResult(spell);
}
public virtual Task<IEnumerable<T>> GetBulkEntitiesById(IEnumerable<TId> ids)
{
_ = ids ?? throw new ArgumentNullException(nameof(ids));
var entities = (List<T>)Entities;
var list = entities.FindAll(p => ids.Contains(p.Id));
return Task.FromResult(list.OrderBy(e => e.Name).ToList().AsEnumerable());
}
protected virtual IEnumerable<T> ApplyFilters(
IEnumerable<T> items,
IEnumerable<NameValuePair<string>> filters)
{
if (filters == null)
{
return items;
}
foreach(var f in filters)
{
if (_filterChecks.TryGetValue(f.Name.ToLower(), out var filterAction))
{
items = items.Where(p => filterAction.Invoke(p, f.Value)).ToList();
}
}
return items;
}
protected virtual IEnumerable<T> GetSortedEntities(
IEnumerable<T> items,
IEnumerable<NameValuePair<string>> sorts)
{
if (sorts == null || sorts.Any() == false)
{
if (_sortChecks.TryGetValue("id", out var sortAction))
{
items = items.OrderBy(p => sortAction.Invoke(p)).ToList();
}
return items;
}
foreach (var s in sorts)
{
if (_sortChecks.TryGetValue(s.Name.ToLower(), out var sortAction))
{
if (s.Value.ToLower() == "dsc")
{
items = items.OrderByDescending(p => sortAction.Invoke(p)).ToList();
}
else
{
items = items.OrderBy(p => sortAction.Invoke(p)).ToList();
}
}
}
return items;
}
}
}
For this fix, we just ensure that the in-memory filtering and sorting definitions are saved in lower case. This way we can have a safe lookup for the property handlers and the filter and sorting options. There are just several calls to ToLower
on corresponding strings.
And for TableQuery
, we can only apply one OrderBy
operation. Our original code applied them all and assumed the last one would take effect. But TableQuery
actually throws an exception when OrderBy
is called more than once. So to only apply the last sort operation, we had to make a slight fix to the TableStorageAdapter
(in the Helpers 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 && sorts.Any())
{
var sort = sorts.Last();
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);
}
}
}
This issue just required a couple of minor changes too. First, we check if any sorts are defined in the list (line #137), and then just use the last item in the list (line #139) in the case where there are more than one listed.
Extending Test Cases
As we did with our game service tests in lesson 6.4, we need to add test case variations for the test methods of each service test. This is repetitive so we will only show the changes made to the ItemTemplateServiceTests
class. All of the other service tests changes are similar and included in the commit for this lesson.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Tests.Helpers;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
namespace SimpleRPG.Game.Services.Tests.Services
{
public class ItemTemplateServiceTests : ServiceTestBase<ItemTemplate, int>
{
private readonly Func<HttpRequest, ILogger, Task<IActionResult>> funcGetItemTemplates =
(r, l) => ItemTemplateService.GetItemTemplates(r, l);
private readonly Func<HttpRequest, ILogger, Task<IActionResult>> funcGetItemTemplateCount =
(r, l) => ItemTemplateService.GetItemTemplateCount(r, l);
private readonly Func<HttpRequest, ILogger, int, Task<IActionResult>> funcGetItemTemplateById =
(r, l, id) => ItemTemplateService.GetItemTemplateById(r, l, id);
private readonly HttpRequest _request = new Mock<HttpRequest>().Object;
private readonly ILogger _logger = new Mock<ILogger>().Object;
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
[InlineData("table", "Pointy stick - Table")]
public async Task GetItemTemplates(string repoType, string expectedItemName) =>
await VerifyGetEntities(repoType, funcGetItemTemplates, 15, expectedItemName);
[Fact]
public async Task GetItemTemplates_WithNullRequest() =>
await VerifyGetEntities_WithNullRequest(funcGetItemTemplates);
[Fact]
public async Task GetItemTemplates_WithNullLogger() =>
await VerifyGetEntities_WithNullLogger(funcGetItemTemplates);
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
[InlineData("table", "Pointy stick - Table")]
public async Task GetItemTemplates_WithOffsetLimit(string repoType, string expectedItemName) =>
await VerifyGetEntities_WithOffsetLimit(
repoType, funcGetItemTemplates, 0, 2, 2, expectedItemName);
[Theory]
[InlineData("resource")]
[InlineData("blob")]
[InlineData("table")]
public async Task GetItemTemplateCount_WithFilters(string repoType)
{
// setup
var req = ServiceTestHelper.CreateFiltersQueryStringMock(repoType, "Category:0");
// test
var result = await ItemTemplateService.GetItemTemplateCount(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var res = (int)okRes.Value;
Assert.Equal(9, res);
}
[Theory]
[InlineData("resource")]
[InlineData("blob")]
[InlineData("table")]
public async Task GetItemTemplates_WithSorting(string repoType)
{
// setup
var req = ServiceTestHelper.CreateSortsQueryStringMock(repoType, "Category:asc|Price:asc");
// test
var result = await ItemTemplateService.GetItemTemplates(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var templateList = okRes.Value as IList<ItemTemplate>;
Assert.Equal(15, templateList.Count);
}
[Fact]
public async Task GetItemTemplateCount_WithNullRequest() =>
await VerifyGetEntityCount_WithNullRequest(funcGetItemTemplateCount);
[Fact]
public async Task GetItemTemplateCount_WithNullLogger() =>
await VerifyGetEntityCount_WithNullLogger(funcGetItemTemplateCount);
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
[InlineData("table", "Pointy stick - Table")]
public async Task GetItemTemplateById(string repoType, string expectedItemName) =>
await VerifyGetEntityById(repoType, funcGetItemTemplateById, 1001, expectedItemName);
[Theory]
[InlineData("resource")]
[InlineData("blob")]
[InlineData("table")]
public async Task GetItemTemplateById_WithInvalidId(string repoType) =>
await VerifyGetEntityById_WithInvalidId(repoType, funcGetItemTemplateById);
[Fact]
public async Task GetItemTemplateById_WithNullRequest() =>
await VerifyGetEntityById_WithNullRequest(funcGetItemTemplateById);
[Fact]
public async Task GetItemTemplateById_WithNullLogger() =>
await VerifyGetEntityById_WithNullLogger(funcGetItemTemplateById);
}
}
With the test class refactoring that we did earlier in this chapter, it was easy to add different test variations for the TableStorageReadRepository
usage. All we have to do is place another [InlineData]
attribute on the appropriate test methods. The Verify*
methods ensure that the expected results are retrieved from the specified repository.
Running Game Services for Table Repository
With our code and tests all in place, we can build and run (Ctrl + F5) our services to try out the table repository. Let’s try a few queries that target the table repository and see the results.
1. Retrieve all items (localhost:7071/api/item?repo=table):

2. Retrieve item by id (localhost:7071/api/item/1001?repo=table):

3. Retrieve all locations (localhost:7071/api/location?repo=table):

4. Retrieve item by id from blob repository (to show that still works): localhost:7071/api/item?repo=blob

As we can see, we are now getting game data from the Azure Tables Storage in addition to the repositories that we already supported.
In conclusion, we can now use Azure Table Storage to load our game data and expose it all the way through our web services. Consumers can now access that functionality by passing the "repo=table"
query string parameter. We encapsulated all of the data access logic and Table Storage integration into the TableStorageReadRepository
. And surface those typed repositories again through our RepositoryFactory
, with no additional logic changes needed in our game services code.
In the next several articles, we are going to look at another Azure storage technology: CosmosDB. It’s a NoSql document store that runs at internet scale.