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
53 changes: 53 additions & 0 deletions docs/rules/DAP057.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# DAP057

CommandDefinition overload is not supported

Dapper.AOT decides what to generate by reading the SQL at build time. The `CommandDefinition`
overloads carry their SQL *inside a struct*, constructed at execution time - so there is nothing
for the generator to read, and the call-site is left running vanilla Dapper.

``` csharp
conn.Execute(new CommandDefinition(sql, args)); // DAP057
```

**The fix**: use the overload that takes the SQL directly.

``` csharp
conn.Execute(sql, args);
```

The named arguments of `CommandDefinition` map onto the longer overloads, so a call using
`transaction`, `commandTimeout`, `commandType` or `flags` has a direct equivalent:

``` csharp
conn.Execute(sql, args, transaction, commandTimeout, commandType);
```

## Why this is a warning under native AOT, and information otherwise

An unhandled call-site does not *break* - it keeps working exactly as vanilla Dapper always did,
by reflection and ref-emit. Under JIT that is a missed optimization, so this reports as
information.

Under native AOT it is a latent crash: the reflection path this falls back to is precisely what
cannot survive publishing, and nothing else warns about it. So when the project sets
`PublishAot`, DAP057 is raised to a **warning** - the same fact, at the severity it deserves for
the person it will actually hurt.

That means upgrading Dapper.AOT does not bury a JIT project in new warnings, while an AOT project
gets told about every call-site that will not make it.

## Why not just support them?

Nothing here is impossible - it would mean tracing the `CommandDefinition` back to its
construction site and recovering the SQL from there, which works when it is built inline and
does not when it arrives as a parameter, comes from a field, or is passed through a helper. The
overload would then be supported *sometimes*, which is a worse contract than supported never:
the failure would depend on how the argument happened to be spelled.

Until that is settled, this diagnostic exists so the situation is at least *visible*. Previously
these call-sites were dropped in silence - they worked in development and failed after an AOT
publish, with nothing said at build time.

See also [DAP001](DAP001.md), which reports the operations that are not supported in any
spelling.
25 changes: 15 additions & 10 deletions notes/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,21 +54,26 @@ 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 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*.
— 28 candidates, 18 `Type`-based non-goals (refused on purpose, with DAP056), 28
unsupported-and-diagnosed, 21 `CommandDefinition`-shaped (refused, with DAP057), 9 never
inspected (helpers, correctly), and **1 still silent**.

**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
examines, so the silently-skipped overloads above are inside it and the diagnosed ones are too.
That one is `GetRowParser<T>(concreteType)`, and it is a reporting artifact rather than a gap:
the report classifies *symbols*, and what this overload does depends on whether a given call
actually passes the `Type` — which a symbol cannot say. Call-sites get DAP056 or are handled, as
appropriate. **The mute class is closed** (2026-09-11, was 40 at the start of the round).

**Behaviour** (Dapper suite, local SQL Server): **729 of 800** pass through generated code, with
**432 of 736** call-sites handled (round 15, 2026-09-11; vanilla control 770/800). Note the
denominator counts what the generator examines, so the refused overloads above are inside it.
Call-site counts are rig-specific — see harness-baseline.md round 15 before comparing with any
earlier round.

What stands between that and "all green", largest first:

