Skip to content

Commit 1a8ebcb

Browse files
authored
Merge pull request #22485 from baywet/feat/csharp-missed-firstordefault-opprtunity
feat(csharp): adds a missed first or default opprtunity rule
2 parents 53db3bd + eb85858 commit 1a8ebcb

12 files changed

Lines changed: 384 additions & 0 deletions

‎csharp/ql/integration-tests/posix/query-suite/csharp-code-quality-extended.qls.expected‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ ql/csharp/ql/src/Likely Bugs/UncheckedCastInEquals.ql
9393
ql/csharp/ql/src/Linq/BadMultipleIteration.ql
9494
ql/csharp/ql/src/Linq/MissedAllOpportunity.ql
9595
ql/csharp/ql/src/Linq/MissedCastOpportunity.ql
96+
ql/csharp/ql/src/Linq/MissedFirstOrDefaultOpportunity.ql
9697
ql/csharp/ql/src/Linq/MissedOfTypeOpportunity.ql
9798
ql/csharp/ql/src/Linq/MissedSelectOpportunity.ql
9899
ql/csharp/ql/src/Linq/MissedWhereOpportunity.ql

‎csharp/ql/integration-tests/posix/query-suite/csharp-code-quality.qls.expected‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ ql/csharp/ql/src/Likely Bugs/StringBuilderCharInit.ql
5555
ql/csharp/ql/src/Likely Bugs/UncheckedCastInEquals.ql
5656
ql/csharp/ql/src/Linq/MissedAllOpportunity.ql
5757
ql/csharp/ql/src/Linq/MissedCastOpportunity.ql
58+
ql/csharp/ql/src/Linq/MissedFirstOrDefaultOpportunity.ql
5859
ql/csharp/ql/src/Linq/MissedOfTypeOpportunity.ql
5960
ql/csharp/ql/src/Linq/MissedSelectOpportunity.ql
6061
ql/csharp/ql/src/Linq/MissedWhereOpportunity.ql

‎csharp/ql/lib/Linq/Helpers.qll‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@ private int numStmts(ForEachStmt fes) {
2020
else result = 1
2121
}
2222

