diff --git a/src/DataverseAnalyzer/PluginStepConfigurationAnalyzer.cs b/src/DataverseAnalyzer/PluginStepConfigurationAnalyzer.cs new file mode 100644 index 0000000..9d400ce --- /dev/null +++ b/src/DataverseAnalyzer/PluginStepConfigurationAnalyzer.cs @@ -0,0 +1,206 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PluginStepConfigurationAnalyzer : DiagnosticAnalyzer +{ + private static readonly Lazy LazyRuleFilteredAttributesOnCreate = new(() => new DiagnosticDescriptor( + "CT0008", + Resources.CT0008_Title, + Resources.CT0008_MessageFormat, + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Resources.CT0008_Description)); + + private static readonly Lazy LazyRulePreImageOnCreate = new(() => new DiagnosticDescriptor( + "CT0009", + Resources.CT0009_Title, + Resources.CT0009_MessageFormat, + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Resources.CT0009_Description)); + + private static readonly Lazy LazyRulePostImageOnDelete = new(() => new DiagnosticDescriptor( + "CT0010", + Resources.CT0010_Title, + Resources.CT0010_MessageFormat, + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Resources.CT0010_Description)); + + public static DiagnosticDescriptor RuleFilteredAttributesOnCreate => LazyRuleFilteredAttributesOnCreate.Value; + + public static DiagnosticDescriptor RulePreImageOnCreate => LazyRulePreImageOnCreate.Value; + + public static DiagnosticDescriptor RulePostImageOnDelete => LazyRulePostImageOnDelete.Value; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + RuleFilteredAttributesOnCreate, + RulePreImageOnCreate, + RulePostImageOnDelete); + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return; + + var methodName = memberAccess.Name.Identifier.ValueText; + + if (methodName == "AddFilteredAttributes") + AnalyzeAddFilteredAttributes(context, invocation, memberAccess); + else if (methodName == "AddImage") + AnalyzeAddImage(context, invocation, memberAccess); + } + + private static void AnalyzeAddFilteredAttributes( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax memberAccess) + { + var operation = FindEventOperation(memberAccess.Expression); + + if (operation == "Create") + { + var diagnostic = Diagnostic.Create( + RuleFilteredAttributesOnCreate, + invocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + + private static void AnalyzeAddImage( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax memberAccess) + { + var operation = FindEventOperation(memberAccess.Expression); + var imageType = GetImageTypeFromArguments(invocation); + + if (operation == "Create" && imageType is "PreImage" or "Both") + { + var diagnostic = Diagnostic.Create( + RulePreImageOnCreate, + invocation.GetLocation(), + imageType); + context.ReportDiagnostic(diagnostic); + } + else if (operation == "Delete" && imageType is "PostImage" or "Both") + { + var diagnostic = Diagnostic.Create( + RulePostImageOnDelete, + invocation.GetLocation(), + imageType); + context.ReportDiagnostic(diagnostic); + } + } + + private static string? FindEventOperation(ExpressionSyntax expression) + { + var current = expression; + + while (current is not null) + { + if (current is InvocationExpressionSyntax inv) + { + var methodName = GetMethodName(inv); + if (methodName == "RegisterPluginStep") + return GetOperationFromRegisterPluginStep(inv); + + current = GetReceiverExpression(inv); + } + else if (current is MemberAccessExpressionSyntax ma) + { + current = ma.Expression; + } + else + { + break; + } + } + + return null; + } + + private static string? GetMethodName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name switch + { + GenericNameSyntax gns => gns.Identifier.ValueText, + IdentifierNameSyntax ins => ins.Identifier.ValueText, + _ => null, + }, + IdentifierNameSyntax id => id.Identifier.ValueText, + GenericNameSyntax gn => gn.Identifier.ValueText, + _ => null, + }; + } + + private static ExpressionSyntax? GetReceiverExpression(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Expression, + _ => null, + }; + } + + private static string? GetOperationFromRegisterPluginStep(InvocationExpressionSyntax invocation) + { + foreach (var arg in invocation.ArgumentList.Arguments) + { + var argText = arg.Expression.ToString(); + + if (argText.IndexOf("Create", StringComparison.Ordinal) >= 0) + return "Create"; + if (argText.IndexOf("Delete", StringComparison.Ordinal) >= 0) + return "Delete"; + if (argText.IndexOf("Update", StringComparison.Ordinal) >= 0) + return "Update"; + } + + return null; + } + + private static string? GetImageTypeFromArguments(InvocationExpressionSyntax invocation) + { + if (invocation.ArgumentList.Arguments.Count == 0) + return null; + + var firstArg = invocation.ArgumentList.Arguments[0].Expression; + var argText = firstArg.ToString(); + + if (argText.IndexOf("PreImage", StringComparison.Ordinal) >= 0) + return "PreImage"; + if (argText.IndexOf("PostImage", StringComparison.Ordinal) >= 0) + return "PostImage"; + if (argText.IndexOf("Both", StringComparison.Ordinal) >= 0) + return "Both"; + + return null; + } +} \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.Designer.cs b/src/DataverseAnalyzer/Resources.Designer.cs index 59074a3..de143f2 100644 --- a/src/DataverseAnalyzer/Resources.Designer.cs +++ b/src/DataverseAnalyzer/Resources.Designer.cs @@ -53,5 +53,23 @@ internal static class Resources internal static string CT0007_Description => GetString(nameof(CT0007_Description)); + internal static string CT0008_Title => GetString(nameof(CT0008_Title)); + + internal static string CT0008_MessageFormat => GetString(nameof(CT0008_MessageFormat)); + + internal static string CT0008_Description => GetString(nameof(CT0008_Description)); + + internal static string CT0009_Title => GetString(nameof(CT0009_Title)); + + internal static string CT0009_MessageFormat => GetString(nameof(CT0009_MessageFormat)); + + internal static string CT0009_Description => GetString(nameof(CT0009_Description)); + + internal static string CT0010_Title => GetString(nameof(CT0010_Title)); + + internal static string CT0010_MessageFormat => GetString(nameof(CT0010_MessageFormat)); + + internal static string CT0010_Description => GetString(nameof(CT0010_Description)); + private static string GetString(string name) => ResourceManager.GetString(name, CultureInfo.InvariantCulture) ?? name; } \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.resx b/src/DataverseAnalyzer/Resources.resx index 3170352..aaa2fec 100644 --- a/src/DataverseAnalyzer/Resources.resx +++ b/src/DataverseAnalyzer/Resources.resx @@ -127,4 +127,31 @@ Entity types should use the type-safe ContainsAttributes method with lambda expressions instead of the string-based Contains method to enable compile-time checking of attribute names. + + AddFilteredAttributes not allowed on Create operations + + + AddFilteredAttributes cannot be used with Create operations because it has no effect + + + Create operations do not support filtered attributes. It has no effect on whether the step gets triggered + + + PreImage not allowed on Create operations + + + AddImage with {0} cannot be used with Create operations because no previous record state exists + + + Create operations cannot have pre-images because the record does not exist before the create operation. ImageType.Both is also invalid as it includes PreImage. + + + PostImage not allowed on Delete operations + + + AddImage with {0} cannot be used with Delete operations because no record state exists after deletion + + + Delete operations cannot have post-images because the record no longer exists after the delete operation. ImageType.Both is also invalid as it includes PostImage. + \ No newline at end of file diff --git a/tests/DataverseAnalyzer.Tests/PluginStepConfigurationAnalyzerTests.cs b/tests/DataverseAnalyzer.Tests/PluginStepConfigurationAnalyzerTests.cs new file mode 100644 index 0000000..e9ead25 --- /dev/null +++ b/tests/DataverseAnalyzer.Tests/PluginStepConfigurationAnalyzerTests.cs @@ -0,0 +1,414 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer.Tests; + +public sealed class PluginStepConfigurationAnalyzerTests +{ + private const string PluginStepApiDefinition = """ + namespace PluginRegistration + { + public enum EventOperation { Create, Update, Delete } + public enum ImageType { PreImage, PostImage, Both } + + public class PluginStepBuilder + { + public PluginStepBuilder AddFilteredAttributes(params string[] attributes) => this; + public PluginStepBuilder AddImage(ImageType type, params string[] attributes) => this; + } + + public static class PluginRegistrar + { + public static PluginStepBuilder RegisterPluginStep(EventOperation op, string stage, System.Action handler) + => new PluginStepBuilder(); + } + } + """; + + [Fact] + public async Task AddFilteredAttributesOnCreateShouldTriggerCT0008() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PreOperation", p => { }) + .AddFilteredAttributes("name", "address"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0008", diagnostics[0].Id); + } + + [Fact] + public async Task AddFilteredAttributesOnUpdateShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Update, "PreOperation", p => { }) + .AddFilteredAttributes("name", "address"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddPreImageOnCreateShouldTriggerCT0009() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PreOperation", p => { }) + .AddImage(ImageType.PreImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0009", diagnostics[0].Id); + } + + [Fact] + public async Task AddBothImageOnCreateShouldTriggerCT0009() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PreOperation", p => { }) + .AddImage(ImageType.Both, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0009", diagnostics[0].Id); + } + + [Fact] + public async Task AddPostImageOnCreateShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PostOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddPostImageOnDeleteShouldTriggerCT0010() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Delete, "PreOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0010", diagnostics[0].Id); + } + + [Fact] + public async Task AddBothImageOnDeleteShouldTriggerCT0010() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Delete, "PreOperation", p => { }) + .AddImage(ImageType.Both, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0010", diagnostics[0].Id); + } + + [Fact] + public async Task AddPreImageOnDeleteShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Delete, "PreOperation", p => { }) + .AddImage(ImageType.PreImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddPreImageOnUpdateShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Update, "PreOperation", p => { }) + .AddImage(ImageType.PreImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddPostImageOnUpdateShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Update, "PreOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddBothImageOnUpdateShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Update, "PreOperation", p => { }) + .AddImage(ImageType.Both, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ChainedAddFilteredAttributesThenPreImageOnCreateShouldTriggerBoth() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PreOperation", p => { }) + .AddFilteredAttributes("name") + .AddImage(ImageType.PreImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Equal(2, diagnostics.Length); + Assert.Contains(diagnostics, d => d.Id == "CT0008"); + Assert.Contains(diagnostics, d => d.Id == "CT0009"); + } + + [Fact] + public async Task MultipleStepsWithDifferentOperationsShouldOnlyTriggerOnInvalid() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PostOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + + PluginRegistrar.RegisterPluginStep(EventOperation.Delete, "PreOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0010", diagnostics[0].Id); + } + + [Fact] + public async Task CT0009DiagnosticContainsImageTypeName() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Create, "PreOperation", p => { }) + .AddImage(ImageType.Both, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Contains("Both", diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task CT0010DiagnosticContainsImageTypeName() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void Configure() + { + PluginRegistrar.RegisterPluginStep(EventOperation.Delete, "PreOperation", p => { }) + .AddImage(ImageType.PostImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Contains("PostImage", diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public async Task AddImageWithoutRegisterPluginStepShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void TestMethod(PluginStepBuilder builder) + { + builder.AddImage(ImageType.PreImage, "name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task AddFilteredAttributesWithoutRegisterPluginStepShouldNotTrigger() + { + var source = PluginStepApiDefinition + """ + + using PluginRegistration; + + class TestClass + { + public void TestMethod(PluginStepBuilder builder) + { + builder.AddFilteredAttributes("name"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + private static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new PluginStepConfigurationAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + return diagnostics.Where(d => d.Id is "CT0008" or "CT0009" or "CT0010").ToArray(); + } +} \ No newline at end of file