| # | what | where it shows up | size |
| --- | --- | --- | --- |
| 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 |
| ~~0~~ | ~~**say something at the mute overloads**~~ | **done 2026-09-11**: DAP057 for the 21 `CommandDefinition` spellings, DAP001 from the generator for the 12 it could not see. Severity follows `PublishAot` — info when a fallback to vanilla Dapper is a missed optimization, warning when it is a latent publish-time crash | — |
| 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 |
Expand Down Expand Up @@ -135,7 +140,7 @@ non-public members, and the "has no meaning" APIs warning - all in §7.
| param filtering (only bind members named in SQL) + `SupportLegacyParameterTokens` | ❓ | med | low | AOT currently *includes* + warns (DAP236); on strict providers that's an error, so may need parity not preference |
| UDTs (`UdtTypeHandler`, geo types) | ⚠️ | low | low | provider-specific, and now expressible: declare a handler for the type. No built-in, so a consumer supplies it |
| XML types (`XmlDocument`/`XDocument`/`XElement`) | ⚠️ | low-med | low | expressible today by declaring a handler; vanilla registers these by default, so the open question is whether we ship built-in declarations rather than whether it *can* work |
| `CommandDefinition` overloads | ❌ | **high** | med | **21 of the 22 silently-skipped rows in the surface report are these** — the analyzer only inspects call-sites carrying SQL as a string argument, and these hide it inside the struct, so nothing is emitted *and nothing is reported*. (It was 27 before #214 moved the `Type`+`CommandDefinition` combinations into the non-goal bucket; the report is the count that matters, not this sentence.) Consumers get vanilla Dapper under JIT and a runtime failure under native AOT with no build-time signal (issues #112, #158, #165). External PR #153 proposes support; a diagnostic is worth having either way, and is cheaper |
| `CommandDefinition` overloads | ❌ | **high** | med | Still not generated — the analyzer only inspects call-sites carrying SQL as a string argument, and these hide it inside the struct. But **no longer silent**: DAP057 names each one (2026-09-11), at a severity that follows `PublishAot`. That closes the *safety* half of issues #112/#158/#165 — a consumer heading for native AOT is now told at build time instead of at publish. The *support* half is still open; external PR #153 proposes it. See the surface report for the current count |
| `CommandFlags` (`Buffered`, `Pipelined`, `NoCache`) | ❓ (behaviour) | med | low-med | `NoCache` is **zero** (no cache to bypass); `Buffered` covered; `Pipelined` is a perf feature to verify — and all of it is moot at a call-site until the row above is fixed |
| `commandTimeout` / `transaction` / `commandType` args | ✅ ❓ | — | — | verify `TableDirect` |
| `CancellationToken` | ✅ | — | — | AOT extends Dapper here (DAP044/045) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
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"),
CommandDefinitionNotSupported = LibraryInfo("DAP057", "CommandDefinition overload is not supported",
"'{0}' passes its SQL inside a CommandDefinition, which Dapper.AOT cannot read at build time; use the overload that takes the SQL directly. This call-site is left on vanilla Dapper, which will not work under native AOT"),

Check warning on line 29 in src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs

View workflow job for this annotation

GitHub Actions / build

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period

Check warning on line 29 in src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs

View workflow job for this annotation

GitHub Actions / build

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period
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",
Expand Down
6 changes: 5 additions & 1 deletion src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,11 @@ private void ValidateDapperMethod(in OperationAnalysisContext ctx, IOperation sq
OnDapperAotHit(); // all good for AOT
if (flags.HasAny(OperationFlags.NotAotSupported))
{
ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnsupportedMethod, location, invoke.GetSignature()));
// left on vanilla Dapper: a missed optimization under JIT, a latent
// publish-time crash under native AOT - so it reports at both severities
ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnsupportedMethod, location,
ctx.Options.TargetsNativeAot() ? DiagnosticSeverity.Warning : DiagnosticSeverity.Info,
additionalLocations: null, properties: null, invoke.GetSignature()));
}
}
else if (!aotAttribExists && !flags.HasAny(OperationFlags.NotAotSupported))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex
var nodes = context.SyntaxProvider.CreateSyntaxProvider(PreFilter, Parse)
.Where(x => x is not null)
.Select((x, _) => x!);
var env = context.CompilationProvider.Select(static (c, _) => CreateEnvironment(c));
var env = context.CompilationProvider.Combine(context.AnalyzerConfigOptionsProvider)
.Select(static (pair, _) => CreateEnvironment(pair.Left, pair.Right.TargetsNativeAot()));
var combined = env.Combine(nodes.Collect());
context.RegisterImplementationSourceOutput(combined, Generate);
}
Expand Down Expand Up @@ -169,19 +170,29 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method)
}
if (flags.HasAny(OperationFlags.NotAotSupported))
{
// not our API (yet); count it, so the scorecard stays honest. DAP001 comes
// from the analyzer - but only where the analyzer can see the call at all
// not our API (yet); count it, so the scorecard stays honest. DAP001 comes from
// the analyzer where it can see the call; where it cannot - the CommandDefinition
// overloads - the generator reports the same id, so both spellings behave alike
var visible = IsVisibleToAnalyzer(op.TargetMethod);
return new SkippedSourceState(new LocationSnapshot(ie.GetLocation()), flags,
diagnosed: IsVisibleToAnalyzer(op.TargetMethod));
diagnosed: true,
reason: visible ? SkipReason.None : SkipReason.UnsupportedInvisibleApi,
methodName: visible ? "" : op.TargetMethod.Name);
}

