From 659bd0d1beeb80aab9292d5f65280a3070131b55 Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Tue, 30 Jun 2026 09:10:22 -0500 Subject: [PATCH] Surface const-sql diagnostics from generated files When the `sql` argument resolves to a `const string`, the SQL parameter analysis already runs, but its diagnostics (DAP214, etc.) were anchored at the constant's declaration. For a constant emitted into a generated file, the analyzer driver drops diagnostics there (generated-code suppression), so only the call-site DAP018 survived and it looked like the SQL was never analyzed. Re-home the SQL-parse diagnostics onto the call-site sql argument when the declaration is in generated code, so they stay visible. Inline literals and constants in hand-written documents (same or sibling file) keep their precise declaration-token location. Closes #177 --- .../CodeAnalysis/DapperAnalyzer.cs | 13 +++- .../Internal/Inspection.cs | 36 ++++++++++ test/Dapper.AOT.Test/Verifiers/DAP214.cs | 67 ++++++++++++++++++- test/Dapper.AOT.Test/Verifiers/Verifier.cs | 20 ++++-- 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs index 31cf8d68..e9c22fe3 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs @@ -431,6 +431,17 @@ StringSyntaxKind.ConcatenatedString or StringSyntaxKind.FormatString location ??= ctx.Operation.Syntax.GetLocation(); + // a constant declared in generated code would have its SQL diagnostics dropped by the driver; + // re-home them onto the call-site argument so they stay visible (inline/same-file keep the token) + // https://github.com/DapperLib/DapperAOT/issues/177 + var sqlLocation = location; + if (sqlSyntax is not null && sqlSyntax.SyntaxTree != sqlSource.Syntax.SyntaxTree + && IsGeneratedDocument(sqlSyntax.SyntaxTree, ctx.Compilation, ctx.CancellationToken)) + { + sqlLocation = sqlSource.Syntax.GetLocation(); + sqlSyntax = null; + } + if (DebugSqlFlags is not null) { var debugModeFlags = DebugSqlFlags.Value; @@ -458,7 +469,7 @@ StringSyntaxKind.ConcatenatedString or StringSyntaxKind.FormatString forgiveSyntaxErrors = true; // we're just taking a punt, honestly goto case SqlSyntax.SqlServer; case SqlSyntax.SqlServer: - var proc = new OperationAnalysisContextTSqlProcessor(ctx, null, flags, location, sqlSyntax); + var proc = new OperationAnalysisContextTSqlProcessor(ctx, null, flags, sqlLocation, sqlSyntax); proc.Execute(sql!, parameters); parseFlags = proc.Flags; // paramMembers); diff --git a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs index 977755a9..9388e247 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs @@ -1450,6 +1450,42 @@ internal static bool IsCommand(INamedTypeSymbol type) return false; } + public static bool IsGeneratedDocument(SyntaxTree tree, Compilation compilation, CancellationToken cancellationToken) + { + // explicit editorconfig `generated_code` + switch (compilation.Options.SyntaxTreeOptionsProvider?.IsGenerated(tree, cancellationToken)) + { + case GeneratedKind.MarkedGenerated: return true; + case GeneratedKind.NotGenerated: return false; + } + + // file-name convention + var name = System.IO.Path.GetFileNameWithoutExtension(tree.FilePath); + if (name.StartsWith("TemporaryGeneratedFile_", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".designer", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".generated", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".g", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".g.i", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // `` header (text scan; only comment trivia contains it, so language-agnostic) + foreach (var trivia in tree.GetRoot(cancellationToken).GetLeadingTrivia()) + { + if (trivia.Span.Length > 0) + { + var text = trivia.ToString(); + if (text.IndexOf("= 0 + || text.IndexOf("= 0) + { + return true; + } + } + } + return false; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0042:Deconstruct variable declaration", Justification = "Fine as is; let's not pay the unwrap cost")] public static SqlSyntax? IdentifySqlSyntax(in ParseState ctx, IOperation op, out bool caseSensitive) { diff --git a/test/Dapper.AOT.Test/Verifiers/DAP214.cs b/test/Dapper.AOT.Test/Verifiers/DAP214.cs index 683b3a58..6cb03012 100644 --- a/test/Dapper.AOT.Test/Verifiers/DAP214.cs +++ b/test/Dapper.AOT.Test/Verifiers/DAP214.cs @@ -1,4 +1,4 @@ -using Dapper.CodeAnalysis; +using Dapper.CodeAnalysis; using System.Threading.Tasks; using Xunit; using Diagnostics = Dapper.CodeAnalysis.DapperAnalyzer.Diagnostics; @@ -10,5 +10,66 @@ public class DAP214 : Verifier public Task VariableNotDeclared() => SqlVerifyAsync(""" select {|#0:@i|}; """, SqlAnalysis.SqlParseInputFlags.KnownParameters, Diagnostic(Diagnostics.VariableNotDeclared).WithLocation(0).WithArguments("@i")); - -} \ No newline at end of file + + private const string SqlClass = """ + static class Sql + { + public const string GetUsers = + "select Id from Users where LoginCount >= {|#2:@MinLogins|}"; + } + """; + + // same class behind an header, as a source generator would emit + private const string GeneratedSqlClass = "// \n" + SqlClass; + + [Fact] // const sql in a generated doc re-homes to the call-site, https://github.com/DapperLib/DapperAOT/issues/177 + public Task ConstSqlInGeneratedDocument() => CSVerifyAsync("""" + using Dapper; + using System.Data.Common; + + [DapperAot] + static class Q + { + public static void ViaConst(DbConnection c) => + c.Query({|#0:Sql.GetUsers|}, {|#1:new { Wrong = 1 }|}); + } + """", DefaultConfig, [ + Diagnostic(Diagnostics.VariableNotDeclared).WithLocation(0).WithArguments("@MinLogins"), + Diagnostic(Diagnostics.SqlParametersNotDetected).WithLocation(1), + ], additionalSources: [GeneratedSqlClass]); + + [Fact] // const sql in a visible sibling doc keeps its declaration token, https://github.com/DapperLib/DapperAOT/issues/177 + public Task ConstSqlInVisibleSiblingDocument() => CSVerifyAsync("""" + using Dapper; + using System.Data.Common; + + [DapperAot] + static class Q + { + public static void ViaConst(DbConnection c) => + c.Query(Sql.GetUsers, {|#1:new { Wrong = 1 }|}); + } + """", DefaultConfig, [ + Diagnostic(Diagnostics.VariableNotDeclared).WithLocation(2).WithArguments("@MinLogins"), + Diagnostic(Diagnostics.SqlParametersNotDetected).WithLocation(1), + ], additionalSources: [SqlClass]); + + [Fact] // const sql in the same doc keeps its declaration token, https://github.com/DapperLib/DapperAOT/issues/177 + public Task ConstSqlInSameDocument() => CSVerifyAsync("""" + using Dapper; + using System.Data.Common; + + [DapperAot] + static class Q + { + public const string GetUsers = + "select Id from Users where LoginCount >= {|#0:@MinLogins|}"; + + public static void ViaConst(DbConnection c) => + c.Query(GetUsers, {|#1:new { Wrong = 1 }|}); + } + """", DefaultConfig, [ + Diagnostic(Diagnostics.VariableNotDeclared).WithLocation(0).WithArguments("@MinLogins"), + Diagnostic(Diagnostics.SqlParametersNotDetected).WithLocation(1), + ]); +} diff --git a/test/Dapper.AOT.Test/Verifiers/Verifier.cs b/test/Dapper.AOT.Test/Verifiers/Verifier.cs index aff134c4..b4af0867 100644 --- a/test/Dapper.AOT.Test/Verifiers/Verifier.cs +++ b/test/Dapper.AOT.Test/Verifiers/Verifier.cs @@ -32,11 +32,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[]? additionalSources = 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, additionalSources); } internal Task CSVerifyAsync(string source, Func[] transforms, @@ -68,9 +69,17 @@ 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[]? additionalSources = null) { test.TestCode = source; + if (additionalSources is not null) + { + foreach (var additionalSource in additionalSources) + { + test.TestState.Sources.Add(additionalSource); + } + } // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (expected is not null) @@ -199,8 +208,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[]? additionalSources = null) + => base.CSVerifyAsync(source, transforms, expected, sqlSyntax, sqlParseInputFlags, refDapperAot, additionalSources); new internal Task CSVerifyAsync(string source, Func[] transforms,