diff --git a/docs/rules/DAP052.md b/docs/rules/DAP052.md new file mode 100644 index 00000000..19ca5e54 --- /dev/null +++ b/docs/rules/DAP052.md @@ -0,0 +1,14 @@ +# DAP052 + +Feature requires a newer Dapper + +Some Dapper.AOT features work by calling into APIs that were added to Dapper itself for the +purpose - for example, `DynamicParameters` support delegates to the bag's own +`AddParameters(IDbCommand)` overload. Dapper.AOT *feature-detects* these APIs on the Dapper +version your project actually references: when the API is present the feature lights up, and +when it is not, the call-site is left on vanilla Dapper and this message tells you exactly +which API was missing - rather than generating code that cannot compile. + +Note that "left on vanilla Dapper" means reflection at runtime: fine under JIT, but it will +not work under native AOT. To enable the feature, update the Dapper package to a version +that includes the named API. diff --git a/notes/parity.md b/notes/parity.md index 2f7a31c4..8aaff32c 100644 --- a/notes/parity.md +++ b/notes/parity.md @@ -59,7 +59,7 @@ Two levers change several complexity scores and are worth naming up front: | --- | --- | --- | --- | --- | | anonymous types / concrete POCOs | ✅ | — | — | | | fields as members | ❓ | low | low | verify | -| `DynamicParameters` | ❌ | **high** | high | **PR #195 open**: delegate to the bag's own protocol (pairs with Dapper #2225); covers subclasses via interface dispatch. Templates ride on the same path | +| `DynamicParameters` | ✅ | — | — | delegates to the bag's own protocol, so templates, per-param options, `Get` and output callbacks all ride along; subclasses covered via interface dispatch. Needs a Dapper with the identity-free overload (Dapper #2225) — probe-gated, DAP052 otherwise | | `SqlMapper.IDynamicParameters` (custom impls) | ❌ | low-med | med | interface receives the `IDbCommand`, so callable directly — blocked on `Identity` (Dapper-internal) in the signature; owning Dapper permits an AOT-friendly overload | | `SqlMapper.ICustomQueryParameter` | ❌ ❓ | med | low | **PR #198 open**: generated code calls it, with vanilla's null semantics; uncovered a teardown bug (**PR #199**: parameters must be cleared on dispose, as vanilla does) | | `IParameterLookup` / `IParameterCallbacks` | ❌ ❓ | low | low-med | obscure but public | diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs index 61f5a7c4..342d636f 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs @@ -25,6 +25,7 @@ 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"), + FeatureNeedsNewerDapper = LibraryInfo("DAP052", "Feature requires a newer Dapper", "Dapper.AOT support for {0} needs '{1}', which the referenced Dapper version does not expose; the call-site is left on vanilla Dapper (which will not work under native AOT) - update the Dapper package to enable this"), NestedInGenericType = LibraryWarning("DAP051", "Type is only generic by containment", "Type '{0}' is generic only because it is declared inside generic type '{1}'; if it does not need the enclosing type parameters, move it to non-generic scope"), NonPublicType = LibraryInfo("DAP017", "Non-accessible type", "Type '{0}' is not accessible; {1} types are not currently supported"), SqlParametersNotDetected = SqlWarning("DAP018", "SQL parameters not detected", "Parameters are being supplied, but no parameters were detected in the command"), diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs index c8f390ad..9c2110dc 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs @@ -727,11 +727,24 @@ internal static Location SharedParseArgsAndFlags(in ParseState ctx, IInvocationO flags |= OperationFlags.DoNotGenerate; ReportGenericTypeParameter(reportDiagnostic, paramType!, argLocation); } - else if (IsMissingOrObjectOrDynamic(paramType) || IsDynamicParameters(paramType, out _)) + else if (IsMissingOrObjectOrDynamic(paramType)) { flags |= OperationFlags.DoNotGenerate; reportDiagnostic?.Invoke(Diagnostic.Create(Diagnostics.UntypedParameter, argLocation)); } + else if (IsDynamicParameters(paramType, out _)) + { + if (!HasIdentityFreeAddParameters(paramType)) + { + // no way to invoke the bag protocol externally on this Dapper version; + // leave the call-site on vanilla Dapper, saying exactly what is missing + // (never emit code that cannot compile against the referenced Dapper) + flags |= OperationFlags.DoNotGenerate; + reportDiagnostic?.Invoke(Diagnostic.Create(Diagnostics.FeatureNeedsNewerDapper, argLocation, + "DynamicParameters", "DynamicParameters.AddParameters(IDbCommand)")); + } + // else: supported - the generated factory delegates to the bag itself + } else if (!IsPublicOrAssemblyLocal(paramType, ctx, out var failing)) { flags |= OperationFlags.DoNotGenerate; @@ -1043,7 +1056,10 @@ void ValidateColumnAttribute() if (flags.HasAny(OperationFlags.StoredProcedure | OperationFlags.TableDirect)) { parseFlags = flags.HasAny(OperationFlags.StoredProcedure) ? SqlParseOutputFlags.MaybeQuery : SqlParseOutputFlags.Query; - mode = ParameterMode.All; + // a DynamicParameters-style bag supplies its own parameters at execution: defer, + // exactly as for command-text (otherwise the map ends up empty and the call-site + // gets the parameterless fallback factory) + mode = IsDynamicParameters(map?.DeclaredType, out _) ? ParameterMode.Defer : ParameterMode.All; } else { diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 4a527d60..f030779a 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -595,6 +595,18 @@ private static void WriteCommandFactory(in GenerateState ctx, string baseFactory { sb.Append("public override global::System.Threading.CancellationToken GetCancellationToken(").Append(declaredType).Append(" args) => args;").NewLine(); } + if (type.IsDynamicBag) + { + // the bag already implements the entire vanilla protocol (per-parameter settings, + // templates, literals, RemoveUnused, output storage for Get); delegate to it + sb.Append("public override void AddParameters(in global::Dapper.UnifiedCommand cmd, ").Append(declaredType).Append(" args)") + .Indent(false).NewLine().Append("=> args.AddParameters(cmd.Command!);").Outdent(false).NewLine().NewLine(); + sb.Append("public override bool RequirePostProcess => true;").NewLine().NewLine(); + sb.Append("public override void PostProcess(in global::Dapper.UnifiedCommand cmd, ").Append(declaredType).Append(" args, int rowCount)").Indent().NewLine() + .Append("if (args is global::Dapper.SqlMapper.IParameterCallbacks callbacks) callbacks.OnCompleted();").Outdent().NewLine(); + sb.Outdent().NewLine().NewLine(); + return; + } var flags = WriteArgsFlags.None; if (string.IsNullOrWhiteSpace(map)) { diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs index 739bf621..c0023e86 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs @@ -1,4 +1,4 @@ -using Dapper.Internal; +using Dapper.Internal; using Microsoft.CodeAnalysis; using System; using System.Data; @@ -19,13 +19,14 @@ internal sealed class ParamPlan : IEquatable public bool IsReferenceType { get; } public bool IsCancellationTokenType { get; } public string? ShapeLambda { get; } // the Cast(args, ...) witness, anonymous types only + public bool IsDynamicBag { get; } // DynamicParameters-style: the factory delegates to the bag itself public bool IsCollection { get; } // multi-exec candidate public string? CastType { get; } public ParamPlan? Element { get; } // multi-exec element (one level only) public EquatableArray Members { get; } private ParamPlan(string typeName, string declaredType, bool isAnonymous, bool isReferenceType, - bool isCancellationTokenType, string? shapeLambda, bool isCollection, string? castType, + bool isCancellationTokenType, string? shapeLambda, bool isDynamicBag, bool isCollection, string? castType, ParamPlan? element, in EquatableArray members) { TypeName = typeName; @@ -34,6 +35,7 @@ private ParamPlan(string typeName, string declaredType, bool isAnonymous, bool i IsReferenceType = isReferenceType; IsCancellationTokenType = isCancellationTokenType; ShapeLambda = shapeLambda; + IsDynamicBag = isDynamicBag; IsCollection = isCollection; CastType = castType; Element = element; @@ -57,10 +59,12 @@ private ParamPlan(string typeName, string declaredType, bool isAnonymous, bool i shapeLambda = sb.ToString(); } + bool isDynamicBag = Inspection.IsDynamicParameters(type, out _) && Inspection.HasIdentityFreeAddParameters(type); + bool isCollection = false; string? castType = null; ParamPlan? element = null; - if (allowCollection && Inspection.IsCollectionType(type, out var elementType, out var castTypeValue)) + if (!isDynamicBag && allowCollection && Inspection.IsCollectionType(type, out var elementType, out var castTypeValue)) { isCollection = true; castType = castTypeValue; @@ -68,19 +72,22 @@ private ParamPlan(string typeName, string declaredType, bool isAnonymous, bool i } EquatableArray members = default; - var memberMap = MemberMap.CreateForParameters(type); - if (memberMap is not null && !memberMap.Members.IsDefaultOrEmpty) + if (!isDynamicBag) { - var arr = new ParamMember[memberMap.Members.Length]; - for (int i = 0; i < arr.Length; i++) + var memberMap = MemberMap.CreateForParameters(type); + if (memberMap is not null && !memberMap.Members.IsDefaultOrEmpty) { - arr[i] = ParamMember.Create(memberMap.Members[i]); + var arr = new ParamMember[memberMap.Members.Length]; + for (int i = 0; i < arr.Length; i++) + { + arr[i] = ParamMember.Create(memberMap.Members[i]); + } + members = new EquatableArray(arr); } - members = new EquatableArray(arr); } return new(typeName, declaredType, type.IsAnonymousType, type.IsReferenceType, - Inspection.IsCancellationToken(type), shapeLambda, isCollection, castType, element, members); + Inspection.IsCancellationToken(type), shapeLambda, isDynamicBag, isCollection, castType, element, members); } private static void AppendShapeLambda(CodeWriter sb, ITypeSymbol parameterType) @@ -119,6 +126,7 @@ public bool Equals(ParamPlan? other) => other is not null && IsReferenceType == other.IsReferenceType && IsCancellationTokenType == other.IsCancellationTokenType && string.Equals(ShapeLambda, other.ShapeLambda, StringComparison.Ordinal) + && IsDynamicBag == other.IsDynamicBag && IsCollection == other.IsCollection && string.Equals(CastType, other.CastType, StringComparison.Ordinal) && Equals(Element, other.Element) diff --git a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs index b4cb2679..a43c1d22 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs @@ -293,6 +293,45 @@ internal static bool IsDynamicParameters(ITypeSymbol? type, out bool needsConstr return false; } + /// + /// Does this parameter-bag type expose the identity-free AddParameters(IDbCommand) + /// self-apply API (Dapper vNext)? When it does, the generated command factory can simply + /// delegate to the bag's own (vanilla) protocol; when it does not, the call-site must be + /// left on vanilla Dapper, since Identity cannot be constructed externally. + /// + public static bool HasIdentityFreeAddParameters(ITypeSymbol? type) + { + while (type is not null) + { + foreach (var member in type.GetMembers("AddParameters")) + { + if (member is IMethodSymbol + { + IsStatic: false, DeclaredAccessibility: Accessibility.Public, + Parameters.Length: 1 + } method + && method.Parameters[0].Type is INamedTypeSymbol + { + Name: "IDbCommand", TypeKind: TypeKind.Interface, ContainingType: null, + ContainingNamespace: + { + Name: "Data", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true + } + } + }) + { + return true; + } + } + type = type.BaseType; + } + return false; + } + public static bool IsPublicOrAssemblyLocal(ISymbol? symbol, in ParseState ctx, out ISymbol? failingSymbol) => IsPublicOrAssemblyLocal(symbol, ctx.SemanticModel.Compilation.Assembly, out failingSymbol); diff --git a/test/Dapper.AOT.Test/Verifiers/DAP015.cs b/test/Dapper.AOT.Test/Verifiers/DAP015.cs index 1fd28f19..80a0fab9 100644 --- a/test/Dapper.AOT.Test/Verifiers/DAP015.cs +++ b/test/Dapper.AOT.Test/Verifiers/DAP015.cs @@ -28,6 +28,9 @@ public class Customer {} } """, DefaultConfig, [ Diagnostic(Diagnostics.UntypedParameter).WithLocation(0), - Diagnostic(Diagnostics.UntypedParameter).WithLocation(1)]); + // DynamicParameters is no longer 'untyped': it gets the specific needs-newer-Dapper + // guidance (or works outright, once the referenced Dapper has the self-apply API) + Diagnostic(Diagnostics.FeatureNeedsNewerDapper).WithLocation(1) + .WithArguments("DynamicParameters", "DynamicParameters.AddParameters(IDbCommand)")]); } \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Verifiers/DAP052.cs b/test/Dapper.AOT.Test/Verifiers/DAP052.cs new file mode 100644 index 00000000..30f515ec --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP052.cs @@ -0,0 +1,36 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using static Dapper.CodeAnalysis.DapperAnalyzer; + +namespace Dapper.AOT.Test.Verifiers; + +public class DAP052 : Verifier +{ + // the last Dapper release WITHOUT AddParameters(IDbCommand); pinned so this test keeps + // guarding the probe-and-refuse path after the project-wide Dapper reference gains the + // API (at which point a positive twin - same code, live reference, no diagnostic - + // becomes writable for the first time) + private const string DapperWithoutTheApi = "2.1.72"; + + [Fact] // a Dapper without AddParameters(IDbCommand): the bag call-site is refused + // with a message naming exactly what is missing + public Task DynamicParametersNeedsNewerDapper() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [DapperAot] + class SomeCode + { + public void Foo(DbConnection conn) + { + var bag = new DynamicParameters(); + bag.Add("id", 42); + conn.Execute("somesql", {|#0:bag|}); + } + } + """, DefaultConfig, [ + Diagnostic(Diagnostics.FeatureNeedsNewerDapper).WithLocation(0) + .WithArguments("DynamicParameters", "DynamicParameters.AddParameters(IDbCommand)"), + ], pinDapperPackageVersion: DapperWithoutTheApi); +} diff --git a/test/Dapper.AOT.Test/Verifiers/Verifier.cs b/test/Dapper.AOT.Test/Verifiers/Verifier.cs index aff134c4..abc53ebf 100644 --- a/test/Dapper.AOT.Test/Verifiers/Verifier.cs +++ b/test/Dapper.AOT.Test/Verifiers/Verifier.cs @@ -1,4 +1,4 @@ -using Dapper.CodeAnalysis; +using Dapper.CodeAnalysis; using Dapper.SqlAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeFixes; @@ -11,6 +11,7 @@ using Microsoft.CodeAnalysis.VisualBasic.Testing; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Text; using System.Threading; @@ -32,11 +33,12 @@ protected static DiagnosticResult InterceptorsGenerated(int handled, int total, internal Task CSVerifyAsync(string source, Func[] transforms, - DiagnosticResult[] expected, SqlSyntax sqlSyntax, SqlParseInputFlags sqlParseInputFlags = SqlParseInputFlags.None, bool refDapperAot = true) + DiagnosticResult[] expected, SqlSyntax sqlSyntax, SqlParseInputFlags sqlParseInputFlags = SqlParseInputFlags.None, bool refDapperAot = true, + string? pinDapperPackageVersion = null) where TAnalyzer : DiagnosticAnalyzer, new() { var test = new CSharpAnalyzerTest(); - return ExecuteAsync(test, source, transforms, expected, sqlSyntax, sqlParseInputFlags, refDapperAot); + return ExecuteAsync(test, source, transforms, expected, sqlSyntax, sqlParseInputFlags, refDapperAot, pinDapperPackageVersion); } internal Task CSVerifyAsync(string source, Func[] transforms, @@ -68,7 +70,8 @@ internal Task VBVerifyAsync(string source, internal Task ExecuteAsync(AnalyzerTest test, string source, Func[] transforms, - DiagnosticResult[] expected, SqlSyntax sqlSyntax, SqlParseInputFlags sqlParseInputFlags, bool refDapperAot) + DiagnosticResult[] expected, SqlSyntax sqlSyntax, SqlParseInputFlags sqlParseInputFlags, bool refDapperAot, + string? pinDapperPackageVersion = null) { test.TestCode = source; @@ -95,7 +98,18 @@ internal Task ExecuteAsync(AnalyzerTest test, string source, { test.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", CreateEditorConfig(sqlSyntax, sqlParseInputFlags))); } - test.TestState.AdditionalReferences.Add(typeof(SqlMapper).Assembly); + if (pinDapperPackageVersion is null) + { + test.TestState.AdditionalReferences.Add(typeof(SqlMapper).Assembly); + } + else + { + // reference a specific shipped Dapper *package* instead of the live assembly; + // this is how feature-detection (DAP052) stays testable after the project-wide + // Dapper reference gains the API being probed for + test.ReferenceAssemblies = test.ReferenceAssemblies.AddPackages( + [new PackageIdentity("Dapper", pinDapperPackageVersion)]); + } if (refDapperAot) { test.TestState.AdditionalReferences.Add(typeof(DapperAotAttribute).Assembly); @@ -199,8 +213,9 @@ class SomeCode internal Task CSVerifyAsync(string source, Func[] transforms, - DiagnosticResult[] expected, SqlSyntax sqlSyntax = SqlSyntax.SqlServer, SqlParseInputFlags sqlParseInputFlags = SqlParseInputFlags.None, bool refDapperAot = true) - => base.CSVerifyAsync(source, transforms, expected, sqlSyntax, sqlParseInputFlags, refDapperAot); + DiagnosticResult[] expected, SqlSyntax sqlSyntax = SqlSyntax.SqlServer, SqlParseInputFlags sqlParseInputFlags = SqlParseInputFlags.None, bool refDapperAot = true, + string? pinDapperPackageVersion = null) + => base.CSVerifyAsync(source, transforms, expected, sqlSyntax, sqlParseInputFlags, refDapperAot, pinDapperPackageVersion); new internal Task CSVerifyAsync(string source, Func[] transforms,