Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions notes/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ Two levers change several complexity scores and are worth naming up front:
| enum results (string→enum case-insens., widening, `ShortEnum`) | ⚠️❓ | high | low-med | Dapper recently changed precedence (prefer type handlers, #2200) — match the *new* behavior |
| `MatchNamesWithUnderscores` | ❓ | med-high | low | snake_case databases; needs a compile-time equivalent (global option/attr) |
| `SetTypeMap` / `CustomPropertyTypeMap` / `ITypeMap` / `TypeMapProvider` | ❌ 🚫? | med | med | runtime config by definition; AOT spelling is `[Column]`+`[UseColumnAttribute]`. Proposal: declare 🚫 for the runtime API, ship attribute equivalents + migration guidance |
| `AddTypeHandler` / `TypeHandler<T>` / `StringTypeHandler` | ❌→⚠️ | **high** | med-high | AOT has its own `TypeHandler<T>`; needs the unification story (how does a *Dapper* handler registration become an AOT one?) |
| `AddTypeMap` / `RemoveTypeMap` (scalar DbType map) | ❌ ❓ | low-med | low | e.g. `DateTime`→`DateTime2`; global compile-time option |
| `AddTypeHandler` / `TypeHandler<T>` / `StringTypeHandler` | | | | runtime registrations honored end-to-end: writes dispatch through vanilla's `LookupDbType` (unrecognized member types), reads through a generated-code bridge into the AOT readers (the lib cannot reference Dapper: StrongName would split the registry). Whole-type handlers override generated row factories, matching vanilla. See [typehandlers-design.md](typehandlers-design.md); the announced-attribute tier (static dispatch, prior art #117/#162) remains as an optimization |
| `AddTypeMap` / `RemoveTypeMap` (scalar DbType map) | ⚠️ | low-med | low | honored for member types the generator does not recognize (they route through `LookupDbType` at execution); *recognized* scalars (the string→AnsiString tests) keep their baked DbType — honoring those means a per-parameter lookup on the hottest types, a trade to take explicitly |
| `Settings.ApplyNullValues` | ❓ | low | low | |
| coercion matrix (`char`, `Nullable<T>`, `Convert.ChangeType` fidelity) | ❓ | high | med | silent-wrongness risk; test-driven, differential against Dapper |
| column-level error reporting (`ThrowDataException` names column+value) | ❓ | med | low | DX parity worth keeping |
Expand Down
98 changes: 98 additions & 0 deletions notes/typehandlers-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Type handlers: the unification story

## What the failing tests actually are

"TypeHandlerTests ×16/provider" decomposes into four families; only the first two are
type-handler work:

1. **Runtime `AddTypeHandler` registrations** (Issue136, Issue1959 ×2, Issue461,
Issue253 ×2, SO24740733 ×2, EnumTypeHandler-preference): the suite registers handlers
at runtime; write-side members bind raw ("No mapping exists from object type
LocalDate…" from the provider) and read-side members never consult the handler.
Issue253 is the sharp one: a *handled collection type* — vanilla checks handlers
**before** list expansion, and our #197 expansion now wins incorrectly.
2. **`AddTypeMap`** (AnsiString ×2): runtime remap of a *recognized* scalar's DbType.
3. **`SetTypeMap`/`CustomPropertyTypeMap`** (TestCustomTypeMap, Test_RemoveTypeMap):
runtime *column-name mapping* — genuinely incompatible with compile-time row
factories; the parity table's 🚫-proposal stands (decision needed).
4. **Coercion tail wearing the wrong filename** (TestBigIntForEverythingWorks: enum
from float/double column needs the pre-convert vanilla does; Issue149 strictness):
not handler work at all.

## Tier 1: runtime dispatch, delegating to vanilla's own decision procedure

`SqlMapper.LookupDbType(Type, name, demand, out ITypeHandler)` is public and
`[Obsolete(…, false)]` — the same suppressible tier as `PackListParameters`, and it *is*
the whole vanilla decision: handlers, the `AddTypeMap` remap, LinqBinary,
`Settings.PreferTypeHandlersForEnums`, and `EnumerableMultiParameter` (i.e. the
handler-before-expansion ordering), evaluated at execution time. `ITypeHandler` itself
(`SetValue(IDbDataParameter, object)` / `Parse(Type, object)`) is public and
non-obsolete. (`TypeHandlerCache<T>` is obsolete-as-**error** — unusable from generated
C#, which is why vanilla can only call it from IL; no new Dapper API is needed, so no
DAP052 gate.)

- **Write, unknown member type** (today: raw `p.Value = …`, provider throws): emit
`LookupDbType(typeof(X), name, demand: true, out var handler)`; handler present →
`handler.SetValue(p, value)` with the **raw** value (null stays null — Issue1959 pins
that the handler sees null; vanilla only sanitizes on the non-handler path); else
apply the returned DbType if any and bind as today. `demand: true` also restores
vanilla's *"The member X of type Y cannot be used as a parameter value"* — which is
exactly what MiscTests.TestUnexpectedDataMessage pins, so that clears too.
- **Write, expandable member**: same lookup *first*; handler present → single handled
parameter, else `PackListParameters` (vanilla's ordering; fixes Issue253).
- **Write, enum member**: branch on `Settings.PreferTypeHandlersForEnums` (static bool,
default false — cheap short-circuit) before the baked enum path.
- **Read, unknown member/result type**: `LookupDbType(typeof(X), "", demand: false,
out var handler)`; handler present → `(X)handler.Parse(typeof(X), reader.GetValue(i))`,
else the current `As<X>` fallback. Per-row lookup for tier 1 — registrations are
mutable (the suite re-registers), so per-shape caching is a later optimization with a
staleness story, not a first cut. Covers constructor binding (Issue461) and the
single-column scalar form (SO24740733).

Deliberately *not* in tier 1:

- **`AddTypeMap` on recognized scalars** (the AnsiString pair): honoring it means every
string member pays a runtime lookup where today the DbType is baked. Possible, small,
but a per-parameter cost on the most common parameter type — decision to take
explicitly rather than slip in.
- **`SetTypeMap` family**: propose 🚫 (runtime column-mapping vs compile-time row
factories); the attribute equivalents (`[Column]` + `[UseColumnAttribute]`) are the
AOT spelling.

## Tier 2: the announced-attribute layer (compile-time)

`[TypeHandler<TValue, THandler>]` and `TypeHandler<T>` already ship in Dapper.AOT — the
generator just never consults them (dormant API). Wiring them gives static dispatch
(no lookup, no mutable registry, trim-friendly) and is the AOT-strict spelling to point
people at. Prior art: external PRs #117 (samcragg — the attribute shape, plus a
`Read(DbDataReader, int)` addition to `TypeHandler<T>`) and #162 (7amou3 — static
per-file handler instances instead of per-call `new`). Both are the right *shape*;
neither implementation can land as-is post-phase-2: #162's `TypeHandlerInstanceRegistry`
keys a dictionary on `INamedTypeSymbol` inside generator state, which is exactly the
Roslyn-objects-in-cached-state trap the plain-data model exists to prevent (ModelShapeTests
enforces it). Tier 2 = their design, re-done as plain-data plans, with credit.

Tier 1 first: it is what the test suite actually measures, needs no consumer changes,
and works with every shipped Dapper.

## Outcomes (recorded after implementation)

- **695 -> 705/793**: the whole runtime-handler family cleared (Issue136, Issue1959 x4,
Issue253 x2, Issue461, SO24740733 x2, Issue149, the enum-preference test), plus the bare
`DataTable` TVP pair and the Xml tests - vanilla registers `DataTableHandler` and the XML
handlers *by default*, so the dispatch reaches them for free.
- **`demand: false`, not vanilla's `demand: true`**, deliberately: when nothing matches we
keep the previous raw bind, because modern providers natively handle types vanilla's map
does not (DateOnly until the Dapper re-enable ships being the live case). Message parity
for genuinely-unusable types (TestUnexpectedDataMessage) is deferred to that bump.
- **A handler receives DBNull, never null** - `SqlMapper.TypeHandler<T>`'s explicit
interface impl special-cases DBNull and NREs on a raw null (struct cast); vanilla's IL
coalesces first, so we do too.
- **`char`/`char?` stay excluded from dispatch**: their map entry is StringFixedLength
*with* SetType, and applying it pads the round-trip (TestCharInputAndOutput). Vanilla
converts char members to length-1 strings on the way out - coercion-tail work, not
handler work.
- **The build-exit lesson, again**: the first measurement showed zero movement because the
harness build had silently failed (generated `typeof` on an annotated reference type is
CS8639, on `dynamic` CS1962 - hence `ParamMember.TypeOfName`, mirroring `RowMember`'s)
and `--no-build` ran stale binaries. Check the exit code, not the presence of output.
118 changes: 117 additions & 1 deletion src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ internal static InterceptorEnvironment CreateEnvironment(Compilation compilation
allowUnsafe: compilation.Options is CSharpCompilationOptions cSharp && cSharp.AllowUnsafe,
assemblyName: compilation.AssemblyName,
hasInterceptsLocationAttribute: PreGeneratedCodeWriter.HasInterceptsLocationAttribute(compilation),
hasModuleInitializer: compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.ModuleInitializerAttribute") is not null,
hasVanillaTypeHandlers: compilation.GetTypeByMetadataName("Dapper.SqlMapper") is { } sqlMapper
&& !sqlMapper.GetMembers("HasTypeHandler").IsEmpty
&& !sqlMapper.GetMembers("LookupDbType").IsEmpty,
needsCommandPrep: needsCommandPrep,
baseCommandFactoryName: baseFactory,
baseFactoryCanConstruct: canConstruct,
Expand Down Expand Up @@ -589,6 +593,18 @@ internal void Generate(in GenerateState ctx)

sb.Outdent().Outdent(); // ends our generated file-scoped class and the namespace

if (env.HasVanillaTypeHandlers)
{
// runtime SqlMapper.AddTypeHandler registrations reach the AOT readers through a
// bridge installed from generated code, which compiles against the consumer's own
// Dapper (the lib cannot reference it: Dapper vs Dapper.StrongName would split)
ctx.GeneratorContext.IncludeGenerationType(IncludedGeneration.TypeHandlerBridge);
if (!env.HasModuleInitializer)
{
ctx.GeneratorContext.IncludeGenerationType(IncludedGeneration.ModuleInitializerAttribute);
}
}

var preGeneratedCodeWriter = new PreGeneratedCodeWriter(sb, env.HasInterceptsLocationAttribute);
preGeneratedCodeWriter.Write(ctx.GeneratorContext.IncludedGenerationTypes);

Expand Down Expand Up @@ -1329,9 +1345,77 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co
// string_split settings, and provider array support
flags &= ~WriteArgsFlags.CanPrepare; // parameter shape varies by list size
sb.Append("#pragma warning disable CS0618 // list-expansion: this *is* the library usage").NewLine()
.Append("_ = global::Dapper.SqlMapper.LookupDbType(typeof(").Append(member.TypeOfName)
.Append("), ").AppendVerbatimLiteral(member.DbName)
.Append(", false, out var typeHandler").Append(member.CodeName).Append(");").NewLine()
.Append("// a runtime type-handler for the collection type wins over expansion,").NewLine()
.Append("// which is the order vanilla's own decision procedure applies").NewLine()
.Append("if (typeHandler").Append(member.CodeName).Append(" is not null)").Indent().NewLine()
.Append("var hp = cmd.CreateParameter();").NewLine()
.Append("hp.ParameterName = ").AppendVerbatimLiteral(member.DbName).Append(";").NewLine()
.Append("hp.Direction = global::System.Data.ParameterDirection.Input;").NewLine()
.Append("typeHandler").Append(member.CodeName).Append(".SetValue(hp, (object?)")
.Append(source).Append(".").Append(member.CodeName).Append(" ?? global::System.DBNull.Value);").NewLine()
.Append("ps.Add(hp);").Outdent().NewLine()
.Append("else").Indent().NewLine()
.Append("global::Dapper.SqlMapper.PackListParameters(cmd.Command!, ").AppendVerbatimLiteral(member.DbName)
.Append(", ").Append(source).Append(".").Append(member.CodeName).Append(");").NewLine()
.Append(", ").Append(source).Append(".").Append(member.CodeName).Append(");").Outdent().NewLine()
.Append("#pragma warning restore CS0618").NewLine();
break;
}
if (!member.HasDbType && !member.IsDbString && member.TypeOfName != "object" && member.TypeOfName != "char")
{
// an unrecognized member type: defer to vanilla's own decision procedure at
// execution time - runtime type-handlers (SqlMapper.AddTypeHandler), the
// AddTypeMap remap, and PreferTypeHandlersForEnums all live in there.
// demand:false, deliberately: when nothing matches we keep today's raw bind,
// because modern providers natively handle types vanilla's map does not
// (DateOnly, until Dapper ships the re-enable) - revisit for message parity
flags &= ~WriteArgsFlags.CanPrepare;
var suffix = member.CodeName;
sb.Append("#pragma warning disable CS0618 // vanilla's decision procedure: this *is* the library usage").NewLine()
.Append("var dbType").Append(suffix).Append(" = global::Dapper.SqlMapper.LookupDbType(typeof(")
.Append(member.TypeOfName).Append("), ").AppendVerbatimLiteral(member.DbName)
.Append(", false, out var typeHandler").Append(suffix).Append(");").NewLine()
.Append("#pragma warning restore CS0618").NewLine();
sb.Append("p = cmd.CreateParameter();").NewLine();
sb.Append("p.ParameterName = ").AppendVerbatimLiteral(member.DbName).Append(";").NewLine();
AppendDbParameterSetting(sb, "Size", member.EffectiveSize);
AppendDbParameterSetting(sb, "Precision", member.Precision);
AppendDbParameterSetting(sb, "Scale", member.Scale);
sb.Append("p.Direction = global::System.Data.ParameterDirection.").Append(direction switch
{
ParameterDirection.Input => nameof(ParameterDirection.Input),
ParameterDirection.InputOutput => nameof(ParameterDirection.InputOutput),
ParameterDirection.Output => nameof(ParameterDirection.Output),
ParameterDirection.ReturnValue => nameof(ParameterDirection.ReturnValue),
_ => direction.ToString(),
}).Append(";").NewLine();
sb.Append("if (typeHandler").Append(suffix).Append(" is not null)").Indent().NewLine()
.Append("typeHandler").Append(suffix).Append(".SetValue(p, (object?)")
.Append(source).Append(".").Append(member.CodeName).Append(" ?? global::System.DBNull.Value);").Outdent().NewLine()
.Append("else").Indent().NewLine()
.Append("if (dbType").Append(suffix).Append(" is not null) p.DbType = dbType").Append(suffix).Append(".GetValueOrDefault();").NewLine();
switch (direction)
{
case ParameterDirection.Input:
case ParameterDirection.InputOutput:
sb.Append("p.Value = AsValue(").Append(source).Append(".").Append(member.CodeName).Append(");").NewLine();
break;
default:
sb.Append("p.Value = global::System.DBNull.Value;").NewLine();
break;
}
sb.Outdent().NewLine();
sb.Append("ps.Add(p);").NewLine();
switch (direction)
{
case ParameterDirection.InputOutput:
case ParameterDirection.Output:
case ParameterDirection.ReturnValue:
flags |= WriteArgsFlags.NeedsPostProcess;
break;
}
break;
}
sb.Append("p = cmd.CreateParameter();").NewLine();
Expand Down Expand Up @@ -1419,6 +1503,38 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co
break;
}

if (!member.HasDbType && !member.IsDbString && member.TypeOfName != "object" && member.TypeOfName != "char")
{
// mirror the Add-mode runtime dispatch; the parameter shape is stable
// (always exactly one), so command reuse stays legal
sb.Append("#pragma warning disable CS0618 // vanilla's decision procedure: this *is* the library usage").NewLine()
.Append("_ = global::Dapper.SqlMapper.LookupDbType(typeof(").Append(member.TypeOfName)
.Append("), ").AppendVerbatimLiteral(member.DbName)
.Append(", false, out var typeHandler").Append(member.CodeName).Append(");").NewLine()
.Append("#pragma warning restore CS0618").NewLine()
.Append("if (typeHandler").Append(member.CodeName).Append(" is not null)").Indent().NewLine()
.Append("typeHandler").Append(member.CodeName).Append(".SetValue(ps[");
if ((flags & WriteArgsFlags.NeedsTest) != 0) sb.AppendVerbatimLiteral(member.DbName);
else sb.Append(parameterIndex);
sb.Append("], (object?)").Append(source).Append(".").Append(member.CodeName).Append(" ?? global::System.DBNull.Value);").Outdent().NewLine()
.Append("else").Indent().NewLine()
.Append("ps[");
if ((flags & WriteArgsFlags.NeedsTest) != 0) sb.AppendVerbatimLiteral(member.DbName);
else sb.Append(parameterIndex);
sb.Append("].Value = ");
switch (direction)
{
case ParameterDirection.Input:
case ParameterDirection.InputOutput:
sb.Append("AsValue(").Append(source).Append(".").Append(member.CodeName).Append(");");
break;
default:
sb.Append("global::System.DBNull.Value;");
break;
}
sb.Outdent().NewLine();
break;
}
sb.Append("ps[");
if ((flags & WriteArgsFlags.NeedsTest) != 0) sb.AppendVerbatimLiteral(member.DbName);
else sb.Append(parameterIndex);
Expand Down
Loading
Loading