23+
private predicate returnsLoopVariable(ForEachStmt fes, Stmt s) {
24+
exists(ReturnStmt ret |
25+
ret = s.stripSingletonBlocks() and
26+
ret.getExpr().stripImplicit().(VariableAccess).getTarget() = fes.getVariable()
27+
)
28+
}
29+
30+
private predicate hasNullDefault(Type t) { t.isRefType() or t instanceof NullableType }
31+
32+
private predicate returnsDefaultValueAfterForeach(ForEachStmt fes) {
33+
exists(BlockStmt enclosingBlock, int i, Type elementType, ReturnStmt ret |
34+
enclosingBlock.getStmt(i) = fes and
35+
enclosingBlock.getStmt(i + 1) = ret and
36+
elementType = fes.getVariable().getType()
37+
|
38+
ret.getExpr().stripImplicit() instanceof NullLiteral and
39+
hasNullDefault(elementType)
40+
or
41+
exists(DefaultValueExpr defaultValue |
42+
defaultValue = ret.getExpr().stripImplicit() and
43+
(
44+
defaultValue.getType() = elementType
45+
or
46+
hasNullDefault(elementType) and
47+
hasNullDefault(defaultValue.getType())
48+
)
49+
)
50+
)
51+
}
52+
2353
private predicate terminatesCallable(Stmt s) {
2454
exists(Stmt stripped | stripped = s.stripSingletonBlocks() |
2555
stripped instanceof ReturnStmt
@@ -183,6 +213,29 @@ predicate missedWhereOpportunity(ForEachStmtGenericEnumerable fes, IfStmt is) {
183213
)
184214
}
185215

216+
/**
217+
* Holds if `foreach` statement `fes` could be converted to a `.FirstOrDefault()` call.
218+
* That is, the loop contains a single `if` statement that accesses the loop variable,
219+
* returns the loop variable when the condition matches, and is followed by a default return.
220+
*/
221+
predicate missedFirstOrDefaultOpportunity(ForEachStmtGenericEnumerable fes, IfStmt is) {
222+
// The loop only checks whether the current element is the first match.
223+
is = firstStmt(fes) and
224+
not exists(is.getElse()) and
225+
numStmts(fes) = 1 and
226+
// Condition relies on loop variable.
227+
exists(VariableAccess va |
228+
va.getTarget() = fes.getVariable() and
229+
va = is.getCondition().getAChildExpr*()
230+
) and
231+
not is.getCondition().getAChildExpr*() instanceof AwaitExpr and
232+
not fes.isAsync() and
233+
not fes.getVariable().isCaptured() and
234+
returnsLoopVariable(fes, is.getThen()) and
235+
fes.getElementType() = fes.getVariable().getType() and
236+
returnsDefaultValueAfterForeach(fes)
237+
}
238+
186239
//#################### CLASSES ####################
187240
/** A LINQ Any(...) call. */
188241
class AnyCall extends MethodCall {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
class MissedFirstOrDefaultOpportunity
5+
{
6+
public static Operation FindOperation(IEnumerable<Operation> operations, string operationId)
7+
{
8+
foreach (var operation in operations)
9+
{
10+
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
11+
return operation;
12+
}
13+
14+
return null;
15+
}
16+
}
17+
18+
class Operation
19+
{
20+
public string OperationId { get; set; }
21+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
<overview>
6+
<p>Programmers sometimes search a sequence by iterating over each element, testing it, and returning
7+
the first element that satisfies the test. If the loop completes without finding a match, the method
8+
then returns a default value such as <code>null</code> or <code>default</code>.</p>
9+
10+
</overview>
11+
<recommendation>
12+
<p>This pattern is directly available as the <code>FirstOrDefault</code> method in LINQ. Using the
13+
library method makes the search intent explicit and avoids manually spelling out the loop and
14+
fallback return.</p>
15+
16+
</recommendation>
17+
<example>
18+
<p>In this example the method searches a list of operations for the first operation with a matching
19+
identifier, returning <code>null</code> if no match is found.</p>
20+
<sample src="MissedFirstOrDefaultOpportunity.cs" />
21+
22+
<p>The LINQ <code>FirstOrDefault</code> method can express this search more directly.</p>
23+
<sample src="MissedFirstOrDefaultOpportunityFix.cs" />
24+
25+
<p>The following examples should not use <code>FirstOrDefault</code>, because they do more than
26+
return the matching element or because the fallback value is not the default value.</p>
27+
<sample src="MissedFirstOrDefaultOpportunityGood.cs" />
28+
29+
</example>
30+
<references>
31+
32+
<li>MSDN: <a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.firstordefault">Enumerable.FirstOrDefault Method</a>.</li>
33+
34+
35+
</references>
36+
</qhelp>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @name Missed opportunity to use FirstOrDefault
3+
* @description The intent of a foreach loop that returns the first sequence element satisfying a predicate, or a default value otherwise,
4+
* can often be better expressed using LINQ's 'FirstOrDefault' method.
5+
* @kind problem
6+
* @problem.severity recommendation
7+
* @precision high
8+
* @id cs/linq/missed-firstordefault
9+
* @tags quality
10+
* maintainability
11+
* readability
12+
* language-features
13+
*/
14+
15+
import csharp
16+
import Linq.Helpers
17+
18+
from ForEachStmtGenericEnumerable fes, IfStmt is
19+
where missedFirstOrDefaultOpportunity(fes, is)
20+
select fes,
21+
"This foreach loop returns the first sequence element satisfying a $@ - consider finding the element explicitly using '.FirstOrDefault(...)'.",
22+
is.getCondition(), "predicate"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
class MissedFirstOrDefaultOpportunityFix
6+
{
7+
public static Operation FindOperation(IEnumerable<Operation> operations, string operationId)
8+
{
9+
return operations.FirstOrDefault(operation =>
10+
string.Equals(operation.OperationId, operationId, StringComparison.Ordinal));
11+
}
12+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
class MissedFirstOrDefaultOpportunityGood
5+
{
6+
public static Operation FindOperationOrThrow(IEnumerable<Operation> operations, string operationId)
7+
{
8+
foreach (var operation in operations)
9+
{
10+
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
11+
throw new InvalidOperationException("Unexpected operation.");
12+
}
13+
14+
return null;
15+
}
16+
17+
public static Operation FindReplacementOperation(IEnumerable<Operation> operations, string operationId)
18+
{
19+
foreach (var operation in operations)
20+
{
21+
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
22+
return operation;
23+
}
24+
25+
return new Operation();
26+
}
27+
28+
public static string FindOperationId(IEnumerable<Operation> operations, string operationId)
29+
{
30+
foreach (var operation in operations)
31+
{
32+
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
33+
return operation.OperationId;
34+
}
35+
36+
return null;
37+
}
38+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: newQuery
3+
---
4+
* Added a new query, `cs/linq/missed-firstordefault`, that detects `foreach` loops that can be expressed more clearly using LINQ's `FirstOrDefault` method.

0 commit comments

Comments
 (0)