Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dapper - Handling custom mapping for ddd entity with read-only fields via constructor

Tags:

c#

orm

dapper

I have the following class which i am trying to hydrate to:

public class Product
{
    public readonly Sku Sku;
    public string Name { get; private set; }
    public string Description { get; private set; }
    public bool IsArchived { get; private set; }

    public Product(Sku sku, string name, string description, bool isArchived)
    {
        Sku = sku;
        Name = name;
        Description = description;
        IsArchived = isArchived;
    }
}

Which uses the below classes that implement concepts from my DDD entity domain model (non-relevant code removed to keep code short, set as readonly to make immutable once constructed):

public class Sku
{
    public readonly VendorId VendorId;
    public readonly string SkuValue;

    public Sku(VendorId vendorId, string skuValue)
    {
        VendorId = vendorId;
        SkuValue = skuValue;
    }
}

public class VendorId
{
    public readonly string VendorShortname;

    public VendorId(string vendorShortname)
    {
        VendorShortname = vendorShortname;
    }
}

I try and run the parameterised query which will hydrate in to a Product object:

using (connection)
{
    connection.Open();
    return connection.QueryFirst<Product>(ReadQuery, new { VendorId = sku.VendorId.VendorShortname, SkuValue = sku.SkuValue });
}

It throws the following exception as it doesn't know how to deal with the Sku type in the constructor:

System.InvalidOperationException: 'A parameterless default constructor or one matching signature (System.String VendorId, System.String SkuValue, System.String Name, System.String Description, System.UInt64 IsArchived) is required for Domain.Model.Products.Product materialization'

I looked into using a custom SqlMapper.TypeHandler<Product> but the Parse(object value) only ever passes in a single parsed value from the VendorId database column (if it passed in an array of values here I could do the mapping myself).

Is there a way to customize the handling of the object so that I can pass all the parameters in to the constructor like the below:

using (connection)
{
    var command = connection.CreateCommand();
    command.CommandText = "SELECT VendorShortname, SkuValue, Name, Description, IsArchived FROM Products WHERE VendorShortname=@VendorShortname AND SkuValue=@SkuValue";
    command.Parameters.AddWithValue("@VendorShortname", sku.VendorId.VendorShortname);
    command.Parameters.AddWithValue("@SkuValue", sku.SkuValue);
    connection.Open();

    var reader = command.ExecuteReader();
    if (reader.HasRows==false)
        return null;

    reader.Read();

    return new Product(
        new Sku(new VendorId(reader.GetString("VendorId")),reader.GetString("SkuValue")),
        reader.GetString("Name"),
        reader.GetString("Description"),
        reader.GetBoolean("IsArchived"));
}

I guess I could create a specific constructor with Product(string VendorShortname, string SkuValue, string Name, string Description, UInt64 IsArchived) but I would rather (must) have this concern in the mapping code rather than in my domain model.

Going over some pseudocode, what I could do is roll my own ORM, but would want to instead do similar via Dapper.

  1. Get all constructors for object by reflection
  2. If any parameters in constructor is a type, get its constructors
  3. For each constructor (including parameters), map the constructor name to the SQL reader column (and type)

This would equate to VendorShortname used for VendorId(string vendorShortname), and Name, Description, isArchived used for public Product(Sku sku, string name, string description, bool isArchived)... something is similarly done by MongoDB as per my answer posted at the following link, a Dapper manual mapping equivalent would be awesome MongoDB Composite Key: InvalidOperationException: {document}.Identity is not supported

like image 442
morleyc Avatar asked Aug 12 '17 20:08

morleyc


1 Answers

Execute a query and map it to a list of dynamic objects

public static IEnumerable<dynamic> Query (
    this IDbConnection cnn, 
    string sql, 
    object param = null, 
    SqlTransaction transaction = null, 
    bool buffered = true
)

You would then construct the desired model using the list of dynamic objects.

So using the example from the original post, the parameterised query would be changed from...

using (connection)
{
    var command = connection.CreateCommand();
    command.CommandText = "SELECT VendorShortname, SkuValue, Name, Description, IsArchived FROM Products WHERE VendorShortname=@VendorShortname AND SkuValue=@SkuValue";
    command.Parameters.AddWithValue("@VendorShortname", sku.VendorId.VendorShortname);
    command.Parameters.AddWithValue("@SkuValue", sku.SkuValue);
    connection.Open();

    var reader = command.ExecuteReader();
    if (reader.HasRows==false)
        return null;

    reader.Read();

    return new Product(
        new Sku(new VendorId(reader.GetString("VendorId")),reader.GetString("SkuValue")),
        reader.GetString("Name"),
        reader.GetString("Description"),
        reader.GetBoolean("IsArchived"));
}

To...

var ReadQuery = "SELECT VendorShortname, SkuValue, Name, Description, IsArchived FROM Products WHERE VendorShortname=@VendorShortname AND SkuValue=@SkuValue";
using (connection) {
    connection.Open();
    return connection.Query(ReadQuery, new { VendorShortname = sku.VendorId.VendorShortname, SkuValue = sku.SkuValue })
            .Select(row => new Product(
                new Sku(new VendorId(row.VendorShortname), row.SkuValue),
                row.Name,
                row.Description,
                row.IsArchived)
            );
}

Which is the intended purpose of the framework. Just make sure that the properties used directly map to the fields returned by the query.

This may seem intensive but this is a viable solution given the complex nature of the target object's constructor.

like image 156
Nkosi Avatar answered Sep 30 '22 13:09

Nkosi