Skip to content
Merged
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
51 changes: 51 additions & 0 deletions docs/rules/DAP056.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# DAP056

Type-based API is not supported

Dapper's `Type`-argument overloads choose the row type at execution time:

``` csharp
_ = connection.Query(typeof(Customer), "select * from Customers"); // DAP056
_ = reader.GetRowParser(typeof(Customer)); // DAP056
_ = reader.GetRowParser<Customer>(concreteType: typeof(Customer)); // DAP056
```

That is the one thing compile-time generation cannot follow, so **these are a deliberate
non-goal** rather than an unimplemented feature. Supporting them would mean a build-time registry
of candidate types plus runtime dispatch keyed on `Type` — which reintroduces exactly what
Dapper.AOT exists to remove: a lookup on the hot path, an open world for the trimmer, and
dispatch the native-AOT compiler cannot resolve unless every announced type is rooted.

**The fix**: use the generic overload, so the type is known at build time.

``` csharp
_ = connection.Query<Customer>("select * from Customers");
_ = reader.GetRowParser<Customer>();
```

Note that `GetRowParser<T>()` is fully supported — it is only *passing* a `concreteType` that
defers the decision, so omitting it (the default) needs no change.

## The polymorphic case

`GetRowParser(reader, Type concreteType, ...)` is the discriminator pattern: read a column,
choose among several types. There is no generic spelling for that, because the choice is
data-dependent — so the AOT answer is to make the choice explicit:

``` csharp
var kind = reader.GetString(kindOrdinal);
var parser = kind switch
{
"cat" => reader.GetRowParser<Cat>(),
"dog" => reader.GetRowParser<Dog>(),
_ => throw new NotSupportedException(kind),
};
```

More code, and strictly better under AOT: it roots exactly the types you actually use, where a
registry roots everything registered.

## If you cannot change the call

The call still works — it is left on vanilla Dapper, which uses reflection. That is fine on a
JIT runtime and fails when published with native AOT, which is what this warning is telling you.
26 changes: 14 additions & 12 deletions notes/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ Two levers change several complexity scores and are worth naming up front:

Two independent measurements, because they answer different questions.

**API surface** (`ApiSurface.expected.txt`, generated): of Dapper's 110 public extension
overloads — 40 candidates, 16 unsupported-and-diagnosed, 13 unsupported-and-undiagnosed, 27
skipped silently, 9 never inspected (helpers, correctly). So **40 of 110 tell the consumer
nothing**, almost all `CommandDefinition`-shaped. That is a defect class of its own, separate
from any missing feature: it is not that these fail, it is that they fail *quietly*.
**API surface** (`ApiSurface.expected.txt`, generated): of Dapper's public extension overloads
— 28 candidates, 18 `Type`-based non-goals (refused on purpose, with DAP056), 16
unsupported-and-diagnosed, 12 unsupported-and-undiagnosed, 22 skipped silently, 9 never inspected
(helpers, correctly). So **34 still tell the consumer nothing**, almost all
`CommandDefinition`-shaped. That is a defect class of its own, separate from any missing feature:
it is not that these fail, it is that they fail *quietly*.

**Behaviour** (Dapper suite, local SQL Server): **677 of 793** pass through generated code, with
**533 of 725** call-sites intercepted (73.5%). Note the denominator counts what the generator
Expand All @@ -67,14 +68,14 @@ What stands between that and "all green", largest first:

