Skip to content

Commit 8fefaef

Browse files
committed
Add Roslyn analyzers and code fixes for migrating EntityFrameworkCore.Triggered triggers
1 parent ce01021 commit 8fefaef

16 files changed

Lines changed: 1662 additions & 0 deletions

Directory.Packages.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,10 @@
2929
<PackageVersion Include="xunit" Version="2.9.3" />
3030
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
3131
<PackageVersion Include="ScenarioTests.XUnit" Version="1.0.1" />
32+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
33+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />
34+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
35+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
36+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
3237
</ItemGroup>
3338
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
9+
</ItemGroup>
10+
11+
<ItemGroup>
12+
<ProjectReference Include="..\EntityFrameworkCore.Triggered.Analyzers\EntityFrameworkCore.Triggered.Analyzers.csproj" />
13+
</ItemGroup>
14+
15+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
12+
namespace EntityFrameworkCore.Triggered.Analyzers.CodeFixes;
13+
14+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
15+
public sealed class MigrateToAsyncTriggerCodeFixProvider : CodeFixProvider
16+
{
17+
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
18+
ImmutableArray.Create("EFCT001");
19+
20+
public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
21+
22+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
23+
{
24+
var diagnostic = context.Diagnostics.First();
25+
var properties = diagnostic.Properties;
26+
27+
if (!properties.TryGetValue("AsyncInterfaceShortName", out var asyncInterfaceName) ||
28+
!properties.TryGetValue("SyncInterfaceShortName", out var syncInterfaceName) ||
29+
!properties.TryGetValue("SyncMethodName", out var syncMethodName) ||
30+
!properties.TryGetValue("AsyncMethodName", out var asyncMethodName))
31+
return;
32+
33+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
34+
if (root == null) return;
35+
36+
var node = root.FindNode(diagnostic.Location.SourceSpan);
37+
var methodDeclaration = node.FirstAncestorOrSelf<MethodDeclarationSyntax>();
38+
if (methodDeclaration == null) return;
39+
40+
var classDeclaration = methodDeclaration.FirstAncestorOrSelf<ClassDeclarationSyntax>();
41+
if (classDeclaration == null) return;
42+
43+
context.RegisterCodeFix(
44+
CodeAction.Create(
45+
title: $"Migrate to {asyncInterfaceName}",
46+
createChangedDocument: ct => MigrateToAsync(context.Document, root, classDeclaration, methodDeclaration, syncInterfaceName!, asyncInterfaceName!, syncMethodName!, asyncMethodName!, ct),
47+
equivalenceKey: "MigrateToAsyncTrigger"),
48+
diagnostic);
49+
}
50+
51+
private static Task<Document> MigrateToAsync(
52+
Document document,
53+
SyntaxNode root,
54+
ClassDeclarationSyntax classDeclaration,
55+
MethodDeclarationSyntax methodDeclaration,
56+
string syncInterfaceName,
57+
string asyncInterfaceName,
58+
string syncMethodName,
59+
string asyncMethodName,
60+
CancellationToken cancellationToken)
61+
{
62+
var newRoot = root;
63+
64+
// Replace interface name in base list
65+
if (classDeclaration.BaseList != null)
66+
{
67+
var newBaseList = classDeclaration.BaseList;
68+
foreach (var baseType in classDeclaration.BaseList.Types)
69+
{
70+
var typeName = GetBaseTypeName(baseType);
71+
if (typeName == syncInterfaceName)
72+
{
73+
var newBaseType = ReplaceInterfaceName(baseType, syncInterfaceName, asyncInterfaceName);
74+
newBaseList = newBaseList.ReplaceNode(baseType, newBaseType);
75+
}
76+
}
77+
78+
if (newBaseList != classDeclaration.BaseList)
79+
{
80+
newRoot = newRoot.ReplaceNode(classDeclaration.BaseList, newBaseList);
81+
}
82+
}
83+
84+
// Find the method again in the updated tree
85+
var updatedMethod = newRoot.FindNode(methodDeclaration.Identifier.Span)
86+
.FirstAncestorOrSelf<MethodDeclarationSyntax>();
87+
88+
if (updatedMethod != null && updatedMethod.Identifier.Text == syncMethodName)
89+
{
90+
var newMethod = updatedMethod.WithIdentifier(
91+
SyntaxFactory.Identifier(asyncMethodName)
92+
.WithTriviaFrom(updatedMethod.Identifier));
93+
newRoot = newRoot.ReplaceNode(updatedMethod, newMethod);
94+
}
95+
96+
return Task.FromResult(document.WithSyntaxRoot(newRoot));
97+
}
98+
99+
private static string? GetBaseTypeName(BaseTypeSyntax baseType)
100+
{
101+
return baseType.Type switch
102+
{
103+
SimpleNameSyntax simple => simple.Identifier.Text,
104+
QualifiedNameSyntax qualified => qualified.Right.Identifier.Text,
105+
_ => null
106+
};
107+
}
108+
109+
private static BaseTypeSyntax ReplaceInterfaceName(BaseTypeSyntax baseType, string oldName, string newName)
110+
{
111+
switch (baseType.Type)
112+
{
113+
case GenericNameSyntax generic when generic.Identifier.Text == oldName:
114+
var newGeneric = generic.WithIdentifier(
115+
SyntaxFactory.Identifier(newName).WithTriviaFrom(generic.Identifier));
116+
return baseType.WithType(newGeneric);
117+
118+
case SimpleNameSyntax simple when simple.Identifier.Text == oldName:
119+
var newSimple = SyntaxFactory.IdentifierName(
120+
SyntaxFactory.Identifier(newName).WithTriviaFrom(simple.Identifier));
121+
return baseType.WithType(newSimple);
122+
123+
case QualifiedNameSyntax qualified when qualified.Right.Identifier.Text == oldName:
124+
SimpleNameSyntax newRight;
125+
if (qualified.Right is GenericNameSyntax rightGeneric)
126+
{
127+
newRight = rightGeneric.WithIdentifier(
128+
SyntaxFactory.Identifier(newName).WithTriviaFrom(rightGeneric.Identifier));
129+
}
130+
else
131+
{
132+
newRight = SyntaxFactory.IdentifierName(
133+
SyntaxFactory.Identifier(newName).WithTriviaFrom(qualified.Right.Identifier));
134+
}
135+
return baseType.WithType(qualified.WithRight(newRight));
136+
137+
default:
138+
return baseType;
139+
}
140+
}
141+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
12+
namespace EntityFrameworkCore.Triggered.Analyzers.CodeFixes;
13+
14+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
15+
public sealed class MigrateToSyncTriggerCodeFixProvider : CodeFixProvider
16+
{
17+
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
18+
ImmutableArray.Create("EFCT001");
19+
20+
public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
21+
22+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
23+
{
24+
var diagnostic = context.Diagnostics.First();
25+
var properties = diagnostic.Properties;
26+
27+
if (!properties.TryGetValue("SyncInterfaceShortName", out var syncInterfaceName) ||
28+
!properties.TryGetValue("SyncMethodName", out var syncMethodName))
29+
return;
30+
31+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
32+
if (root == null) return;
33+
34+
var node = root.FindNode(diagnostic.Location.SourceSpan);
35+
var methodDeclaration = node.FirstAncestorOrSelf<MethodDeclarationSyntax>();
36+
if (methodDeclaration == null) return;
37+
38+
context.RegisterCodeFix(
39+
CodeAction.Create(
40+
title: $"Migrate to sync {syncInterfaceName}",
41+
createChangedDocument: ct => MigrateToSync(context.Document, root, methodDeclaration, ct),
42+
equivalenceKey: "MigrateToSyncTrigger"),
43+
diagnostic);
44+
}
45+
46+
private static Task<Document> MigrateToSync(
47+
Document document,
48+
SyntaxNode root,
49+
MethodDeclarationSyntax methodDeclaration,
50+
CancellationToken cancellationToken)
51+
{
52+
var newMethod = methodDeclaration;
53+
54+
// Change return type from Task to void
55+
newMethod = newMethod.WithReturnType(
56+
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword))
57+
.WithTriviaFrom(methodDeclaration.ReturnType));
58+
59+
// Remove CancellationToken parameter
60+
var parameters = newMethod.ParameterList.Parameters;
61+
var filteredParameters = parameters.Where(p =>
62+
{
63+
var typeName = p.Type?.ToString();
64+
return typeName != "CancellationToken" && typeName != "System.Threading.CancellationToken";
65+
}).ToArray();
66+
67+
newMethod = newMethod.WithParameterList(
68+
SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(filteredParameters))
69+
.WithTriviaFrom(newMethod.ParameterList));
70+
71+
// Remove async modifier if present
72+
if (newMethod.Modifiers.Any(SyntaxKind.AsyncKeyword))
73+
{
74+
var newModifiers = SyntaxFactory.TokenList(
75+
newMethod.Modifiers.Where(m => !m.IsKind(SyntaxKind.AsyncKeyword)));
76+
newMethod = newMethod.WithModifiers(newModifiers);
77+
}
78+
79+
// Replace return Task.CompletedTask / return Task.FromResult(...) with plain return
80+
if (newMethod.Body != null)
81+
{
82+
var newBody = RewriteReturnStatements(newMethod.Body);
83+
newMethod = newMethod.WithBody(newBody);
84+
}
85+
else if (newMethod.ExpressionBody != null)
86+
{
87+
// Handle expression body: Task.CompletedTask => remove expression body, add empty body
88+
var exprText = newMethod.ExpressionBody.Expression.ToString();
89+
if (exprText.EndsWith("Task.CompletedTask") || exprText.Contains("Task.FromResult"))
90+
{
91+
newMethod = newMethod
92+
.WithExpressionBody(null)
93+
.WithSemicolonToken(SyntaxFactory.MissingToken(SyntaxKind.SemicolonToken))
94+
.WithBody(SyntaxFactory.Block());
95+
}
96+
}
97+
98+
var newRoot = root.ReplaceNode(methodDeclaration, newMethod);
99+
return Task.FromResult(document.WithSyntaxRoot(newRoot));
100+
}
101+
102+
private static BlockSyntax RewriteReturnStatements(BlockSyntax body)
103+
{
104+
var rewriter = new ReturnStatementRewriter();
105+
return (BlockSyntax)rewriter.Visit(body);
106+
}
107+
108+
private sealed class ReturnStatementRewriter : CSharpSyntaxRewriter
109+
{
110+
public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node)
111+
{
112+
if (node.Expression == null)
113+
return base.VisitReturnStatement(node);
114+
115+
var exprText = node.Expression.ToString();
116+
117+
// return Task.CompletedTask; / return System.Threading.Tasks.Task.CompletedTask; => return;
118+
if (exprText.EndsWith("Task.CompletedTask"))
119+
{
120+
return SyntaxFactory.ReturnStatement()
121+
.WithLeadingTrivia(node.GetLeadingTrivia())
122+
.WithTrailingTrivia(node.GetTrailingTrivia());
123+
}
124+
125+
// return Task.FromResult(...); => return;
126+
if (node.Expression is InvocationExpressionSyntax invocation &&
127+
invocation.Expression.ToString().EndsWith("Task.FromResult"))
128+
{
129+
return SyntaxFactory.ReturnStatement()
130+
.WithLeadingTrivia(node.GetLeadingTrivia())
131+
.WithTrailingTrivia(node.GetTrailingTrivia());
132+
}
133+
134+
return base.VisitReturnStatement(node);
135+
}
136+
}
137+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<IncludeBuildOutput>false</IncludeBuildOutput>
6+
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
7+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
8+
<PackageId>EntityFrameworkCore.Triggered.Analyzers</PackageId>
9+
<Description>Roslyn analyzers and code fixes to help migrate EntityFrameworkCore.Triggered triggers to the new sync/async interface pattern.</Description>
10+
<DevelopmentDependency>true</DevelopmentDependency>
11+
<NoPackageAnalysis>true</NoPackageAnalysis>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="..\EntityFrameworkCore.Triggered.Analyzers\EntityFrameworkCore.Triggered.Analyzers.csproj" PrivateAssets="all" />
16+
<ProjectReference Include="..\EntityFrameworkCore.Triggered.Analyzers.CodeFixes\EntityFrameworkCore.Triggered.Analyzers.CodeFixes.csproj" PrivateAssets="all" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<None Include="$(OutputPath)\EntityFrameworkCore.Triggered.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
21+
<None Include="$(OutputPath)\EntityFrameworkCore.Triggered.Analyzers.CodeFixes.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
22+
</ItemGroup>
23+
24+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
5+
<IsPackable>false</IsPackable>
6+
<Nullable>disable</Nullable>
7+
<NoWarn>NU1701</NoWarn>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
12+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
13+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
14+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
15+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" />
16+
<PackageReference Include="xunit" />
17+
<PackageReference Include="xunit.runner.visualstudio">
18+
<PrivateAssets>all</PrivateAssets>
19+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
20+
</PackageReference>
21+
<PackageReference Include="coverlet.collector">
22+
<PrivateAssets>all</PrivateAssets>
23+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
24+
</PackageReference>
25+
</ItemGroup>
26+
27+
<ItemGroup>
28+
<ProjectReference Include="..\EntityFrameworkCore.Triggered.Analyzers\EntityFrameworkCore.Triggered.Analyzers.csproj" />
29+
<ProjectReference Include="..\EntityFrameworkCore.Triggered.Analyzers.CodeFixes\EntityFrameworkCore.Triggered.Analyzers.CodeFixes.csproj" />
30+
</ItemGroup>
31+
32+
</Project>

0 commit comments

Comments
 (0)