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:
- The
OpenStorageTable
method retrieves the object that represents a particular Table based on the account connection string and the table’s name. - The
CreateQueryFromOptions
method uses the filters and sorting options to build a specificTableQuery
that represent those options in Table Storage format. - The
CreateQueryFromIds
method builds aTableQuery
to find multiple rows based on their id/RowKey. - The
ExecuteQuery
method executes anyTableQuery
and returns a list of results. It takes anEntityResolver
function which transforms results into the specified type (this type is explained later in this article). - The
ExecuteOperation
method runs a singleTableOperation
and returns a single result. It also uses anEntityResolver
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 theTableClient
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 theTableQuery
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 theRowKey
in the query. And multiple conditions are ORed together. Finally the conditions are applied to theTableQuery.Where
clause and returned. - The
ExecuteQuery
method (lines #72-93) uses theTableQuery
object in theCloudTable.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 theentityResolver
to convert them to our expected results type. Finally, we return the converted list. - The
ExecuteOperation
method (lines #95-113) uses theTableOperation
object in theCloudTable.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 theentityResolver
. 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 theTableQuery.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 theTableQuery
object. - The
GenerateFilterCondition
helper method (lines #155-189) is needed to ensure theTableQuery
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 likeGuid
andDateTime
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 anItemTemplate
instance with the appropriate properties:- The
RowKey
is mapped to theId
property. - The
Name
property is straightforward. - The
Category
andPrice
properties are converted to integer values. - The
Damage
andHeals
properties are optional (meaning they may not have values in theDynamicTableEntry
), so we have helper methods to deal with that. - Finally, if there are any exceptions during the conversion we rethrow them as
FormatExceptions
.
- The
- The
ToMonsterTemplate
method (lines #35-62) is largely the same code, but creates aMonsterTemplate
instance instead. The additional interesting property isLootItems
, which is an array of objects. In our Table, this is represented as a snippet of JSON. So, we use theConvertJsonProperty
helper method to parse that text snippet and convert it into an array ofLootItem
. - 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 anint
or returns 0 if the property is not defined. - The
GetOptionalNullableIntProperty
method (lines #172-178) returns a nullint
value. - The
GetOptionalProperty
method (lines #180-191) retrieves anEntityProperty
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 theJsonSerializer
, 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:
- 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 ourIRepositoryBuilder
classes (in the next lesson). - The
GetEntities
method (lines #41-57) starts by opening the specified table. Then, it uses theITableAdapter
to build the options query. Then, it executes the query. And finally, returns the resulting list. This method supports filtering, sorting, and pagination settings. - 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. - 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 theITableAdapter
to execute that operation. Finally, it returns the result to the caller. - The
GetBulkEntitiesById
method (lines #84-94) works very much like theGetEntities
method. We open the table that we are working on. Then, we use theITableAdapter
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 theTableEntityResolver
forToItemTemplate
. - The
ItemTemplate_GetEntityCount
test method (lines #21-34):- Sets up the
TableStorageReadRepository
for theItemTemplate
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.
- Sets up the
- The
ItemTemplate_GetEntities
test method (lines #36-53) does very similar operations but callsTableStorageReadRepository.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.
Where did you define IReadableRepository?
LikeLike
That is defined in the following article: https://darthpedro.net/2020/12/02/lesson-5-6-build-repository-pattern-for-data-access/
LikeLike