Skip to content

Commit 42778fa

Browse files
authored
analyzer: Warn about potentially incorrect pseudo-positional arguments processing (#118)
* verify that it does not throw error * split test * add diagnostic * docs * expand test & address PR comments * fix unwanted indent * change phrasing in analyzer diagnostics as well * address PR comments * add processing of errors and other errors * only 1 regex execution per sql analysis * final touch * review
1 parent 526745f commit 42778fa

File tree

6 files changed

+213
-33
lines changed

6 files changed

+213
-33
lines changed

docs/rules/DAP245.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# DAP245
2+
3+
There is a non-zero chance of Dapper treating character sequence in SQL query like `?something?` as a pseudo-positional parameter **inside of a literal**,
4+
even if that is not an actual pseudo-positional parameter like in this example:
5+
```sql
6+
select '?this_is_my_string_data?'
7+
```
8+
9+
_Note: it will not be considered a pseudo-positional parameter in case there are spaces:_
10+
```sql
11+
select '?this is not pseudo-positional?'
12+
```
13+
14+
See [github issue](https://github.com/DapperLib/DapperAOT/issues/60) for more details.
15+
16+
To mitigate, you can split the string via concatenation:
17+
``` sql
18+
select '?this_is' + 'my_string_data?'
19+
```

src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ public static readonly DiagnosticDescriptor
101101
InterpolatedStringSqlExpression = SqlWarning("DAP241", "Interpolated string usage", "Data values should not be interpolated into SQL string - use parameters instead"),
102102
ConcatenatedStringSqlExpression = SqlWarning("DAP242", "Concatenated string usage", "Data values should not be concatenated into SQL string - use parameters instead"),
103103
InvalidDatepartToken = SqlWarning("DAP243", "Valid datepart token expected", "Date functions require a recognized datepart argument"),
104-
SelectAggregateMismatch = SqlWarning("DAP244", "SELECT aggregate mismatch", "SELECT has mixture of aggregate and non-aggregate expressions");
104+
SelectAggregateMismatch = SqlWarning("DAP244", "SELECT aggregate mismatch", "SELECT has mixture of aggregate and non-aggregate expressions"),
105+
PseudoPositionalParameter = SqlError("DAP245", "Avoid SQL pseudo-positional parameter", "It is more like Dapper will incorrectly treat this literal as a pseudo-positional parameter")
106+
;
105107
}
106108
}

src/Dapper.AOT.Analyzers/Internal/DiagnosticTSqlProcessor.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ protected override void OnNullLiteralComparison(Location location)
7676
protected override void OnParseError(ParseError error, Location location)
7777
=> OnDiagnostic(DapperAnalyzer.Diagnostics.ParseError, location, error.Number, error.Message);
7878

79+
protected override void OnPseudoPositionalParameter(Location location)
80+
=> OnDiagnostic(DapperAnalyzer.Diagnostics.PseudoPositionalParameter, location);
81+
7982
protected override void OnScalarVariableUsedAsTable(Variable variable)
8083
=> OnDiagnostic(DapperAnalyzer.Diagnostics.ScalarVariableUsedAsTable, variable.Location, variable.Name);
8184

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Text.RegularExpressions;
4+
using Dapper.Internal;
5+
using Microsoft.SqlServer.TransactSql.ScriptDom;
6+
7+
namespace Dapper.SqlAnalysis;
8+
9+
internal readonly struct SqlProcessingContext
10+
{
11+
private readonly TSqlProcessor.VariableTrackingVisitor _variableVisitor;
12+
private readonly IReadOnlyDictionary<int, string> _pseudoPositionalArgumentsOffsetNames;
13+
14+
public SqlProcessingContext(TSqlProcessor.VariableTrackingVisitor variableVisitor, string sql)
15+
{
16+
_variableVisitor = variableVisitor;
17+
_pseudoPositionalArgumentsOffsetNames = BuildPseudoPositionalArgsOffsetNamesMap();
18+
19+
IReadOnlyDictionary<int, string> BuildPseudoPositionalArgsOffsetNamesMap()
20+
{
21+
var offsetNamesMap = new Dictionary<int, string>();
22+
var regexMatch = CompiledRegex.PseudoPositional.Match(sql);
23+
while (regexMatch.Success)
24+
{
25+
foreach (var match in regexMatch.Groups.OfType<Match>())
26+
{
27+
offsetNamesMap.Add(match.Index, match.Value.Trim('?'));
28+
}
29+
30+
regexMatch = regexMatch.NextMatch();
31+
}
32+
33+
return offsetNamesMap;
34+
}
35+
}
36+
37+
public IEnumerable<ParseError> GetErrorsToReport(IList<ParseError>? parseErrors)
38+
{
39+
if (parseErrors is null || parseErrors.Count == 0) yield break;
40+
foreach (var parseError in parseErrors)
41+
{
42+
if (_pseudoPositionalArgumentsOffsetNames.Count != 0 && IsPseudoPositionalParameterGenuineUsage(parseError))
43+
{
44+
continue;
45+
}
46+
47+
yield return parseError;
48+
}
49+
}
50+
51+
public void MarkPseudoPositionalVariablesUsed(in ISet<string> parameters)
52+
{
53+
foreach (var name in _pseudoPositionalArgumentsOffsetNames.Values)
54+
{
55+
if (_variableVisitor.TryGetByName(name, out var variable) && parameters.Contains(name))
56+
{
57+
var usedVar = variable.WithUsed();
58+
usedVar = usedVar.WithConsumed();
59+
_variableVisitor.SetVariable(usedVar);
60+
}
61+
}
62+
}
63+
64+
bool IsPseudoPositionalParameterGenuineUsage(ParseError error)
65+
{
66+
if (error.Number != 46010) return false; // `46010` is `Incorrect syntax around ...`
67+
return _pseudoPositionalArgumentsOffsetNames.ContainsKey(error.Offset);
68+
}
69+
}

