Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/Rules/MA0042.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ public sealed class Sample

The rule does not report a diagnostic for `IDbContextFactory<TContext>.CreateDbContext()`. The `CreateDbContextAsync()` overload was introduced only for specific edge-case scenarios where the factory itself must perform asynchronous initialization, and is not intended as a general-purpose replacement. See [dotnet/efcore#26630](https://github.com/dotnet/efcore/issues/26630) for more details.

The rule does not report a diagnostic for the following SQLite APIs by default:
- `SqliteConnection.CreateCommand()`
- `SqliteCommand.ExecuteNonQuery()`
- `SqliteCommand.ExecuteScalar()`
- `SqliteCommand.ExecuteReader()`

`SqliteCommand` does not override `DisposeAsync`, and SQLite async APIs have documented limitations. See [Async limitations](https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/async) for details.

The rule will not report a diagnostic for a `using` statement on a `Stream`, `DbConnection`, or `DbCommand` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. `Stream.DisposeAsync`, `DbConnection.DisposeAsync`, and `DbCommand.DisposeAsync` merely call `Dispose()` synchronously by default, so switching to `await using` brings no benefit for such types. When the instance is obtained from a factory method rather than a direct `new` expression, the rule still reports a diagnostic because the runtime type may be a deeper subclass that does override `DisposeAsync`.

````csharp
Expand All @@ -78,6 +86,14 @@ public async Task Sample()
}
````

## Configuration

````
# .editorconfig file
# Enable SQLite special-cases for MA0042/MA0045. default: true
MA0042.enable_sqlite_special_cases = true
````

## Additional resources

- [Enforcing asynchronous code good practices using a Roslyn analyzer](https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm)
14 changes: 14 additions & 0 deletions docs/Rules/MA0045.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ public async Task Sample()
}
````

This rule shares the same SQLite special-cases as [MA0042](MA0042.md) by default:
- `SqliteConnection.CreateCommand()`
- `SqliteCommand.ExecuteNonQuery()`
- `SqliteCommand.ExecuteScalar()`
- `SqliteCommand.ExecuteReader()`

## Configuration

````
# .editorconfig file
# Enable SQLite special-cases for MA0042/MA0045. default: true
MA0042.enable_sqlite_special_cases = true
````

## Additional resources

