diff --git a/docs/rules/DAP056.md b/docs/rules/DAP056.md new file mode 100644 index 00000000..fba806bc --- /dev/null +++ b/docs/rules/DAP056.md @@ -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(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("select * from Customers"); +_ = reader.GetRowParser(); +``` + +Note that `GetRowParser()` 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(), + "dog" => reader.GetRowParser(), + _ => 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. diff --git a/notes/parity.md b/notes/parity.md index 593cdfe6..8f66bce3 100644 --- a/notes/parity.md +++ b/notes/parity.md @@ -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 @@ -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` + `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 @@ -94,20 +95,21 @@ non-public members, and the "has no meaning" APIs warning - all in §7. | `QueryFirst/Single[OrDefault]` + async | ✅ | — | — | row-count guidance via DAP229/230 | | `Query` (non-generic → `dynamic` rows) | ✅ | — | — | see §3 dynamic-row fidelity | | `Query` / 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` 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 map, ...)` | ❌ | low-med | low* | *after* multi-map + announced types land; incremental on both | +| `Query(sql, Type[] types, Func 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` typing is a second problem (instance calls, not interceptable — likely: announced types + runtime dispatch) | | `Execute` / `ExecuteAsync` | ✅ | — | — | | | `Execute` with `IEnumerable` (multi-exec) | ✅ | — | low (verify) | AOT batches (`DbBatch`, `[BatchSize]`) — *better*; verify semantics match Dapper (order, transaction, partial failure, total rowcount) | | `ExecuteScalar` / `ExecuteScalar` + async | ✅ | — | — | conversion fidelity in §3 | | `ExecuteReader` / `ExecuteReaderAsync` | ❌ | med | low-med | command setup already generated; return the (wrapped) reader; `WrappedReader`/`IWrappedDataReader` disposal semantics | | `GetRowParser(reader)` | ✅ | — | — | | -| `GetRowParser(reader, Type concreteType, ...)` | ❌ | med | low* | discriminator/polymorphism pattern; dictionary lookup once types are announced | -| `Parse` / `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()` 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()` itself stays ✅** — only *passing* a `concreteType` defers the decision | +| `Parse(Type)` | 🚫 | — | — | same decision as the rows above | +| `Parse` / `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` | 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`. Its generic strengthening **already exists**: `GetRowParser` (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`, same slicing knobs — which 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 `` 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 | diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs index 6a23878e..6d4db073 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs @@ -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", diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 0fd0c74d..8c957230 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -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 @@ -498,6 +507,14 @@ internal void Generate(in GenerateState ctx) int unsupported = 0, refusedWithDiagnostics = 0, skippedSilently = 0; foreach (var skip in ctx.Nodes.OfType()) { + 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 @@ -1898,6 +1915,39 @@ internal static bool IsVisibleToAnalyzer(IMethodSymbol method) return false; } + /// + /// Does this call actually supply a runtime for the row shape? Judged at + /// the call-site, not the symbol, because GetRowParser<T>(concreteType: null) - + /// the default, and the common case - is perfectly supportable; only a call that passes one + /// defers the type to execution time. + /// + 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, + /// + /// A Type-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. + /// + TypeBasedApi = 1, + } + internal sealed class SkippedSourceState : SourceState { // a call-site that Dapper.AOT is *not* handling - either the API is not supported at @@ -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) + /// + /// Why we skipped, where the generator is the only thing positioned to say so. Kept as a + /// plain enum rather than a Diagnostic so the cached model stays equatable data. + /// + public SkipReason Reason { get; } + + /// The Dapper method name, for a diagnostic message; empty when unused. + 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; diff --git a/test/Dapper.AOT.Test/ApiSurface.expected.txt b/test/Dapper.AOT.Test/ApiSurface.expected.txt index e47c0bb0..01cc4388 100644 --- a/test/Dapper.AOT.Test/ApiSurface.expected.txt +++ b/test/Dapper.AOT.Test/ApiSurface.expected.txt @@ -18,8 +18,11 @@ Dispositions: vanilla Dapper under JIT and a runtime failure under native AOT, with no build-time signal. Every row here is a defect + non-goal: Type-based refused on purpose, with DAP056: the row type comes + from a Type at execution time, which generation + cannot follow. Use the generic overload -## candidate (40) +## candidate (28) Execute(string, object?, IDbTransaction?, int?, CommandType?) ExecuteAsync(string, object?, IDbTransaction?, int?, CommandType?) @@ -27,41 +30,50 @@ Dispositions: ExecuteScalar<1>(string, object?, IDbTransaction?, int?, CommandType?) ExecuteScalarAsync(string, object?, IDbTransaction?, int?, CommandType?) ExecuteScalarAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) - Query(Type, string, object?, IDbTransaction?, bool, int?, CommandType?) Query(string, object?, IDbTransaction?, bool, int?, CommandType?) - Query<1>(string, Type[], Func, object?, IDbTransaction?, bool, string, int?, CommandType?) Query<1>(string, object?, IDbTransaction?, bool, int?, CommandType?) - QueryAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) QueryAsync(string, object?, IDbTransaction?, int?, CommandType?) - QueryAsync<1>(string, Type[], Func, object?, IDbTransaction?, bool, string, int?, CommandType?) QueryAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) - QueryFirst(Type, string, object?, IDbTransaction?, int?, CommandType?) QueryFirst(string, object?, IDbTransaction?, int?, CommandType?) QueryFirst<1>(string, object?, IDbTransaction?, int?, CommandType?) - QueryFirstAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) QueryFirstAsync(string, object?, IDbTransaction?, int?, CommandType?) QueryFirstAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) - QueryFirstOrDefault(Type, string, object?, IDbTransaction?, int?, CommandType?) QueryFirstOrDefault(string, object?, IDbTransaction?, int?, CommandType?) QueryFirstOrDefault<1>(string, object?, IDbTransaction?, int?, CommandType?) - QueryFirstOrDefaultAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) QueryFirstOrDefaultAsync(string, object?, IDbTransaction?, int?, CommandType?) QueryFirstOrDefaultAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) - QuerySingle(Type, string, object?, IDbTransaction?, int?, CommandType?) QuerySingle(string, object?, IDbTransaction?, int?, CommandType?) QuerySingle<1>(string, object?, IDbTransaction?, int?, CommandType?) - QuerySingleAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) QuerySingleAsync(string, object?, IDbTransaction?, int?, CommandType?) QuerySingleAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) - QuerySingleOrDefault(Type, string, object?, IDbTransaction?, int?, CommandType?) QuerySingleOrDefault(string, object?, IDbTransaction?, int?, CommandType?) QuerySingleOrDefault<1>(string, object?, IDbTransaction?, int?, CommandType?) - QuerySingleOrDefaultAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) QuerySingleOrDefaultAsync(string, object?, IDbTransaction?, int?, CommandType?) QuerySingleOrDefaultAsync<1>(string, object?, IDbTransaction?, int?, CommandType?) QueryUnbufferedAsync(string, object?, DbTransaction?, int?, CommandType?) QueryUnbufferedAsync<1>(string, object?, DbTransaction?, int?, CommandType?) +## non-goal: Type-based (18) + + GetRowParser(Type, int, int, bool) + Query(Type, string, object?, IDbTransaction?, bool, int?, CommandType?) + Query<1>(string, Type[], Func, object?, IDbTransaction?, bool, string, int?, CommandType?) + QueryAsync(Type, CommandDefinition) + QueryAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) + QueryAsync<1>(string, Type[], Func, object?, IDbTransaction?, bool, string, int?, CommandType?) + QueryFirst(Type, string, object?, IDbTransaction?, int?, CommandType?) + QueryFirstAsync(Type, CommandDefinition) + QueryFirstAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) + QueryFirstOrDefault(Type, string, object?, IDbTransaction?, int?, CommandType?) + QueryFirstOrDefaultAsync(Type, CommandDefinition) + QueryFirstOrDefaultAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) + QuerySingle(Type, string, object?, IDbTransaction?, int?, CommandType?) + QuerySingleAsync(Type, CommandDefinition) + QuerySingleAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) + QuerySingleOrDefault(Type, string, object?, IDbTransaction?, int?, CommandType?) + QuerySingleOrDefaultAsync(Type, CommandDefinition) + QuerySingleOrDefaultAsync(Type, string, object?, IDbTransaction?, int?, CommandType?) + ## not inspected (9) AsList<1>() @@ -74,7 +86,7 @@ Dispositions: ReplaceLiterals(IDbCommand) SetTypeName(string) -## skipped silently (27) +## skipped silently (22) Execute(CommandDefinition) ExecuteAsync(CommandDefinition) @@ -85,23 +97,18 @@ Dispositions: GetRowParser<1>(Type?, int, int, bool) Query<1>(CommandDefinition) QueryAsync(CommandDefinition) - QueryAsync(Type, CommandDefinition) QueryAsync<1>(CommandDefinition) QueryFirst<1>(CommandDefinition) QueryFirstAsync(CommandDefinition) - QueryFirstAsync(Type, CommandDefinition) QueryFirstAsync<1>(CommandDefinition) QueryFirstOrDefault<1>(CommandDefinition) QueryFirstOrDefaultAsync(CommandDefinition) - QueryFirstOrDefaultAsync(Type, CommandDefinition) QueryFirstOrDefaultAsync<1>(CommandDefinition) QuerySingle<1>(CommandDefinition) QuerySingleAsync(CommandDefinition) - QuerySingleAsync(Type, CommandDefinition) QuerySingleAsync<1>(CommandDefinition) QuerySingleOrDefault<1>(CommandDefinition) QuerySingleOrDefaultAsync(CommandDefinition) - QuerySingleOrDefaultAsync(Type, CommandDefinition) QuerySingleOrDefaultAsync<1>(CommandDefinition) ## unsupported API (diagnosed) (16) @@ -123,13 +130,12 @@ Dispositions: QueryMultiple(string, object?, IDbTransaction?, int?, CommandType?) QueryMultipleAsync(string, object?, IDbTransaction?, int?, CommandType?) -## unsupported API (undiagnosed) (13) +## unsupported API (undiagnosed) (12) ExecuteReader(CommandDefinition) ExecuteReader(CommandDefinition, CommandBehavior) ExecuteReaderAsync(CommandDefinition) ExecuteReaderAsync(CommandDefinition, CommandBehavior) - GetRowParser(Type, int, int, bool) QueryAsync<3>(CommandDefinition, Func, string) QueryAsync<4>(CommandDefinition, Func, string) QueryAsync<5>(CommandDefinition, Func, string) diff --git a/test/Dapper.AOT.Test/ApiSurfaceCoverageTests.cs b/test/Dapper.AOT.Test/ApiSurfaceCoverageTests.cs index 93a18e44..ac0f29af 100644 --- a/test/Dapper.AOT.Test/ApiSurfaceCoverageTests.cs +++ b/test/Dapper.AOT.Test/ApiSurfaceCoverageTests.cs @@ -93,6 +93,9 @@ skipped silently dropped with nothing reported - the consumer gets vanilla Dapper under JIT and a runtime failure under native AOT, with no build-time signal. Every row here is a defect + non-goal: Type-based refused on purpose, with DAP056: the row type comes + from a Type at execution time, which generation + cannot follow. Use the generic overload """.Replace("\r\n", "\n")); @@ -119,6 +122,20 @@ private static string Classify(IMethodSymbol method) // examined at all - which is right for the helpers that need no interception if (!DapperInterceptorGenerator.IsCandidate(method.Name)) return "not inspected"; + // a *required* Type parameter means every call defers the row type to execution time, + // which is a declared non-goal (DAP056). An optional one - GetRowParser's + // concreteType - cannot be judged from the symbol: it depends on whether the call + // passes it, so those stay in whichever bucket they would otherwise fall into + foreach (var p in method.Parameters.Skip(1)) + { + var pt = p.Type is IArrayTypeSymbol array ? array.ElementType : p.Type; + if (!p.IsOptional + && pt is { Name: "Type", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } }) + { + return "non-goal: Type-based"; + } + } + // the analyzer only inspects (and so only reports on) call-sites carrying SQL as a // string argument; an overload that hides it inside CommandDefinition is dropped mute var diagnosed = DapperInterceptorGenerator.IsVisibleToAnalyzer(method); diff --git a/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.netfx.txt index 08388ab0..370120bf 100644 --- a/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.netfx.txt +++ b/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.netfx.txt @@ -1,4 +1,10 @@ -Generator produced 1 diagnostics: +Generator produced 3 diagnostics: Hidden DAP000 L1 C1 -Dapper.AOT handled 2 of 4 enabled call-sites (1 unsupported API, 0 refused with diagnostics, 1 skipped silently) using 2 interceptors, 0 commands and 1 readers +Dapper.AOT handled 2 of 4 enabled call-sites (0 unsupported API, 2 refused with diagnostics, 0 skipped silently) using 2 interceptors, 0 commands and 1 readers + +Warning DAP056 Interceptors/GetRowParser.input.cs L22 C22 +'GetRowParser' 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) + +Warning DAP056 Interceptors/GetRowParser.input.cs L42 C22 +'GetRowParser' 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) diff --git a/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.txt b/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.txt index 08388ab0..370120bf 100644 --- a/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.txt +++ b/test/Dapper.AOT.Test/Interceptors/GetRowParser.output.txt @@ -1,4 +1,10 @@ -Generator produced 1 diagnostics: +Generator produced 3 diagnostics: Hidden DAP000 L1 C1 -Dapper.AOT handled 2 of 4 enabled call-sites (1 unsupported API, 0 refused with diagnostics, 1 skipped silently) using 2 interceptors, 0 commands and 1 readers +Dapper.AOT handled 2 of 4 enabled call-sites (0 unsupported API, 2 refused with diagnostics, 0 skipped silently) using 2 interceptors, 0 commands and 1 readers + +Warning DAP056 Interceptors/GetRowParser.input.cs L22 C22 +'GetRowParser' 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) + +Warning DAP056 Interceptors/GetRowParser.input.cs L42 C22 +'GetRowParser' 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) diff --git a/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.input.cs b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.input.cs new file mode 100644 index 00000000..c72db5be --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.input.cs @@ -0,0 +1,27 @@ +using Dapper; +using System.Data.Common; + +[module: DapperAot] + +// Type-argument overloads choose the row type at execution time, which is the one thing +// compile-time generation cannot follow. Declared a non-goal (2026-08-26) rather than building a +// Type-keyed registry: DAP056 says so at the call-site and names the generic form to use. +public static class Foo +{ + static void SomeCode(DbConnection connection, DbDataReader reader) + { + // the generic forms: supported, and what DAP056 points people at + _ = connection.Query("select * from Customers"); + _ = reader.GetRowParser(); + + // ...and the Type-based forms: all refused, all reported + _ = connection.Query(typeof(Customer), "select * from Customers"); + _ = connection.QueryFirst(typeof(Customer), "select top 1 * from Customers"); + _ = connection.QuerySingleOrDefault(typeof(Customer), "select * from Customers where Id = 1"); + _ = reader.GetRowParser(typeof(Customer)); + + // an explicitly-passed concreteType is Type-based; omitting it (above) is not + _ = reader.GetRowParser(concreteType: typeof(Customer)); + } +} +public class Customer { public int Id { get; set; } } diff --git a/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.cs b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.cs new file mode 100644 index 00000000..522684a6 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.cs @@ -0,0 +1,119 @@ +#nullable enable +#pragma warning disable IDE0078 // unnecessary suppression is necessary +#pragma warning disable CS9270 // SDK-dependent change to interceptors usage +namespace Dapper.AOT // interceptors must be in a known namespace +{ + file static class DapperGeneratedInterceptors + { + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors/TypeBasedApis.input.cs", 14, 24)] + internal static global::System.Collections.Generic.IEnumerable Query0(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, Buffered, Text, BindResultsByName + // returns data: global::Customer + 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(buffered is true); + global::System.Diagnostics.Debug.Assert(param is null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), DefaultCommandFactory).QueryBuffered(param, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors/TypeBasedApis.input.cs", 15, 20)] + internal static global::System.Func GetRowParser1(this global::System.Data.Common.DbDataReader reader, global::System.Type? concreteType, int startIndex, int length, bool returnNullIfFirstMissing) + { + // TypedResult, GetRowParser + // returns data: global::Customer + global::System.Diagnostics.Debug.Assert(concreteType is null); + + return RowFactory0.Instance.GetRowParser(reader, startIndex, length, returnNullIfFirstMissing); + + } + + private class CommonCommandFactory : global::Dapper.CommandFactory + { + public override global::System.Data.Common.DbCommand GetCommand(global::System.Data.Common.DbConnection connection, string sql, global::System.Data.CommandType commandType, T args) + { + var cmd = base.GetCommand(connection, sql, commandType, args); + // apply special per-provider command initialization logic for OracleCommand + if (cmd is global::Oracle.ManagedDataAccess.Client.OracleCommand cmd0) + { + cmd0.BindByName = true; + cmd0.InitialLONGFetchSize = -1; + + } + return cmd; + } + + } + + private static readonly CommonCommandFactory DefaultCommandFactory = new(); + + 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 926444256U when NormalizedEquals(name, "id"): + token = type == typeof(int) ? 0 : 1; // two tokens for right-typed and type-flexible + break; + + } + tokens[i] = token; + columnOffset++; + + } + return null; + } + public override global::Customer Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Customer result = new(); + foreach (var token in tokens) + { + switch (token) + { + case 0: + result.Id = reader.GetInt32(columnOffset); + break; + case 1: + result.Id = GetValue(reader, columnOffset); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + + } +} +namespace System.Runtime.CompilerServices +{ + // this type is needed by the compiler to implement interceptors - it doesn't need to + // come from the runtime itself, though + + [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] + sealed file class InterceptsLocationAttribute : global::System.Attribute + { + public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber) + { + _ = path; + _ = lineNumber; + _ = columnNumber; + } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.cs new file mode 100644 index 00000000..522684a6 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.cs @@ -0,0 +1,119 @@ +#nullable enable +#pragma warning disable IDE0078 // unnecessary suppression is necessary +#pragma warning disable CS9270 // SDK-dependent change to interceptors usage +namespace Dapper.AOT // interceptors must be in a known namespace +{ + file static class DapperGeneratedInterceptors + { + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors/TypeBasedApis.input.cs", 14, 24)] + internal static global::System.Collections.Generic.IEnumerable Query0(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, Buffered, Text, BindResultsByName + // returns data: global::Customer + 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(buffered is true); + global::System.Diagnostics.Debug.Assert(param is null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), DefaultCommandFactory).QueryBuffered(param, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors/TypeBasedApis.input.cs", 15, 20)] + internal static global::System.Func GetRowParser1(this global::System.Data.Common.DbDataReader reader, global::System.Type? concreteType, int startIndex, int length, bool returnNullIfFirstMissing) + { + // TypedResult, GetRowParser + // returns data: global::Customer + global::System.Diagnostics.Debug.Assert(concreteType is null); + + return RowFactory0.Instance.GetRowParser(reader, startIndex, length, returnNullIfFirstMissing); + + } + + private class CommonCommandFactory : global::Dapper.CommandFactory + { + public override global::System.Data.Common.DbCommand GetCommand(global::System.Data.Common.DbConnection connection, string sql, global::System.Data.CommandType commandType, T args) + { + var cmd = base.GetCommand(connection, sql, commandType, args); + // apply special per-provider command initialization logic for OracleCommand + if (cmd is global::Oracle.ManagedDataAccess.Client.OracleCommand cmd0) + { + cmd0.BindByName = true; + cmd0.InitialLONGFetchSize = -1; + + } + return cmd; + } + + } + + private static readonly CommonCommandFactory DefaultCommandFactory = new(); + + 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 926444256U when NormalizedEquals(name, "id"): + token = type == typeof(int) ? 0 : 1; // two tokens for right-typed and type-flexible + break; + + } + tokens[i] = token; + columnOffset++; + + } + return null; + } + public override global::Customer Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Customer result = new(); + foreach (var token in tokens) + { + switch (token) + { + case 0: + result.Id = reader.GetInt32(columnOffset); + break; + case 1: + result.Id = GetValue(reader, columnOffset); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + + } +} +namespace System.Runtime.CompilerServices +{ + // this type is needed by the compiler to implement interceptors - it doesn't need to + // come from the runtime itself, though + + [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] + sealed file class InterceptsLocationAttribute : global::System.Attribute + { + public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber) + { + _ = path; + _ = lineNumber; + _ = columnNumber; + } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.txt new file mode 100644 index 00000000..cd887b70 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.netfx.txt @@ -0,0 +1,19 @@ +Generator produced 6 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 2 of 7 enabled call-sites (0 unsupported API, 5 refused with diagnostics, 0 skipped silently) using 2 interceptors, 0 commands and 1 readers + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L18 C13 +'Query' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L19 C13 +'QueryFirst' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L20 C13 +'QuerySingleOrDefault' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L21 C13 +'GetRowParser' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L24 C13 +'GetRowParser' 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) diff --git a/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.txt b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.txt new file mode 100644 index 00000000..cd887b70 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeBasedApis.output.txt @@ -0,0 +1,19 @@ +Generator produced 6 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 2 of 7 enabled call-sites (0 unsupported API, 5 refused with diagnostics, 0 skipped silently) using 2 interceptors, 0 commands and 1 readers + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L18 C13 +'Query' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L19 C13 +'QueryFirst' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L20 C13 +'QuerySingleOrDefault' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L21 C13 +'GetRowParser' 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) + +Warning DAP056 Interceptors/TypeBasedApis.input.cs L24 C13 +'GetRowParser' 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)