src/Dapper.AOT.Analyzers/SqlAnalysis/TSqlProcessor.cs

Lines changed: 86 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,22 @@ public virtual bool Execute(string sql, ImmutableArray<SqlParameter> members = d
8686
{
8787
Flags |= SqlParseOutputFlags.SqlAdjustedForDapperSyntax;
8888
}
89+
90+
var sqlProcessingContext = new SqlProcessingContext(_visitor, fixedSql);
91+
92+
var memberNamesSet = new HashSet<string>(members.Select(x => x.Name), CaseSensitive ? StringComparer.InvariantCulture : StringComparer.InvariantCultureIgnoreCase);
93+
sqlProcessingContext.MarkPseudoPositionalVariablesUsed(memberNamesSet);
94+
8995
var parser = new TSql160Parser(true, SqlEngineType.All);
9096
TSqlFragment tree;
9197
using (var reader = new StringReader(fixedSql))
9298
{
9399
tree = parser.Parse(reader, out var errors);
94100
if (errors is not null && errors.Count != 0)
95101
{
96-
Flags |= SqlParseOutputFlags.SyntaxError;
97-
foreach (var error in errors)
102+
foreach (var error in sqlProcessingContext.GetErrorsToReport(errors))
98103
{
104+
Flags |= SqlParseOutputFlags.SyntaxError;
99105
OnParseError(error, new Location(error.Line, error.Column, error.Offset, 0));
100106
}
101107
}
@@ -118,7 +124,7 @@ public virtual bool Execute(string sql, ImmutableArray<SqlParameter> members = d
118124
{
119125
if (_visitor.KnownParameters) OnVariableNotDeclared(variable);
120126
}
121-
else if (variable.IsUnconsumed && !variable.IsTable && !variable.IsOutputParameter)
127+
else if (variable is { IsUnconsumed: true, IsTable: false, IsOutputParameter: false })
122128
{
123129
if (_visitor.AssignmentTracking) OnVariableValueNotConsumed(variable);
124130
}
@@ -173,7 +179,10 @@ protected virtual void OnTableVariableUsedAsScalar(Variable variable)
173179
=> OnError($"Table variable {variable.Name} is used like a scalar", variable.Location);
174180

175181
protected virtual void OnNullLiteralComparison(Location location)
176-
=> OnError($"Null literals should not be used in binary comparisons; prefer 'is null' and 'is not null'", location);
182+
=> OnError("Null literals should not be used in binary comparisons; prefer 'is null' and 'is not null'", location);
183+
184+
protected virtual void OnPseudoPositionalParameter(Location location)
185+
=> OnError("It is more like Dapper will incorrectly treat this literal as a pseudo-positional parameter", location);
177186

178187
private void OnSimplifyExpression(Location location, int? value)
179188
=> OnSimplifyExpression(location, value is null ? "null" : value.Value.ToString(CultureInfo.InvariantCulture));
@@ -186,77 +195,77 @@ private void OnSimplifyExpression(Location location, decimal? value)
186195
});
187196

188197
protected virtual void OnDivideByZero(Location location)
189-
=> OnError($"Division by zero detected", location);
198+
=> OnError("Division by zero detected", location);
190199
protected virtual void OnTrivialOperand(Location location)
191-
=> OnError($"Operand makes this calculation trivial; it can be simplified", location);
200+
=> OnError("Operand makes this calculation trivial; it can be simplified", location);
192201
protected virtual void OnInvalidNullExpression(Location location)
193-
=> OnError($"Operation requires non-null operand", location);
202+
=> OnError("Operation requires non-null operand", location);
194203

195204
protected virtual void OnSimplifyExpression(Location location, string value)
196205
=> OnError($"Expression can be simplified to '{value}'", location);
197206

198207
protected virtual void OnAdditionalBatch(Location location)
199-
=> OnError($"Multiple batches are not permitted", location);
208+
=> OnError("Multiple batches are not permitted", location);
200209

201210
protected virtual void OnGlobalIdentity(Location location)
202-
=> OnError($"@@identity should not be used; use SCOPE_IDENTITY() instead", location);
211+
=> OnError("@@identity should not be used; use SCOPE_IDENTITY() instead", location);
203212

204213
protected virtual void OnSelectScopeIdentity(Location location)
205-
=> OnError($"Consider OUTPUT INSERTED.yourid on the INSERT instead of SELECT SCOPE_IDENTITY()", location);
214+
=> OnError("Consider OUTPUT INSERTED.yourid on the INSERT instead of SELECT SCOPE_IDENTITY()", location);
206215

207216
protected virtual void OnExecComposedSql(Location location)
208-
=> OnError($"EXEC with composed SQL may be susceptible to SQL injection; consider EXEC sp_executesql with parameters", location);
217+
=> OnError("EXEC with composed SQL may be susceptible to SQL injection; consider EXEC sp_executesql with parameters", location);
209218

210219
protected virtual void OnTableVariableOutputParameter(Variable variable)
211220
=> OnError($"Table variable {variable.Name} cannot be used as an output parameter", variable.Location);
212221

213222
protected virtual void OnInsertColumnsNotSpecified(Location location)
214-
=> OnError($"Target columns for INSERT should be explicitly specified", location);
223+
=> OnError("Target columns for INSERT should be explicitly specified", location);
215224

216225
protected virtual void OnInsertColumnMismatch(Location location)
217-
=> OnError($"The columns specified in the INSERT do not match the source columns provided", location);
226+
=> OnError("The columns specified in the INSERT do not match the source columns provided", location);
218227
protected virtual void OnInsertColumnsUnbalanced(Location location)
219-
=> OnError($"The rows specified in the INSERT have differing widths", location);
228+
=> OnError("The rows specified in the INSERT have differing widths", location);
220229

221230
protected virtual void OnSelectStar(Location location)
222-
=> OnError($"SELECT columns should be explicitly specified", location);
231+
=> OnError("SELECT columns should be explicitly specified", location);
223232
protected virtual void OnSelectEmptyColumnName(Location location, int column)
224233
=> OnError($"SELECT statement with missing column name: {column}", location);
225234
protected virtual void OnSelectDuplicateColumnName(Location location, string name)
226235
=> OnError($"SELECT statement with duplicate column name: {name}", location);
227236
protected virtual void OnSelectAssignAndRead(Location location)
228-
=> OnError($"SELECT statement has both assignment and results", location);
237+
=> OnError("SELECT statement has both assignment and results", location);
229238

230239
protected virtual void OnDeleteWithoutWhere(Location location)
231-
=> OnError($"DELETE statement without WHERE clause", location);
240+
=> OnError("DELETE statement without WHERE clause", location);
232241
protected virtual void OnUpdateWithoutWhere(Location location)
233-
=> OnError($"UPDATE statement without WHERE clause", location);
242+
=> OnError("UPDATE statement without WHERE clause", location);
234243

235244
protected virtual void OnSelectSingleTopError(Location location)
236-
=> OnError($"SELECT for Single* should use TOP 2; if you do not need to test over-read, use First*", location);
245+
=> OnError("SELECT for Single* should use TOP 2; if you do not need to test over-read, use First*", location);
237246
protected virtual void OnSelectFirstTopError(Location location)
238-
=> OnError($"SELECT for First* should use TOP 1", location);
247+
=> OnError("SELECT for First* should use TOP 1", location);
239248
protected virtual void OnSelectSingleRowWithoutWhere(Location location)
240-
=> OnError($"SELECT for single row without WHERE or (TOP and ORDER BY)", location);
249+
=> OnError("SELECT for single row without WHERE or (TOP and ORDER BY)", location);
241250
protected virtual void OnSelectAggregateAndNonAggregate(Location location)
242-
=> OnError($"SELECT has mixture of aggregate and non-aggregate expressions", location);
251+
=> OnError("SELECT has mixture of aggregate and non-aggregate expressions", location);
243252
protected virtual void OnNonPositiveTop(Location location)
244-
=> OnError($"TOP literals should be positive", location);
253+
=> OnError("TOP literals should be positive", location);
245254
protected virtual void OnNonPositiveFetch(Location location)
246-
=> OnError($"FETCH literals should be positive", location);
255+
=> OnError("FETCH literals should be positive", location);
247256
protected virtual void OnNegativeOffset(Location location)
248-
=> OnError($"OFFSET literals should be non-negative", location);
257+
=> OnError("OFFSET literals should be non-negative", location);
249258
protected virtual void OnNonIntegerTop(Location location)
250-
=> OnError($"TOP literals should be integers", location);
259+
=> OnError("TOP literals should be integers", location);
251260
protected virtual void OnFromMultiTableMissingAlias(Location location)
252-
=> OnError($"FROM expressions with multiple elements should use aliases", location);
261+
=> OnError("FROM expressions with multiple elements should use aliases", location);
253262
protected virtual void OnFromMultiTableUnqualifiedColumn(Location location, string name)
254263
=> OnError($"FROM expressions with multiple elements should qualify all columns; it is unclear where '{name}' is located", location);
255264

256265
protected virtual void OnInvalidDatepartToken(Location location)
257-
=> OnError($"Valid datepart token expected", location);
266+
=> OnError("Valid datepart token expected", location);
258267
protected virtual void OnTopWithOffset(Location location)
259-
=> OnError($"TOP cannot be used when OFFSET is specified", location);
268+
=> OnError("TOP cannot be used when OFFSET is specified", location);
260269

261270

262271
internal readonly struct Location
@@ -364,7 +373,8 @@ private Variable(scoped in Variable source, Location location)
364373
public Variable WithLocation(TSqlFragment node) => new(in this, node);
365374
public Variable WithName(string name) => new(in this, name);
366375
}
367-
class LoggingVariableTrackingVisitor : VariableTrackingVisitor
376+
377+
internal class LoggingVariableTrackingVisitor : VariableTrackingVisitor
368378
{
369379
private readonly Action<string> log;
370380
public LoggingVariableTrackingVisitor(SqlParseInputFlags flags, TSqlProcessor parser, Action<string> log) : base(flags, parser)
@@ -406,7 +416,8 @@ public override void Visit(TSqlFragment node)
406416
base.Visit(node);
407417
}
408418
}
409-
class VariableTrackingVisitor : TSqlFragmentVisitor
419+
420+
internal class VariableTrackingVisitor : TSqlFragmentVisitor
410421
{
411422
// important note for anyone maintaining this;
412423
//
@@ -474,7 +485,19 @@ private bool SlowTryGetVariable(string name, out Variable variable)
474485
return false;
475486
}
476487