- [Enforcing asynchronous code good practices using a Roslyn analyzer](https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using Meziantou.Analyzer.Configurations;
using Meziantou.Analyzer.Internals;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
Expand Down Expand Up @@ -78,6 +79,8 @@ public Context(Compilation compilation)
StreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.Stream");
DbConnectionSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbConnection");
DbCommandSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbCommand");
SqliteConnectionSymbol = compilation.GetBestTypeByMetadataName("Microsoft.Data.Sqlite.SqliteConnection");
SqliteCommandSymbol = compilation.GetBestTypeByMetadataName("Microsoft.Data.Sqlite.SqliteCommand");
CancellationTokenSymbol = compilation.GetBestTypeByMetadataName("System.Threading.CancellationToken");
ObsoleteAttributeSymbol = compilation.GetBestTypeByMetadataName("System.ObsoleteAttribute");

Expand Down Expand Up @@ -128,6 +131,8 @@ public Context(Compilation compilation)
private ISymbol? ProcessSymbol { get; }
private INamedTypeSymbol? DbConnectionSymbol { get; }
private INamedTypeSymbol? DbCommandSymbol { get; }
private INamedTypeSymbol? SqliteConnectionSymbol { get; }
private INamedTypeSymbol? SqliteCommandSymbol { get; }
private ISymbol[] ConsoleErrorAndOutSymbols { get; }
private INamedTypeSymbol? CancellationTokenSymbol { get; }
private INamedTypeSymbol? ObsoleteAttributeSymbol { get; }
Expand Down Expand Up @@ -164,23 +169,25 @@ internal void AnalyzeInvocation(OperationAnalysisContext context)
{
var operation = (IInvocationOperation)context.Operation;
var targetMethod = operation.TargetMethod;
var sqliteSpecialCasesEnabled = IsSqliteSpecialCasesEnabled(context, operation);
var isSqliteSpecialCaseMethod = IsSqliteSpecialCaseMethod(targetMethod);

// The cache only contains methods with no async equivalent methods.
// This optimizes the best-case scenario where code is correctly written according to this analyzer.
if (_symbolsWithNoAsyncOverloads.Contains(targetMethod))
if (!isSqliteSpecialCaseMethod && _symbolsWithNoAsyncOverloads.Contains(targetMethod))
return;

if (HasAsyncEquivalent(operation, out var diagnosticMessage))
if (HasAsyncEquivalent(operation, sqliteSpecialCasesEnabled, out var diagnosticMessage))
{
ReportDiagnosticIfNeeded(context, diagnosticMessage.CreateProperties(), operation, diagnosticMessage.DiagnosticMessage);
}
else
else if (!isSqliteSpecialCaseMethod)
{
_symbolsWithNoAsyncOverloads.Add(targetMethod);
}
}

private bool HasAsyncEquivalent(IInvocationOperation operation, [NotNullWhen(true)] out DiagnosticData? data)
private bool HasAsyncEquivalent(IInvocationOperation operation, bool sqliteSpecialCasesEnabled, [NotNullWhen(true)] out DiagnosticData? data)
{
data = null;
var targetMethod = operation.TargetMethod;
Expand Down Expand Up @@ -263,6 +270,14 @@ private bool HasAsyncEquivalent(IInvocationOperation operation, [NotNullWhen(tru
return false;
}

// SqliteConnection.CreateCommand() always returns SqliteCommand.
// SqliteCommand does not override DisposeAsync, so there is no async alternative to require.
// https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/async
else if (sqliteSpecialCasesEnabled && IsSqliteSpecialCaseMethod(targetMethod))
{
return false;
}

else if (Moq_MockSymbol is not null && targetMethod.Name is "Raise" && targetMethod.ContainingType.OriginalDefinition.IsEqualTo(Moq_MockSymbol))
{
return false;
Expand Down Expand Up @@ -298,6 +313,36 @@ private bool HasAsyncEquivalent(IInvocationOperation operation, [NotNullWhen(tru
return false;
}

private static bool IsSqliteSpecialCasesEnabled(OperationAnalysisContext context, IOperation operation)
{
var defaultValue = context.Options.GetConfigurationValue(operation, RuleIdentifiers.DoNotUseBlockingCallInAsyncContext + ".enable_sqlite_special_cases", defaultValue: true);
return context.Options.GetConfigurationValue(operation, RuleIdentifiers.DoNotUseBlockingCall + ".enable_sqlite_special_cases", defaultValue);
}

private bool IsSqliteConnectionCreateCommand(IMethodSymbol targetMethod)
{
if (SqliteConnectionSymbol is null || SqliteCommandSymbol is null)
return false;

return targetMethod.Name is "CreateCommand" &&
targetMethod.ContainingType.IsEqualTo(SqliteConnectionSymbol) &&
targetMethod.ReturnType.IsEqualTo(SqliteCommandSymbol);
}

private bool IsSqliteCommandMethod(IMethodSymbol targetMethod)
{
if (SqliteCommandSymbol is null)
return false;

return targetMethod.ContainingType.IsEqualTo(SqliteCommandSymbol) &&
targetMethod.Name is "ExecuteNonQuery" or "ExecuteScalar" or "ExecuteReader";
}

private bool IsSqliteSpecialCaseMethod(IMethodSymbol targetMethod)
{
return IsSqliteConnectionCreateCommand(targetMethod) || IsSqliteCommandMethod(targetMethod);
}

private IMethodSymbol? FindPotentialAsyncEquivalent(IInvocationOperation operation, IMethodSymbol targetMethod, string methodName)
{
var options = new OverloadOptions(
Expand Down Expand Up @@ -492,8 +537,16 @@ private bool HasDisposeAsyncMethodDeclaredInSubclass(INamedTypeSymbol symbol, IN
return false;
}

private bool CanBeAwaitUsing(IOperation operation)
private bool CanBeAwaitUsing(IOperation operation, bool sqliteSpecialCasesEnabled)
{
var unwrappedOperation = operation.UnwrapImplicitConversionOperations();
if (sqliteSpecialCasesEnabled &&
unwrappedOperation is IInvocationOperation invocationOperation &&
IsSqliteConnectionCreateCommand(invocationOperation.TargetMethod))
{
return false;
}

if (operation.GetActualType() is not INamedTypeSymbol type)
return false;

Expand All @@ -503,8 +556,7 @@ private bool CanBeAwaitUsing(IOperation operation)
// Dispose() synchronously by default, so it is not a meaningful async override.
if (StreamSymbol is INamedTypeSymbol streamSymbol && type.InheritsFrom(streamSymbol))
{
var unwrapped = operation.UnwrapImplicitConversionOperations();
if (unwrapped is IObjectCreationOperation)
if (unwrappedOperation is IObjectCreationOperation)
return HasDisposeAsyncMethodDeclaredInSubclass(type, streamSymbol);
}

Expand All @@ -514,8 +566,7 @@ private bool CanBeAwaitUsing(IOperation operation)
// Dispose() synchronously, so it is not a meaningful async override.
if (DbConnectionSymbol is not null && type.InheritsFrom(DbConnectionSymbol))
{
var unwrapped = operation.UnwrapImplicitConversionOperations();
if (unwrapped is IObjectCreationOperation)
if (unwrappedOperation is IObjectCreationOperation)
return HasDisposeAsyncMethodDeclaredInSubclass(type, DbConnectionSymbol);
}

Expand All @@ -525,21 +576,20 @@ private bool CanBeAwaitUsing(IOperation operation)
// Dispose() synchronously, so it is not a meaningful async override.
if (DbCommandSymbol is not null && type.InheritsFrom(DbCommandSymbol))
{
var unwrapped = operation.UnwrapImplicitConversionOperations();
if (unwrapped is IObjectCreationOperation)
if (unwrappedOperation is IObjectCreationOperation)
return HasDisposeAsyncMethodDeclaredInSubclass(type, DbCommandSymbol);
}

return HasDisposeAsyncMethod(type);
}

private bool ReportIfCanBeAwaitUsing(OperationAnalysisContext context, IOperation usingOperation, IVariableDeclarationGroupOperation operation)
private bool ReportIfCanBeAwaitUsing(OperationAnalysisContext context, IOperation usingOperation, IVariableDeclarationGroupOperation operation, bool sqliteSpecialCasesEnabled)
{
foreach (var declaration in operation.Declarations)
{
if ((declaration.Initializer?.Value) is not null)
{
if (CanBeAwaitUsing(declaration.Initializer.Value))
if (CanBeAwaitUsing(declaration.Initializer.Value, sqliteSpecialCasesEnabled))
{
var data = new DiagnosticData("Prefer using 'await using'", DoNotUseBlockingCallInAsyncContextData.Using);
ReportDiagnosticIfNeeded(context, data.CreateProperties(), usingOperation, data.DiagnosticMessage);
Expand All @@ -549,7 +599,7 @@ private bool ReportIfCanBeAwaitUsing(OperationAnalysisContext context, IOperatio

foreach (var declarator in declaration.Declarators)
{
if (declarator.Initializer is not null && CanBeAwaitUsing(declarator.Initializer.Value))
if (declarator.Initializer is not null && CanBeAwaitUsing(declarator.Initializer.Value, sqliteSpecialCasesEnabled))
{
var data = new DiagnosticData("Prefer using 'await using'", DoNotUseBlockingCallInAsyncContextData.UsingDeclarator);
ReportDiagnosticIfNeeded(context, data.CreateProperties(), usingOperation, data.DiagnosticMessage);
Expand All @@ -567,13 +617,14 @@ internal void AnalyzeUsing(OperationAnalysisContext context)
if (operation.IsAsynchronous)
return;

var sqliteSpecialCasesEnabled = IsSqliteSpecialCasesEnabled(context, operation);
if (operation.Resources is IVariableDeclarationGroupOperation variableDeclarationGroupOperation)
{
if (ReportIfCanBeAwaitUsing(context, operation, variableDeclarationGroupOperation))
if (ReportIfCanBeAwaitUsing(context, operation, variableDeclarationGroupOperation, sqliteSpecialCasesEnabled))
return;
}

if (CanBeAwaitUsing(operation.Resources))
if (CanBeAwaitUsing(operation.Resources, sqliteSpecialCasesEnabled))
{
var data = new DiagnosticData("Prefer using 'await using'", DoNotUseBlockingCallInAsyncContextData.Using);
ReportDiagnosticIfNeeded(context, data.CreateProperties(), operation, data.DiagnosticMessage);
Expand All @@ -586,7 +637,8 @@ internal void AnalyzeUsingDeclaration(OperationAnalysisContext context)
if (operation.IsAsynchronous)
return;

ReportIfCanBeAwaitUsing(context, operation, operation.DeclarationGroup);
var sqliteSpecialCasesEnabled = IsSqliteSpecialCasesEnabled(context, operation);
ReportIfCanBeAwaitUsing(context, operation, operation.DeclarationGroup, sqliteSpecialCasesEnabled);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
using System.Collections.Immutable;
using System.IO;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using Meziantou.Analyzer.Rules;
using Meziantou.Analyzer.Test.Helpers;
using TestHelper;
Expand Down Expand Up @@ -1998,6 +2002,100 @@ async Task A()
.ValidateAsync();
}

[Fact]
[Trait("Issue", "https://github.com/meziantou/Meziantou.Analyzer/issues/1121")]
public async Task SqliteConnection_CreateCommand_NoDiagnostic()
{
await CreateProjectBuilder()
.WithTargetFramework(TargetFramework.Net8_0)
.AddNuGetReference("Microsoft.Data.Sqlite.Core", "8.0.0", "lib/net8.0/")
.WithSourceCode("""
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;

class Test
{
public async Task A(SqliteConnection connection)
{
using var command = connection.CreateCommand();
}
}
""")
.ValidateAsync();
}

[Fact]
[Trait("Issue", "https://github.com/meziantou/Meziantou.Analyzer/issues/1121")]
public async Task SqliteCommand_ExecuteMethods_NoDiagnostic()
{
await CreateProjectBuilder()
.WithTargetFramework(TargetFramework.Net8_0)
.AddNuGetReference("Microsoft.Data.Sqlite.Core", "8.0.0", "lib/net8.0/")
.WithSourceCode("""
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;

class Test
{
public async Task A(SqliteCommand command)
{
command.ExecuteNonQuery();
command.ExecuteScalar();
command.ExecuteReader();
}
}
""")
.ValidateAsync();
}

[Fact]
[Trait("Issue", "https://github.com/meziantou/Meziantou.Analyzer/issues/1121")]
public async Task SqliteConnection_CreateCommand_OptionDisabled_Diagnostic()
{
await CreateProjectBuilder()
.WithTargetFramework(TargetFramework.Net8_0)
.AddNuGetReference("Microsoft.Data.Sqlite.Core", "8.0.0", "lib/net8.0/")
.AddAnalyzerConfiguration("MA0042.enable_sqlite_special_cases", "false")
.WithSourceCode("""
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;

class Test
{
public async Task A(SqliteConnection connection)
{
[|using var command = connection.CreateCommand();|]
}
}
""")
.ValidateAsync();
}

[Fact]
[Trait("Issue", "https://github.com/meziantou/Meziantou.Analyzer/issues/1121")]
public async Task SqliteCommand_ExecuteMethods_OptionDisabled_Diagnostic()
{
await CreateProjectBuilder()
.WithTargetFramework(TargetFramework.Net8_0)
.AddNuGetReference("Microsoft.Data.Sqlite.Core", "8.0.0", "lib/net8.0/")
.AddAnalyzerConfiguration("MA0042.enable_sqlite_special_cases", "false")
.WithSourceCode("""
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;

class Test
{
public async Task A(SqliteCommand command)
{
[|command.ExecuteNonQuery()|];
[|command.ExecuteScalar()|];
[|command.ExecuteReader()|];
}
}
""")
.ValidateAsync();
}

[Fact]
public async Task IAsyncEnumerable()
{
Expand Down
Loading
Loading