The code generated by Dapper.AOT
can be viewed in Visual Studio in the solution explorer, by expanding:
You can double-click on this .cs
file to see the contents. We’ll use the code from Getting Started to illustrate.
The code is broken down into three main pieces:
Interceptors are how Dapper.AOT changes your code; for example, in response to the source code (the bit you write):
public static Product GetProduct(DbConnection connection, int productId) => connection.QueryFirst<Product>(
"select * from Production.Product where ProductId=@productId", new { productId });
we see, in the generated file:
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute("C:\\Code\\DapperAOT\\test\\UsageLinker\\Product.cs", 14, 92)]
internal static global::UsageLinker.Product QueryFirst1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType)
{
// Query, TypedResult, HasParameters, SingleRow, Text, AtLeastOne, BindResultsByName, KnownParameters
// takes parameter: <anonymous type: int productId>
// parameter map: productId
// returns data: global::UsageLinker.Product
global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql));
global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text);
global::System.Diagnostics.Debug.Assert(param is not null);
return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryFirst(param, RowFactory0.Instance);
}
The [InterceptsLocation(...)]
usage here tells the build SDK to “intercept” the method call in the specified file/line/column (.QueryFirst<Product>(...)
in our case); this means that instead of calling Dapper’s QueryFirst<T>
method,
we actually call this generated code (it needs to have the same signature, note). The generated code uses a new API - DapperAotExtensions.Command
, which works similarly to Dapper, but note that the generated code
passes in CommandFactory0.Instance
and RowFactory0.Instance
- this is our command factory and row factory for this method.
A row factory is the code that deals with materializing results - in this case, creating a Product
from the DbDataReader
that ADO.NET provides, pushing data into the appropriate Product
fields/properties. We see:
private sealed class RowFactory0 : global::Dapper.RowFactory<global::UsageLinker.Product>
{
internal static readonly RowFactory0 Instance = new();
private RowFactory0() {}
public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span<int> tokens, int columnOffset)
{
for (int i = 0; i < tokens.Length; i++)
{
int token = -1;
var name = reader.GetName(columnOffset);
var type = reader.GetFieldType(columnOffset);
switch (NormalizedHash(name))
{
case 2521315361U when NormalizedEquals(name, "productid"):
token = type == typeof(int) ? 0 : 25; // two tokens for right-typed and type-flexible
break;
case 2369371622U when NormalizedEquals(name, "name"):
token = type == typeof(string) ? 1 : 26;
break;
// snip, more columns here
}
tokens[i] = token;
columnOffset++;
}
return null;
}
public override global::UsageLinker.Product Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan<int> tokens, int columnOffset, object? state)
{
global::UsageLinker.Product result = new();
foreach (var token in tokens)
{
switch (token)
{
case 0:
result.ProductID = reader.GetInt32(columnOffset);
break;
case 25:
result.ProductID = GetValue<int>(reader, columnOffset);
break;
case 1:
result.Name = reader.GetString(columnOffset);
break;
case 26:
result.Name = GetValue<string>(reader, columnOffset);
break;
// snip, more columns here
}
columnOffset++;
}
return result;
}
}
There are two fundamental operations Dapper.AOT uses for parsing rows:
Tokenize
looks at the columns returned from the database and identifies how to handle each (assuming it is recognized at all); for each expected column we generate two paths - one for the ideal case
where the data-type is what we expected, and one for when we might need some more flexibility in coercing data; this happens once per result, not per row (although in the case of QueryFirst
this
difference is perhaps moot)Read
iterates though the columns of the data-reader and populates a record, using the token data that we reported during Tokenize
; this happens once per rowA command factory is responsible for preparing a command for use with ADO.NET; most of the work here happens behind the scenes, so the generated code usually just has to handle parameters; we can see:
private sealed class CommandFactory0 : global::Dapper.CommandFactory<object?> // <anonymous type: int productId>
{
internal static readonly CommandFactory0 Instance = new();
public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args)
{
var typed = Cast(args, static () => new { productId = default(int) }); // expected shape
var ps = cmd.Parameters;
global::System.Data.Common.DbParameter p;
p = cmd.CreateParameter();
p.ParameterName = "productId";
p.DbType = global::System.Data.DbType.Int32;
p.Direction = global::System.Data.ParameterDirection.Input;
p.Value = AsValue(typed.productId);
ps.Add(p);
}
public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args)
{
var typed = Cast(args, static () => new { productId = default(int) }); // expected shape
var ps = cmd.Parameters;
ps[0].Value = AsValue(typed.productId);
}
public override bool CanPrepare => true;
}
Our original source code used an anonymous type; anonymous types cannot be referenced directly, even in “interceptor” code, so instead in this case we generate a command factory that processes object?
. We
need to generate code that configures the ADO.NET parameters from this object (we might also have code for in-place updating of parameters, post-processing parameters, etc). The first thing we need to do is
to use the Cast
call to get the value back as the anonymous type, so that we can access the values. Then it adds them following the usual ADO.NET rules. Note that UnifiedCommand
here is a Dapper.AOT
device that provides a common API over the DbCommand
and DbBatchCommand
APIs, so that this one method can work for both regular commands and the new “batch” command API.
The generated code isn’t scary. It might be lengthy for large projects, but each part is fairly simple and looks more or less like hand-written ADO.NET code; even the specialized hashing of the column names is clear, since we always need to check the actual column name (because of the risk of hash collisions). Depending on your exact usage, there may be some additional pieces that we haven’t explored here, but the intent is usually fairly clear (and the generated code contains explanatory comments, as shown).
The code looks a little unusual because it eschews using
directives, instead preferring to fully-qualify types (global::System.Data.IDbConnection
etc). This is because when dealing with arbitrary user code
it is impossible to rule out the chance of conflicts and ambiguities with user types. When generating code, using fully qualified syntax simply makes a lot of sense.
Using this approach:
Dapper
code works, but now with AOTDapper.AOT
code