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
OpenStorageTablemethod retrieves the object that represents a particular Table based on the account connection string and the table’s name. - The
CreateQueryFromOptionsmethod uses the filters and sorting options to build a specificTableQuerythat represent those options in Table Storage format. - The
CreateQueryFromIdsmethod builds aTableQueryto find multiple rows based on their id/RowKey. - The
ExecuteQuerymethod executes anyTableQueryand returns a list of results. It takes anEntityResolverfunction which transforms results into the specified type (this type is explained later in this article). - The
ExecuteOperationmethod runs a singleTableOperationand returns a single result. It also uses anEntityResolverfor 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
OpenStorageTablemethod (lines #21-39) calls all of the setup methods to parse the connection string into a valid storage account, then get theTableClientAPI 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
CreateQueryFromOptionsmethod (lines 41-50) just calls helper methods to adding a filtering query and sort order to theTableQueryobject. - The
CreateQueryFromIdsmethod (lines #52-69) loops through all of the specified Ids and creates the corresponding conditions string. Each id is mapped to theRowKeyin the query. And multiple conditions are ORed together. Finally the conditions are applied to theTableQuery.Whereclause and returned. - The
ExecuteQuerymethod (lines #72-93) uses theTableQueryobject in theCloudTable.Querymethod. And uses the offset and limit parameters to get just the specified amount of results. Then, we loop through those results and use theentityResolverto convert them to our expected results type. Finally, we return the converted list. - The
ExecuteOperationmethod (lines #95-113) uses theTableOperationobject in theCloudTable.ExecuteAsyncmethod. 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
ApplyFiltershelper 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.Whereclause. - The
ApplySortinghelper 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 theTableQueryobject. - The
GenerateFilterConditionhelper method (lines #155-189) is needed to ensure theTableQueryuses 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 likeGuidandDateTimeprepend 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
ToItemTemplatemethod (lines #11-33) validate that we have a valid input entity. Then it creates anItemTemplateinstance with the appropriate properties:- The
RowKeyis mapped to theIdproperty. - The
Nameproperty is straightforward. - The
CategoryandPriceproperties are converted to integer values. - The
DamageandHealsproperties 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
ToMonsterTemplatemethod (lines #35-62) is largely the same code, but creates aMonsterTemplateinstance 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 theConvertJsonPropertyhelper method to parse that text snippet and convert it into an array ofLootItem. - The
GetOptionalStringPropertymethod (lines #159-162) returns a null string if the property is not defined. - The
GetOptionalIntPropertymethod (lines #164-170) converts the property to anintor returns 0 if the property is not defined. - The
GetOptionalNullableIntPropertymethod (lines #172-178) returns a nullif the property is not defined; otherwise it retrieves theintvalue. - The
GetOptionalPropertymethod (lines #180-191) retrieves anEntityPropertyif 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
ConvertJsonPropertymethod (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
ITableAdapterto use, and the function that performs the entity resolution. We will tie all of these dependencies together in ourIRepositoryBuilderclasses (in the next lesson). - The
GetEntitiesmethod (lines #41-57) starts by opening the specified table. Then, it uses theITableAdapterto build the options query. Then, it executes the query. And finally, returns the resulting list. This method supports filtering, sorting, and pagination settings. - The
GetEntityCountmethod (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
GetEntityByIdmethod (lines #71-82) opens the specified table. Then, it creates a retrieval operation with the specified entity id mapped to RowKey. Then, it uses theITableAdapterto execute that operation. Finally, it returns the result to the caller. - The
GetBulkEntitiesByIdmethod (lines #84-94) works very much like theGetEntitiesmethod. We open the table that we are working on. Then, we use theITableAdapterto 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 theTableEntityResolverforToItemTemplate. - The
ItemTemplate_GetEntityCounttest method (lines #21-34):- Sets up the
TableStorageReadRepositoryfor theItemTemplatetype, and the connection string, table name, adapter, and entity resolver defined earlier. - Calls the
TableStorageReadRepository.GetEntityCountmethod. - Finally, validates we get the expected number of items.
- Sets up the
- The
ItemTemplate_GetEntitiestest method (lines #36-53) does very similar operations but callsTableStorageReadRepository.GetEntitiesinstead. 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