var location = DapperAnalyzer.SharedParseArgsAndFlags(ctx, op, ref flags, out var sql, out var argExpression, reportDiagnostic: null, out var resultType, exitFirstFailure: true);
if (flags.HasAny(OperationFlags.DoNotGenerate))
{
// the analyzer's identical pass told us to leave it alone - and will have said
// why, *if* the shape of this overload is one the analyzer inspects at all
// why, *if* the shape of this overload is one the analyzer inspects at all.
// Where it is not, the operation itself is supported and only this *spelling*
// is not, so say exactly that rather than dropping the call-site in silence
if (IsVisibleToAnalyzer(op.TargetMethod))
{
return new SkippedSourceState(new LocationSnapshot(location), flags, diagnosed: true);
}
return new SkippedSourceState(new LocationSnapshot(location), flags,
diagnosed: IsVisibleToAnalyzer(op.TargetMethod));
diagnosed: true, SkipReason.CommandDefinition, op.TargetMethod.Name);
}


Expand Down Expand Up @@ -271,7 +282,7 @@ static string BuildParameterMap(in ParseState ctx, IInvocationOperation op, stri
}


internal static InterceptorEnvironment CreateEnvironment(Compilation compilation)
internal static InterceptorEnvironment CreateEnvironment(Compilation compilation, bool targetsNativeAot = false)
{
var dbCommandTypes = IdentifyDbCommandTypes(compilation, out var needsCommandPrep);
EquatableArray<SpecialDbCommandType> special = default;
Expand Down Expand Up @@ -300,7 +311,8 @@ internal static InterceptorEnvironment CreateEnvironment(Compilation compilation
baseFactoryCanConstruct: canConstruct,
specialCommandTypes: special,
systemObjectPlan: ParamPlan.Create(compilation.GetSpecialType(SpecialType.System_Object))!,
typeHandlers: GetTypeHandlers(compilation));
typeHandlers: GetTypeHandlers(compilation),
targetsNativeAot: targetsNativeAot);
}

/// <summary>
Expand Down Expand Up @@ -515,6 +527,29 @@ internal void Generate(in GenerateState ctx)
refusedWithDiagnostics++;
continue;
}
if (skip.Reason == SkipReason.CommandDefinition)
{
// the operation is supported; only this *spelling* is not, because the SQL is
// inside the struct. Info when the consumer is not publishing native AOT (a
// missed optimization), warning when they are (a latent crash at publish)
ctx.ReportDiagnostic(Diagnostic.Create(DapperAnalyzer.Diagnostics.CommandDefinitionNotSupported,
skip.Location.AsLocation(),
ctx.TargetsNativeAot ? DiagnosticSeverity.Warning : DiagnosticSeverity.Info,
additionalLocations: null, properties: null, skip.MethodName));
refusedWithDiagnostics++;
continue;
}
if (skip.Reason == SkipReason.UnsupportedInvisibleApi)
{
// DAP001, from here rather than the analyzer, which cannot see this overload -
// so both spellings of an unsupported API report the same thing
ctx.ReportDiagnostic(Diagnostic.Create(DapperAnalyzer.Diagnostics.UnsupportedMethod,
skip.Location.AsLocation(),
ctx.TargetsNativeAot ? DiagnosticSeverity.Warning : DiagnosticSeverity.Info,
additionalLocations: null, properties: null, skip.MethodName));
unsupported++;
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 @@ -1946,6 +1981,17 @@ internal enum SkipReason
/// the one thing compile-time generation cannot follow. Declared non-goal, 2026-08-26.
/// </summary>
TypeBasedApi = 1,
/// <summary>
/// An overload carrying its SQL inside a <c>CommandDefinition</c>, for an operation we
/// otherwise support. The analyzer never sees these (no <c>sql</c> string parameter), so
/// the generator is what speaks - otherwise the drop is completely silent.
/// </summary>
CommandDefinition = 2,
/// <summary>
/// An API we do not support at all, on an overload the analyzer cannot see - so the
/// DAP001 its visible siblings get has to come from here instead.
/// </summary>
UnsupportedInvisibleApi = 3,
}