| # | what | where it shows up | size |
| --- | --- | --- | --- |
| 0 | **say something at the 40 mute overloads** | 27 skipped silently + 13 unsupported-undiagnosed | small, and it is the cheapest safety win on the list: it turns a runtime AOT failure into a build warning without supporting anything new |
| 0 | **say something at the 34 mute overloads** | 22 skipped silently + 12 unsupported-undiagnosed | small, and it is the cheapest safety win on the list: it turns a runtime AOT failure into a build warning without supporting anything new |
| 1 | **multi-map** (`Query<T1..T7,TReturn>` + `splitOn`) | unsupported API - outside the 725 | large |
| 2 | **`QueryMultiple` / `GridReader`** | unsupported API | large; needs a Dapper-side extension point first |
| 3 | **corpus adoption of `[TypeHandler]`** | TypeHandlerTests x16/provider | a harness edit, not product work - but not all of it converts, see below |
| 4 | **literal injection `{=name}`** (generator half; the analyzer half shipped as #191) | Literal x5 + Async x3 per provider | medium |
| 5 | **the coercion tail** | MiscTests x10/provider | medium, and the highest silent-wrongness risk |
| 6 | **`ExecuteReader`** | unsupported API | small-medium |
| 7 | **announced types** (`Query(Type, ...)`, `GetRowParser(Type)`, `Parse(Type)`) | DAP015 x30 | medium; one design unlocks several rows |
| 7 | **announced types**, for *untyped parameters* only (`object`-typed args) | DAP015 x30 | medium. Note this shrank on 2026-08-26: the `Type`-based **result** APIs it used to also cover are now a non-goal, so this is no longer a design that unlocks several rows |
| 8 | tuples, `ISupportInitialize`, SqlDecimal read-side, legacy `?` token, constructors | scattered singles | small each |

Two known ceilings rather than gaps: tests that register a *specific handler instance* and then
Expand All @@ -94,20 +95,21 @@ non-public members, and the "has no meaning" APIs warning - all in §7.
| `QueryFirst/Single[OrDefault]<T>` + async | ✅ | — | — | row-count guidance via DAP229/230 |
| `Query` (non-generic → `dynamic` rows) | ✅ | — | — | see §3 dynamic-row fidelity |
| `Query<object>` / untyped | ✅ | — | — | `QueryUntyped` fixture |
| `Query(Type, sql, ...)` + `First/Single[OrDefault]` + async | | med | med | needs announced types; see [type-vs-generic.md](type-vs-generic.md) |
| `Query(Type, sql, ...)` + `First/Single[OrDefault]` + async | 🚫 | | | **decided 2026-08-26**: the row type is chosen at execution time, which compile-time generation cannot follow. Supporting it needs a `Type`-keyed registry plus runtime dispatch — the shape #206 was closed over. The generic overload is the answer and DAP056 names it. 12 overloads, plus 6 more in the `Type`+`CommandDefinition` combinations |
| `Query<TFirst,...,TReturn>` multi-map (2–7 + splitOn) | ❌ | **high** | med-high | `Arity > 1` → `NotAotSupported`. New read shape (splitOn slicing, per-type readers, user delegate), all sync/async/buffered variants |
| `Query(sql, Type[] types, Func<object[],TReturn> map, ...)` | | low-med | low* | *after* multi-map + announced types land; incremental on both |
| `Query(sql, Type[] types, Func<object[],TReturn> map, ...)` | 🚫 | | | doubly out: `Type`-based *and* multi-map. Covered by the same decision and the same DAP056 |
| `QueryMultiple` / `QueryMultipleAsync` (`GridReader`) | ❌ | **high** | high | interceptor must return Dapper's `GridReader` → needs a Dapper-side extension point (subclassable GridReader) or an AOT-owned grid API; then per-`Read<T>` typing is a second problem (instance calls, not interceptable — likely: announced types + runtime dispatch) |
| `Execute` / `ExecuteAsync` | ✅ | — | — | |
| `Execute` with `IEnumerable<T>` (multi-exec) | ✅ | — | low (verify) | AOT batches (`DbBatch`, `[BatchSize]`) — *better*; verify semantics match Dapper (order, transaction, partial failure, total rowcount) |
| `ExecuteScalar` / `ExecuteScalar<T>` + async | ✅ | — | — | conversion fidelity in §3 |
| `ExecuteReader` / `ExecuteReaderAsync` | ❌ | med | low-med | command setup already generated; return the (wrapped) reader; `WrappedReader`/`IWrappedDataReader` disposal semantics |
| `GetRowParser<T>(reader)` | ✅ | — | — | |
| `GetRowParser(reader, Type concreteType, ...)` | ❌ | med | low* | discriminator/polymorphism pattern; dictionary lookup once types are announced |
| `Parse<T>` / `Parse(Type)` / `Parse` (dynamic) | ❌ | low | low | same reader machinery, different entry point. Report: *not inspected* — all three sit outside the generator's name filter, so nothing is emitted and nothing is said |
| `GetRowParser(reader, Type concreteType, ...)` | 🚫 | — | — | the discriminator/polymorphism pattern, and the one row where "use the generic form" is not available advice — the choice is data-dependent. Decided out anyway: the AOT-correct spelling is an explicit `switch` over `GetRowParser<T>()` per candidate, which roots exactly the types used rather than everything registered. DAP056 says so; [DAP056.md](../docs/rules/DAP056.md) shows the pattern. **`GetRowParser<T>()` itself stays ✅** — only *passing* a `concreteType` defers the decision |
| `Parse(Type)` | 🚫 | — | — | same decision as the rows above |
| `Parse<T>` / `Parse` (dynamic) | ❌ | low | low | same reader machinery, different entry point. Report: *not inspected* — these sit outside the generator's name filter, so nothing is emitted and nothing is said. Note the filter cannot simply be widened: `Parse` is far too common a method name to make every `.Parse(` call-site a generator candidate |
| `AsTableValuedParameter` (`DataTable` / `SqlDataRecord`) | ⚠️ | low | low | the result *is* an `ICustomQueryParameter`, so covered above. A **bare** `DataTable` member needs a handler declared for `DataTable`; vanilla registers one by default, so this is the same "do we ship built-in declarations" question as the XML row |
| `AsList<T>` | n/a | — | — | trivial helper; confirm it doesn't count as a candidate site |
| `GetTypeDeserializer(Type, reader, startBound, length, ...)` | | low-med | low* | a valid raw-materializer API, not mere plumbing: with announced types it's the same dispatch map, returning a boxed `Func<DbDataReader, object>`. Its generic strengthening **already exists**: `GetRowParser<T>` (same slicing knobs), which AOT supports |
| `GetTypeDeserializer(Type, reader, startBound, length, ...)` | 🚫 | | | `Type`-based, so it goes with the rows above. Its generic strengthening **already exists**`GetRowParser<T>`, same slicing knobswhich is exactly why the decision is cheap here. Not an extension method, so no DAP056: it is called directly and the generator never sees it |
| `CreateParamInfoGenerator(Identity, ...)` | ❌ | low | med | the raw parameter-binder factory; **no generic counterpart exists in Dapper** — see "Strengthened APIs" in [type-vs-generic.md](type-vs-generic.md) for the proposed `<T>` form |
| `ReadChar` / `ReadNullableChar` / `SanitizeParameterValue` | ✅ | — | — | plain static helpers, AOT-safe as-is; nothing to intercept |
| `PurgeQueryCache` / `GetCachedSQL*` / `GetHashCollissions` / `QueryCachePurged` | 🚫 | **zero** | — | there is no ref-emit plan cache in AOT — but usage should *warn*, see §7 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public static readonly DiagnosticDescriptor
DapperAotTupleParameter = LibraryInfo("DAP014", "Tuple-type parameter", "Tuple-type parameters are not currently supported"),
UntypedParameter = LibraryInfo("DAP015", "Untyped parameter", "The parameter type could not be resolved"),
GenericTypeParameter = LibraryInfo("DAP016", "Generic type parameter", "Generic type parameters ({0}) are not currently supported"),
TypeBasedApiNotSupported = LibraryWarning("DAP056", "Type-based API is not supported",
"'{0}' chooses the row type from a Type at execution time, which Dapper.AOT cannot generate for; use the generic overload so the type is known at build time, and the call-site is left on vanilla Dapper (which will not work under native AOT)"),
DuplicateTypeHandler = LibraryWarning("DAP055", "Duplicate type-handler",
"Type '{0}' has more than one handler registered ('{1}' and '{2}'); '{2}' will be ignored"),
UnusableTypeHandler = LibraryWarning("DAP054", "Type-handler cannot be used",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method)
{
return null;
}
if (PassesRuntimeType(op))
{
// a Type argument means the row type is chosen at execution time; declared a
// non-goal rather than reintroducing a Type-keyed registry (see parity.md §1).
// Reported from here because most of these overloads are invisible to the
// analyzer - they carry no `sql` string, or none at all
return new SkippedSourceState(new LocationSnapshot(ie.GetLocation()), flags,
diagnosed: true, SkipReason.TypeBasedApi, op.TargetMethod.Name);
}
if (flags.HasAny(OperationFlags.NotAotSupported))
{
// not our API (yet); count it, so the scorecard stays honest. DAP001 comes
Expand Down Expand Up @@ -498,6 +507,14 @@ internal void Generate(in GenerateState ctx)
int unsupported = 0, refusedWithDiagnostics = 0, skippedSilently = 0;
foreach (var skip in ctx.Nodes.OfType<SkippedSourceState>())
{
if (skip.Reason == SkipReason.TypeBasedApi)
{
// the analyzer cannot see most of these, so the generator is what speaks
ctx.ReportDiagnostic(Diagnostic.Create(DapperAnalyzer.Diagnostics.TypeBasedApiNotSupported,
skip.Location.AsLocation(), skip.MethodName));
refusedWithDiagnostics++;
continue;
}
if (skip.Flags.HasAny(OperationFlags.NotAotSupported)) unsupported++;
else if (skip.Diagnosed) refusedWithDiagnostics++;
else skippedSilently++; // nothing told the consumer; each one of these is a bug of ours
Expand Down Expand Up @@ -1898,6 +1915,39 @@ internal static bool IsVisibleToAnalyzer(IMethodSymbol method)
return false;
}

/// <summary>
/// Does this call actually supply a runtime <see cref="Type"/> for the row shape? Judged at
/// the call-site, not the symbol, because <c>GetRowParser&lt;T&gt;(concreteType: null)</c> -
/// the default, and the common case - is perfectly supportable; only a call that passes one
/// defers the type to execution time.
/// </summary>
internal static bool PassesRuntimeType(IInvocationOperation op)
{
foreach (var arg in op.Arguments)
{
var type = arg.Parameter?.Type;
if (type is null) continue;
if (type is IArrayTypeSymbol array) type = array.ElementType;
if (type is not { Name: "Type", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } }) continue;

// an omitted or explicitly-null optional Type (concreteType) changes nothing
if (arg.Value is IDefaultValueOperation) continue;
if (arg.ConstantValue is { HasValue: true, Value: null }) continue;
return true;
}
return false;
}

internal enum SkipReason
{
None = 0,
/// <summary>
/// A <c>Type</c>-argument overload: the row type is chosen at execution time, which is
/// the one thing compile-time generation cannot follow. Declared non-goal, 2026-08-26.
/// </summary>
TypeBasedApi = 1,
}

internal sealed class SkippedSourceState : SourceState
{
// a call-site that Dapper.AOT is *not* handling - either the API is not supported at
Expand All @@ -1906,14 +1956,28 @@ internal sealed class SkippedSourceState : SourceState
public OperationFlags Flags { get; }
public bool Diagnosed { get; }

public SkippedSourceState(in LocationSnapshot location, OperationFlags flags, bool diagnosed) : base(location)
/// <summary>
/// Why we skipped, where the generator is the only thing positioned to say so. Kept as a
/// plain enum rather than a <c>Diagnostic</c> so the cached model stays equatable data.
/// </summary>
public SkipReason Reason { get; }

/// <summary>The Dapper method name, for a diagnostic message; empty when unused.</summary>
public string MethodName { get; }

public SkippedSourceState(in LocationSnapshot location, OperationFlags flags, bool diagnosed,
SkipReason reason = SkipReason.None, string methodName = "") : base(location)
{
Flags = flags;
Diagnosed = diagnosed;
Reason = reason;
MethodName = methodName;
}

public bool Equals(SkippedSourceState? other) => other is not null
&& Diagnosed == other.Diagnosed
&& Reason == other.Reason
&& string.Equals(MethodName, other.MethodName, StringComparison.Ordinal)
&& Location.Equals(other.Location) && Flags == other.Flags;
public override bool Equals(object? obj) => Equals(obj as SkippedSourceState);
public override int GetHashCode() => Location.GetHashCode() ^ (int)Flags;
Expand Down
Loading
Loading