Skip to content

Type handlers: runtime SqlMapper.AddTypeHandler registrations honored end-to-end - #206

Open
mgravell wants to merge 4 commits into
mainfrom
typehandlers
Open

Type handlers: runtime SqlMapper.AddTypeHandler registrations honored end-to-end#206
mgravell wants to merge 4 commits into
mainfrom
typehandlers

Conversation

@mgravell

Copy link
Copy Markdown
Member

The biggest remaining behavioral class. Design and probed facts in notes/typehandlers-design.md (committed here); the short version is the delegate-to-vanilla doctrine applied to runtime-mutable state — the generator cannot see AddTypeHandler calls at compile time, so generated code defers to vanilla's own decision procedure at execution time:

  • Writes: a member type the generator doesn't recognize dispatches through SqlMapper.LookupDbType (public, CS0618-suppressible — the same tier as PackListParameters). Handler present → handler.SetValue, passing DBNull for null (never null itself — TypeHandler<T>'s interface impl NREs on the struct cast; probed, and vanilla coalesces too). Otherwise any returned DbType applies and the value binds raw exactly as before (demand: false deliberately: modern providers natively handle types vanilla's map doesn't — DateOnly, until the Dapper re-enable ships). Expandable members check the handler first — a handled collection type must not list-expand (Issue253), which is the order vanilla applies. Update-mode mirrors the dispatch, so command reuse stays legal (the parameter shape is stable).
  • Reads: the lib deliberately doesn't reference Dapper (a consumer may use Dapper or Dapper.StrongName, and a hard reference would load both and split the handler registry — I believe this is why the csproj has that commented-out PackageReference). Generated code installs a TypeHandlerBridge from a module initializer, compiled against the consumer's own Dapper; the flexible read path consults it, and a whole-type handler overrides a generated row factory (RowFactory<T>.Resolve), matching vanilla's handler-before-member-binding order. Down-level targets get a probe-gated ModuleInitializerAttribute polyfill; the whole feature is inert against a Dapper too old to have HasTypeHandler/LookupDbType.
  • char/char? and object/dynamic stay excluded from dispatch (padding and runtime-typing respectively); ParamMember gains TypeOfName mirroring RowMember's, because typeof on an annotated reference type (CS8639) or dynamic (CS1962) doesn't compile — found the hard way when the harness build failed silently.

Dapper test suite: 677 → 705/793. The entire runtime-handler family clears (Issue136, Issue1959 ×4, Issue253 ×2, Issue461, SO24740733 ×2, Issue149, the PreferTypeHandlersForEnums test — LookupDbType checks that setting internally, so it came free), plus the bare-DataTable TVP pair and the Xml tests, because vanilla registers DataTableHandler and the XML handlers by default and the dispatch reaches them like any registration.

Deliberately not here (recorded in the design note): AddTypeMap on recognized scalars (the string→AnsiString tests — honoring it costs a per-parameter lookup on the hottest types; explicit trade to discuss), and the SetTypeMap runtime column-mapping family (the parity table's 🚫 proposal stands). Prior art #117 (samcragg) and #162 (7amou3) are the announced-attribute tier — the right shape for static dispatch, and still worth doing as an optimization on top of this, redone on the plain-data model (#162's symbol-keyed registry predates phase 2).

SqlMapper.AddTypeHandler registrations now work under interception, in both
directions, by delegating to vanilla's own decision procedure at execution
time rather than trying to see runtime state at compile time:

- writes: a member type the generator does not recognize emits a dispatch
  through SqlMapper.LookupDbType (public, CS0618-suppressible - the same
  tier as PackListParameters): handler present -> handler.SetValue (with
  DBNull for null, never null itself - the TypeHandler<T> interface impl
  NREs on a raw null); otherwise any returned DbType is applied and the
  value binds raw as before (demand:false deliberately - modern providers
  natively handle types vanilla's map does not, DateOnly being the live
  case until the Dapper re-enable ships). Update-mode mirrors it, so
  command reuse stays legal (the parameter shape is stable); an expandable
  member checks the handler *first*, which is the order vanilla applies
  (a handled collection type must not list-expand - Issue253).
- reads: the lib cannot reference Dapper (a consumer may use Dapper or
  Dapper.StrongName, and a hard reference would split the handler registry
  between the two), so generated code installs a TypeHandlerBridge from a
  module initializer, compiled against the consumer's own Dapper; the
  flexible read path consults it, and a whole-type handler overrides a
  generated row factory (RowFactory<T>.Resolve), matching vanilla's
  handler-before-member-binding order. A ModuleInitializerAttribute
  polyfill is emitted for down-level targets, probe-gated like the
  interceptor attribute; the whole feature is inert against a Dapper too
  old to have HasTypeHandler/LookupDbType.
- char/char? stay excluded (their StringFixedLength map entry pads the
  round-trip); object/dynamic stay excluded (runtime-typed values);
  ParamMember gains TypeOfName, mirroring RowMember's, because typeof on
  an annotated reference type or dynamic does not compile.

This also clears the bare-DataTable TVP shape and the Xml types for free -
vanilla registers DataTableHandler and the XML handlers by default, and the
dispatch reaches them like any other registration.

Dapper test suite: 677 -> 705/793. Design and probed facts in
notes/typehandlers-design.md; prior art PRs #117 and #162 (the announced-
attribute tier) remain as the static-dispatch optimization, redone on the
plain-data model.
Enum parameters are common, and the runtime dispatch was charging them a
dictionary lookup per execution for a feature that only applies under
Settings.PreferTypeHandlersForEnums (vanilla consults enum handlers only
under that setting too). The lookup is now guarded by the static bool, so
enum parameters pay a single field read unless the feature is actually in
use; on a Dapper too old to have the setting, enums keep the fully baked
path (probe-gated, like the rest).

Also records the agreed direction in the design note: declarative
[module: ...] config attributes as the primary spelling - better scoped
than the process-global registries they replace - with the runtime bridge
as compatibility and a strict switch to disable it outright.
@mgravell

Copy link
Copy Markdown
Member Author

Two additions from the design discussion: enum parameters no longer pay the lookup unless Settings.PreferTypeHandlersForEnums is actually on (a static bool read guards it; vanilla only consults enum handlers under that setting anyway), and the design note now records the agreed direction — declarative [module: ...] config attributes as the primary spelling (better scoped than the process-global registries they replace, per-assembly and deterministic), with this PR's runtime bridge as the compatibility tier and a strict switch to come. Post-gate cost profile: recognized scalars pay zero, enums pay a field read, one dictionary hit per query for the whole-type check — the remaining per-parameter lookups land only on types that were previously an exception. Suite holds at 705/793.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant