From 4bc2bc9a52375cf594e39d2d497f20894fbc1e4c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 14 Nov 2023 12:33:04 +0000 Subject: [PATCH] docs --- docs/generatedcode.md | 173 +++++++++++++++++++++++++++++++++++++++++ docs/gettingstarted.md | 4 +- docs/readme.md | 1 + 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 docs/generatedcode.md diff --git a/docs/generatedcode.md b/docs/generatedcode.md new file mode 100644 index 00000000..9090efed --- /dev/null +++ b/docs/generatedcode.md @@ -0,0 +1,173 @@ +# Generated Code + +The code generated by `Dapper.AOT` can be viewed in Visual Studio in the solution explorer, by expanding: + +- (your project) + - Dependencies + - Analyzers + - Dapper.AOT.Analyzers + - Dapper.CodeAnalysis.DapperInterceptorGenerator + - (your project).generated.cs + +You can double-click on this `.cs` file to see the contents. We'll use the code from [Getting Started](/gettingstarted) to illustrate. + +The code is broken down into three main pieces: + +- interceptors +- row factories +- command factories + +## Interceptors + +Interceptors are how Dapper.AOT changes your code; for example, in response to the *source* code (the bit you write): + +``` csharp +public static Product GetProduct(DbConnection connection, int productId) => connection.QueryFirst( + "select * from Production.Product where ProductId=@productId", new { productId }); +``` + +we see, in the generated file: + +``` csharp +[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: + // 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(...)` in our case); this means that *instead of* calling Dapper's `QueryFirst` 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. + +## Row Factories + +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: + +``` csharp +private sealed class RowFactory0 : global::Dapper.RowFactory +{ + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span 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 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(reader, columnOffset); + break; + case 1: + result.Name = reader.GetString(columnOffset); + break; + case 26: + result.Name = GetValue(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 *row* + +## Command Factories + +A 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: + +``` csharp +private sealed class CommandFactory0 : global::Dapper.CommandFactory // +{ + 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`](https://learn.microsoft.com/dotnet/api/system.data.common.dbcommand) and [`DbBatchCommand`](https://learn.microsoft.com/dotnet/api/system.data.common.dbbatchcommand) +APIs, so that this one method can work for both regular commands and the new "batch" command API. + +## Final notes + +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). + +Using this approach: + +1. you don't need to change your code; your existing `Dapper` code works, but now with AOT +2. we don't need to generate any code at runtime - all the code to handle queries, rows and parameters: exists +3. we don't need a strategy cache; each call *knows in advance* which row factories and command factories will be used +4. linkers and trimmers can see all the code that is being used, including code that isn't needed by your *application* code but which *is* used by the generated `Dapper.AOT` code +5. consumers can have confidence that nothing nefarious is going on in the generated code diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 907bfb27..186b87c3 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -138,8 +138,8 @@ Build succeeded. 0 Error(s) ``` -That's.. underwhelming, but: a lot is going on under the covers. The fact that you didn't need to change your code is intentional. Your data-access code is now -working build build-time support, and should work with AOT deployment. +That's.. underwhelming, but: [a lot is going on behind the scenes](/generatedcode). The fact that you didn't need to change your code is intentional. Your data-access code is now +working with build-time code generation, and should work with AOT deployment. ## SQL Analysis diff --git a/docs/readme.md b/docs/readme.md index 942fa61e..a8500576 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -7,6 +7,7 @@ Index: - [Getting Started](/gettingstarted) - [SQL Syntax](/sqlsyntax) +- [Generated Code](/generatedcode) - [Frequently Asked Questions](/faq) Packages: