diff --git a/docs/rules/DAP241.md b/docs/rules/DAP241.md new file mode 100644 index 00000000..67521c20 --- /dev/null +++ b/docs/rules/DAP241.md @@ -0,0 +1,28 @@ +# DAP241 + +Dapper allows writing [parameterized queries](https://github.com/DapperLib/Dapper/blob/main/Readme.md#parameterized-queries), +but developer should **never interpolate** values into the query string (`sql` parameter for any Dapper-method). + +Bad: + +``` csharp +var id = 42; +conn.Execute($"select Id from Customers where Id = {id}"); +``` + +Instead the intended way is to pass the anynomous object, +members of which will be mapped into the query parameter using the same name. + +In example for the object `new { A = 42, B = "hello world" }`: +- member `A` will be mapped into the parameter `@A` +- member `B` will be mapped into the parameter `@B` + +Good: + +``` csharp +var id = 42; +conn.Execute( + $"select Id from Customers where Id = @queryId", + new { queryId = id } +); +``` \ No newline at end of file diff --git a/docs/rules/DAP242.md b/docs/rules/DAP242.md new file mode 100644 index 00000000..19eeb3cd --- /dev/null +++ b/docs/rules/DAP242.md @@ -0,0 +1,28 @@ +# DAP242 + +Dapper allows writing [parameterized queries](https://github.com/DapperLib/Dapper/blob/main/Readme.md#parameterized-queries), +but developer should **never concatenate** values into the query string (`sql` parameter for any Dapper-method). + +Bad: + +``` csharp +var id = 42; +conn.Execute("select Id from Customers where Id = " + id); +``` + +Instead the intended way is to pass the anynomous object, +members of which will be mapped into the query parameter using the same name. + +In example for the object `new { A = 42, B = "hello world" }`: +- member `A` will be mapped into the parameter `@A` +- member `B` will be mapped into the parameter `@B` + +Good: + +``` csharp +var id = 42; +conn.Execute( + $"select Id from Customers where Id = @queryId", + new { queryId = id } +); +``` \ No newline at end of file diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs index 06b8735b..3044453c 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs @@ -89,6 +89,8 @@ public static readonly DiagnosticDescriptor DivideByZero = SqlWarning("DAP237", "Divide by zero", "Division by zero detected"), TrivialOperand = SqlWarning("DAP238", "Trivial operand", "Operand makes this calculation trivial; it can be simplified"), InvalidNullExpression = SqlWarning("DAP239", "Invalid null expression", "Operation requires a non-null operand"), - VariableParameterConflict = SqlError("DAP240", "Parameter/variable conflict", "The declaration of variable '{0}' conflicts with a parameter"); + VariableParameterConflict = SqlError("DAP240", "Parameter/variable conflict", "The declaration of variable '{0}' conflicts with a parameter"), + InterpolatedStringSqlExpression = SqlWarning("DAP241", "Interpolated string usage", "Data values should not be interpolated into SQL string - use parameters instead"), + ConcatenatedStringSqlExpression = SqlWarning("DAP242", "Concatenated string usage", "Data values should not be concatenated into SQL string - use parameters instead"); } } diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs index 477c42b2..7891900c 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs @@ -2,6 +2,7 @@ using Dapper.Internal.Roslyn; using Dapper.SqlAnalysis; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; using System; @@ -371,7 +372,23 @@ private void ValidateSql(in OperationAnalysisContext ctx, IOperation sqlSource, if (caseSensitive) flags |= SqlParseInputFlags.CaseSensitive; // can we get the SQL itself? - if (!TryGetConstantValueWithSyntax(sqlSource, out string? sql, out var sqlSyntax)) return; + if (!TryGetConstantValueWithSyntax(sqlSource, out string? sql, out var sqlSyntax, out var stringSyntaxKind)) + { + DiagnosticDescriptor? descriptor = stringSyntaxKind switch + { + StringSyntaxKind.InterpolatedString + => Diagnostics.InterpolatedStringSqlExpression, + StringSyntaxKind.ConcatenatedString or StringSyntaxKind.FormatString + => Diagnostics.ConcatenatedStringSqlExpression, + _ => null + }; + + if (descriptor is not null) + { + ctx.ReportDiagnostic(Diagnostic.Create(descriptor, sqlSource.Syntax.GetLocation())); + } + return; + } if (string.IsNullOrWhiteSpace(sql) || !HasWhitespace.IsMatch(sql)) return; // need non-trivial content to validate location ??= ctx.Operation.Syntax.GetLocation(); @@ -461,7 +478,7 @@ internal static Location SharedParseArgsAndFlags(in ParseState ctx, IInvocationO switch (arg.Parameter?.Name) { case "sql": - if (TryGetConstantValueWithSyntax(arg, out string? s, out _)) + if (TryGetConstantValueWithSyntax(arg, out string? s, out _, out _)) { sql = s; } diff --git a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs index 1417f354..cb39eb98 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs @@ -888,7 +888,7 @@ public static bool IsDapperMethod(this IInvocationOperation operation, out Opera public static bool HasAll(this OperationFlags value, OperationFlags testFor) => (value & testFor) == testFor; public static bool TryGetConstantValue(IOperation op, out T? value) - => TryGetConstantValueWithSyntax(op, out value, out _); + => TryGetConstantValueWithSyntax(op, out value, out _, out _); public static ITypeSymbol? GetResultType(this IInvocationOperation invocation, OperationFlags flags) { @@ -907,7 +907,7 @@ internal static bool CouldBeNullable(ITypeSymbol symbol) => symbol.IsValueType ? symbol.NullableAnnotation == NullableAnnotation.Annotated : symbol.NullableAnnotation != NullableAnnotation.NotAnnotated; - public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value, out SyntaxNode? syntax) + public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value, out SyntaxNode? syntax, out StringSyntaxKind? syntaxKind) { try { @@ -915,6 +915,7 @@ public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value { value = (T?)val.ConstantValue.Value; syntax = val.Syntax; + syntaxKind = val.TryDetectOperationStringSyntaxKind(); return true; } if (val is IArgumentOperation arg) @@ -927,11 +928,29 @@ public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value val = conv.Operand; } + if (!val.ConstantValue.HasValue) + { + var stringSyntaxKind = val.TryDetectOperationStringSyntaxKind(); + switch (stringSyntaxKind) + { + case StringSyntaxKind.ConcatenatedString: + case StringSyntaxKind.InterpolatedString: + case StringSyntaxKind.FormatString: + { + value = default!; + syntax = null; + syntaxKind = stringSyntaxKind; + return false; + } + } + } + // type-level constants if (val is IFieldReferenceOperation field && field.Field.HasConstantValue) { value = (T?)field.Field.ConstantValue; syntax = field.Field.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + syntaxKind = val.TryDetectOperationStringSyntaxKind(); return true; } @@ -940,6 +959,7 @@ public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value { value = (T?)local.Local.ConstantValue; syntax = local.Local.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + syntaxKind = val.TryDetectOperationStringSyntaxKind(); return true; } @@ -948,6 +968,7 @@ public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value { value = (T?)val.ConstantValue.Value; syntax = val.Syntax; + syntaxKind = val.TryDetectOperationStringSyntaxKind(); return true; } @@ -957,12 +978,14 @@ public static bool TryGetConstantValueWithSyntax(IOperation val, out T? value // we already ruled out explicit constant above, so: must be default value = default; syntax = val.Syntax; + syntaxKind = val.TryDetectOperationStringSyntaxKind(); return true; } } catch { } value = default!; syntax = null; + syntaxKind = StringSyntaxKind.NotRecognized; return false; } diff --git a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.CSharp.cs b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.CSharp.cs index 5e74b3f2..c055d10f 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.CSharp.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.CSharp.cs @@ -2,7 +2,9 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; using System; +using System.Linq; namespace Dapper.Internal.Roslyn; partial class LanguageHelper @@ -78,6 +80,34 @@ internal override bool TryGetStringSpan(SyntaxToken token, string text, scoped i skip = take = 0; return false; } + + internal override StringSyntaxKind? TryDetectOperationStringSyntaxKind(IOperation operation) + { + if (operation is not ILocalReferenceOperation localOperation) + { + return base.TryDetectOperationStringSyntaxKind(operation); + } + + var referenceSyntax = localOperation.Local?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + if (referenceSyntax is VariableDeclaratorSyntax variableDeclaratorSyntax) + { + var initializer = variableDeclaratorSyntax.Initializer?.Value; + if (initializer is null) + { + return StringSyntaxKind.NotRecognized; + } + if (initializer is InterpolatedStringExpressionSyntax) + { + return StringSyntaxKind.InterpolatedString; + } + if (initializer is BinaryExpressionSyntax) + { + return StringSyntaxKind.ConcatenatedString; + } + } + + return StringSyntaxKind.NotRecognized; + } } static int CountQuotes(string text) diff --git a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.Null.cs b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.Null.cs index 796b059e..7375f8a3 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.Null.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.Null.cs @@ -26,5 +26,8 @@ internal override bool TryGetStringSpan(SyntaxToken syntax, string text, scoped internal override string GetDisplayString(ISymbol symbol) => symbol?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)!; + + internal override StringSyntaxKind? TryDetectOperationStringSyntaxKind(IOperation operation) + => null; } } diff --git a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.VisualBasic.cs b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.VisualBasic.cs index f7301eb1..c511ca89 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.VisualBasic.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.VisualBasic.cs @@ -1,8 +1,10 @@ using Dapper.SqlAnalysis; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; using Microsoft.CodeAnalysis.VisualBasic; using Microsoft.CodeAnalysis.VisualBasic.Syntax; using System; +using System.Linq; namespace Dapper.Internal.Roslyn; @@ -53,5 +55,48 @@ internal override bool TryGetStringSpan(SyntaxToken token, string text, scoped i skip = take = 0; return false; } + + internal override StringSyntaxKind? TryDetectOperationStringSyntaxKind(IOperation operation) + { + if (operation is not ILocalReferenceOperation localOperation) + { + return base.TryDetectOperationStringSyntaxKind(operation); + } + + var referenceSyntax = localOperation.Local?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + var variableDeclaratorSyntax = FindVariableDeclarator(); + if (variableDeclaratorSyntax is not null) + { + var initializer = variableDeclaratorSyntax.Initializer?.Value; + if (initializer is null) + { + return StringSyntaxKind.NotRecognized; + } + if (initializer is InterpolatedStringExpressionSyntax) + { + return StringSyntaxKind.InterpolatedString; + } + if (initializer is BinaryExpressionSyntax) + { + return StringSyntaxKind.ConcatenatedString; + } + } + + return StringSyntaxKind.NotRecognized; + + VariableDeclaratorSyntax? FindVariableDeclarator() + { + if (referenceSyntax is ModifiedIdentifierSyntax modifiedIdentifierSyntax) + { + if (modifiedIdentifierSyntax.Parent is VariableDeclaratorSyntax) + { + return (VariableDeclaratorSyntax)modifiedIdentifierSyntax.Parent; + } + } + + if (referenceSyntax is VariableDeclaratorSyntax varDeclar) return varDeclar; + return null; + } + } } } \ No newline at end of file diff --git a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.cs b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.cs index 923ae5d9..cf131453 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Roslyn/LanguageHelper.cs @@ -26,6 +26,9 @@ public static bool IsMemberAccess(this SyntaxNode syntax) public static bool IsMethodDeclaration(this SyntaxNode syntax) => GetHelper(syntax.Language).IsMethodDeclaration(syntax); + public static StringSyntaxKind? TryDetectOperationStringSyntaxKind(this IOperation operation) + => GetHelper(operation.Syntax?.Language).TryDetectOperationStringSyntaxKind(operation); + public static Location ComputeLocation(this SyntaxToken token, scoped in TSqlProcessor.Location location) { var origin = token.GetLocation(); @@ -96,4 +99,32 @@ internal abstract partial class LanguageHelper internal abstract bool TryGetStringSpan(SyntaxToken token, string text, scoped in TSqlProcessor.Location location, out int skip, out int take); internal abstract bool IsName(SyntaxNode syntax); internal abstract string GetDisplayString(ISymbol method); + + internal virtual StringSyntaxKind? TryDetectOperationStringSyntaxKind(IOperation operation) + { + if (operation is null) return null; + if (operation is IBinaryOperation) + { + return StringSyntaxKind.ConcatenatedString; + } + if (operation is IInterpolatedStringOperation) + { + return StringSyntaxKind.InterpolatedString; + } + if (operation is IInvocationOperation invocation) + { + // `string.Format()` + if (invocation.TargetMethod is + { + Name: "Format", + ContainingType: { SpecialType: SpecialType.System_String }, + ContainingNamespace: { Name: "System" } + }) + { + return StringSyntaxKind.FormatString; + } + } + + return StringSyntaxKind.NotRecognized; + } } \ No newline at end of file diff --git a/src/Dapper.AOT.Analyzers/Internal/Roslyn/StringSyntaxKind.cs b/src/Dapper.AOT.Analyzers/Internal/Roslyn/StringSyntaxKind.cs new file mode 100644 index 00000000..e21dd4ce --- /dev/null +++ b/src/Dapper.AOT.Analyzers/Internal/Roslyn/StringSyntaxKind.cs @@ -0,0 +1,18 @@ +namespace Dapper.Internal.Roslyn +{ + /// + /// Represents a syntaxKind for the string usage + /// + internal enum StringSyntaxKind + { + NotRecognized, + + ConcatenatedString, + InterpolatedString, + + /// + /// Represents a syntax for `string.Format()` API + /// + FormatString + } +} diff --git a/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj b/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj index edf11ced..841ac5cf 100644 --- a/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj +++ b/test/Dapper.AOT.Test/Dapper.AOT.Test.csproj @@ -21,7 +21,11 @@ $([System.String]::Copy(%(Filename)).Replace('.output.netfx', '.input.cs')) - + + $([System.String]::Copy(%(Filename)).Replace('.VB.cs', '.cs')) + + + diff --git a/test/Dapper.AOT.Test/Verifiers/DAP241.VB.cs b/test/Dapper.AOT.Test/Verifiers/DAP241.VB.cs new file mode 100644 index 00000000..5f784456 --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP241.VB.cs @@ -0,0 +1,34 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using Diagnostics = Dapper.CodeAnalysis.DapperAnalyzer.Diagnostics; + +namespace Dapper.AOT.Test.Verifiers; + +public partial class DAP241 +{ + [Fact] + public Task VBInterpolatedStringDetection_DirectUsage() => VerifyVBInterpolatedTest(""" + Dim id As Integer = 42 + conn.Execute({|#0:$"select Id from Customers where Id = {id}"|}) + """); + + [Fact] + public Task VBInterpolatedStringDetection_LocalVariableUsage() => VerifyVBInterpolatedTest(""" + Dim id As Integer = 42 + Dim sqlQuery As String = $"select Id from Customers where Id = {id}" + conn.Execute({|#0:sqlQuery|}) + """); + + private Task VerifyVBInterpolatedTest(string methodCode) => VBVerifyAsync($$""" + Imports Dapper + Imports System.Data.Common + + + Class SomeCode + Public Sub Foo(conn As DbConnection) + {{methodCode}} + End Sub + End Class + """, DefaultConfig, [Diagnostic(Diagnostics.InterpolatedStringSqlExpression).WithLocation(0)]); +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Verifiers/DAP241.cs b/test/Dapper.AOT.Test/Verifiers/DAP241.cs new file mode 100644 index 00000000..2e2ac20b --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP241.cs @@ -0,0 +1,54 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using Diagnostics = Dapper.CodeAnalysis.DapperAnalyzer.Diagnostics; + +namespace Dapper.AOT.Test.Verifiers; + +public partial class DAP241 : Verifier +{ + [Fact] + public Task CSharpInterpolatedStringDetection_DirectUsage() => VerifyCSharpInterpolatedTest(""" + int id = 1; + _ = connection.Query({|#0:$"select Id from Customers where Id = {id}"|}); + """); + + [Fact] + public Task InterpolatedRawStringLiteralsDetection_DirectUsage() => VerifyCSharpInterpolatedTest("""" + int id = 1; + _ = connection.Query({|#0:$""" + select Id from Customers where Id = {id} + """|}); + """"); + + [Fact] + public Task InterpolatedRawStringLiteralsDetection_LocalVariableUsage() => VerifyCSharpInterpolatedTest("""" + int id = 1; + var sqlQuery = $""" + select Id from Customers where Id = {id} + """; + _ = connection.Query({|#0:sqlQuery|}); + """"); + + [Fact] + public Task InterpolatedStringDetection_LocalVariableUsage() => VerifyCSharpInterpolatedTest(""" + int id = 1; + var sqlQuery = $"select Id from Customers where Id = {id}"; + _ = connection.Query({|#0:sqlQuery|}); + """); + + private Task VerifyCSharpInterpolatedTest(string methodCode) => CSVerifyAsync($$""" + using Dapper; + using System.Data; + using System.Data.Common; + + [DapperAot] + class HasUnusedParameter + { + void SomeMethod(DbConnection connection) + { + {{methodCode}} + } + } + """, DefaultConfig, [ Diagnostic(Diagnostics.InterpolatedStringSqlExpression).WithLocation(0) ]); +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Verifiers/DAP242.VB.cs b/test/Dapper.AOT.Test/Verifiers/DAP242.VB.cs new file mode 100644 index 00000000..e92626d3 --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP242.VB.cs @@ -0,0 +1,47 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using Diagnostics = Dapper.CodeAnalysis.DapperAnalyzer.Diagnostics; + +namespace Dapper.AOT.Test.Verifiers; + +public partial class DAP242 : Verifier +{ + [Fact] + public Task VBConcatenatedStringDetection_DirectUsage_PlusInt() => VerifyVBInterpolatedTest(""" + Dim id As Integer = 42 + conn.Execute({|#0:"select Id from Customers where Id = " + id|}) + """); + + [Fact] + public Task VBConcatenatedStringDetection_DirectUsage_PlusString() => VerifyVBInterpolatedTest(""" + Dim id = "42" + conn.Execute({|#0:"select Id from Customers where Id = " + id|}) + """); + + [Fact] + public Task VBConcatenatedStringDetection_DirectUsage_MultiVariablesConcat() => VerifyVBInterpolatedTest(""" + Dim id = 42 + Dim name = "dima" + conn.Execute({|#0:"select Id from Customers where Id = " + id + " and name = " + name|}) + """); + + [Fact] + public Task VBConcatenatedStringDetection_LocalVariableUsage() => VerifyVBInterpolatedTest(""" + Dim id As Integer = 42 + Dim sqlQuery As String = "select Id from Customers where Id = " + id + conn.Execute({|#0:sqlQuery|}) + """); + + private Task VerifyVBInterpolatedTest(string methodCode) => VBVerifyAsync($$""" + Imports Dapper + Imports System.Data.Common + + + Class SomeCode + Public Sub Foo(conn As DbConnection) + {{methodCode}} + End Sub + End Class + """, DefaultConfig, [Diagnostic(Diagnostics.ConcatenatedStringSqlExpression).WithLocation(0)]); +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Verifiers/DAP242.cs b/test/Dapper.AOT.Test/Verifiers/DAP242.cs new file mode 100644 index 00000000..fc4de346 --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP242.cs @@ -0,0 +1,56 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using Diagnostics = Dapper.CodeAnalysis.DapperAnalyzer.Diagnostics; + +namespace Dapper.AOT.Test.Verifiers; + +public partial class DAP242 : Verifier +{ + [Fact] + public Task ConcatenatedStringDetection_DirectUsage_PlusInt() => VerifyCSharpConcatenatedTest(""" + int id = 1; + _ = connection.Query({|#0:"select Id from Customers where Id = " + id|}); + """); + + [Fact] + public Task ConcatenatedStringDetection_DirectUsage_PlusString() => VerifyCSharpConcatenatedTest(""" + string id = "1"; + _ = connection.Query({|#0:"select Id from Customers where Id = " + id|}); + """); + + [Fact] + public Task ConcatenatedStringDetection_DirectUsage_MultiVariablesConcat() => VerifyCSharpConcatenatedTest(""" + int id = 1; + string name = "dima"; + _ = connection.Query({|#0:"select Id from Customers where Id = " + id + " and Name = " + name|}); + """); + + [Fact] + public Task ConcatenatedStringDetection_LocalVariableUsage() => VerifyCSharpConcatenatedTest(""" + int id = 1; + var sqlQuery = "select Id from Customers where Id = " + id; + _ = connection.Query({|#0:sqlQuery|}); + """); + + [Fact] + public Task ConcatenatedStringDetection_StringFormat() => VerifyCSharpConcatenatedTest(""" + int id = 1; + _ = connection.Query({|#0:string.Format("select Id from Customers where Id = {0}", id)|}); + """); + + private Task VerifyCSharpConcatenatedTest(string methodCode) => CSVerifyAsync($$""" + using Dapper; + using System.Data; + using System.Data.Common; + + [DapperAot] + class HasUnusedParameter + { + void SomeMethod(DbConnection connection) + { + {{methodCode}} + } + } + """, DefaultConfig, [ Diagnostic(Diagnostics.ConcatenatedStringSqlExpression).WithLocation(0) ]); +} \ No newline at end of file