477-
private Variable SetVariable(Variable variable) => _variables[variable.Name] = variable;
488+
internal Variable SetVariable(Variable variable) => _variables[variable.Name] = variable;
489+
490+
internal bool TryGetByName(string name, out Variable variable)
491+
{
492+
if (_variables.TryGetValue(name, out var value))
493+
{
494+
variable = value;
495+
return true;
496+
}
497+
498+
variable = default;
499+
return false;
500+
}
478501

479502
private readonly Dictionary<string, Variable> _variables;
480503
private int batchCount;
@@ -623,6 +646,32 @@ public override void Visit(BooleanComparisonExpression node)
623646
base.Visit(node);
624647
}
625648

649+
public override void Visit(WhereClause whereClause)
650+
{
651+
base.Visit(whereClause);
652+
}
653+
654+
public override void Visit(StringLiteral node)
655+
{
656+
foreach (var token in node.ScriptTokenStream)
657+
{
658+
if (token.TokenType is not TSqlTokenType.AsciiStringLiteral)
659+
{
660+
continue;
661+
}
662+
663+
int firstAppearance;
664+
if ((firstAppearance = token.Text.IndexOf('?')) >= 0
665+
&& token.Text.IndexOf('?', firstAppearance + 1) >= 0 // if there are 2 appearances of `?`
666+
&& CompiledRegex.PseudoPositional.IsMatch(node.Value))
667+
{
668+
parser.OnPseudoPositionalParameter(node);
669+
}
670+
}
671+
672+
base.Visit(node);
673+
}
674+
626675
public override void ExplicitVisit(QuerySpecification node)
627676
{
628677
var oldDemandAlias = _demandAliases;
@@ -647,6 +696,11 @@ public override void ExplicitVisit(QuerySpecification node)
647696
}
648697
}
649698

699+
public override void Visit(QueryExpression expression)
700+
{
701+
base.Visit(expression);
702+
}
703+
650704
public override void ExplicitVisit(SelectSetVariable node)
651705
{
652706
Visit(node);

0 commit comments

Comments
 (0)