internal sealed class SkippedSourceState : SourceState
Expand Down
22 changes: 21 additions & 1 deletion src/Dapper.AOT.Analyzers/CodeAnalysis/GlobalOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,29 @@ public static class Keys
public const string GlobalOptions_DapperSqlSyntax = "dapper.sqlsyntax";
public const string GlobalOptions_DapperDebugSqlParseInputFlags = "dapper.debug_mode_sql_parse_input_flags"; // this is for test purposes; if you find and use this: don't blame me!
public const string ProjectProperties_DapperSqlSyntax = "build_property.Dapper_SqlSyntax";


/// <summary>
/// Set by the SDK when <c>PublishAot</c> is on, and compiler-visible by default - so we
/// can tell a native-AOT project from a JIT one without shipping any build props of our
/// own. <c>PublishAot</c> itself is *not* compiler-visible, which is why this stands in.
/// </summary>
public const string ProjectProperties_EnableAotAnalyzer = "build_property.EnableAotAnalyzer";
}

/// <inheritdoc cref="TargetsNativeAot(AnalyzerConfigOptionsProvider?)"/>
public static bool TargetsNativeAot(this AnalyzerOptions? options)
=> options?.AnalyzerConfigOptionsProvider.TargetsNativeAot() ?? false;

/// <summary>
/// Is the consuming project headed for native AOT? Decides whether a call-site we leave on
/// vanilla Dapper is a missed optimization or a latent publish-time crash.
/// </summary>
public static bool TargetsNativeAot(this AnalyzerConfigOptionsProvider? provider)
=> provider is not null
&& provider.GlobalOptions.TryGetValue(Keys.ProjectProperties_EnableAotAnalyzer, out var value)
&& bool.TryParse(value, out var enabled)
&& enabled;

public static bool TryGetSqlSyntax(this AnalyzerOptions? options, out SqlSyntax syntax)
{
if (options is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ internal sealed class InterceptorEnvironment : IEquatable<InterceptorEnvironment
public ParamPlan SystemObjectPlan { get; } // the parameterless command-factory fallback
public EquatableArray<TypeHandlerRegistration> TypeHandlers { get; } // [TypeHandler(...)] at module/assembly level

/// <summary>
/// Is this project headed for native AOT? Decides whether leaving a call-site on vanilla
/// Dapper is merely a missed optimization (info) or a latent publish-time crash (warning).
/// </summary>
public bool TargetsNativeAot { get; }

public InterceptorEnvironment(bool allowUnsafe, string? assemblyName, bool hasInterceptsLocationAttribute,
bool needsCommandPrep, string? baseCommandFactoryName, bool baseFactoryCanConstruct,
in EquatableArray<SpecialDbCommandType> specialCommandTypes, ParamPlan systemObjectPlan,
in EquatableArray<TypeHandlerRegistration> typeHandlers)
in EquatableArray<TypeHandlerRegistration> typeHandlers, bool targetsNativeAot = false)
{
TargetsNativeAot = targetsNativeAot;
AllowUnsafe = allowUnsafe;
AssemblyName = assemblyName;
HasInterceptsLocationAttribute = hasInterceptsLocationAttribute;
Expand All @@ -43,7 +50,8 @@ public bool Equals(InterceptorEnvironment? other) => other is not null
&& BaseFactoryCanConstruct == other.BaseFactoryCanConstruct
&& SpecialCommandTypes.Equals(other.SpecialCommandTypes)
&& SystemObjectPlan.Equals(other.SystemObjectPlan)
&& TypeHandlers.Equals(other.TypeHandlers);
&& TypeHandlers.Equals(other.TypeHandlers)
&& TargetsNativeAot == other.TargetsNativeAot;

public override bool Equals(object? obj) => Equals(obj as InterceptorEnvironment);
public override int GetHashCode()
Expand Down
Loading
Loading