-
Notifications
You must be signed in to change notification settings - Fork 11
Instance Type Resolvers
ℹ️ Not sure if you need an instance type resolver? Learn about the different ways to extend mapping in Nevermore.
When you query a document, Nevermore will call the JSON serializer and ask it to deserialize your document into the .NET type that your document map is for.
However, there may be times where you want to return a different type. An example of this is where you use inheritance to allow parts of your application to be extended. For example, in Octopus, we want extensions to be able to provide different types of accounts. We have a base class, and a table, but we don't know the implementations.
To model a scenario like this, the rules are:
- All documents in the hierarchy must be stored in the same table
- There should only be one
DocumentMap
, for the base type - Your table or select statements must return a column named
Type
, and it must come before your[JSON]
column
You can then provide an IInstanceTypeResolver
that returns different types depending on the value of the Type
column.
As an example, let's imagine we have this document model:
abstract class Account
{
public string Id { get; set; }
public string Name { get; set; }
// This property doesn't need to exist on the document, but you
// do at least need a column in the result set called `Type` which
// is a string. Alternatively, you can define it here and map it. If
// you map it, it doesn't have to be called Type, and you can use
// enums or other types to manage it. See the bottom of this page
// for details.
public abstract string Type { get; }
}
class AzureAccount : Account
{
public string AzureSubscriptionId { get; set; }
public override string Type => "Azure";
}
class AwsAccount : Account
{
public string SecretKey { get; set; }
public override string Type => "AWS";
}
We are going to store it on this table:
create table Account
(
Id nvarchar(200),
Name nvarchar(200),
Type nvarchar(50),
[JSON] nvarchar(max)
)
Note that the Type
property is stored as a column, and it comes before the [JSON]
column.
Our DocumentMap
is:
class AccountMap : DocumentMap<Account>
{
public AccountMap()
{
// You could define the property with a different name, and just map
// it to a column called Type if you want
Column(a => a.Name);
TypeColumn(a => a.Type).SaveOnly();
}
}
You then provide an IInstanceTypeResolver
that knows how to map the different values in the Type
column to different CLR types.
You could do this in a few ways. You could have a single type resolver that knows how to map all values in the Type
column to every possible concrete type. That might make sense if all the documents are defined in the same codebase.
Alternatively, you can create handlers for each document. They will be called in order, and each gets a chance to see if it knows how to map the given type.
class AwsAccountTypeResolver : IInstanceTypeResolver
{
public Type Resolve(Type baseType, object typeColumnValue)
{
if (!typeof(Account).IsAssignableFrom(baseType))
return null;
if ((string) typeColumnValue == "AWS")
return typeof(AwsAccount);
return null;
}
}
class AzureAccountTypeResolver : IInstanceTypeResolver
{
public Type Resolve(Type baseType, object typeColumnValue)
{
if (!typeof(Account).IsAssignableFrom(baseType))
return null;
if ((string) typeColumnValue == "Azure")
return typeof(AzureAccount);
return null;
}
}
Writing data is easy - just new up the types and save them as you would expect.
using var transaction = Store.BeginTransaction();
transaction.Insert(new AwsAccount { SecretKey = "keys9812"});
transaction.Insert(new AzureAccount { AzureSubscriptionId = "sub128721"});
transaction.Commit();
Updating works the same way.
You can load them back out with the concrete type:
transaction.Load<AwsAccount>("Accounts-1").SecretKey.Should().Be("keys9812");
Or you can load them with the base type, but the result will be a concrete type:
transaction.Load<Account>("Accounts-1").Should.BeOfType<AwsAccount>();
You can query against the base class, or against the concrete classes:
// Get all accounts, no matter the type
var accounts = transaction.Query<Account>().ToList();
// Just AWS accounts. You can do this, but it will actually read all `Account` objects, then
// discard those that don't match the type. So we pay the cost to fetch and
// deserialize them just to ignore them.
var accounts = transaction.Query<AwsAccount>().ToList();
// A faster way is to query against the type - it's a column after all
accounts = transaction.Query<AwsAccount>().Where(a => a.Type == "AWS").ToList();
accounts.Count.Should().Be(1);
When reading, if the value of the Type
column is null for a given row, Nevermore will not consult the type resolvers, and instead continue reading using the type you queried. If your base type is abstract, you'll get:
Type is an interface or abstract class and cannot be instantiated
If Nevermore reads a non-null Type
value, but can't find a concrete type (that is, no instance type resolver is registered that knows how to handle the value), it will throw a nice exception:
The type column has a value of 'dunno' (String), but no type resolver was able to map it to a concrete type to deserialize. Either register an instance type resolver that knows how to interpret the value, or consider fixing the data.
This is probably what you want if you provide all the types, as it probably means a bug in your data, or in your code.
However, if you're doing some kind of extensibility, you might want to have a more graceful fallback.
Type resolvers are called in order, so you can register a "fallback" resolver that has a higher Order
property (the default is 0, and they are called in order):
class UnknownAccount : Account
{
public override string Type => "?";
}
class UnknownAccountTypeResolver : IInstanceTypeResolver
{
// Runs after all other type handlers
public int Order => int.MaxValue;
public Type Resolve(Type baseType, object typeColumnValue)
{
if (!typeof(Account).IsAssignableFrom(baseType))
return null;
return typeof(UnknownAccount);
}
}
When this is registered, you'll get an UnknownAccount
for any account where the Type
property is unrecognized. Your UI logic can then handle this special type.
If you provide a column mapping for the Type
column, then it doesn't need to be a string. The logic looks something like this:
while reading the data reader...
if columnName == "Type":
is there a column mapping?
yes ->
great! we can see it's mapped to an enum or some other type
Are there any ITypeHandlers registered for that type?
There are! Call them. Now instead of a string (or int,
or whatever the DB column is) we have our concrete type.
Now look for an InstanceTypeResolver that can process the type,
and pass it the enum, class or whatever that the TypeHandler returned
no ->
assume it's a string, and call the InstanceTypeResolver
This means that if you have a base class like:
class Account
{
public AccountType Type { get; }
}
And it's mapped as a column in your DocumentMap
:
class AccountMap : DocumentMap<Account>
{
public AccountMap()
{
// ...
Column(a => a.Type).SaveOnly();
}
}
Your IInstanceTypeResolver
will be passed an enum instead of the raw string from the database.
If you have a custom ITypeHandler
for the property type (e.g., a Uri or tiny type), your ITypeHandler
will be called first, can convert the DB type to the CLR type, then your IInstanceTypeResolver
will get that converted value.
Overview
Getting started
Extensibility
Misc