diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index eb0068f..4cb78f2 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -716,6 +716,235 @@ public void DoSomething() { } xpc3002Diagnostics.Should().BeEmpty("XPC3002 should NOT be reported when modern API is also present"); } + [Fact] + public void Should_Generate_Types_Even_When_Handler_Method_Not_Found() + { + // Arrange - method reference points to NonExistentMethod but types should still be generated + // This enables a better DX where developers can create the method with correct signature + // using the generated PreImage/PostImage types + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.NonExistentMethod) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // Different method, NonExistentMethod doesn't exist + } + + public class TestService : ITestService + { + public void Process() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - Types should be generated even though handler method doesn't exist + result.GeneratedTrees.Should().NotBeEmpty( + "PreImage/PostImage types should be generated even when handler method doesn't exist"); + + // Verify PreImage class is generated + var generatedSource = result.GeneratedTrees.First().ToString(); + generatedSource.Should().Contain("public sealed class PreImage", + "PreImage class should be generated to allow developers to create the handler method with correct signature"); + } + + [Fact] + public void Should_Generate_Types_Even_When_Handler_Method_Wrong_Signature() + { + // Arrange - method reference points to MethodWithoutImage but types should still be generated + // This enables a better DX where developers can create the method with correct signature + // using the generated PreImage/PostImage types + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.MethodWithoutImage) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // Different method + void MethodWithoutImage(); // Method exists but wrong signature (missing PreImage parameter) + } + + public class TestService : ITestService + { + public void Process() { } + public void MethodWithoutImage() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - Types should be generated even though handler method doesn't exist + result.GeneratedTrees.Should().NotBeEmpty( + "PreImage/PostImage types should be generated even when handler method doesn't exist"); + + // Verify PreImage class is generated + var generatedSource = result.GeneratedTrees.First().ToString(); + generatedSource.Should().Contain("public sealed class PreImage", + "PreImage class should be generated to allow developers to create the handler method with correct signature"); + + generatedSource.Should().Contain("namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation", + "Generated types should be in the correct namespace"); + } + + [Fact] + public void Should_Generate_Unique_Files_For_Same_Named_Plugins_In_Different_Namespaces() + { + // Arrange - Two plugins with the same class name but in different namespaces + // Both register the same entity/operation/stage combination + // Previously this would cause a hint name collision + // Note: We don't use GetCompleteSource here because it strips namespaces + const string source = """ + using System; + using Microsoft.Xrm.Sdk; + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using XrmPluginCore.Tests.Context.BusinessDomain; + + namespace Namespace1 + { + public class AccountPlugin : Plugin + { + public AccountPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.HandleUpdate) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } + } + + namespace Namespace2 + { + public class AccountPlugin : Plugin + { + public AccountPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.HandleUpdate) + .WithPreImage(x => x.AccountNumber); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } + } + """; + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - Both plugins should generate separate files with unique hint names + result.GeneratedSources.Should().HaveCount(2, + "both plugins should generate code without hint name collision"); + + // Index sources by hint name for precise verification + var sourcesByHintName = result.GeneratedSources.ToDictionary(gs => gs.HintName, gs => gs.SourceText); + + // Find the hint names for each namespace + var namespace1HintName = sourcesByHintName.Keys.Single(h => h.Contains("Namespace1_")); + var namespace2HintName = sourcesByHintName.Keys.Single(h => h.Contains("Namespace2_")); + + // Verify Namespace1 source: correct namespace AND correct property (Name) + var namespace1Source = sourcesByHintName[namespace1HintName]; + namespace1Source.Should().Contain("namespace Namespace1.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation", + "Namespace1 hint name should map to Namespace1 generated namespace"); + namespace1Source.Should().Contain("public string Name =>", + "Namespace1 plugin registered Name attribute"); + + // Verify Namespace2 source: correct namespace AND correct property (AccountNumber) + var namespace2Source = sourcesByHintName[namespace2HintName]; + namespace2Source.Should().Contain("namespace Namespace2.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation", + "Namespace2 hint name should map to Namespace2 generated namespace"); + namespace2Source.Should().Contain("public string AccountNumber =>", + "Namespace2 plugin registered AccountNumber attribute"); + + // Verify each source only contains its own namespace (not cross-contaminated) + namespace1Source.Should().NotContain("namespace Namespace2", + "Namespace1 source should not contain Namespace2 namespace declaration"); + namespace2Source.Should().NotContain("namespace Namespace1", + "Namespace2 source should not contain Namespace1 namespace declaration"); + } + private static async Task> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer) { var compilation = CompilationHelper.CreateCompilation(source); diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs index 87afe20..51b2e12 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs @@ -35,11 +35,17 @@ public static GeneratorRunResult RunGenerator(CSharpCompilation compilation) .Where(tree => !compilation.SyntaxTrees.Contains(tree)) .ToArray(); + // Get generated sources with hint names from the run result + var generatedSources = runResult.Results[0].GeneratedSources + .Select(gs => new GeneratedSourceInfo(gs.HintName, gs.SourceText.ToString())) + .ToArray(); + return new GeneratorRunResult { OutputCompilation = (CSharpCompilation)outputCompilation, Diagnostics = [.. diagnostics], GeneratedTrees = generatedTrees, + GeneratedSources = generatedSources, GeneratorDiagnostics = [.. runResult.Results[0].Diagnostics] }; } @@ -105,9 +111,15 @@ public class GeneratorRunResult public required CSharpCompilation OutputCompilation { get; init; } public required Diagnostic[] Diagnostics { get; init; } public required SyntaxTree[] GeneratedTrees { get; init; } + public required GeneratedSourceInfo[] GeneratedSources { get; init; } public required Diagnostic[] GeneratorDiagnostics { get; init; } } +/// +/// Information about a generated source file including its hint name. +/// +public record GeneratedSourceInfo(string HintName, string SourceText); + /// /// Result from compiling generated code. /// diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 7ed0687..6674102 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -9,7 +9,6 @@ using XrmPluginCore.SourceGenerator.Helpers; using XrmPluginCore.SourceGenerator.Models; using XrmPluginCore.SourceGenerator.Parsers; -using XrmPluginCore.SourceGenerator.Validation; namespace XrmPluginCore.SourceGenerator.Generators; @@ -94,11 +93,6 @@ private static IEnumerable TransformToMetadata( if (mergedMetadata is null) continue; - // Validate handler method signature - HandlerMethodValidator.ValidateHandlerMethod( - mergedMetadata, - semanticModel.Compilation); - // Include if: // - Has method reference (for ActionWrapper generation) // - OR has images with attributes (for image wrapper generation) @@ -185,10 +179,6 @@ private void GenerateSourceFromMetadata( } } - // Skip generation if validation failed (analyzer will report the error) - if (metadata?.HasValidationError == true) - return; - // Generate code if we have a handler method reference (ActionWrapper always needed) if (string.IsNullOrEmpty(metadata?.HandlerMethodName)) return; diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index c12609b..9436ac9 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -42,12 +42,6 @@ internal sealed class PluginStepMetadata /// public List Diagnostics { get; set; } = []; - /// - /// If true, generation should be skipped for this registration due to validation errors. - /// The analyzer will report the appropriate diagnostic. Not included in equality comparison. - /// - public bool HasValidationError { get; set; } - /// /// Gets the namespace for generated wrapper classes. /// Format: {OriginalNamespace}.PluginRegistrations.{PluginClassName}.{Entity}{Op}{Stage} @@ -57,10 +51,11 @@ internal sealed class PluginStepMetadata /// /// Gets a unique identifier for this registration. - /// Includes plugin class name to differentiate multiple registrations for the same entity/operation/stage. + /// Includes namespace and plugin class name to differentiate multiple registrations + /// for the same entity/operation/stage across different namespaces. /// public string UniqueId => - $"{PluginClassName}_{EntityTypeName}_{EventOperation}_{ExecutionStage}"; + $"{Namespace?.Replace(".", "_")}_{PluginClassName}_{EntityTypeName}_{EventOperation}_{ExecutionStage}"; public override bool Equals(object obj) { diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs deleted file mode 100644 index b5fe934..0000000 --- a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.CodeAnalysis; -using System.Linq; -using XrmPluginCore.SourceGenerator.Helpers; -using XrmPluginCore.SourceGenerator.Models; - -namespace XrmPluginCore.SourceGenerator.Validation; - -internal static class HandlerMethodValidator -{ - /// - /// Validates handler method existence and signature. - /// Sets HasValidationError on metadata if validation fails. - /// Note: XPC4001 and XPC4002 diagnostics are handled by separate analyzers. - /// IMPORTANT: We only block generation for XPC4001 (method not found), NOT for XPC4002 (signature mismatch). - /// This is intentional - the generated types (PreImage/PostImage) must exist before the user can update - /// their handler signature to use them. This prevents a chicken-and-egg problem. - /// - public static void ValidateHandlerMethod( - PluginStepMetadata metadata, - Compilation compilation) - { - if (string.IsNullOrEmpty(metadata.HandlerMethodName) || - string.IsNullOrEmpty(metadata.ServiceTypeFullName)) - { - return; - } - - var serviceType = compilation.GetTypeByMetadataName(metadata.ServiceTypeFullName); - if (serviceType is null) - return; - - var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, metadata.HandlerMethodName); - if (!methods.Any()) - { - // Method not found - abort generation for this registration - // XPC4001 diagnostic is handled by HandlerMethodNotFoundAnalyzer - metadata.HasValidationError = true; - return; - } - - // NOTE: We intentionally do NOT set HasValidationError for signature mismatch (XPC4002). - // The PreImage/PostImage types must be generated first so the user can reference them - // in their handler method signature. The analyzer (XPC4002/XPC4003) will report if - // the signature doesn't match after the types are available. - } -}