Skip to content

Instance Type Resolvers

Paul Stovell edited this page May 5, 2020 · 12 revisions

ℹ️ 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.

Example

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

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.

Querying

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);

Graceful fallbacks

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.

Other types of... Types?

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.