From 5396c2ae4f6160278ba5c92024225d335b5044f8 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Mon, 24 Nov 2025 09:27:02 +0100 Subject: [PATCH 01/22] ADD: WithPreImage and WithPostImage methods to register images, which introduce a requirement to handle Pre and PostImages correctly. ADD: Source Generator that creates type-safe image entity classes --- .claude/settings.local.json | 14 +- .editorconfig | 4 +- CLAUDE.md | 239 +++++++++++- README.md | 105 +++++- .../AnalyzerReleases.Shipped.md | 6 + .../AnalyzerReleases.Unshipped.md | 9 + .../CodeGeneration/WrapperClassGenerator.cs | 188 +++++++++ XrmPluginCore.SourceGenerator/Constants.cs | 22 ++ .../DiagnosticDescriptors.cs | 51 +++ .../Generators/PluginImageGenerator.cs | 176 +++++++++ .../Helpers/SyntaxHelper.cs | 185 +++++++++ .../Models/PluginStepMetadata.cs | 144 +++++++ .../Parsers/RegistrationParser.cs | 308 +++++++++++++++ .../XrmPluginCore.SourceGenerator.csproj | 15 + .../Integration/PluginIntegrationTests.cs | 1 - .../TestPlugins/Bedrock/SamplePlugin.cs | 4 +- .../TypeSafe/TypeSafeAccountPlugin.cs | 75 ++++ .../TypeSafe/TypeSafeAccountService.cs | 32 ++ .../TypeSafe/TypeSafeContactPlugin.cs | 78 ++++ .../TypeSafe/TypeSafeContactService.cs | 32 ++ XrmPluginCore.Tests/TypeSafePluginTests.cs | 357 ++++++++++++++++++ .../XrmPluginCore.Tests.csproj | 15 +- XrmPluginCore.sln | 16 +- XrmPluginCore/CHANGELOG.md | 7 +- .../Extensions/ServiceProviderExtensions.cs | 9 +- XrmPluginCore/IEntityImageWrapper.cs | 24 ++ XrmPluginCore/Plugin.cs | 105 +++++- XrmPluginCore/Plugins/PluginStepBuilders.cs | 267 +++++++++++++ .../Plugins/PluginStepConfigBuilder.cs | 10 +- .../Plugins/PluginStepRegistration.cs | 28 +- XrmPluginCore/XrmPluginCore.csproj | 6 + 31 files changed, 2481 insertions(+), 51 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md create mode 100644 XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md create mode 100644 XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs create mode 100644 XrmPluginCore.SourceGenerator/Constants.cs create mode 100644 XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs create mode 100644 XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs create mode 100644 XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs create mode 100644 XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj create mode 100644 XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs create mode 100644 XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs create mode 100644 XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs create mode 100644 XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs create mode 100644 XrmPluginCore.Tests/TypeSafePluginTests.cs create mode 100644 XrmPluginCore/IEntityImageWrapper.cs create mode 100644 XrmPluginCore/Plugins/PluginStepBuilders.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e2819af..fed0a9b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,9 +2,19 @@ "permissions": { "allow": [ "Bash(dotnet build:*)", - "Bash(dotnet test:*)" + "Bash(dotnet test:*)", + "Bash(dotnet restore:*)", + "Bash(dotnet clean:*)", + "Bash(dotnet msbuild:*)", + "Bash(dotnet pack:*)", + "Bash(dotnet list:*)", + "Bash(find:*)", + "Bash(cat:*)", + "Bash(findstr:*)", + "Bash(dir:*)" ], "deny": [], "ask": [] - } + }, + "outputStyle": "default" } diff --git a/.editorconfig b/.editorconfig index a7fe81d..4c28479 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,7 +17,7 @@ root = true charset = utf-8 indent_style = tab # Use tabs for indentation, change from ATC indent_size = 4 -insert_final_newline = false +insert_final_newline = true # Add newline at end of file, change from ATC trim_trailing_whitespace = true ########################################## @@ -523,4 +523,4 @@ dotnet_diagnostic.CA1515.severity = none # We allow controllers to be public dotnet_diagnostic.S3925.severity = none # rule that requires an implementation that is obsolete in .Net8 -dotnet_diagnostic.SA1010.severity = none # Disabled until fix for C#12 has been released for collection expressions \ No newline at end of file +dotnet_diagnostic.SA1010.severity = none # Disabled until fix for C#12 has been released for collection expressions diff --git a/CLAUDE.md b/CLAUDE.md index bc8445b..fcd1b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ XrmPluginCore is a NuGet library that provides base functionality for developing The project consists of: - **XrmPluginCore**: Main implementation library - **XrmPluginCore.Abstractions**: Interfaces and enums used for plugin/custom API registration +- **XrmPluginCore.SourceGenerator**: Compile-time source generator for type-safe filtered attributes - **XrmPluginCore.Tests**: Unit and integration tests ## Build & Test Commands @@ -43,14 +44,17 @@ dotnet pack --configuration Release --no-build --output ./nupkg - Invokes the appropriate registered action 2. **Registration Pattern**: Plugins register their steps in the constructor using fluent builders: - - `RegisterStep(EventOperation, ExecutionStage, Action)` - Modern DI-based approach + - `RegisterStep(EventOperation, ExecutionStage, Action)` - Standard DI-based approach with optional type-safe wrappers - `RegisterPluginStep(EventOperation, ExecutionStage, Action)` - Legacy approach (deprecated) - `RegisterAPI(string name, Action)` - For Custom APIs + When `AddFilteredAttributes()` or `AddImage()` are used, the source generator automatically creates wrapper classes that are discovered at runtime by naming convention. + 3. **Service Provider Pattern**: - `ExtendedServiceProvider` wraps the Dynamics SDK's IServiceProvider - `ServiceProviderExtensions.BuildServiceProvider()` creates a scoped DI container per execution - Built-in services injected: IPluginExecutionContext, IOrganizationServiceFactory, ITracingService (as ExtendedTracingService), ILogger + - Type-safe registrations automatically register generated wrapper classes (Target, PreImage, PostImage) directly in DI - Custom services registered via `OnBeforeBuildServiceProvider()` override 4. **Configuration Builders**: @@ -76,20 +80,241 @@ dotnet pack --configuration Release --no-build --output ./nupkg - `IPluginDefinition.cs` - Interface for retrieving plugin step configurations - `ICustomApiDefinition.cs` - Interface for retrieving custom API configuration +**XrmPluginCore.SourceGenerator/** (Compile-time code generation) +- `Generators/TargetEntityGenerator.cs` - Incremental source generator that scans for Plugin classes +- `Parsers/RegistrationParser.cs` - Extracts metadata from RegisterStep invocations +- `CodeGeneration/WrapperClassGenerator.cs` - Generates type-safe wrapper classes +- `Helpers/SyntaxHelper.cs` - Roslyn syntax tree analysis utilities +- `Models/PluginStepMetadata.cs` - Data models for storing registration metadata + +### Type-Safe Images + +The source generator provides compile-time type safety for plugin images (PreImage/PostImage) with **compile-time enforcement** that prevents developers from accidentally ignoring registered images. + +#### API Design + +Use `WithPreImage`/`WithPostImage` to register images. The `Execute` method signature is **enforced** by the compiler to accept the registered image types: + +```csharp +// PreImage only - Execute MUST accept PreImage parameter +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .Execute((service, preImage) => service.HandleUpdate(preImage)); + +// PostImage only - Execute MUST accept PostImage parameter +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name) + .WithPostImage(x => x.Name, x => x.AccountNumber) + .Execute((service, postImage) => service.HandleUpdate(postImage)); + +// Both images - Execute MUST accept both parameters +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.AccountNumber) + .Execute((service, pre, post) => service.HandleUpdate(pre, post)); +``` + +**Key benefit**: If you register an image with `WithPreImage`, there is NO way to complete the registration without accepting the image in `Execute`. This prevents developers from accidentally ignoring registered images. + +#### How It Works + +1. **Compile-Time Analysis**: The source generator scans all classes that inherit from `Plugin` and finds `RegisterStep` calls that use `WithPreImage()` or `WithPostImage()`. + +2. **Metadata Extraction**: For each registration, it extracts: + - Plugin class name + - Entity type (TEntity) + - Event operation and execution stage + - Filtered attributes from `AddFilteredAttributes()` calls + - Pre/Post image attributes from `WithPreImage()`/`WithPostImage()` calls + +3. **Code Generation**: Generates image wrapper classes in isolated namespaces: + - Namespace: `{Namespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage}` + - Classes: `PreImage`, `PostImage` (simple names, no prefixes) + +4. **Runtime Execution**: When the plugin executes: + - The `Execute` action is invoked with the service and image instances + - Images are constructed using `Activator.CreateInstance(typeof(TImage), entity)` from the execution context + - Services receive strongly-typed image wrappers as parameters + +#### Example Usage + +```csharp +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.AccountPlugin.AccountUpdatePostOperation; + +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // Type-safe API with compile-time enforcement + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.AccountNumber) + .Execute((service, pre, post) => service.HandleUpdate(pre, post)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } +} + +public class AccountService +{ + // Images are passed directly to the method - no DI injection needed + public void HandleUpdate(PreImage preImage, PostImage postImage) + { + var previousName = preImage.Name; // Type-safe, IntelliSense works + var previousRevenue = preImage.Revenue; + var newName = postImage.Name; + } +} +``` + +#### Generated Code Example + +The source generator creates wrapper classes in isolated namespaces: + +```csharp +// Generated in: {Namespace}.PluginImages.AccountPlugin.AccountUpdatePostOperation +namespace YourNamespace.PluginImages.AccountPlugin.AccountUpdatePostOperation +{ + public class PreImage + { + private readonly Entity _entity; + + public PreImage(Entity entity) + { + _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + } + + public string Name => _entity.GetAttributeValue("name"); + public Money Revenue => _entity.GetAttributeValue("revenue"); + + public T ToEntity() where T : Entity => _entity.ToEntity(); + } + + public class PostImage + { + private readonly Entity _entity; + + public PostImage(Entity entity) + { + _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + } + + public string Name => _entity.GetAttributeValue("name"); + public string Accountnumber => _entity.GetAttributeValue("accountnumber"); + + public T ToEntity() where T : Entity => _entity.ToEntity(); + } +} +``` + +#### Builder Pattern + +The API uses a type-state builder pattern that enforces image acceptance at compile time: + +- `RegisterStep(op, stage)` → returns `PluginStepBuilder` +- `.WithPreImage(...)` → returns `PluginStepBuilderWithPreImage` (must call `Execute`) +- `.WithPostImage(...)` → returns `PluginStepBuilderWithPostImage` (must call `Execute`) +- `.WithPreImage(...).WithPostImage(...)` → returns `PluginStepBuilderWithBothImages` (must call `Execute`) + +#### Migration from AddImage + +The old `AddImage` API is marked as `[Obsolete]`. Migrate to the new API: + +```csharp +// Old API (obsolete, no enforcement) +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process()) + .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue); + +// New API (enforced at compile time) +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.Name, x => x.Revenue) + .Execute((service, preImage) => service.Process(preImage)); +``` + +#### Benefits + +- **Compile-time enforcement**: Cannot register an image without accepting it in Execute +- **Type safety**: Wrong image types cause compile errors +- **IntelliSense support**: Auto-completion for available image attributes +- **No runtime overhead**: Simple property accessors, no reflection at access time +- **Null safety**: Missing attributes return null instead of throwing exceptions +- **Namespace isolation**: Each step gets its own namespace, preventing naming conflicts + ### Dependency Injection -Override `OnBeforeBuildServiceProvider()` in your base plugin class to register services: +XrmPluginCore supports three patterns for registering custom services: + +#### Pattern 1: Direct Override (Simple, Single Plugin) + +Override `OnBeforeBuildServiceProvider()` directly in your plugin class: ```csharp -protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) +public class MyPlugin : Plugin { - return services - .AddScoped() - .AddSingleton(); + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } } ``` -Services are scoped to the plugin execution and disposed automatically. +**Use when**: You have a single plugin class with unique services. + +#### Pattern 2: Base Class (Inheritance-based Sharing) + +Create a base plugin class that registers shared services, then inherit from it: + +```csharp +public class BasePlugin : Plugin +{ + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services + .AddScoped() + .AddScoped(); + } +} + +public class AccountPlugin : BasePlugin { } +public class ContactPlugin : BasePlugin { } +``` + +**Use when**: Multiple plugins need the same services and share a common inheritance hierarchy. + +#### Pattern 3: Extension Method (Composition-based Sharing) + +Create static extension methods to encapsulate service registration logic: + +```csharp +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSharedServices(this IServiceCollection services) + { + return services + .AddScoped() + .AddScoped(); + } +} + +public class AccountPlugin : Plugin +{ + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddSharedServices(); + } +} +``` + +**Use when**: You want to share service registration logic across plugins that may not share inheritance, or when you need to compose multiple service registration modules. + +**Note**: Services are scoped to the plugin execution and disposed automatically. ### Multi-Targeting diff --git a/README.md b/README.md index 35919b6..28019ee 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -# XrmPluginCore -![XrmPluginCore NuGet Version](https://img.shields.io/nuget/v/XrmPluginCore?label=XrmPluginCore%20NuGet) ![XrmPluginCore.Abstractions NuGet Version](https://img.shields.io/nuget/v/XrmPluginCore.Abstractions?label=Abstractions%20NuGet) +# XrmPluginCore +![XrmPluginCore NuGet Version](https://img.shields.io/nuget/v/XrmPluginCore?label=XrmPluginCore%20NuGet) +![XrmPluginCore.Abstractions NuGet Version](https://img.shields.io/nuget/v/XrmPluginCore.Abstractions?label=Abstractions%20NuGet) +![.NET Framework 4.6.2](https://img.shields.io/badge/.NET-4.6.2-blue) +![.NET 8](https://img.shields.io/badge/.NET-8-blue) XrmPluginCore provides base functionality for developing plugins and custom APIs in Dynamics 365. It includes context wrappers and registration utilities to streamline the development process. ## Features -- **Context Wrappers**: Simplify access to plugin execution context. -- **Registration Utilities**: Easily register plugins and custom APIs. -- **Compatibility**: Supports .NET Standard 2.0, .NET Framework 4.6.2, and .NET 8. +- **Dependency Injection**: Modern DI-based plugin architecture with built-in service registration +- **Simple Plugin Creation**: Streamlined workflow for creating and registering plugins +- **Context Wrappers**: Simplify access to plugin execution context +- **Registration Utilities**: Easily register plugins and custom APIs +- **Type-Safe Images**: Compile-time type safety for PreImages and PostImages via source generators +- **Compatibility**: Supports .NET Framework 4.6.2 and .NET 8 ## Usage ### Creating a Plugin -1. Create a new class that inherits from `Plugin`. -2. Register the plugin using the `RegisterStep` helper method. -3. Implement the function in the custom action - -#### Using the a service - Create a service interface and concrete implementation: ```csharp @@ -86,9 +86,84 @@ namespace Some.Namespace { } ``` -#### Using the LocalPluginContext wrapper +### Type-Safe Images (Advanced) + +XrmPluginCore includes a source generator that creates type-safe wrapper classes for your plugin images (PreImage/PostImage), giving you compile-time safety and IntelliSense support. -**NOTE**: This is only support to support legacy DAXIF/XrmFramework style plugins. It is recommended to use dependency injection based plugins instead. +#### Quick Start + +```csharp +using XrmPluginCore; +using XrmPluginCore.Enums; +using MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation; + +namespace MyPlugin { + public class AccountUpdatePlugin : Plugin { + public AccountUpdatePlugin() { + // Type-safe API: WithPreImage enforces that Execute receives PreImage + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .Execute((service, pre) => service.Process(pre)); + // ↑ Compile error if you don't accept PreImage! + } + } + + public interface IAccountService { + void Process(PreImage preImage); + } + + public class AccountService : IAccountService { + public void Process(PreImage preImage) { + // Type-safe access to pre-image attributes! + var oldName = preImage.Name; // IntelliSense works! + var oldRevenue = preImage.Revenue; // Type-safe Money access + } + } +} +``` + +**Benefits of type-safe images:** +- **Compile-time enforcement** - You MUST handle registered images +- **IntelliSense support** - Auto-completion for available attributes +- **Null safety** - Proper handling of missing attributes +- **No boilerplate** - Just add a `using` statement for the generated namespace + +#### Working with Both Images + +```csharp +using MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation; + +public class AccountUpdatePlugin : Plugin { + public AccountUpdatePlugin() { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.AccountNumber) + .Execute((service, pre, post) => service.Process(pre, post)); + // Must accept both PreImage AND PostImage! + } +} + +public class AccountService : IAccountService { + public void Process(PreImage preImage, PostImage postImage) { + var oldRevenue = preImage.Revenue; // Type-safe pre-image access + var newAccountNum = postImage.Accountnumber; // Type-safe post-image access + } +} +``` + +**Generated Namespace Convention:** +``` +{YourNamespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage} +``` +Example: `MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation` + +Inside this namespace you'll find simple class names: `PreImage` and `PostImage` + +### Using the LocalPluginContext wrapper (Legacy) + +**NOTE**: This is only supported for legacy DAXIF/XrmFramework style plugins. It is recommended to use dependency injection based plugins instead. ```csharp namespace Some.Namespace { @@ -133,6 +208,7 @@ The following services are available for injection into your plugin or custom AP |---------|-------------| | [IExtendedTracingService](XrmPluginCore/IExtendedTracingService.cs) | Extension to ITracingService with additional helper methods. | | [ILogger 🔗](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/application-insights-ilogger) | The Plugin Telemetry Service logger interface. | +| [IManagedIdentityService 🔗](https://learn.microsoft.com/en-us/dotnet/api/microsoft.xrm.sdk.imanagedidentityservice) | Service to obtain access tokens for Azure resources using Managed Identity. | | [IOrganizationServiceFactory 🔗](https://learn.microsoft.com/en-us/dotnet/api/microsoft.xrm.sdk.iorganizationservicefactory) | Represents a factory for creating IOrganizationService instances. | | [IPluginExecutionContext 🔗](https://learn.microsoft.com/en-us/dotnet/api/microsoft.xrm.sdk.ipluginexecutioncontext) | The plugin execution context provides information about the current plugin execution, including input and output parameters, the message name, and the stage of execution. | | [IPluginExecutionContext2 🔗](https://learn.microsoft.com/en-us/dotnet/api/microsoft.xrm.sdk.ipluginexecutioncontext2) | Extension to IPluginExecutionContext with additional properties and methods. | @@ -155,6 +231,9 @@ Use [XrmSync](https://github.com/delegateas/XrmSync) to automatically register r XrmPluginCore and XrmSync does not currently support [Dependent Assemblies](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/build-and-package). If your plugin depends on other assemblies, you can use ILRepack or a similar tool to merge the assemblies into a single DLL before deploying. +> [!NOTE] +> Microsoft does not officially support ILMerged assemblies for Dynamics 365 plugins. + To ensure XrmPluginCore, and it's dependencies are included, you can use the following settings for ILRepack: ```xml diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..39071b5 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..e3c0d66 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe wrapper classes +XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes +XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol +XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 Property not found in entity type +XPC4002 | XrmPluginCore.SourceGenerator | Warning | XPC4002 No parameterless constructor found diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs new file mode 100644 index 0000000..59c8cc0 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using XrmPluginCore.SourceGenerator.Models; + +namespace XrmPluginCore.SourceGenerator.CodeGeneration; + +/// +/// Generates wrapper class source code for plugin step registrations +/// +internal static class WrapperClassGenerator +{ + /// + /// Generates a complete source file containing wrapper classes for a plugin step registration. + /// Only generates PreImage and PostImage wrappers (no Target wrapper). + /// + public static string GenerateWrapperClasses(PluginStepMetadata metadata) + { + // Only generate if there are images with attributes + var imagesWithAttributes = metadata.Images.Where(i => i.Attributes.Any()).ToList(); + if (!imagesWithAttributes.Any()) + { + return null; + } + + // Estimate capacity: ~500 chars per image wrapper class + var estimatedCapacity = imagesWithAttributes.Count * 500; + var sb = new StringBuilder(estimatedCapacity); + + // File header + sb.AppendLine("// "); + sb.AppendLine(); + + // Using directives + sb.AppendLine("using System;"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using Microsoft.Xrm.Sdk;"); + sb.AppendLine("using XrmPluginCore;"); + sb.AppendLine(); + + // Namespace declaration on the format {Namespace}.PluginImages.{PluginClassName}.{EntityTypeName}{EventOperation}{ExecutionStage} + sb.AppendLine($"namespace {metadata.ImageNamespace}"); + sb.AppendLine("{"); + + // Generate Image wrapper classes + foreach (var image in imagesWithAttributes) + { + GenerateImageWrapperClass(sb, metadata, image); + } + + // Close namespace + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Generates an Image wrapper class (PreImage or PostImage) + /// + private static void GenerateImageWrapperClass(StringBuilder sb, PluginStepMetadata metadata, ImageMetadata image) + { + // Simple class name - just "PreImage" or "PostImage" + var className = image.WrapperClassName; + + // XML documentation + sb.AppendLine(" /// "); + sb.AppendLine($" /// Type-safe wrapper for {metadata.EntityTypeName} {metadata.EventOperation} {metadata.ExecutionStage} {image.ImageType}"); + sb.AppendLine(" /// "); + + // CompilerGenerated attribute + sb.AppendLine(" [CompilerGenerated]"); + + // Class declaration with interface implementation + sb.AppendLine($" public class {className} : IEntityImageWrapper"); + sb.AppendLine(" {"); + + // Private entity field + sb.AppendLine(" private readonly Entity entity;"); + sb.AppendLine(); + + // Constructor + sb.AppendLine(" /// "); + sb.AppendLine($" /// Initializes a new instance of {className}"); + sb.AppendLine(" /// "); + sb.AppendLine(" /// The image entity"); + sb.AppendLine($" public {className}(Entity entity)"); + sb.AppendLine(" {"); + sb.AppendLine(" this.entity = entity ?? throw new ArgumentNullException(nameof(entity));"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Generate properties for each image attribute + foreach (var attr in image.Attributes) + { + GenerateProperty(sb, attr); + } + + // ToEntity method - uses SDK's ToEntity() for early-bound access + sb.AppendLine(" /// "); + sb.AppendLine(" /// Converts the underlying Entity to an early-bound entity type"); + sb.AppendLine(" /// "); + sb.AppendLine(" /// The early-bound entity type"); + sb.AppendLine(" public T ToEntity() where T : Entity => entity.ToEntity();"); + sb.AppendLine(); + + // GetUnderlyingEntity method - provides access to the raw Entity + sb.AppendLine(" /// "); + sb.AppendLine(" /// Gets the underlying Entity object for direct attribute access or service operations"); + sb.AppendLine(" /// "); + sb.AppendLine(" public Entity GetUnderlyingEntity() => entity;"); + + // Close class + sb.AppendLine(" }"); + sb.AppendLine(); + } + + /// + /// Generates a property for an entity attribute. + /// GetAttributeValue<T> is already null-safe (returns default(T) for missing attributes), + /// so we don't need the Contains check. + /// + private static void GenerateProperty(StringBuilder sb, AttributeMetadata attr) + { + var propertyType = attr.TypeName; + + // XML documentation + sb.AppendLine(" /// "); + sb.AppendLine($" /// Gets the {attr.PropertyName} attribute"); + sb.AppendLine(" /// "); + + // Property declaration using expression body - GetAttributeValue is already null-safe + sb.AppendLine($" public {propertyType} {attr.PropertyName} => entity.GetAttributeValue<{propertyType}>(\"{attr.LogicalName}\");"); + sb.AppendLine(); + } + + /// + /// Generates a unique hint name for the source file + /// + public static string GenerateHintName(PluginStepMetadata metadata) + { + // UniqueId already contains PluginClassName, so no need to duplicate + return $"{metadata.UniqueId}.g.cs"; + } + + /// + /// Merges multiple metadata instances that represent the same registration but with different attributes + /// This handles the edge case where the same entity/operation/stage is registered multiple times + /// + public static PluginStepMetadata MergeMetadata(IEnumerable metadataList) + { + var list = metadataList.ToList(); + if (!list.Any()) + return null; + if (list.Count == 1) + return list[0]; + + var merged = new PluginStepMetadata + { + EntityTypeName = list[0].EntityTypeName, + EventOperation = list[0].EventOperation, + ExecutionStage = list[0].ExecutionStage, + Namespace = list[0].Namespace, + PluginClassName = list[0].PluginClassName, + Images = [] + }; + + // Merge all images (remove duplicates) + var allImages = list.SelectMany(m => m.Images) + .GroupBy(i => new { i.ImageType, i.ImageName }) + .Select(g => + { + var first = g.First(); + return new ImageMetadata + { + ImageType = first.ImageType, + ImageName = first.ImageName, + Attributes = [.. g.SelectMany(i => i.Attributes) + .GroupBy(a => a.LogicalName) + .Select(ag => ag.First())] + }; + }) + .ToList(); + + merged.Images.AddRange(allImages); + + return merged; + } +} diff --git a/XrmPluginCore.SourceGenerator/Constants.cs b/XrmPluginCore.SourceGenerator/Constants.cs new file mode 100644 index 0000000..9760539 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Constants.cs @@ -0,0 +1,22 @@ +namespace XrmPluginCore.SourceGenerator; + +/// +/// Constants used throughout the source generator +/// +internal static class Constants +{ + // Plugin framework constants + public const string PluginBaseClassName = "Plugin"; + public const string PluginNamespace = "XrmPluginCore"; + public const string LogicalNameAttributeName = "AttributeLogicalNameAttribute"; + + // Method names + public const string RegisterStepMethodName = "RegisterStep"; + public const string WithPreImageMethodName = "WithPreImage"; + public const string WithPostImageMethodName = "WithPostImage"; + public const string AddImageMethodName = "AddImage"; + + // Image types + public const string PreImageTypeName = "PreImage"; + public const string PostImageTypeName = "PostImage"; +} diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs new file mode 100644 index 0000000..689ae9d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; + +namespace XrmPluginCore.SourceGenerator; + +/// +/// Diagnostic descriptors for the source generator +/// +internal static class DiagnosticDescriptors +{ + private const string Category = "XrmPluginCore.SourceGenerator"; + + public static readonly DiagnosticDescriptor GenerationSuccess = new( + id: "XPC1000", + title: "Generated type-safe wrapper classes", + messageFormat: "Generated {0} wrapper class(es) for {1}", + category: Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GenerationError = new( + id: "XPC5000", + title: "Failed to generate wrapper classes", + messageFormat: "Exception during generation: {0}", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor SymbolResolutionFailed = new( + id: "XPC4000", + title: "Failed to resolve symbol", + messageFormat: "Could not resolve RegisterStep method symbol in {0}. Image wrappers will not be generated for this registration.", + category: Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor PropertyNotFound = new( + id: "XPC4001", + title: "Property not found in entity type", + messageFormat: "Property '{0}' not found in entity type '{1}'. This property will be excluded from the generated image wrapper.", + category: Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NoParameterlessConstructor = new( + id: "XPC4002", + title: "No parameterless constructor found", + messageFormat: "Plugin class '{0}' has no parameterless constructor. Image wrappers will not be generated for this plugin.", + category: Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); +} diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs new file mode 100644 index 0000000..79da141 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -0,0 +1,176 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using XrmPluginCore.SourceGenerator.CodeGeneration; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; +using XrmPluginCore.SourceGenerator.Parsers; + +namespace XrmPluginCore.SourceGenerator.Generators; + +/// +/// Incremental source generator that creates type-safe wrapper classes for plugin images (PreImage/PostImage) +/// +[Generator] +public class PluginImageGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Create incremental pipeline that processes each plugin class individually + var pluginMetadata = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => IsCandidateClass(node), + transform: static (ctx, ct) => TransformToMetadata(ctx, ct)) + .Where(static m => m is not null) + .SelectMany(static (list, _) => list); // Flatten multiple registrations per class + + // Register source output per metadata item (incremental - only changed items reprocessed) + context.RegisterSourceOutput(pluginMetadata, (spc, metadata) => GenerateSourceFromMetadata(metadata, spc)); + } + + /// + /// Fast syntax-based predicate to identify candidate classes + /// + private static bool IsCandidateClass(SyntaxNode node) + { + // Look for class declarations + if (node is not ClassDeclarationSyntax classDecl) + return false; + + // Must have a constructor + if (!classDecl.Members.OfType().Any()) + return false; + + return true; + } + + /// + /// Transform phase: Extract all metadata from a plugin class. + /// This does all the heavy semantic analysis in a cacheable transform phase. + /// Roslyn will cache results per class and only reprocess changed classes. + /// + private static IEnumerable TransformToMetadata( + GeneratorSyntaxContext context, + CancellationToken cancellationToken) + { + if (context.Node is not ClassDeclarationSyntax classDecl) + return null; + + // Check cancellation + if (cancellationToken.IsCancellationRequested) + return null; + + // Use SemanticModel from context (provided by Roslyn, cached per syntax tree) + var semanticModel = context.SemanticModel; + + // Check if inherits from Plugin + if (!SyntaxHelper.InheritsFromPlugin(classDecl, semanticModel)) + return null; + + // Parse registrations (all heavy work here, in cacheable transform) + var metadataList = RegistrationParser.ParsePluginClass(classDecl, semanticModel); + if (!metadataList.Any()) + return null; + + // Store location for diagnostic reporting + var location = classDecl.GetLocation(); + + // Group metadata by unique registration (EntityType + EventOperation + ExecutionStage) + var groupedMetadata = metadataList.GroupBy(m => m.UniqueId); + + var results = new List(); + + foreach (var group in groupedMetadata) + { + // Check cancellation + if (cancellationToken.IsCancellationRequested) + return null; + + // Merge multiple registrations for the same entity/operation/stage + var mergedMetadata = WrapperClassGenerator.MergeMetadata(group); + + // Only include if there are images with attributes + if (mergedMetadata?.Images.Any(i => i.Attributes.Any()) == true) + { + // Store location for diagnostics + mergedMetadata.Location = location; + results.Add(mergedMetadata); + } + } + + return results; + } + + /// + /// Generates source code from metadata. + /// This is called per metadata item, enabling true incrementality. + /// + private void GenerateSourceFromMetadata( + PluginStepMetadata metadata, + SourceProductionContext context) + { + if (metadata?.Images.Any(i => i.Attributes.Any()) != true) + return; + + try + { + // Generate the wrapper classes + var sourceCode = WrapperClassGenerator.GenerateWrapperClasses(metadata); + + if (sourceCode == null) + return; + + // Generate unique hint name + var hintName = WrapperClassGenerator.GenerateHintName(metadata); + + // Add the source to the compilation + // Use SourceText.From() to ensure language-agnostic parsing (Roslyn will use compilation's ParseOptions) + context.AddSource(hintName, SourceText.From(sourceCode, Encoding.UTF8)); + + // Report diagnostic for successful generation (optional, for debugging) + ReportGenerationSuccess(context, metadata); + } + catch (System.Exception ex) + { + // Report diagnostic error + ReportGenerationError(context, metadata, ex); + } + } + + /// + /// Reports a diagnostic for successful code generation + /// + private void ReportGenerationSuccess( + SourceProductionContext context, + PluginStepMetadata metadata) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.GenerationSuccess, + metadata.Location ?? Location.None, + 1, // wrapper class count + metadata.ImageNamespace); + + // Uncomment to see generation info in build output + context.ReportDiagnostic(diagnostic); + } + + /// + /// Reports a diagnostic error when code generation fails + /// + private void ReportGenerationError( + SourceProductionContext context, + PluginStepMetadata metadata, + System.Exception exception) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.GenerationError, + metadata.Location ?? Location.None, + exception.Message); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs new file mode 100644 index 0000000..a4e2f10 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs @@ -0,0 +1,185 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Helper methods for analyzing syntax trees +/// +internal static class SyntaxHelper +{ + /// + /// Determines if a class inherits from XrmPluginCore.Plugin + /// + public static bool InheritsFromPlugin(ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel) + { + var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration); + if (classSymbol == null) + return false; + + var baseType = classSymbol.BaseType; + while (baseType != null) + { + if (baseType.Name == Constants.PluginBaseClassName && + baseType.ContainingNamespace?.ToString() == Constants.PluginNamespace) + { + return true; + } + baseType = baseType.BaseType; + } + + return false; + } + + /// + /// Finds all RegisterStep method invocations in a constructor + /// + public static IEnumerable FindRegisterStepInvocations(ConstructorDeclarationSyntax constructor) + { + // Handle block body: public MyPlugin() { ... } + if (constructor.Body != null) + { + foreach (var statement in constructor.Body.Statements) + { + foreach (var invocation in statement.DescendantNodes().OfType()) + { + if (IsRegisterStepInvocation(invocation)) + { + yield return invocation; + } + } + } + } + // Handle expression body: public MyPlugin() => RegisterStep(...); + else if (constructor.ExpressionBody != null) + { + foreach (var invocation in constructor.ExpressionBody.DescendantNodes().OfType()) + { + if (IsRegisterStepInvocation(invocation)) + { + yield return invocation; + } + } + } + } + + /// + /// Determines if an invocation is a RegisterStep call + /// + private static bool IsRegisterStepInvocation(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + // Handle member access: this.RegisterStep<...>(...) + var methodName = GetMethodName(memberAccess.Name); + return methodName == Constants.RegisterStepMethodName; + } + else + { + // Handle direct call: RegisterStep<...>(...) or RegisterStep(...) + var methodName = GetMethodName(invocation.Expression); + return methodName == Constants.RegisterStepMethodName; + } + } + + /// + /// Extracts method name from various syntax node types (handles generic methods) + /// + private static string GetMethodName(SyntaxNode node) + { + return node switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + _ => null + }; + } + + /// + /// Finds all WithPreImage, WithPostImage, AddPreImage, AddPostImage, or AddImage calls chained to a RegisterStep invocation. + /// Handles both generic methods (AddPreImage<T>) and non-generic methods. + /// + public static IEnumerable FindImageInvocations(InvocationExpressionSyntax registerStepInvocation) + { + var parent = registerStepInvocation.Parent; + while (parent != null) + { + if (parent is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + // Handle both GenericNameSyntax (AddPreImage) and IdentifierNameSyntax (AddPreImage) + var methodName = GetMethodName(memberAccess.Name); + if (methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName || + methodName == Constants.AddImageMethodName) + { + yield return invocation; + } + } + parent = parent.Parent; + } + } + + /// + /// Extracts lambda expressions from method arguments + /// + public static IEnumerable ExtractLambdas(ArgumentListSyntax argumentList) + { + foreach (var arg in argumentList.Arguments) + { + if (arg.Expression is LambdaExpressionSyntax lambda) + { + yield return lambda; + } + } + } + + /// + /// Extracts the property name from a lambda expression like "x => x.PropertyName" + /// + public static string GetPropertyNameFromLambda(LambdaExpressionSyntax lambda) + { + // Handle: x => x.PropertyName + if (lambda is SimpleLambdaExpressionSyntax simpleLambda) + { + if (simpleLambda.Body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + } + + return null; + } + + /// + /// Extracts the property name from a nameof expression like "nameof(Entity.PropertyName)" + /// + public static string GetPropertyNameFromNameof(ExpressionSyntax expression) + { + // Handle: nameof(Entity.PropertyName) + if (expression is InvocationExpressionSyntax invocation && + invocation.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "nameof") + { + if (invocation.ArgumentList.Arguments.Count > 0) + { + var argument = invocation.ArgumentList.Arguments[0].Expression; + + // Handle: nameof(Entity.PropertyName) + if (argument is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + + // Handle: nameof(PropertyName) + if (argument is IdentifierNameSyntax identifierName) + { + return identifierName.Identifier.Text; + } + } + } + + return null; + } +} diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs new file mode 100644 index 0000000..f1adce5 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -0,0 +1,144 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; + +namespace XrmPluginCore.SourceGenerator.Models; + +/// +/// Represents metadata about a plugin step registration that includes filtered attributes +/// +internal sealed class PluginStepMetadata +{ + public string EntityTypeName { get; set; } + public string EventOperation { get; set; } + public string ExecutionStage { get; set; } + public List Images { get; set; } = []; + public string Namespace { get; set; } + public string PluginClassName { get; set; } + + /// + /// Source location for diagnostic reporting. Not included in equality comparison. + /// + public Location Location { get; set; } + + /// + /// Gets the namespace for generated image wrapper classes. + /// Format: {OriginalNamespace}.PluginImages.{PluginClassName}.{Entity}{Op}{Stage} + /// + public string ImageNamespace => + $"{Namespace}.PluginImages.{PluginClassName}.{EntityTypeName}{EventOperation}{ExecutionStage}"; + + /// + /// Gets a unique identifier for this registration. + /// Includes plugin class name to differentiate multiple registrations for the same entity/operation/stage. + /// + public string UniqueId => + $"{PluginClassName}_{EntityTypeName}_{EventOperation}_{ExecutionStage}"; + + public override bool Equals(object obj) + { + if (obj is PluginStepMetadata other) + { + return PluginClassName == other.PluginClassName + && EntityTypeName == other.EntityTypeName + && EventOperation == other.EventOperation + && ExecutionStage == other.ExecutionStage + && Images.SequenceEqual(other.Images) + && Namespace == other.Namespace; + } + return false; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (PluginClassName?.GetHashCode() ?? 0); + hash = (hash * 31) + (EntityTypeName?.GetHashCode() ?? 0); + hash = (hash * 31) + (EventOperation?.GetHashCode() ?? 0); + hash = (hash * 31) + (ExecutionStage?.GetHashCode() ?? 0); + hash = (hash * 31) + (Namespace?.GetHashCode() ?? 0); + foreach (var img in Images) + { + hash = (hash * 31) + img.GetHashCode(); + } + return hash; + } + } +} + +/// +/// Represents metadata about an entity attribute +/// +internal sealed class AttributeMetadata +{ + public string PropertyName { get; set; } + public string LogicalName { get; set; } + public string TypeName { get; set; } + + public override bool Equals(object obj) + { + if (obj is AttributeMetadata other) + { + return PropertyName == other.PropertyName + && LogicalName == other.LogicalName + && TypeName == other.TypeName; + } + return false; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (PropertyName?.GetHashCode() ?? 0); + hash = (hash * 31) + (LogicalName?.GetHashCode() ?? 0); + hash = (hash * 31) + (TypeName?.GetHashCode() ?? 0); + return hash; + } + } +} + +/// +/// Represents metadata about a plugin step image (PreImage or PostImage) +/// +internal sealed class ImageMetadata +{ + public string ImageType { get; set; } // "PreImage" or "PostImage" + public string ImageName { get; set; } + public List Attributes { get; set; } = []; + + /// + /// Gets the generated wrapper class name for this image. + /// Simply "PreImage" or "PostImage" - namespace provides isolation. + /// + public string WrapperClassName => ImageType; + + public override bool Equals(object obj) + { + if (obj is ImageMetadata other) + { + return ImageType == other.ImageType + && ImageName == other.ImageName + && Attributes.SequenceEqual(other.Attributes); + } + return false; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (ImageType?.GetHashCode() ?? 0); + hash = (hash * 31) + (ImageName?.GetHashCode() ?? 0); + foreach (var attr in Attributes) + { + hash = (hash * 31) + attr.GetHashCode(); + } + return hash; + } + } +} diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs new file mode 100644 index 0000000..e8015aa --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -0,0 +1,308 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; + +namespace XrmPluginCore.SourceGenerator.Parsers; + +/// +/// Parses plugin class syntax to extract registration metadata +/// +internal static class RegistrationParser +{ + /// + /// Parses a plugin class and extracts all plugin step metadata + /// + public static IEnumerable ParsePluginClass( + ClassDeclarationSyntax classDeclaration, + SemanticModel semanticModel) + { + // Find the parameterless constructor (registration pipeline only supports parameterless) + var constructor = classDeclaration.Members + .OfType() + .FirstOrDefault(c => c.ParameterList.Parameters.Count == 0); + + if (constructor == null) + yield break; + + // Find all RegisterStep invocations + foreach (var registerStep in SyntaxHelper.FindRegisterStepInvocations(constructor)) + { + var metadata = ParseRegisterStepInvocation(registerStep, semanticModel, classDeclaration); + if (metadata != null) + { + yield return metadata; + } + } + } + + /// + /// Parses a single RegisterStep invocation + /// + private static PluginStepMetadata ParseRegisterStepInvocation( + InvocationExpressionSyntax registerStepInvocation, + SemanticModel semanticModel, + ClassDeclarationSyntax classDeclaration) + { + // Get the symbol info to extract type arguments + var symbolInfo = semanticModel.GetSymbolInfo(registerStepInvocation); + + // Handle both resolved symbols and candidate symbols (when overload resolution is ambiguous) + IMethodSymbol methodSymbol = symbolInfo.Symbol as IMethodSymbol; + if (methodSymbol == null && symbolInfo.CandidateSymbols.Length > 0) + { + methodSymbol = symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); + } + + if (methodSymbol == null) + { + return null; + } + + // Extract entity type from generic parameter TEntity + if (methodSymbol.TypeArguments.Length == 0) + { + return null; + } + + var entityType = methodSymbol.TypeArguments[0]; + var metadata = new PluginStepMetadata + { + EntityTypeName = entityType.Name, + Namespace = classDeclaration.GetNamespace(), + PluginClassName = classDeclaration.Identifier.Text + }; + + // Extract EventOperation and ExecutionStage from arguments + var arguments = registerStepInvocation.ArgumentList.Arguments; + if (arguments.Count >= 2) + { + metadata.EventOperation = ExtractEnumValue(arguments[0].Expression); + metadata.ExecutionStage = ExtractEnumValue(arguments[1].Expression); + } + + // Find image calls + foreach (var imageCall in SyntaxHelper.FindImageInvocations(registerStepInvocation)) + { + var imageMetadata = ParseImageInvocation(imageCall, entityType); + if (imageMetadata != null) + { + metadata.Images.Add(imageMetadata); + } + } + + // Only return metadata if we have images with attributes + return metadata.Images.Any(i => i.Attributes.Any()) ? metadata : null; + } + + /// + /// Parses WithPreImage, WithPostImage, AddPreImage, AddPostImage, or AddImage call to extract image metadata. + /// Handles both the old API (AddImage with ImageType) and new API (WithPreImage/WithPostImage). + /// + private static ImageMetadata ParseImageInvocation( + InvocationExpressionSyntax imageInvocation, + ITypeSymbol entityType) + { + if (imageInvocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return null; + + // Get method name - handle both generic (AddPreImage) and non-generic (AddPreImage) + string methodName; + bool isGenericMethod = false; + if (memberAccess.Name is GenericNameSyntax genericName) + { + methodName = genericName.Identifier.Text; + isGenericMethod = true; + } + else if (memberAccess.Name is IdentifierNameSyntax identifierName) + { + methodName = identifierName.Identifier.Text; + } + else + { + return null; + } + + var imageMetadata = new ImageMetadata(); + var arguments = imageInvocation.ArgumentList.Arguments; + int attributeStartIndex = 0; + + // Determine image type and starting index for attributes + if (methodName == Constants.AddImageMethodName) + { + // Old API: AddImage(ImageType.PreImage, "name", attr1, attr2, ...) + if (arguments.Count > 0) + { + var imageTypeArg = arguments[0].Expression; + imageMetadata.ImageType = ExtractEnumValue(imageTypeArg); + + // Skip first argument (ImageType), process remaining + attributeStartIndex = 1; + } + } + else if (methodName == Constants.WithPreImageMethodName) + { + // New API: WithPreImage(x => x.Name, ...) + imageMetadata.ImageType = Constants.PreImageTypeName; + attributeStartIndex = 0; + } + else if (methodName == Constants.WithPostImageMethodName) + { + // New API: WithPostImage(x => x.Name, ...) + imageMetadata.ImageType = Constants.PostImageTypeName; + attributeStartIndex = 0; + } + + // For new API (WithPreImage/WithPostImage), all arguments are attributes + // For old API with AddImage, first string after ImageType might be image name + bool allArgumentsAreAttributes = isGenericMethod || + methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName; + + // Process arguments starting from attributeStartIndex + for (int i = attributeStartIndex; i < arguments.Count; i++) + { + var argument = arguments[i]; + + // Try to extract from nameof expression + string value = SyntaxHelper.GetPropertyNameFromNameof(argument.Expression); + + // Try to extract from string literal + if (value is null && argument.Expression is LiteralExpressionSyntax literal) + { + value = literal.Token.ValueText; + } + + // Try to extract from lambda + if (value is null && argument.Expression is LambdaExpressionSyntax lambda) + { + value = SyntaxHelper.GetPropertyNameFromLambda(lambda); + } + + if (value is not null) + { + // Lambdas are always attributes, never image names + // String literals in old AddImage API: first one might be image name + bool isLambda = argument.Expression is LambdaExpressionSyntax; + bool treatAsAttribute = allArgumentsAreAttributes || isLambda || !string.IsNullOrEmpty(imageMetadata.ImageName); + + if (treatAsAttribute) + { + // This is an attribute + var attrMetadata = GetAttributeMetadata(value, entityType); + if (attrMetadata != null) + { + imageMetadata.Attributes.Add(attrMetadata); + } + } + else + { + // Old AddImage API: first string literal is image name + imageMetadata.ImageName = value; + } + } + } + + // Default image name if not provided + if (string.IsNullOrEmpty(imageMetadata.ImageName)) + { + imageMetadata.ImageName = imageMetadata.ImageType; + } + + return imageMetadata.Attributes.Any() ? imageMetadata : null; + } + + /// + /// Gets attribute metadata (property name, logical name, type) for a property + /// + private static AttributeMetadata GetAttributeMetadata( + string propertyName, + ITypeSymbol entityType) + { + // Find the property in the entity type + var property = entityType.GetMembers(propertyName) + .OfType() + .FirstOrDefault(); + + if (property == null) + return null; + + // Get the logical name from AttributeLogicalName attribute if present + var logicalName = GetLogicalNameFromAttribute(property) ?? propertyName.ToLowerInvariant(); + + return new AttributeMetadata + { + PropertyName = propertyName, + LogicalName = logicalName, + TypeName = property.Type.ToDisplayString() + }; + } + + /// + /// Extracts the logical name from [AttributeLogicalName("name")] attribute + /// + private static string GetLogicalNameFromAttribute(IPropertySymbol property) + { + var attribute = property.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == Constants.LogicalNameAttributeName); + + if (attribute?.ConstructorArguments.Length > 0) + { + return attribute.ConstructorArguments[0].Value?.ToString(); + } + + return null; + } + + /// + /// Extracts enum value name from expression + /// + private static string ExtractEnumValue(ExpressionSyntax expression) + { + // Handle direct enum access like EventOperation.Update + if (expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + + // Handle string literal for custom messages + if (expression is LiteralExpressionSyntax literal) + { + return literal.Token.ValueText; + } + + return "Unknown"; + } +} + +/// +/// Extension methods for syntax nodes +/// +internal static class SyntaxExtensions +{ + public static string GetNamespace(this SyntaxNode node) + { + var namespaces = new List(); + + while (node != null) + { + if (node is NamespaceDeclarationSyntax namespaceDecl) + { + namespaces.Add(namespaceDecl.Name.ToString()); + } + else if (node is FileScopedNamespaceDeclarationSyntax fileScopedNs) + { + namespaces.Add(fileScopedNs.Name.ToString()); + } + node = node.Parent; + } + + if (namespaces.Count == 0) + return "GlobalNamespace"; + + // Reverse to get outer-to-inner order, then join + namespaces.Reverse(); + return string.Join(".", namespaces); + } +} diff --git a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj new file mode 100644 index 0000000..a823197 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + 14 + true + true + + + + + + + + diff --git a/XrmPluginCore.Tests/Integration/PluginIntegrationTests.cs b/XrmPluginCore.Tests/Integration/PluginIntegrationTests.cs index bde0f2f..ffaa519 100644 --- a/XrmPluginCore.Tests/Integration/PluginIntegrationTests.cs +++ b/XrmPluginCore.Tests/Integration/PluginIntegrationTests.cs @@ -4,7 +4,6 @@ using Microsoft.Xrm.Sdk; using System; using System.Linq; -using XrmPluginCore; using Xunit; using XrmPluginCore.Extensions; diff --git a/XrmPluginCore.Tests/TestPlugins/Bedrock/SamplePlugin.cs b/XrmPluginCore.Tests/TestPlugins/Bedrock/SamplePlugin.cs index 693a7f3..73c9021 100644 --- a/XrmPluginCore.Tests/TestPlugins/Bedrock/SamplePlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/Bedrock/SamplePlugin.cs @@ -1,7 +1,5 @@ -using XrmPluginCore.Enums; -using XrmPluginCore.Tests; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; -using XrmPluginCore; namespace XrmPluginCore.Tests.TestPlugins.Bedrock { diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs new file mode 100644 index 0000000..5f94b86 --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using XrmPluginCore.Enums; + +// Import the generated PreImage/PostImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeAccountPlugin.AccountUpdatePreOperation; + +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +{ + /// + /// Test plugin using the type-safe image API. + /// PreImage and PostImage wrappers are generated by the source generator + /// and passed directly to the action callback. + /// + public class TypeSafeAccountPlugin : Plugin + { + public bool UpdateExecuted { get; private set; } + public PreImage LastPreImage { get; private set; } + public PostImage LastPostImage { get; private set; } + + public TypeSafeAccountPlugin() + { + // Type-safe API: Images are passed directly to the action + // Using WithPreImage/WithPostImage enforces that Execute receives both images + RegisterStep(EventOperation.Update, ExecutionStage.PreOperation) + .AddFilteredAttributes(x => x.Name, x => x.Accountnumber) + .WithPreImage(x => x.Name, x => x.Accountnumber, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.Accountnumber) + .Execute((service, pre, post) => service.HandleUpdate(pre, post)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(_ => new TypeSafeAccountService(this)); + return base.OnBeforeBuildServiceProvider(services); + } + + internal void SetExecutionResult(PreImage preImage, PostImage postImage) + { + UpdateExecuted = true; + LastPreImage = preImage; + LastPostImage = postImage; + } + } + + /// + /// Simple Account entity class for testing + /// + [Microsoft.Xrm.Sdk.Client.EntityLogicalName("account")] + public class Account : Entity + { + public Account() : base("account") { } + + [AttributeLogicalName("name")] + public string Name + { + get => GetAttributeValue("name"); + set => SetAttributeValue("name", value); + } + + [AttributeLogicalName("accountnumber")] + public string Accountnumber + { + get => GetAttributeValue("accountnumber"); + set => SetAttributeValue("accountnumber", value); + } + + [AttributeLogicalName("revenue")] + public Money Revenue + { + get => GetAttributeValue("revenue"); + set => SetAttributeValue("revenue", value); + } + } +} diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs new file mode 100644 index 0000000..d68d436 --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs @@ -0,0 +1,32 @@ +using System; + +// Import the generated PreImage/PostImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeAccountPlugin.AccountUpdatePreOperation; + +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +{ + /// + /// Service for TypeSafeAccountPlugin that receives images directly + /// + public class TypeSafeAccountService + { + private readonly TypeSafeAccountPlugin plugin; + + public TypeSafeAccountService(TypeSafeAccountPlugin plugin) + { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); + } + + public void HandleUpdate(PreImage preImage, PostImage postImage) + { + if (preImage != null) + { + _ = preImage.Name; + _ = preImage.Accountnumber; + _ = preImage.Revenue; + } + + plugin.SetExecutionResult(preImage, postImage); + } + } +} diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs new file mode 100644 index 0000000..2211198 --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using XrmPluginCore.Enums; + +// Import the generated PreImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeContactPlugin.ContactCreatePostOperation; + +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +{ + /// + /// Test plugin using the type-safe image API. + /// PreImage wrapper is generated by the source generator + /// and passed directly to the action callback. + /// + public class TypeSafeContactPlugin : Plugin + { + public bool CreateExecuted { get; private set; } + public PreImage LastPreImage { get; private set; } + + public TypeSafeContactPlugin() + { + // Type-safe API: PreImage is passed directly to the action + // Using WithPreImage enforces that Execute receives PreImage + RegisterStep(EventOperation.Create, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Firstname, x => x.Lastname, x => x.Emailaddress1) + .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone) + .Execute((service, pre) => service.HandleCreate(pre)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(_ => new TypeSafeContactService(this)); + return base.OnBeforeBuildServiceProvider(services); + } + + internal void SetExecutionResult(PreImage preImage) + { + CreateExecuted = true; + LastPreImage = preImage; + } + } + + /// + /// Simple Contact entity class for testing + /// + public class Contact : Entity + { + public Contact() : base("contact") { } + + [AttributeLogicalName("firstname")] + public string Firstname + { + get => GetAttributeValue("firstname"); + set => SetAttributeValue("firstname", value); + } + + [AttributeLogicalName("lastname")] + public string Lastname + { + get => GetAttributeValue("lastname"); + set => SetAttributeValue("lastname", value); + } + + [AttributeLogicalName("emailaddress1")] + public string Emailaddress1 + { + get => GetAttributeValue("emailaddress1"); + set => SetAttributeValue("emailaddress1", value); + } + + [AttributeLogicalName("mobilephone")] + public string Mobilephone + { + get => GetAttributeValue("mobilephone"); + set => SetAttributeValue("mobilephone", value); + } + } +} diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs new file mode 100644 index 0000000..6fdddfd --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs @@ -0,0 +1,32 @@ +using System; + +// Import the generated PreImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeContactPlugin.ContactCreatePostOperation; + +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +{ + /// + /// Service for TypeSafeContactPlugin that receives PreImage directly + /// + public class TypeSafeContactService + { + private readonly TypeSafeContactPlugin plugin; + + public TypeSafeContactService(TypeSafeContactPlugin plugin) + { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); + } + + public void HandleCreate(PreImage preImage) + { + if (preImage != null) + { + _ = preImage.Firstname; + _ = preImage.Lastname; + _ = preImage.Mobilephone; + } + + plugin.SetExecutionResult(preImage); + } + } +} diff --git a/XrmPluginCore.Tests/TypeSafePluginTests.cs b/XrmPluginCore.Tests/TypeSafePluginTests.cs new file mode 100644 index 0000000..93a60fb --- /dev/null +++ b/XrmPluginCore.Tests/TypeSafePluginTests.cs @@ -0,0 +1,357 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using System; +using Xunit; +using XrmPluginCore.Enums; +using XrmPluginCore.Tests.Helpers; +using XrmPluginCore.Tests.TestPlugins.TypeSafe; + +namespace XrmPluginCore.Tests +{ + /// + /// Tests for type-safe image access via the new builder-based API. + /// Images (PreImage/PostImage) are passed directly to the Execute callback. + /// + public class TypeSafePluginTests + { + #region Account Plugin Tests (PreImage + PostImage) + + [Fact] + public void AccountPlugin_ShouldExecuteWithImages() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("account"); + mockProvider.SetupMessageName("Update"); + mockProvider.SetupStage((int)ExecutionStage.PreOperation); + + var targetEntity = new Entity("account") + { + Id = Guid.NewGuid(), + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + var inputParameters = new ParameterCollection + { + { "Target", targetEntity } + }; + mockProvider.SetupInputParameters(inputParameters); + + // Setup PreImage + var preImageEntity = new Entity("account") + { + Id = targetEntity.Id, + ["name"] = "Old Account Name", + ["accountnumber"] = "ACC-OLD", + ["revenue"] = new Money(50000) + }; + mockProvider.SetupPreEntityImages(new EntityImageCollection { { "PreImage", preImageEntity } }); + + // Setup PostImage + var postImageEntity = new Entity("account") + { + Id = targetEntity.Id, + ["name"] = "New Account Name", + ["accountnumber"] = "ACC-001" + }; + mockProvider.SetupPostEntityImages(new EntityImageCollection { { "PostImage", postImageEntity } }); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert + plugin.UpdateExecuted.Should().BeTrue(); + plugin.LastPreImage.Should().NotBeNull(); + plugin.LastPostImage.Should().NotBeNull(); + } + + [Fact] + public void AccountPlugin_PreImage_ShouldProvideAttributes() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("account"); + mockProvider.SetupMessageName("Update"); + mockProvider.SetupStage((int)ExecutionStage.PreOperation); + + var targetEntity = new Entity("account") { Id = Guid.NewGuid() }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + // Setup PreImage with specific values + var preImageEntity = new Entity("account") + { + Id = targetEntity.Id, + ["name"] = "PreImage Name", + ["accountnumber"] = "PRE-001", + ["revenue"] = new Money(100000) + }; + mockProvider.SetupPreEntityImages(new EntityImageCollection { { "PreImage", preImageEntity } }); + mockProvider.SetupPostEntityImages(new EntityImageCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert - Type-safe access to PreImage attributes + plugin.LastPreImage.Should().NotBeNull(); + plugin.LastPreImage.Name.Should().Be("PreImage Name"); + plugin.LastPreImage.Accountnumber.Should().Be("PRE-001"); + plugin.LastPreImage.Revenue.Value.Should().Be(100000); + } + + [Fact] + public void AccountPlugin_ShouldHandleNoImages() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("account"); + mockProvider.SetupMessageName("Update"); + mockProvider.SetupStage((int)ExecutionStage.PreOperation); + + var targetEntity = new Entity("account") { Id = Guid.NewGuid() }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + // No images + mockProvider.SetupPreEntityImages(new EntityImageCollection()); + mockProvider.SetupPostEntityImages(new EntityImageCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert - Images are null when not present + plugin.UpdateExecuted.Should().BeTrue(); + plugin.LastPreImage.Should().BeNull(); + plugin.LastPostImage.Should().BeNull(); + } + + [Fact] + public void AccountPlugin_ShouldNotExecuteForWrongMessage() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("account"); + mockProvider.SetupMessageName("Create"); // Wrong message - plugin registered for Update + mockProvider.SetupStage((int)ExecutionStage.PreOperation); + mockProvider.SetupInputParameters(new ParameterCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert + plugin.UpdateExecuted.Should().BeFalse(); + } + + #endregion + + #region Contact Plugin Tests (PreImage only) + + [Fact] + public void ContactPlugin_ShouldExecuteWithPreImage() + { + // Arrange + var plugin = new TypeSafeContactPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("contact"); + mockProvider.SetupMessageName("Create"); + mockProvider.SetupStage((int)ExecutionStage.PostOperation); + + var targetEntity = new Entity("contact") { Id = Guid.NewGuid() }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + // Setup PreImage + var preImageEntity = new Entity("contact") + { + Id = targetEntity.Id, + ["firstname"] = "John", + ["lastname"] = "Doe", + ["mobilephone"] = "555-1234" + }; + mockProvider.SetupPreEntityImages(new EntityImageCollection { { "PreImage", preImageEntity } }); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert + plugin.CreateExecuted.Should().BeTrue(); + plugin.LastPreImage.Should().NotBeNull(); + } + + [Fact] + public void ContactPlugin_PreImage_ShouldProvideAttributes() + { + // Arrange + var plugin = new TypeSafeContactPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("contact"); + mockProvider.SetupMessageName("Create"); + mockProvider.SetupStage((int)ExecutionStage.PostOperation); + + var targetEntity = new Entity("contact") { Id = Guid.NewGuid() }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + // Setup PreImage with specific values + var preImageEntity = new Entity("contact") + { + Id = targetEntity.Id, + ["firstname"] = "Jane", + ["lastname"] = "Smith", + ["mobilephone"] = "555-5678" + }; + mockProvider.SetupPreEntityImages(new EntityImageCollection { { "PreImage", preImageEntity } }); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert - Type-safe access to PreImage attributes + plugin.LastPreImage.Should().NotBeNull(); + plugin.LastPreImage.Firstname.Should().Be("Jane"); + plugin.LastPreImage.Lastname.Should().Be("Smith"); + plugin.LastPreImage.Mobilephone.Should().Be("555-5678"); + } + + [Fact] + public void ContactPlugin_ShouldHandleNoPreImage() + { + // Arrange + var plugin = new TypeSafeContactPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("contact"); + mockProvider.SetupMessageName("Create"); + mockProvider.SetupStage((int)ExecutionStage.PostOperation); + + var targetEntity = new Entity("contact") { Id = Guid.NewGuid() }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + // No PreImage + mockProvider.SetupPreEntityImages(new EntityImageCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert + plugin.CreateExecuted.Should().BeTrue(); + plugin.LastPreImage.Should().BeNull(); + } + + [Fact] + public void ContactPlugin_ShouldNotExecuteForWrongStage() + { + // Arrange + var plugin = new TypeSafeContactPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("contact"); + mockProvider.SetupMessageName("Create"); + mockProvider.SetupStage((int)ExecutionStage.PreOperation); // Wrong stage + mockProvider.SetupInputParameters(new ParameterCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert + plugin.CreateExecuted.Should().BeFalse(); + } + + #endregion + + #region Registration Tests + + [Fact] + public void AccountPlugin_Registration_ShouldIncludeFilteredAttributes() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + + // Act + var registrations = plugin.GetRegistrations(); + + // Assert + var registration = registrations.Should().ContainSingle().Subject; + registration.FilteredAttributes.Should().Be("name,accountnumber"); + } + + [Fact] + public void AccountPlugin_Registration_ShouldIncludeImages() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + + // Act + var registrations = plugin.GetRegistrations(); + + // Assert + var registration = registrations.Should().ContainSingle().Subject; + registration.ImageSpecifications.Should().HaveCount(2); + } + + [Fact] + public void ContactPlugin_Registration_ShouldIncludeFilteredAttributes() + { + // Arrange + var plugin = new TypeSafeContactPlugin(); + + // Act + var registrations = plugin.GetRegistrations(); + + // Assert + var registration = registrations.Should().ContainSingle().Subject; + registration.FilteredAttributes.Should().Be("firstname,lastname,emailaddress1"); + } + + #endregion + + #region ToEntity Tests + + [Fact] + public void AccountPlugin_PreImage_ToEntity_ShouldReturnEarlyBoundEntity() + { + // Arrange + var plugin = new TypeSafeAccountPlugin(); + var mockProvider = new MockServiceProvider(); + + mockProvider.SetupPrimaryEntityName("account"); + mockProvider.SetupMessageName("Update"); + mockProvider.SetupStage((int)ExecutionStage.PreOperation); + + var accountId = Guid.NewGuid(); + var targetEntity = new Entity("account") { Id = accountId }; + mockProvider.SetupInputParameters(new ParameterCollection { { "Target", targetEntity } }); + + var preImageEntity = new Entity("account") + { + Id = accountId, + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001", + ["revenue"] = new Money(50000) + }; + mockProvider.SetupPreEntityImages(new EntityImageCollection { { "PreImage", preImageEntity } }); + mockProvider.SetupPostEntityImages(new EntityImageCollection()); + + // Act + plugin.Execute(mockProvider.ServiceProvider); + + // Assert - ToEntity() should return early-bound entity + plugin.LastPreImage.Should().NotBeNull(); + + var account = plugin.LastPreImage.ToEntity(); + account.Should().NotBeNull(); + account.Should().BeOfType(); + account.Name.Should().Be("Test Account"); + account.Accountnumber.Should().Be("ACC-001"); + account.Revenue.Value.Should().Be(50000); + } + + #endregion + } +} diff --git a/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj b/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj index 6c18e34..ea767ad 100644 --- a/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj +++ b/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj @@ -1,9 +1,13 @@ - + net462;net8.0 false true + + + true + $(BaseIntermediateOutputPath)Generated @@ -32,6 +36,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/XrmPluginCore.sln b/XrmPluginCore.sln index f2a49ca..c12ce6c 100644 --- a/XrmPluginCore.sln +++ b/XrmPluginCore.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore", "XrmPluginCore\XrmPluginCore.csproj", "{40D9E4DE-A933-412C-866E-C5B5B91EC59C}" EndProject @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore.Tests", "XrmPluginCore.Tests\XrmPluginCore.Tests.csproj", "{7F25A8FD-41B4-46CB-B9E7-0D18FD50E6E4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore.SourceGenerator", "XrmPluginCore.SourceGenerator\XrmPluginCore.SourceGenerator.csproj", "{4544F34A-FCFD-48EE-AA5A-4FAA342DE889}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {7F25A8FD-41B4-46CB-B9E7-0D18FD50E6E4}.Release|x64.Build.0 = Release|Any CPU {7F25A8FD-41B4-46CB-B9E7-0D18FD50E6E4}.Release|x86.ActiveCfg = Release|Any CPU {7F25A8FD-41B4-46CB-B9E7-0D18FD50E6E4}.Release|x86.Build.0 = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|x64.ActiveCfg = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|x64.Build.0 = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|x86.ActiveCfg = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Debug|x86.Build.0 = Debug|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|Any CPU.Build.0 = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x64.ActiveCfg = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x64.Build.0 = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x86.ActiveCfg = Release|Any CPU + {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 032becc..1ce88c2 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,8 @@ +### v1.2.0 - 21 November 2025 +* Add: Type-Safe Images feature with compile-time enforcement via `WithPreImage()` and `WithPostImage()` builder methods +* Add: `PluginStepBuilder` pattern that enforces image handling at compile time +* Deprecate: `AddImage()`, methods in favor of new `WithPreImage()`/`WithPostImage()` API + ### v1.1.1 - 14 November 2025 * Add: IManagedIdentityService to service provider (#1) @@ -36,4 +41,4 @@ * Fixes to project file so version and dependencies are picked up correctly ### v0.0.1 - 14 March 2025 -* Initial release of XrmPluginCore. \ No newline at end of file +* Initial release of XrmPluginCore. diff --git a/XrmPluginCore/Extensions/ServiceProviderExtensions.cs b/XrmPluginCore/Extensions/ServiceProviderExtensions.cs index 0eaf978..3222f62 100644 --- a/XrmPluginCore/Extensions/ServiceProviderExtensions.cs +++ b/XrmPluginCore/Extensions/ServiceProviderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.PluginTelemetry; using System; @@ -7,7 +7,12 @@ namespace XrmPluginCore.Extensions { public static class ServiceProviderExtensions { - public static ExtendedServiceProvider BuildServiceProvider(this IServiceProvider serviceProvider, Func onBeforeBuild) + /// + /// Builds a local scoped service provider for plugin execution. + /// + public static ExtendedServiceProvider BuildServiceProvider( + this IServiceProvider serviceProvider, + Func onBeforeBuild) { // Get services of the ServiceProvider var tracingService = serviceProvider.GetService() ?? throw new Exception("Unable to get Tracing service"); diff --git a/XrmPluginCore/IEntityImageWrapper.cs b/XrmPluginCore/IEntityImageWrapper.cs new file mode 100644 index 0000000..281f41d --- /dev/null +++ b/XrmPluginCore/IEntityImageWrapper.cs @@ -0,0 +1,24 @@ +using Microsoft.Xrm.Sdk; + +namespace XrmPluginCore +{ + /// + /// Represents a type-safe wrapper around an entity image (PreImage or PostImage) + /// with conversion capabilities to early-bound entity types. + /// + public interface IEntityImageWrapper + { + /// + /// Converts the underlying entity to a strongly-typed early-bound entity. + /// + /// The early-bound entity type + /// A strongly-typed entity instance + T ToEntity() where T : Entity; + + /// + /// Gets the underlying Entity object for direct attribute access or service operations. + /// + /// The underlying Entity instance + Entity GetUnderlyingEntity(); + } +} diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 0c8604f..b1cb920 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -20,12 +20,15 @@ namespace XrmPluginCore public abstract class Plugin : IPlugin, IPluginDefinition, ICustomApiDefinition { private string ChildClassName { get; } + private string ChildClassShortName { get; } private List RegisteredPluginSteps { get; } = new List(); private CustomApiRegistration RegisteredCustomApi { get; set; } protected Plugin() { - ChildClassName = GetType().ToString(); + var type = GetType(); + ChildClassName = type.ToString(); + ChildClassShortName = type.Name; } /// @@ -54,17 +57,36 @@ public void Execute(IServiceProvider serviceProvider) throw new ArgumentNullException(nameof(serviceProvider)); } - // Build a local service provider to manage the lifetime of services for this execution + // Build a local service provider var localServiceProvider = serviceProvider.BuildServiceProvider(OnBeforeBuildServiceProvider); try { localServiceProvider.Trace(string.Format(CultureInfo.InvariantCulture, "Entered {0}.Execute()", ChildClassName)); - var context = localServiceProvider.GetService() ?? throw new Exception("Unable to get Plugin Execution Context"); - var pluginAction = GetAction(context); + var context = localServiceProvider.GetService() + ?? throw new Exception("Unable to get Plugin Execution Context"); + + // Find the matching registration to determine if we need to register IPluginContext + var matchingRegistration = GetMatchingRegistration(context); + var pluginAction = matchingRegistration?.Action; if (pluginAction == null) { + // Check if this is an incomplete builder chain (registration exists but Execute() was never called) + if (matchingRegistration?.ConfigBuilder != null) + { + throw new InvalidPluginExecutionException( + OperationStatus.Failed, + string.Format( + CultureInfo.InvariantCulture, + "Plugin step registration for Entity: {0}, Message: {1} in {2} is incomplete. " + + "Ensure Execute() is called on the builder to complete the registration.", + context.PrimaryEntityName, + context.MessageName, + ChildClassName + )); + } + localServiceProvider.Trace(string.Format( CultureInfo.InvariantCulture, "No registered event found for Entity: {0}, Message: {1} in {2}", @@ -229,10 +251,68 @@ protected PluginStepConfigBuilder RegisterStep( where T : Entity { var builder = new PluginStepConfigBuilder(eventOperation, executionStage); - RegisteredPluginSteps.Add(new PluginStepRegistration(builder, action)); + var registration = new PluginStepRegistration(builder, action) + { + // Store metadata for convention-based type-safe wrapper discovery + EntityTypeName = typeof(T).Name, + EventOperation = eventOperation, + ExecutionStage = executionStage.ToString(), + PluginClassName = ChildClassShortName + }; + RegisteredPluginSteps.Add(registration); return builder; } + /// + /// Register a plugin step for the given entity type with type-safe image support. + /// Use WithPreImage/WithPostImage to add images, then call Execute to complete registration. + /// + /// The entity type to register the plugin for + /// The service type to pass to the action + /// The event operation to register the plugin for + /// The execution stage of the plugin registration + /// A for configuring images and completing registration + protected PluginStepBuilder RegisterStep( + EventOperation eventOperation, ExecutionStage executionStage) + where TEntity : Entity + { + return RegisterStep(eventOperation.ToString(), executionStage); + } + + /// + /// Register a plugin step for the given entity type with type-safe image support. + /// Use WithPreImage/WithPostImage to add images, then call Execute to complete registration. + ///
+ /// + /// NOTE: It is strongly advised to use the method instead if possible.
+ /// Only use this method if you are registering for a non-standard message. + ///
+ ///
+ /// The entity type to register the plugin for + /// The service type to pass to the action + /// The event operation to register the plugin for + /// The execution stage of the plugin registration + /// A for configuring images and completing registration + protected PluginStepBuilder RegisterStep( + string eventOperation, ExecutionStage executionStage) + where TEntity : Entity + { + var builder = new PluginStepConfigBuilder(eventOperation, executionStage); + + // Create registration immediately so XrmSync/DAXIF can find it via GetRegistrations() + // Action is set later when Execute() is called on the builder + var registration = new PluginStepRegistration(builder, null) + { + EntityTypeName = typeof(TEntity).Name, + EventOperation = eventOperation, + ExecutionStage = executionStage.ToString(), + PluginClassName = ChildClassShortName + }; + RegisteredPluginSteps.Add(registration); + + return new PluginStepBuilder(builder, registration); + } + /// /// /// Register a CustomAPI with the given name and action.
@@ -277,23 +357,20 @@ protected CustomApiConfigBuilder RegisterAPI(string name, Action GetAction(IPluginExecutionContext context) + private PluginStepRegistration GetMatchingRegistration(IPluginExecutionContext context) { // Iterate over all of the expected registered events to ensure that the plugin // has been invoked by an expected event // For any given plug-in event at an instance in time, we would expect at most 1 result to match. - var pluginAction = - RegisteredPluginSteps - .FirstOrDefault(a => a.ConfigBuilder?.Matches(context) == true)? - .Action; + var pluginStepRegistration = RegisteredPluginSteps.FirstOrDefault(a => a.ConfigBuilder?.Matches(context) == true); - if (pluginAction != null) + // If no plugin step found and we have a CustomAPI, return a registration with that action + if (pluginStepRegistration == null && RegisteredCustomApi != null) { - return pluginAction; + return new PluginStepRegistration(null, RegisteredCustomApi.Action); } - // If no plugin step was found, check if this is a CustomAPI call - return RegisteredCustomApi?.Action; + return pluginStepRegistration; } } } diff --git a/XrmPluginCore/Plugins/PluginStepBuilders.cs b/XrmPluginCore/Plugins/PluginStepBuilders.cs new file mode 100644 index 0000000..199c12f --- /dev/null +++ b/XrmPluginCore/Plugins/PluginStepBuilders.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using System; +using System.Linq; +using System.Linq.Expressions; + +#pragma warning disable CS0618 // Type or member is obsolete - we use AddImage internally + +namespace XrmPluginCore.Plugins +{ + /// + /// Helper class for retrieving plugin images and wrapping actions. + /// + internal static class PluginImageHelper + { + internal static Action WrapAction(Action action) + { + return sp => action(sp.GetRequiredService()); + } + + internal static Action WrapActionWithPreImage( + Action action) + where TPreImage : class + { + return sp => action( + sp.GetRequiredService(), + GetPreImage(sp)); + } + + internal static Action WrapActionWithPostImage( + Action action) + where TPostImage : class + { + return sp => action( + sp.GetRequiredService(), + GetPostImage(sp)); + } + + internal static Action WrapActionWithBothImages( + Action action) + where TPreImage : class + where TPostImage : class + { + return sp => action( + sp.GetRequiredService(), + GetPreImage(sp), + GetPostImage(sp)); + } + + private static T GetPreImage(IExtendedServiceProvider sp) where T : class + { + var context = sp.GetService(); + var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault(); + if (preImageEntity == null) return null; + return (T)Activator.CreateInstance(typeof(T), preImageEntity); + } + + private static T GetPostImage(IExtendedServiceProvider sp) where T : class + { + var context = sp.GetService(); + var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault(); + if (postImageEntity == null) return null; + return (T)Activator.CreateInstance(typeof(T), postImageEntity); + } + } + + /// + /// Base builder for plugin steps that may have images. + /// Use WithPreImage/WithPostImage to add type-safe images, then call Execute to complete registration. + /// + public class PluginStepBuilder where TEntity : Entity + { + private readonly PluginStepRegistration registration; + + internal PluginStepBuilder( + PluginStepConfigBuilder configBuilder, + PluginStepRegistration registration) + { + ConfigBuilder = configBuilder; + this.registration = registration; + } + + /// + /// Gets the underlying config builder for additional configuration (filtered attributes, deployment, etc.) + /// + public PluginStepConfigBuilder ConfigBuilder { get; } + + /// + /// Add filtered attributes to this step. + /// + public PluginStepBuilder AddFilteredAttributes(params Expression>[] attributes) + { + ConfigBuilder.AddFilteredAttributes(attributes); + return this; + } + + /// + /// Add a PreImage to this step. Returns a builder that requires PreImage in Execute. + /// + public PluginStepBuilderWithPreImage WithPreImage(params Expression>[] attributes) + { + ConfigBuilder.AddImage(Enums.ImageType.PreImage, attributes); + return new PluginStepBuilderWithPreImage(ConfigBuilder, registration); + } + + /// + /// Add a PostImage to this step. Returns a builder that requires PostImage in Execute. + /// + public PluginStepBuilderWithPostImage WithPostImage(params Expression>[] attributes) + { + ConfigBuilder.AddImage(Enums.ImageType.PostImage, attributes); + return new PluginStepBuilderWithPostImage(ConfigBuilder, registration); + } + + /// + /// Complete registration with an action that receives only the service. + /// + public PluginStepConfigBuilder Execute(Action action) + { + registration.Action = PluginImageHelper.WrapAction(action); + return ConfigBuilder; + } + } + + /// + /// Builder for plugin steps with a PreImage. Execute requires accepting PreImage. + /// + public class PluginStepBuilderWithPreImage where TEntity : Entity + { + private readonly PluginStepRegistration registration; + + internal PluginStepBuilderWithPreImage( + PluginStepConfigBuilder configBuilder, + PluginStepRegistration registration) + { + ConfigBuilder = configBuilder; + this.registration = registration; + } + + /// + /// Gets the underlying config builder for additional configuration. + /// + public PluginStepConfigBuilder ConfigBuilder { get; } + + /// + /// Add filtered attributes to this step. + /// + public PluginStepBuilderWithPreImage AddFilteredAttributes(params Expression>[] attributes) + { + ConfigBuilder.AddFilteredAttributes(attributes); + return this; + } + + /// + /// Add a PostImage to this step. Returns a builder that requires both images in Execute. + /// + public PluginStepBuilderWithBothImages WithPostImage(params Expression>[] attributes) + { + ConfigBuilder.AddImage(Enums.ImageType.PostImage, attributes); + return new PluginStepBuilderWithBothImages(ConfigBuilder, registration); + } + + /// + /// Complete registration with an action that receives service and PreImage. + /// + /// The PreImage wrapper type (generated by source generator) + public PluginStepConfigBuilder Execute(Action action) + where TPreImage : class + { + registration.Action = PluginImageHelper.WrapActionWithPreImage(action); + return ConfigBuilder; + } + } + + /// + /// Builder for plugin steps with a PostImage. Execute requires accepting PostImage. + /// + public class PluginStepBuilderWithPostImage where TEntity : Entity + { + private readonly PluginStepRegistration registration; + + internal PluginStepBuilderWithPostImage( + PluginStepConfigBuilder configBuilder, + PluginStepRegistration registration) + { + ConfigBuilder = configBuilder; + this.registration = registration; + } + + /// + /// Gets the underlying config builder for additional configuration. + /// + public PluginStepConfigBuilder ConfigBuilder { get; } + + /// + /// Add filtered attributes to this step. + /// + public PluginStepBuilderWithPostImage AddFilteredAttributes(params Expression>[] attributes) + { + ConfigBuilder.AddFilteredAttributes(attributes); + return this; + } + + /// + /// Add a PreImage to this step. Returns a builder that requires both images in Execute. + /// + public PluginStepBuilderWithBothImages WithPreImage(params Expression>[] attributes) + { + ConfigBuilder.AddImage(Enums.ImageType.PreImage, attributes); + return new PluginStepBuilderWithBothImages(ConfigBuilder, registration); + } + + /// + /// Complete registration with an action that receives service and PostImage. + /// + /// The PostImage wrapper type (generated by source generator) + public PluginStepConfigBuilder Execute(Action action) + where TPostImage : class + { + registration.Action = PluginImageHelper.WrapActionWithPostImage(action); + return ConfigBuilder; + } + } + + /// + /// Builder for plugin steps with both PreImage and PostImage. Execute requires accepting both. + /// + public class PluginStepBuilderWithBothImages where TEntity : Entity + { + private readonly PluginStepRegistration registration; + + internal PluginStepBuilderWithBothImages( + PluginStepConfigBuilder configBuilder, + PluginStepRegistration registration) + { + ConfigBuilder = configBuilder; + this.registration = registration; + } + + /// + /// Gets the underlying config builder for additional configuration. + /// + public PluginStepConfigBuilder ConfigBuilder { get; } + + /// + /// Add filtered attributes to this step. + /// + public PluginStepBuilderWithBothImages AddFilteredAttributes(params Expression>[] attributes) + { + ConfigBuilder.AddFilteredAttributes(attributes); + return this; + } + + /// + /// Complete registration with an action that receives service, PreImage, and PostImage. + /// + /// The PreImage wrapper type (generated by source generator) + /// The PostImage wrapper type (generated by source generator) + public PluginStepConfigBuilder Execute(Action action) + where TPreImage : class + where TPostImage : class + { + registration.Action = PluginImageHelper.WrapActionWithBothImages(action); + return ConfigBuilder; + } + } +} diff --git a/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs b/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs index 084371f..dfa34b4 100644 --- a/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs +++ b/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs @@ -1,4 +1,4 @@ -using XrmPluginCore.Enums; +using XrmPluginCore.Enums; using Microsoft.Xrm.Sdk; using System; using System.Collections.ObjectModel; @@ -139,26 +139,31 @@ public PluginStepConfigBuilder AddFilteredAttributes(params string[] attribut return this; } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType) { return AddImage(imageType.ToString(), imageType.ToString(), imageType); } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType) { return AddImage(name, entityAlias, imageType, (string[])null); } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType, params string[] attributes) { return AddImage(imageType.ToString(), imageType.ToString(), imageType, attributes); } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType, params Expression>[] attributes) { return AddImage(imageType.ToString(), imageType.ToString(), imageType, attributes); } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType, params string[] attributes) { Images.Add(new PluginStepImage(name, entityAlias, imageType, attributes)); @@ -166,6 +171,7 @@ public PluginStepConfigBuilder AddImage(string name, string entityAlias, Imag return this; } + [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType, params Expression>[] attributes) { Images.Add(PluginStepImage.Create(name, entityAlias, imageType, attributes)); @@ -173,4 +179,4 @@ public PluginStepConfigBuilder AddImage(string name, string entityAlias, Imag return this; } } -} \ No newline at end of file +} diff --git a/XrmPluginCore/Plugins/PluginStepRegistration.cs b/XrmPluginCore/Plugins/PluginStepRegistration.cs index 73b16d3..73ffe75 100644 --- a/XrmPluginCore/Plugins/PluginStepRegistration.cs +++ b/XrmPluginCore/Plugins/PluginStepRegistration.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace XrmPluginCore.Plugins { @@ -11,7 +11,31 @@ public PluginStepRegistration(IPluginStepConfigBuilder pluginStepConfig, Action< } public IPluginStepConfigBuilder ConfigBuilder { get; set; } - + public Action Action { get; set; } + + /// + /// Gets or sets the plugin class name for type-safe wrapper discovery. + /// Used to compute wrapper class names by convention. + /// + public string PluginClassName { get; set; } + + /// + /// Gets or sets the entity type name for type-safe wrapper discovery. + /// Used to compute wrapper class names by convention. + /// + public string EntityTypeName { get; set; } + + /// + /// Gets or sets the event operation for type-safe wrapper discovery. + /// Used to compute wrapper class names by convention. + /// + public string EventOperation { get; set; } + + /// + /// Gets or sets the execution stage for type-safe wrapper discovery. + /// Used to compute wrapper class names by convention. + /// + public string ExecutionStage { get; set; } } } diff --git a/XrmPluginCore/XrmPluginCore.csproj b/XrmPluginCore/XrmPluginCore.csproj index d7fb13b..7b5de27 100644 --- a/XrmPluginCore/XrmPluginCore.csproj +++ b/XrmPluginCore/XrmPluginCore.csproj @@ -49,5 +49,11 @@ + + + + + + \ No newline at end of file From 8ed1a861088e0871d5be794b7dfb97c4884a34f1 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 25 Nov 2025 09:26:40 +0100 Subject: [PATCH 02/22] Test: Tests for the source generator --- .../.editorconfig | 5 + .../DiagnosticReportingTests.cs | 172 ++++++++ .../WrapperClassGenerationTests.cs | 204 +++++++++ .../Helpers/CompilationHelper.cs | 61 +++ .../Helpers/GeneratorTestHelper.cs | 135 ++++++ .../Helpers/TestFixtures.cs | 407 ++++++++++++++++++ .../IntegrationTests/CompilationTests.cs | 199 +++++++++ .../ParsingTests/RegisterStepParsingTests.cs | 211 +++++++++ XrmPluginCore.SourceGenerator.Tests/README.md | 269 ++++++++++++ .../GeneratedCodeSnapshotTests.cs | 163 +++++++ ...XrmPluginCore.SourceGenerator.Tests.csproj | 43 ++ XrmPluginCore.sln | 14 + 12 files changed, 1883 insertions(+) create mode 100644 XrmPluginCore.SourceGenerator.Tests/.editorconfig create mode 100644 XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/README.md create mode 100644 XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj diff --git a/XrmPluginCore.SourceGenerator.Tests/.editorconfig b/XrmPluginCore.SourceGenerator.Tests/.editorconfig new file mode 100644 index 0000000..431cbab --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/.editorconfig @@ -0,0 +1,5 @@ +# C# files +[*.cs] + +dotnet_diagnostic.CS0618.severity = none # Suppress 'obsolete' warnings for test project +dotnet_diagnostic.CA1707.severity = none # Suppress 'identifiers should not contain underscores' warnings for test project diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs new file mode 100644 index 0000000..a5a6369 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -0,0 +1,172 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests; + +/// +/// Tests for verifying diagnostic reporting from the source generator. +/// +public class DiagnosticReportingTests +{ + [Fact] + public void Should_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var successDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC1000") + .ToArray(); + + successDiagnostics.Should().NotBeEmpty("XPC1000 should be reported on successful generation"); + successDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Info); + } + + [Fact(Skip = "XPC4001 diagnostic not yet implemented - property validation happens silently")] + public void Should_Report_XPC4001_When_Property_Not_Found() + { + // Arrange - plugin references a property that doesn't exist on the entity + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.NonExistentProperty) + .Execute((service, preImage) => service.Process(preImage)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(object image); } + public class TestService : ITestService { public void Process(object image) { } } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4001") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4001 should be reported when property is not found"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + } + + [Fact(Skip = "XPC4002 diagnostic not yet implemented - constructor validation happens silently")] + public void Should_Report_XPC4002_When_Entity_Has_No_Parameterless_Constructor() + { + // Arrange - entity without parameterless constructor + var entitySource = @" +using System; +using Microsoft.Xrm.Sdk; + +namespace TestNamespace +{ + [EntityLogicalName(""customentity"")] + public class CustomEntity : Entity + { + // No parameterless constructor + public CustomEntity(string requiredParam) : base(""customentity"") { } + + [AttributeLogicalName(""name"")] + public string Name + { + get => GetAttributeValue(""name""); + set => SetAttributeValue(""name"", value); + } + } +}"; + + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.Name) + .Execute((service, preImage) => service.Process(preImage)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(object image); } + public class TestService : ITestService { public void Process(object image) { } } +}"; + + var source = TestFixtures.GetCompleteSource(entitySource, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4002") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when entity has no parameterless constructor"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + } + + [Fact] + public void Should_Handle_XPC5000_Generation_Error_Gracefully() + { + // This test verifies that the generator doesn't crash on unexpected errors + // We can't easily force an XPC5000 error, but we verify the compilation doesn't fail + + // Arrange - complex but valid source + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - should not have critical errors + var criticalErrors = result.GeneratorDiagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + + criticalErrors.Should().BeEmpty("generator should not produce critical errors for valid source"); + + // Verify generation succeeded + result.GeneratedTrees.Should().NotBeEmpty("code should be generated"); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs new file mode 100644 index 0000000..3470eb8 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs @@ -0,0 +1,204 @@ +using FluentAssertions; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.GenerationTests; + +/// +/// Tests for verifying wrapper class code generation structure and content. +/// +public class WrapperClassGenerationTests +{ + [Fact] + public void Should_Generate_PreImage_Class_With_Properties() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify class structure + generatedSource.Should().Contain("public class PreImage"); + generatedSource.Should().Contain("private readonly Entity entity;"); + generatedSource.Should().Contain("public PreImage(Entity entity)"); + + // Verify properties + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("entity.GetAttributeValue(\"name\")"); + + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + generatedSource.Should().Contain("entity.GetAttributeValue(\"revenue\")"); + } + + [Fact] + public void Should_Generate_PostImage_Class_With_Properties() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify class structure + generatedSource.Should().Contain("public class PostImage"); + generatedSource.Should().Contain("private readonly Entity entity;"); + generatedSource.Should().Contain("public PostImage(Entity entity)"); + + // Verify properties + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("public string AccountNumber"); + } + + [Fact] + public void Should_Generate_Both_Image_Classes_In_Same_Namespace() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Both classes should be in the same namespace + var namespaceCount = System.Text.RegularExpressions.Regex.Matches( + generatedSource, + @"namespace\s+TestNamespace\.PluginImages\.TestPlugin\.AccountUpdatePostOperation").Count; + + namespaceCount.Should().Be(1, "both classes should be in the same namespace"); + + // Both classes should exist + generatedSource.Should().Contain("public class PreImage"); + generatedSource.Should().Contain("public class PostImage"); + } + + [Fact] + public void Should_Generate_Properties_With_Correct_Types() + { + // Arrange + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.Name, x => x.Revenue, x => x.IndustryCode, x => x.PrimaryContactId) + .Execute((service, preImage) => service.Process(preImage)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(object image); } + public class TestService : ITestService { public void Process(object image) { } } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify different types + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("entity.GetAttributeValue"); + + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + generatedSource.Should().Contain("entity.GetAttributeValue"); + + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.OptionSetValue IndustryCode"); + generatedSource.Should().Contain("entity.GetAttributeValue"); + + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.EntityReference PrimaryContactId"); + generatedSource.Should().Contain("entity.GetAttributeValue"); + } + + [Fact] + public void Should_Include_ToEntity_Method() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain("public T ToEntity() where T : Entity"); + generatedSource.Should().Contain("=> entity.ToEntity();"); + } + + [Fact] + public void Should_Include_GetUnderlyingEntity_Method() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain("public Entity GetUnderlyingEntity()"); + generatedSource.Should().Contain("=> entity;"); + } + + [Fact] + public void Should_Implement_IEntityImageWrapper_Interface() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain(": IEntityImageWrapper"); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs new file mode 100644 index 0000000..144468c --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs @@ -0,0 +1,61 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Reflection; +using XrmPluginCore.Enums; + +namespace XrmPluginCore.SourceGenerator.Tests.Helpers; + +/// +/// Helper for creating test compilations with proper references for testing source generators. +/// +public static class CompilationHelper +{ + /// + /// Creates a CSharpCompilation with the necessary references for Dataverse plugin development. + /// + /// The C# source code to compile + /// Optional assembly name (defaults to random GUID) + /// A configured CSharpCompilation + public static CSharpCompilation CreateCompilation(string source, string? assemblyName = null) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp11)); + + var references = GetMetadataReferences(); + + return CSharpCompilation.Create( + assemblyName ?? $"TestAssembly_{Guid.NewGuid():N}", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + } + + /// + /// Gets all necessary metadata references for plugin compilation. + /// + private static IEnumerable GetMetadataReferences() + { + // Basic .NET references + yield return MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(Console).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location); // System.Linq.Expressions + yield return MetadataReference.CreateFromFile(typeof(System.ComponentModel.DescriptionAttribute).Assembly.Location); // System.ComponentModel + yield return MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location); + yield return MetadataReference.CreateFromFile(Assembly.Load("System.Collections").Location); + yield return MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location); + + // Dataverse SDK references + yield return MetadataReference.CreateFromFile(typeof(Microsoft.Xrm.Sdk.IPlugin).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(Microsoft.Xrm.Sdk.Entity).Assembly.Location); + + // XrmPluginCore references + yield return MetadataReference.CreateFromFile(typeof(Plugin).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(EventOperation).Assembly.Location); + + // Microsoft.Extensions.DependencyInjection (required by Plugin base class) + yield return MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.IServiceCollection).Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions).Assembly.Location); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs new file mode 100644 index 0000000..248c571 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs @@ -0,0 +1,135 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Reflection; +using System.Runtime.Loader; +using XrmPluginCore.SourceGenerator.Generators; + +namespace XrmPluginCore.SourceGenerator.Tests.Helpers; + +/// +/// Helper for running source generators and testing their output. +/// +public static class GeneratorTestHelper +{ + /// + /// Runs the PluginImageGenerator on the provided compilation and returns the updated compilation. + /// + public static GeneratorRunResult RunGenerator(CSharpCompilation compilation) + { + var generator = new PluginImageGenerator(); + // Pass the compilation's parse options to the driver so generated syntax trees use the same language version + var driver = CSharpGeneratorDriver.Create( + generators: new[] { generator.AsSourceGenerator() }, + parseOptions: (CSharpParseOptions?)compilation.SyntaxTrees.FirstOrDefault()?.Options); + + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var diagnostics); + + var runResult = driver.GetRunResult(); + + // Get generated trees from the output compilation (they have consistent parse options) + // instead of from runResult.GeneratedTrees (which may have inconsistent options) + var generatedTrees = outputCompilation.SyntaxTrees + .Where(tree => !compilation.SyntaxTrees.Contains(tree)) + .ToArray(); + + return new GeneratorRunResult + { + OutputCompilation = (CSharpCompilation)outputCompilation, + Diagnostics = diagnostics.ToArray(), + GeneratedTrees = generatedTrees, + GeneratorDiagnostics = runResult.Results[0].Diagnostics.ToArray() + }; + } + + /// + /// Runs the generator and compiles the output to an in-memory assembly. + /// + public static CompiledGeneratorResult RunGeneratorAndCompile(string source) + { + var compilation = CompilationHelper.CreateCompilation(source); + var result = RunGenerator(compilation); + + using var ms = new MemoryStream(); + var emitResult = result.OutputCompilation.Emit(ms); + + if (!emitResult.Success) + { + var errors = emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToArray(); + + return new CompiledGeneratorResult + { + Success = false, + Errors = errors, + GeneratorResult = result + }; + } + + ms.Seek(0, SeekOrigin.Begin); + + return new CompiledGeneratorResult + { + Success = true, + AssemblyBytes = ms.ToArray(), + GeneratorResult = result + }; + } + + /// + /// Loads a compiled assembly in an isolated AssemblyLoadContext for testing. + /// + public static LoadedAssemblyContext LoadAssembly(byte[] assemblyBytes, string contextName = "TestContext") + { + var context = new AssemblyLoadContext(contextName, isCollectible: true); + using var ms = new MemoryStream(assemblyBytes); + var assembly = context.LoadFromStream(ms); + + return new LoadedAssemblyContext + { + Context = context, + Assembly = assembly + }; + } +} + +/// +/// Result from running the source generator. +/// +public class GeneratorRunResult +{ + public required CSharpCompilation OutputCompilation { get; init; } + public required Diagnostic[] Diagnostics { get; init; } + public required SyntaxTree[] GeneratedTrees { get; init; } + public required Diagnostic[] GeneratorDiagnostics { get; init; } +} + +/// +/// Result from compiling generated code. +/// +public class CompiledGeneratorResult +{ + public required bool Success { get; init; } + public byte[]? AssemblyBytes { get; init; } + public string[]? Errors { get; init; } + public required GeneratorRunResult GeneratorResult { get; init; } +} + +/// +/// A loaded assembly in an isolated context that can be unloaded. +/// +public class LoadedAssemblyContext : IDisposable +{ + public required AssemblyLoadContext Context { get; init; } + public required Assembly Assembly { get; init; } + + public void Dispose() + { + Context.Unload(); + GC.SuppressFinalize(this); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs new file mode 100644 index 0000000..8976533 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -0,0 +1,407 @@ +namespace XrmPluginCore.SourceGenerator.Tests.Helpers; + +/// +/// Provides reusable test fixtures for source generator testing. +/// +public static class TestFixtures +{ + /// + /// Sample Account entity class with common attributes. + /// + public const string AccountEntity = @" +using System; +using System.ComponentModel; +using Microsoft.Xrm.Sdk; + +namespace TestNamespace +{ + [EntityLogicalName(""account"")] + public class Account : Entity + { + public const string EntityLogicalName = ""account""; + + public Account() : base(EntityLogicalName) { } + + [AttributeLogicalName(""name"")] + public string Name + { + get => GetAttributeValue(""name""); + set => SetAttributeValue(""name"", value); + } + + [AttributeLogicalName(""accountnumber"")] + public string AccountNumber + { + get => GetAttributeValue(""accountnumber""); + set => SetAttributeValue(""accountnumber"", value); + } + + [AttributeLogicalName(""revenue"")] + public Money Revenue + { + get => GetAttributeValue(""revenue""); + set => SetAttributeValue(""revenue"", value); + } + + [AttributeLogicalName(""industrycode"")] + public OptionSetValue IndustryCode + { + get => GetAttributeValue(""industrycode""); + set => SetAttributeValue(""industrycode"", value); + } + + [AttributeLogicalName(""primarycontactid"")] + public EntityReference PrimaryContactId + { + get => GetAttributeValue(""primarycontactid""); + set => SetAttributeValue(""primarycontactid"", value); + } + } +}"; + + /// + /// Sample Contact entity class with common attributes. + /// + public const string ContactEntity = @" +using System; +using System.ComponentModel; +using Microsoft.Xrm.Sdk; + +namespace TestNamespace +{ + [EntityLogicalName(""contact"")] + public class Contact : Entity + { + public const string EntityLogicalName = ""contact""; + + public Contact() : base(EntityLogicalName) { } + + [AttributeLogicalName(""firstname"")] + public string FirstName + { + get => GetAttributeValue(""firstname""); + set => SetAttributeValue(""firstname"", value); + } + + [AttributeLogicalName(""lastname"")] + public string LastName + { + get => GetAttributeValue(""lastname""); + set => SetAttributeValue(""lastname"", value); + } + + [AttributeLogicalName(""emailaddress1"")] + public string EmailAddress + { + get => GetAttributeValue(""emailaddress1""); + set => SetAttributeValue(""emailaddress1"", value); + } + } +}"; + + /// + /// Plugin with PreImage only using WithPreImage. + /// + public static string GetPluginWithPreImage(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) + .WithPreImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}) + .Execute((service, preImage) => service.Process(preImage)); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(object image); + }} + + public class TestService : ITestService + {{ + public void Process(object image) {{ }} + }} +}}"; + + /// + /// Plugin with PostImage only using WithPostImage. + /// + public static string GetPluginWithPostImage(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) + .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}) + .Execute((service, postImage) => service.Process(postImage)); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(object image); + }} + + public class TestService : ITestService + {{ + public void Process(object image) {{ }} + }} +}}"; + + /// + /// Plugin with both PreImage and PostImage using WithPreImage and WithPostImage. + /// + public static string GetPluginWithBothImages(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + .AddFilteredAttributes(x => x.Name) + .WithPreImage(x => x.Name, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}) + .WithPostImage(x => x.Name, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}) + .Execute((service, preImage, postImage) => service.Process(preImage, postImage)); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(object preImage, object postImage); + }} + + public class TestService : ITestService + {{ + public void Process(object preImage, object postImage) {{ }} + }} +}}"; + + /// + /// Plugin using old AddImage API for backward compatibility testing. + /// + public static string GetPluginWithOldImageApi(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, service => service.Process()) + .AddFilteredAttributes(x => x.Name) + .AddImage(ImageType.PreImage, x => x.Name, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(); + }} + + public class TestService : ITestService + {{ + public void Process() {{ }} + }} +}}"; + + /// + /// Gets a complete compilable source with entity and plugin. + /// + public static string GetCompleteSource(string entitySource, string pluginSource) + { + // Determine which entity is being used by checking if pluginSource contains RegisterStep GetAttributeValue(""name""); + set => SetAttributeValue(""name"", value); + }} + + [AttributeLogicalName(""accountnumber"")] + public string AccountNumber + {{ + get => GetAttributeValue(""accountnumber""); + set => SetAttributeValue(""accountnumber"", value); + }} + + [AttributeLogicalName(""revenue"")] + public Money Revenue + {{ + get => GetAttributeValue(""revenue""); + set => SetAttributeValue(""revenue"", value); + }} + + [AttributeLogicalName(""industrycode"")] + public OptionSetValue IndustryCode + {{ + get => GetAttributeValue(""industrycode""); + set => SetAttributeValue(""industrycode"", value); + }} + + [AttributeLogicalName(""primarycontactid"")] + public EntityReference PrimaryContactId + {{ + get => GetAttributeValue(""primarycontactid""); + set => SetAttributeValue(""primarycontactid"", value); + }} + }} + + [Microsoft.Xrm.Sdk.Client.EntityLogicalName(""contact"")] + public class Contact : Entity + {{ + public const string EntityLogicalName = ""contact""; + + public Contact() : base(EntityLogicalName) {{ }} + + [AttributeLogicalName(""firstname"")] + public string FirstName + {{ + get => GetAttributeValue(""firstname""); + set => SetAttributeValue(""firstname"", value); + }} + + [AttributeLogicalName(""lastname"")] + public string LastName + {{ + get => GetAttributeValue(""lastname""); + set => SetAttributeValue(""lastname"", value); + }} + + [AttributeLogicalName(""emailaddress1"")] + public string EmailAddress + {{ + get => GetAttributeValue(""emailaddress1""); + set => SetAttributeValue(""emailaddress1"", value); + }} + }} + + {StripNamespaceAndUsings(pluginSource)} +}}"; + } + + /// + /// Removes namespace declaration and using statements from source code. + /// + private static string StripNamespaceAndUsings(string source) + { + var lines = source.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + var result = new System.Text.StringBuilder(); + bool inNamespace = false; + int braceCount = 0; + + foreach (var line in lines) + { + var trimmed = line.Trim(); + + // Skip using statements + if (trimmed.StartsWith("using ")) + continue; + + // Skip namespace declaration + if (trimmed.StartsWith("namespace ")) + { + inNamespace = true; + continue; + } + + // Skip opening brace of namespace + if (inNamespace && trimmed == "{") + { + inNamespace = false; + braceCount++; + continue; + } + + // Track braces + braceCount += line.Count(c => c == '{'); + braceCount -= line.Count(c => c == '}'); + + // Skip closing brace if it would close the namespace + if (braceCount == 0 && trimmed == "}") + continue; + + result.AppendLine(line); + } + + return result.ToString(); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs new file mode 100644 index 0000000..55a73db --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -0,0 +1,199 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.IntegrationTests; + +/// +/// Integration tests that verify generated code compiles and runs correctly. +/// +public class CompilationTests +{ + [Fact] + public void Should_Compile_Generated_Code_Without_Errors() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + + // Assert + result.Success.Should().BeTrue( + because: $"compilation should succeed. Errors: {string.Join(", ", result.Errors ?? Array.Empty())}"); + result.AssemblyBytes.Should().NotBeNull(); + } + + [Fact] + public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Create test entity + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["revenue"] = new Money(100000) + }; + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var preImageType = loadedAssembly.Assembly.GetType( + "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PreImage"); + + preImageType.Should().NotBeNull("PreImage class should be generated"); + + var preImageInstance = Activator.CreateInstance(preImageType!, entity); + + // Assert + preImageInstance.Should().NotBeNull(); + + var nameProperty = preImageType!.GetProperty("Name"); + nameProperty.Should().NotBeNull(); + var nameValue = nameProperty!.GetValue(preImageInstance); + nameValue.Should().Be("Test Account"); + + var revenueProperty = preImageType.GetProperty("Revenue"); + revenueProperty.Should().NotBeNull(); + var revenueValue = revenueProperty!.GetValue(preImageInstance) as Money; + revenueValue.Should().NotBeNull(); + revenueValue!.Value.Should().Be(100000); + } + + [Fact] + public void Should_Access_Properties_And_Verify_Values() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-12345" + }; + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var postImageType = loadedAssembly.Assembly.GetType( + "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PostImage"); + + var postImageInstance = Activator.CreateInstance(postImageType!, entity); + + // Assert + var nameProperty = postImageType!.GetProperty("Name"); + nameProperty!.GetValue(postImageInstance).Should().Be("Test Account"); + + var accountNumberProperty = postImageType.GetProperty("AccountNumber"); + accountNumberProperty!.GetValue(postImageInstance).Should().Be("ACC-12345"); + } + + [Fact] + public void Should_Work_With_Both_PreImage_And_PostImage() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + var preEntity = new Entity("account") + { + ["name"] = "Old Name", + ["revenue"] = new Money(50000) + }; + + var postEntity = new Entity("account") + { + ["name"] = "New Name", + ["accountnumber"] = "ACC-12345" + }; + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var baseNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"; + + var preImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PreImage"); + var postImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PostImage"); + + var preImageInstance = Activator.CreateInstance(preImageType!, preEntity); + var postImageInstance = Activator.CreateInstance(postImageType!, postEntity); + + // Assert - PreImage + preImageType!.GetProperty("Name")!.GetValue(preImageInstance).Should().Be("Old Name"); + var preRevenue = preImageType.GetProperty("Revenue")!.GetValue(preImageInstance) as Money; + preRevenue!.Value.Should().Be(50000); + + // Assert - PostImage + postImageType!.GetProperty("Name")!.GetValue(postImageInstance).Should().Be("New Name"); + postImageType.GetProperty("AccountNumber")!.GetValue(postImageInstance).Should().Be("ACC-12345"); + } + + [Fact] + public void Should_Handle_Null_Attribute_Values_Gracefully() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Entity with missing attributes + var entity = new Entity("account"); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var preImageType = loadedAssembly.Assembly.GetType( + "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PreImage"); + + var preImageInstance = Activator.CreateInstance(preImageType!, entity); + + // Assert - should return null for missing attributes, not throw + var nameProperty = preImageType!.GetProperty("Name"); + var nameValue = nameProperty!.GetValue(preImageInstance); + nameValue.Should().BeNull(); + + var revenueProperty = preImageType.GetProperty("Revenue"); + var revenueValue = revenueProperty!.GetValue(preImageInstance); + revenueValue.Should().BeNull(); + } + + [Fact] + public void Should_Verify_Namespace_Isolation_Per_Registration() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + + // Assert - namespace should follow pattern: {Namespace}.PluginImages.{Plugin}.{Entity}{Operation}{Stage} + var expectedNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"; + var preImageType = loadedAssembly.Assembly.GetType($"{expectedNamespace}.PreImage"); + + preImageType.Should().NotBeNull("PreImage should be in the expected namespace"); + preImageType!.Namespace.Should().Be(expectedNamespace); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs new file mode 100644 index 0000000..55cabe8 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs @@ -0,0 +1,211 @@ +using FluentAssertions; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.ParsingTests; + +/// +/// Tests for parsing RegisterStep invocations and extracting metadata. +/// +public class RegisterStepParsingTests +{ + [Fact] + public void Should_Parse_WithPreImage_Registration() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("class PreImage"); + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + } + + [Fact] + public void Should_Parse_WithPostImage_Registration() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("class PostImage"); + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("public string AccountNumber"); + } + + [Fact] + public void Should_Parse_Both_PreImage_And_PostImage() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain("class PreImage"); + generatedSource.Should().Contain("class PostImage"); + + // PreImage should have Name and Revenue + generatedSource.Should().Match("*PreImage*Name*"); + generatedSource.Should().Match("*PreImage*Revenue*"); + + // PostImage should have Name and AccountNumber + generatedSource.Should().Match("*PostImage*Name*"); + generatedSource.Should().Match("*PostImage*AccountNumber*"); + } + + [Fact] + public void Should_Parse_Old_AddImage_Api_For_Backward_Compatibility() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithOldImageApi()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("class PreImage"); + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + } + + [Fact] + public void Should_Parse_Lambda_Syntax_For_Attributes() + { + // Arrange - GetPluginWithPreImage uses lambda syntax: x => x.Name + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("public string Name"); + } + + [Fact] + public void Should_Handle_Contact_Entity() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.ContactEntity, + TestFixtures.GetPluginWithPreImage("Contact")); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("class PreImage"); + generatedSource.Should().Contain("public string FirstName"); + generatedSource.Should().Contain("public string EmailAddress"); + } + + [Fact] + public void Should_Generate_Correct_Namespace_For_Registration() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Namespace pattern: {Namespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage} + generatedSource.Should().Contain("namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"); + } + + [Fact] + public void Should_Handle_Multiple_Attributes_In_Same_Image() + { + // Arrange + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.Name, x => x.AccountNumber, x => x.Revenue, x => x.IndustryCode) + .Execute((service, preImage) => service.Process(preImage)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(object image); + } + + public class TestService : ITestService + { + public void Process(object image) { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain("public string Name"); + generatedSource.Should().Contain("public string AccountNumber"); + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.OptionSetValue IndustryCode"); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/README.md b/XrmPluginCore.SourceGenerator.Tests/README.md new file mode 100644 index 0000000..945ea4a --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/README.md @@ -0,0 +1,269 @@ +# XrmPluginCore.SourceGenerator.Tests + +This project contains unit and integration tests for the XrmPluginCore.SourceGenerator, which generates type-safe wrapper classes for plugin images (PreImage/PostImage). + +## Overview + +The source generator analyzes plugin classes that inherit from `Plugin` and generates strongly-typed wrapper classes for images registered via `WithPreImage()` and `WithPostImage()` methods. These tests verify that the generator correctly parses plugin registrations, generates valid code, and that the generated code compiles and runs as expected. + +## Testing Approach + +This project uses a **hybrid testing approach** combining two complementary strategies: + +### 1. Compiled Execution Testing (Primary) +Tests functional correctness by: +- Running the generator on test source code +- Compiling the generated code +- Loading the compiled assembly in an isolated `AssemblyLoadContext` +- Verifying runtime behavior via reflection + +**Benefits:** +- Tests what actually matters: does the generated code work? +- Resilient to implementation changes (refactoring-friendly) +- Validates generated code compiles and runs correctly +- Can test actual runtime behavior + +### 2. Snapshot Testing (Structural Verification) +Tests generated code structure by: +- Capturing the exact generated source code +- Verifying presence of expected patterns and elements +- Ensuring consistent code generation + +**Benefits:** +- Fast execution +- Catches unintended changes in code generation +- Ensures consistent code patterns + +## Test Organization + +### Helpers/ +Reusable test infrastructure: + +- **CompilationHelper.cs** - Creates `CSharpCompilation` instances with proper references to Dataverse SDK and XrmPluginCore assemblies +- **GeneratorTestHelper.cs** - Runs the generator, compiles output, loads assemblies in isolated contexts +- **TestFixtures.cs** - Provides sample entity classes (Account, Contact) and common plugin registration patterns + +### ParsingTests/ +Tests for metadata extraction from plugin source code: + +- `RegisterStepParsingTests.cs` - Tests parsing of `RegisterStep` invocations with various image configurations + - WithPreImage only + - WithPostImage only + - Both images + - Old AddImage API (backward compatibility) + - Lambda, nameof, and string literal attribute syntax + - Multiple attributes per image + +### GenerationTests/ +Tests for code generation structure and content: + +- `WrapperClassGenerationTests.cs` - Verifies generated wrapper class structure + - PreImage/PostImage class structure + - Property generation with correct types + - ToEntity() method + - GetUnderlyingEntity() method + - IEntityImageWrapper interface implementation + +### IntegrationTests/ +End-to-end tests that verify generated code compiles and runs: + +- `CompilationTests.cs` - Tests complete generation → compilation → execution flow + - Compilation success + - Assembly loading and instantiation + - Property access and value verification + - Null handling + - Namespace isolation + +### DiagnosticTests/ +Tests for source generator diagnostic reporting: + +- `DiagnosticReportingTests.cs` - Verifies diagnostic codes are reported correctly + - XPC1000: Generation success (Info) + - XPC4001: Property not found (Warning) + - XPC4002: No parameterless constructor (Warning) + - XPC5000: Generation error handling + +### SnapshotTests/ +Tests for exact code structure verification: + +- `GeneratedCodeSnapshotTests.cs` - Verifies generated code follows expected patterns + - Class structure elements + - XML documentation + - Namespace patterns + - [CompilerGenerated] attribute + +## Running Tests + +### Run All Tests +```bash +dotnet test --configuration Release +``` + +### Run Specific Test Class +```bash +dotnet test --filter "FullyQualifiedName~CompilationTests" +``` + +### Run Single Test +```bash +dotnet test --filter "FullyQualifiedName~Should_Compile_Generated_Code_Without_Errors" +``` + +### Run with Detailed Output +```bash +dotnet test --configuration Release --verbosity normal +``` + +## Adding New Tests + +### Adding a Parsing Test +1. Create test source code (or use `TestFixtures` helpers) +2. Run the generator via `GeneratorTestHelper.RunGenerator()` +3. Assert on `result.GeneratedTrees` content + +```csharp +[Fact] +public void Should_Parse_New_Pattern() +{ + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("expected pattern"); +} +``` + +### Adding a Compilation Test +1. Create test source code +2. Run generator and compile via `GeneratorTestHelper.RunGeneratorAndCompile()` +3. Load assembly via `GeneratorTestHelper.LoadAssembly()` +4. Test via reflection + +```csharp +[Fact] +public void Should_Test_Runtime_Behavior() +{ + // Arrange + var source = TestFixtures.GetCompleteSource(...); + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var type = loadedAssembly.Assembly.GetType("Namespace.PreImage"); + var instance = Activator.CreateInstance(type, testEntity); + + // Assert + var property = type.GetProperty("PropertyName"); + property!.GetValue(instance).Should().Be("expected value"); +} +``` + +### Adding a Diagnostic Test +1. Create source code that should trigger a diagnostic +2. Run the generator +3. Assert on `result.GeneratorDiagnostics` + +```csharp +[Fact] +public void Should_Report_Diagnostic_Code() +{ + // Arrange + var source = "code that triggers diagnostic"; + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var diagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC####") + .ToArray(); + + diagnostics.Should().NotBeEmpty(); +} +``` + +## Test Coverage + +Current test coverage includes: + +**Parsing (~8 tests)** +- ✅ WithPreImage registration +- ✅ WithPostImage registration +- ✅ Both PreImage and PostImage +- ✅ Old AddImage API +- ✅ Lambda syntax +- ✅ Multiple entities +- ✅ Namespace generation +- ✅ Multiple attributes + +**Generation (~7 tests)** +- ✅ PreImage class structure +- ✅ PostImage class structure +- ✅ Both classes in same namespace +- ✅ Property types (string, Money, OptionSetValue, EntityReference) +- ✅ ToEntity() method +- ✅ GetUnderlyingEntity() method +- ✅ IEntityImageWrapper interface + +**Integration (~6 tests)** +- ✅ Compilation success +- ✅ PreImage instantiation +- ✅ Property access +- ✅ Both images +- ✅ Null handling +- ✅ Namespace isolation + +**Diagnostics (~4 tests)** +- ✅ XPC1000 success diagnostic +- ✅ XPC4001 property not found +- ✅ XPC4002 no parameterless constructor +- ✅ XPC5000 error handling + +**Snapshots (~5 tests)** +- ✅ PreImage structure +- ✅ PostImage structure +- ✅ XML documentation +- ✅ Namespace pattern +- ✅ [CompilerGenerated] attribute + +**Total: ~30 tests** with standard coverage of core scenarios and common patterns. + +## Common Issues and Solutions + +### Issue: "Type or namespace could not be found" +**Solution:** Ensure `CompilationHelper.CreateCompilation()` includes all necessary references. The helper automatically includes Dataverse SDK and XrmPluginCore references. + +### Issue: "AssemblyLoadContext cannot be unloaded" +**Solution:** Always use `using` statements with `LoadedAssemblyContext` to ensure proper cleanup: +```csharp +using var loadedAssembly = GeneratorTestHelper.LoadAssembly(bytes); +// ... test code ... +// Automatically unloaded when scope exits +``` + +### Issue: Tests pass locally but fail in CI +**Solution:** Verify all required NuGet packages are restored. Run `dotnet restore` before `dotnet test`. + +## Dependencies + +- **xUnit** - Test framework +- **FluentAssertions** - Fluent assertion library +- **Microsoft.CodeAnalysis.CSharp** - For creating compilations and running generators +- **Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.XUnit** - For snapshot testing support +- **Microsoft.PowerPlatform.Dataverse.Client** - Dataverse SDK for test compilations + +## References + +- [Roslyn Source Generators Documentation](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.md) +- [Testing Source Generators - Thinktecture](https://www.thinktecture.com/en/net/roslyn-source-generators-analyzers-code-fixes-testing/) +- [How to Test Source Generators - Meziantou](https://www.meziantou.net/how-to-test-roslyn-source-generators.htm) diff --git a/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs new file mode 100644 index 0000000..417ef0f --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.SnapshotTests; + +/// +/// Snapshot tests that verify the exact structure of generated code. +/// These tests ensure consistency in code generation patterns. +/// +public class GeneratedCodeSnapshotTests +{ + [Fact] + public void Should_Generate_PreImage_Class_With_Expected_Structure() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify essential structure elements + var expectedElements = new[] + { + "namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation", + "public class PreImage : IEntityImageWrapper", + "private readonly Entity entity;", + "public PreImage(Entity entity)", + "this.entity = entity ?? throw new ArgumentNullException(nameof(entity));", + "public Entity GetUnderlyingEntity()", + "=> entity;", + "public T ToEntity() where T : Entity", + "=> entity.ToEntity();", + "[CompilerGenerated]" + }; + + foreach (var element in expectedElements) + { + generatedSource.Should().Contain(element, $"generated code should contain: {element}"); + } + } + + [Fact] + public void Should_Generate_PostImage_Class_With_Expected_Structure() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify essential structure elements + var expectedElements = new[] + { + "namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation", + "public class PostImage : IEntityImageWrapper", + "private readonly Entity entity;", + "public PostImage(Entity entity)", + "this.entity = entity ?? throw new ArgumentNullException(nameof(entity));", + "public Entity GetUnderlyingEntity()", + "public T ToEntity() where T : Entity", + "[CompilerGenerated]" + }; + + foreach (var element in expectedElements) + { + generatedSource.Should().Contain(element, $"generated code should contain: {element}"); + } + } + + [Fact] + public void Should_Include_XML_Documentation_Comments() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Check for XML documentation + generatedSource.Should().Contain("/// "); + generatedSource.Should().Contain("/// "); + } + + [Fact] + public void Should_Follow_Namespace_Pattern() + { + // Arrange - test different entity/operation combinations + var testCases = new[] + { + new + { + Source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()), + ExpectedNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation" + }, + new + { + Source = TestFixtures.GetCompleteSource( + TestFixtures.ContactEntity, + TestFixtures.GetPluginWithPreImage("Contact")), + ExpectedNamespace = "TestNamespace.PluginImages.TestPlugin.ContactUpdatePostOperation" + } + }; + + foreach (var testCase in testCases) + { + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(testCase.Source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain($"namespace {testCase.ExpectedNamespace}", + $"namespace should follow pattern: {{Namespace}}.PluginImages.{{Plugin}}.{{Entity}}{{Operation}}{{Stage}}"); + } + } + + [Fact] + public void Should_Mark_Classes_With_CompilerGenerated_Attribute() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Should have using statement + generatedSource.Should().Contain("using System.Runtime.CompilerServices;"); + + // Both classes should be marked + generatedSource.Should().Contain("[CompilerGenerated]"); + + // Count occurrences - should be at least 2 (one for each class) + var matches = System.Text.RegularExpressions.Regex.Matches(generatedSource, @"\[CompilerGenerated\]"); + matches.Count.Should().BeGreaterOrEqualTo(2, "both PreImage and PostImage classes should be marked"); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..8b38225 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XrmPluginCore.sln b/XrmPluginCore.sln index c12ce6c..ea9dd70 100644 --- a/XrmPluginCore.sln +++ b/XrmPluginCore.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore.Tests", "XrmP EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore.SourceGenerator", "XrmPluginCore.SourceGenerator\XrmPluginCore.SourceGenerator.csproj", "{4544F34A-FCFD-48EE-AA5A-4FAA342DE889}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XrmPluginCore.SourceGenerator.Tests", "XrmPluginCore.SourceGenerator.Tests\XrmPluginCore.SourceGenerator.Tests.csproj", "{9DED6072-25F9-4FB1-A9BB-0353E834EEDE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +85,18 @@ Global {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x64.Build.0 = Release|Any CPU {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x86.ActiveCfg = Release|Any CPU {4544F34A-FCFD-48EE-AA5A-4FAA342DE889}.Release|x86.Build.0 = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|x64.Build.0 = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Debug|x86.Build.0 = Debug|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|Any CPU.Build.0 = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|x64.ActiveCfg = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|x64.Build.0 = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|x86.ActiveCfg = Release|Any CPU + {9DED6072-25F9-4FB1-A9BB-0353E834EEDE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From b69c56aeedb34547347a7aee2a584caccdec9d68 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 25 Nov 2025 10:22:42 +0100 Subject: [PATCH 03/22] Fix: Implement parameterless constructor diagnostic and test --- .../DiagnosticReportingTests.cs | 84 ++----------------- .../AnalyzerReleases.Unshipped.md | 3 +- .../DiagnosticDescriptors.cs | 10 +-- .../Generators/PluginImageGenerator.cs | 18 +++- .../Models/PluginStepMetadata.cs | 15 ++++ .../Parsers/RegistrationParser.cs | 32 +++++++ 6 files changed, 74 insertions(+), 88 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index a5a6369..842c59f 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -31,13 +31,13 @@ public void Should_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() successDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Info); } - [Fact(Skip = "XPC4001 diagnostic not yet implemented - property validation happens silently")] - public void Should_Report_XPC4001_When_Property_Not_Found() + [Fact] + public void Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() { - // Arrange - plugin references a property that doesn't exist on the entity + // Arrange - plugin class with only a parameterized constructor (no parameterless) var pluginSource = @" using XrmPluginCore; -using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; @@ -45,10 +45,11 @@ namespace TestNamespace { public class TestPlugin : Plugin { - public TestPlugin() + // Only has a constructor WITH parameters - no parameterless constructor + public TestPlugin(string config) { RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.NonExistentProperty) + .WithPreImage(x => x.Name) .Execute((service, preImage) => service.Process(preImage)); } @@ -68,79 +69,12 @@ public class TestService : ITestService { public void Process(object image) { } var result = GeneratorTestHelper.RunGenerator( CompilationHelper.CreateCompilation(source)); - // Assert + // Assert - should report XPC4001 (was XPC4002, now renamed) var errorDiagnostics = result.GeneratorDiagnostics .Where(d => d.Id == "XPC4001") .ToArray(); - errorDiagnostics.Should().NotBeEmpty("XPC4001 should be reported when property is not found"); - errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); - } - - [Fact(Skip = "XPC4002 diagnostic not yet implemented - constructor validation happens silently")] - public void Should_Report_XPC4002_When_Entity_Has_No_Parameterless_Constructor() - { - // Arrange - entity without parameterless constructor - var entitySource = @" -using System; -using Microsoft.Xrm.Sdk; - -namespace TestNamespace -{ - [EntityLogicalName(""customentity"")] - public class CustomEntity : Entity - { - // No parameterless constructor - public CustomEntity(string requiredParam) : base(""customentity"") { } - - [AttributeLogicalName(""name"")] - public string Name - { - get => GetAttributeValue(""name""); - set => SetAttributeValue(""name"", value); - } - } -}"; - - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name) - .Execute((service, preImage) => service.Process(preImage)); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService { void Process(object image); } - public class TestService : ITestService { public void Process(object image) { } } -}"; - - var source = TestFixtures.GetCompleteSource(entitySource, pluginSource); - - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); - - // Assert - var errorDiagnostics = result.GeneratorDiagnostics - .Where(d => d.Id == "XPC4002") - .ToArray(); - - errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when entity has no parameterless constructor"); + errorDiagnostics.Should().NotBeEmpty("XPC4001 should be reported when plugin class has no parameterless constructor"); errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); } diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index e3c0d66..0b7b26e 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,5 +5,4 @@ Rule ID | Category | Severity | Notes XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe wrapper classes XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol -XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 Property not found in entity type -XPC4002 | XrmPluginCore.SourceGenerator | Warning | XPC4002 No parameterless constructor found +XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 689ae9d..7502994 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -33,16 +33,8 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor PropertyNotFound = new( - id: "XPC4001", - title: "Property not found in entity type", - messageFormat: "Property '{0}' not found in entity type '{1}'. This property will be excluded from the generated image wrapper.", - category: Category, - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - public static readonly DiagnosticDescriptor NoParameterlessConstructor = new( - id: "XPC4002", + id: "XPC4001", title: "No parameterless constructor found", messageFormat: "Plugin class '{0}' has no parameterless constructor. Image wrappers will not be generated for this plugin.", category: Category, diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 79da141..7754f6c 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -93,8 +93,9 @@ private static IEnumerable TransformToMetadata( // Merge multiple registrations for the same entity/operation/stage var mergedMetadata = WrapperClassGenerator.MergeMetadata(group); - // Only include if there are images with attributes - if (mergedMetadata?.Images.Any(i => i.Attributes.Any()) == true) + // Include if there are images with attributes OR if there are diagnostics to report + if (mergedMetadata?.Images.Any(i => i.Attributes.Any()) == true || + mergedMetadata?.Diagnostics?.Any() == true) { // Store location for diagnostics mergedMetadata.Location = location; @@ -113,6 +114,19 @@ private void GenerateSourceFromMetadata( PluginStepMetadata metadata, SourceProductionContext context) { + // Report any collected diagnostics first + if (metadata?.Diagnostics != null) + { + foreach (var diagnosticInfo in metadata.Diagnostics) + { + var diagnostic = Diagnostic.Create( + diagnosticInfo.Descriptor, + diagnosticInfo.Location, + diagnosticInfo.MessageArgs); + context.ReportDiagnostic(diagnostic); + } + } + if (metadata?.Images.Any(i => i.Attributes.Any()) != true) return; diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index f1adce5..c5721b9 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -21,6 +21,11 @@ internal sealed class PluginStepMetadata ///
public Location Location { get; set; } + /// + /// Diagnostics to report for this plugin step. Not included in equality comparison. + /// + public List Diagnostics { get; set; } = []; + /// /// Gets the namespace for generated image wrapper classes. /// Format: {OriginalNamespace}.PluginImages.{PluginClassName}.{Entity}{Op}{Stage} @@ -142,3 +147,13 @@ public override int GetHashCode() } } } + +/// +/// Represents a diagnostic to be reported during source generation +/// +internal sealed class DiagnosticInfo +{ + public DiagnosticDescriptor Descriptor { get; set; } + public Location Location { get; set; } + public object[] MessageArgs { get; set; } +} diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index e8015aa..81db0ad 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -19,6 +19,38 @@ public static IEnumerable ParsePluginClass( ClassDeclarationSyntax classDeclaration, SemanticModel semanticModel) { + // Check if plugin class has a parameterless constructor + var hasParameterlessConstructor = classDeclaration.Members + .OfType() + .Any(c => c.ParameterList.Parameters.Count == 0); + + // Check if class has ANY explicit constructors + var hasExplicitConstructors = classDeclaration.Members + .OfType() + .Any(); + + // If class has explicit constructors but no parameterless one, report diagnostic + if (hasExplicitConstructors && !hasParameterlessConstructor) + { + var diagnosticMetadata = new PluginStepMetadata + { + PluginClassName = classDeclaration.Identifier.Text, + Namespace = classDeclaration.GetNamespace(), + Location = classDeclaration.GetLocation(), + Images = new List() // Empty - no generation + }; + + diagnosticMetadata.Diagnostics.Add(new DiagnosticInfo + { + Descriptor = DiagnosticDescriptors.NoParameterlessConstructor, + Location = classDeclaration.Identifier.GetLocation(), + MessageArgs = new object[] { classDeclaration.Identifier.Text } + }); + + yield return diagnosticMetadata; + yield break; + } + // Find the parameterless constructor (registration pipeline only supports parameterless) var constructor = classDeclaration.Members .OfType() From 7c054fe1b8fd3093b21002341b13e35b9130fcf7 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 25 Nov 2025 10:52:00 +0100 Subject: [PATCH 04/22] Add: Compile time check for Execute method when plugin images are defined --- .../DiagnosticReportingTests.cs | 47 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../DiagnosticDescriptors.cs | 8 ++++ .../Helpers/SyntaxHelper.cs | 20 ++++++++ .../Models/PluginStepMetadata.cs | 1 + .../Parsers/RegistrationParser.cs | 18 ++++++- 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 842c59f..0f93937 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -103,4 +103,51 @@ public void Should_Handle_XPC5000_Generation_Error_Gracefully() // Verify generation succeeded result.GeneratedTrees.Should().NotBeEmpty("code should be generated"); } + + [Fact] + public void Should_Report_XPC4002_When_Execute_Not_Called_After_WithImage() + { + // Arrange - plugin with WithPreImage but NO Execute() call + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + // This registration has WithPreImage but NO Execute() - incomplete! + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + .WithPreImage(x => x.Name, x => x.Revenue); + // Missing: .Execute((service, preImage) => service.Process(preImage)); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(object image); } + public class TestService : ITestService { public void Process(object image) { } } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - should report XPC4002 + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4002") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when Execute() is not called after WithPreImage/WithPostImage"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + } } diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index 0b7b26e..a86e389 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -6,3 +6,4 @@ XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found +XPC4002 | XrmPluginCore.SourceGenerator | Warning | XPC4002 Missing Execute() call on plugin step registration diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 7502994..649bebc 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -40,4 +40,12 @@ internal static class DiagnosticDescriptors category: Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MissingExecuteCall = new( + id: "XPC4002", + title: "Missing Execute() call on plugin step registration", + messageFormat: "Plugin step registration for '{0}' has image registrations but Execute() was never called. The registration is incomplete.", + category: Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs index a4e2f10..d83ca6b 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs @@ -182,4 +182,24 @@ invocation.Expression is IdentifierNameSyntax identifier && return null; } + + /// + /// Checks if Execute() is called anywhere in the builder chain starting from RegisterStep + /// + public static bool HasExecuteCall(InvocationExpressionSyntax registerStepInvocation) + { + var current = registerStepInvocation.Parent; + while (current is not null) + { + if (current is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == "Execute") + return true; + } + current = current.Parent; + } + return false; + } } diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index c5721b9..1edc555 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -15,6 +15,7 @@ internal sealed class PluginStepMetadata public List Images { get; set; } = []; public string Namespace { get; set; } public string PluginClassName { get; set; } + public bool HasExecuteCall { get; set; } /// /// Source location for diagnostic reporting. Not included in equality comparison. diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index 81db0ad..4f1913c 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -37,14 +37,14 @@ public static IEnumerable ParsePluginClass( PluginClassName = classDeclaration.Identifier.Text, Namespace = classDeclaration.GetNamespace(), Location = classDeclaration.GetLocation(), - Images = new List() // Empty - no generation + Images = [] // Empty - no generation }; diagnosticMetadata.Diagnostics.Add(new DiagnosticInfo { Descriptor = DiagnosticDescriptors.NoParameterlessConstructor, Location = classDeclaration.Identifier.GetLocation(), - MessageArgs = new object[] { classDeclaration.Identifier.Text } + MessageArgs = [classDeclaration.Identifier.Text] }); yield return diagnosticMetadata; @@ -125,6 +125,20 @@ private static PluginStepMetadata ParseRegisterStepInvocation( } } + // Check if Execute() was called + metadata.HasExecuteCall = SyntaxHelper.HasExecuteCall(registerStepInvocation); + + // If images exist but Execute() is not called, add diagnostic + if (metadata.Images.Any(i => i.Attributes.Any()) && !metadata.HasExecuteCall) + { + metadata.Diagnostics.Add(new DiagnosticInfo + { + Descriptor = DiagnosticDescriptors.MissingExecuteCall, + Location = registerStepInvocation.GetLocation(), + MessageArgs = [$"{metadata.EntityTypeName}.{metadata.EventOperation}"] + }); + } + // Only return metadata if we have images with attributes return metadata.Images.Any(i => i.Attributes.Any()) ? metadata : null; } From 6b751a82fba8cc429fff6978508710e1021379ed Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 25 Nov 2025 14:35:28 +0100 Subject: [PATCH 05/22] REFACTOR: Get rid of the Execute method by generating a type-safe wrapper class instead --- CLAUDE.md | 132 +++-- README.md | 36 +- .../DiagnosticReportingTests.cs | 467 +++++++++++++++++- .../WrapperClassGenerationTests.cs | 248 +++++++++- .../Helpers/CompilationHelper.cs | 1 + .../Helpers/TestFixtures.cs | 84 +++- .../IntegrationTests/CompilationTests.cs | 41 +- .../ParsingTests/RegisterStepParsingTests.cs | 81 ++- .../GeneratedCodeSnapshotTests.cs | 70 ++- .../AnalyzerReleases.Unshipped.md | 4 +- .../CodeGeneration/WrapperClassGenerator.cs | 92 +++- .../DiagnosticDescriptors.cs | 24 +- .../Generators/PluginImageGenerator.cs | 30 +- .../Helpers/SyntaxHelper.cs | 19 - .../Models/PluginStepMetadata.cs | 32 +- .../Parsers/RegistrationParser.cs | 64 ++- .../Validation/HandlerMethodValidator.cs | 113 +++++ .../TypeSafe/TypeSafeAccountPlugin.cs | 11 +- .../TypeSafe/TypeSafeAccountService.cs | 2 +- .../TypeSafe/TypeSafeContactPlugin.cs | 11 +- .../TypeSafe/TypeSafeContactService.cs | 2 +- .../XrmPluginCore.Tests.csproj | 1 + XrmPluginCore/CHANGELOG.md | 4 +- XrmPluginCore/IActionWrapper.cs | 15 + XrmPluginCore/Plugin.cs | 102 +++- XrmPluginCore/Plugins/PluginStepBuilders.cs | 267 ---------- .../Plugins/PluginStepConfigBuilder.cs | 28 +- .../Plugins/PluginStepRegistration.cs | 20 +- 28 files changed, 1466 insertions(+), 535 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs create mode 100644 XrmPluginCore/IActionWrapper.cs delete mode 100644 XrmPluginCore/Plugins/PluginStepBuilders.cs diff --git a/CLAUDE.md b/CLAUDE.md index fcd1b27..f1e3d4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,66 +93,68 @@ The source generator provides compile-time type safety for plugin images (PreIma #### API Design -Use `WithPreImage`/`WithPostImage` to register images. The `Execute` method signature is **enforced** by the compiler to accept the registered image types: +Use `WithPreImage`/`WithPostImage` (convenience methods for `AddImage`) to register images. The method reference pattern (`service => service.HandleUpdate`) enables the source generator to validate that your handler method signature matches the registered images: ```csharp -// PreImage only - Execute MUST accept PreImage parameter -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) +// Basic plugin (no images) +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, s => s.DoSomething()) + .AddFilteredAttributes(x => x.Name); + +// PreImage only - handler method MUST accept PreImage parameter +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) - .WithPreImage(x => x.Name, x => x.Revenue) - .Execute((service, preImage) => service.HandleUpdate(preImage)); + .WithPreImage(x => x.Name, x => x.Revenue); -// PostImage only - Execute MUST accept PostImage parameter -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) +// PostImage only - handler method MUST accept PostImage parameter +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name) - .WithPostImage(x => x.Name, x => x.AccountNumber) - .Execute((service, postImage) => service.HandleUpdate(postImage)); + .WithPostImage(x => x.Name, x => x.AccountNumber); -// Both images - Execute MUST accept both parameters -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) +// Both images - handler method MUST accept both parameters +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) - .WithPostImage(x => x.Name, x => x.AccountNumber) - .Execute((service, pre, post) => service.HandleUpdate(pre, post)); + .WithPostImage(x => x.Name, x => x.AccountNumber); ``` -**Key benefit**: If you register an image with `WithPreImage`, there is NO way to complete the registration without accepting the image in `Execute`. This prevents developers from accidentally ignoring registered images. +**Key benefit**: The source generator emits diagnostics if your handler method signature does not match the registered images. This prevents developers from accidentally ignoring registered images. #### How It Works -1. **Compile-Time Analysis**: The source generator scans all classes that inherit from `Plugin` and finds `RegisterStep` calls that use `WithPreImage()` or `WithPostImage()`. +1. **Compile-Time Analysis**: The source generator scans all classes that inherit from `Plugin` and finds `RegisterStep` calls that use `WithPreImage()`, `WithPostImage()`, or `AddImage()`. 2. **Metadata Extraction**: For each registration, it extracts: - Plugin class name - Entity type (TEntity) - Event operation and execution stage - Filtered attributes from `AddFilteredAttributes()` calls - - Pre/Post image attributes from `WithPreImage()`/`WithPostImage()` calls + - Pre/Post image attributes from `WithPreImage()`/`WithPostImage()`/`AddImage()` calls + - Method reference from the action delegate + +3. **Code Generation**: Generates wrapper classes in isolated namespaces: + - Namespace: `{Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage}` + - Classes: `PreImage`, `PostImage`, `ActionWrapper` (simple names, no prefixes) -3. **Code Generation**: Generates image wrapper classes in isolated namespaces: - - Namespace: `{Namespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage}` - - Classes: `PreImage`, `PostImage` (simple names, no prefixes) +4. **Signature Validation**: The source generator validates that the handler method signature matches the registered images and emits compile-time diagnostics if there is a mismatch. -4. **Runtime Execution**: When the plugin executes: - - The `Execute` action is invoked with the service and image instances - - Images are constructed using `Activator.CreateInstance(typeof(TImage), entity)` from the execution context - - Services receive strongly-typed image wrappers as parameters +5. **Runtime Execution**: When the plugin executes: + - Images are constructed from the execution context + - The handler method is invoked with strongly-typed image wrappers as parameters #### Example Usage ```csharp -using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.AccountPlugin.AccountUpdatePostOperation; +using MyNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation; public class AccountPlugin : Plugin { public AccountPlugin() { - // Type-safe API with compile-time enforcement - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + // Type-safe API with compile-time enforcement via method reference + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) - .WithPostImage(x => x.Name, x => x.AccountNumber) - .Execute((service, pre, post) => service.HandleUpdate(pre, post)); + .WithPostImage(x => x.Name, x => x.AccountNumber); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -163,7 +165,7 @@ public class AccountPlugin : Plugin public class AccountService { - // Images are passed directly to the method - no DI injection needed + // Handler signature MUST match registered images (enforced by source generator diagnostics) public void HandleUpdate(PreImage preImage, PostImage postImage) { var previousName = preImage.Name; // Type-safe, IntelliSense works @@ -178,69 +180,54 @@ public class AccountService The source generator creates wrapper classes in isolated namespaces: ```csharp -// Generated in: {Namespace}.PluginImages.AccountPlugin.AccountUpdatePostOperation -namespace YourNamespace.PluginImages.AccountPlugin.AccountUpdatePostOperation +// Generated in: {Namespace}.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation +namespace YourNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation { - public class PreImage + public sealed class PreImage { - private readonly Entity _entity; + private readonly Entity entity; public PreImage(Entity entity) { - _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + this.entity = entity ?? throw new ArgumentNullException(nameof(entity)); } - public string Name => _entity.GetAttributeValue("name"); - public Money Revenue => _entity.GetAttributeValue("revenue"); + public string Name => entity.GetAttributeValue("name"); + public Money Revenue => entity.GetAttributeValue("revenue"); - public T ToEntity() where T : Entity => _entity.ToEntity(); + public T ToEntity() where T : Entity => entity.ToEntity(); } - public class PostImage + public sealed class PostImage { - private readonly Entity _entity; + private readonly Entity entity; public PostImage(Entity entity) { - _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + this.entity = entity ?? throw new ArgumentNullException(nameof(entity)); } - public string Name => _entity.GetAttributeValue("name"); - public string Accountnumber => _entity.GetAttributeValue("accountnumber"); + public string Name => entity.GetAttributeValue("name"); + public string Accountnumber => entity.GetAttributeValue("accountnumber"); - public T ToEntity() where T : Entity => _entity.ToEntity(); + public T ToEntity() where T : Entity => entity.ToEntity(); } } ``` -#### Builder Pattern - -The API uses a type-state builder pattern that enforces image acceptance at compile time: - -- `RegisterStep(op, stage)` → returns `PluginStepBuilder` -- `.WithPreImage(...)` → returns `PluginStepBuilderWithPreImage` (must call `Execute`) -- `.WithPostImage(...)` → returns `PluginStepBuilderWithPostImage` (must call `Execute`) -- `.WithPreImage(...).WithPostImage(...)` → returns `PluginStepBuilderWithBothImages` (must call `Execute`) - -#### Migration from AddImage +#### Image Registration Methods -The old `AddImage` API is marked as `[Obsolete]`. Migrate to the new API: +The following methods are available for registering images: -```csharp -// Old API (obsolete, no enforcement) -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process()) - .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue); +- `WithPreImage(params Expression>[] attributes)` - Convenience method to register a PreImage with selected attributes +- `WithPostImage(params Expression>[] attributes)` - Convenience method to register a PostImage with selected attributes +- `AddImage(ImageType imageType, params Expression>[] attributes)` - General method to register any image type -// New API (enforced at compile time) -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name, x => x.Revenue) - .Execute((service, preImage) => service.Process(preImage)); -``` +All three methods are valid and supported. `WithPreImage` and `WithPostImage` are convenience wrappers around `AddImage`. #### Benefits -- **Compile-time enforcement**: Cannot register an image without accepting it in Execute +- **Compile-time enforcement**: Source generator diagnostics ensure handler signature matches registered images - **Type safety**: Wrong image types cause compile errors - **IntelliSense support**: Auto-completion for available image attributes - **No runtime overhead**: Simple property accessors, no reflection at access time @@ -341,12 +328,19 @@ RegisterStep("custom_CustomMessage", ExecutionStage.PostOperation, s = ### Plugin Step Images -Images are configured through the builder: +Images are configured through the builder using `WithPreImage`, `WithPostImage`, or `AddImage`: ```csharp -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, s => s.Process()) +// Using convenience methods (recommended) +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.AccountNumber); + +// Using AddImage directly +RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) - .AddPreImage("PreImage", x => x.Name, x => x.Revenue) - .AddPostImage("PostImage", x => x.Name, x => x.Revenue); + .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue) + .AddImage(ImageType.PostImage, x => x.Name, x => x.AccountNumber); ``` ### Custom APIs diff --git a/README.md b/README.md index 28019ee..1c26e10 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ namespace Some.Namespace { } ``` -### Type-Safe Images (Advanced) +### Type-Safe Images XrmPluginCore includes a source generator that creates type-safe wrapper classes for your plugin images (PreImage/PostImage), giving you compile-time safety and IntelliSense support. @@ -95,17 +95,19 @@ XrmPluginCore includes a source generator that creates type-safe wrapper classes ```csharp using XrmPluginCore; using XrmPluginCore.Enums; -using MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation; +using MyPlugin.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation; namespace MyPlugin { public class AccountUpdatePlugin : Plugin { public AccountUpdatePlugin() { - // Type-safe API: WithPreImage enforces that Execute receives PreImage - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + // Type-safe API: method reference enables source generator validation + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + service => service.Process) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) - .WithPreImage(x => x.Name, x => x.Revenue) - .Execute((service, pre) => service.Process(pre)); - // ↑ Compile error if you don't accept PreImage! + .WithPreImage(x => x.Name, x => x.Revenue); + // Source generator validates that Process accepts PreImage parameter } } @@ -124,7 +126,7 @@ namespace MyPlugin { ``` **Benefits of type-safe images:** -- **Compile-time enforcement** - You MUST handle registered images +- **Compile-time enforcement** - Source generator diagnostics ensure handler signature matches registered images - **IntelliSense support** - Auto-completion for available attributes - **Null safety** - Proper handling of missing attributes - **No boilerplate** - Just add a `using` statement for the generated namespace @@ -132,16 +134,18 @@ namespace MyPlugin { #### Working with Both Images ```csharp -using MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation; +using MyPlugin.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation; public class AccountUpdatePlugin : Plugin { public AccountUpdatePlugin() { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + service => service.Process) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) - .WithPostImage(x => x.Name, x => x.AccountNumber) - .Execute((service, pre, post) => service.Process(pre, post)); - // Must accept both PreImage AND PostImage! + .WithPostImage(x => x.Name, x => x.AccountNumber); + // Handler method must accept both PreImage AND PostImage! } } @@ -155,11 +159,11 @@ public class AccountService : IAccountService { **Generated Namespace Convention:** ``` -{YourNamespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage} +{YourNamespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage} ``` -Example: `MyPlugin.PluginImages.AccountUpdatePlugin.AccountUpdatePostOperation` +Example: `MyPlugin.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation` -Inside this namespace you'll find simple class names: `PreImage` and `PostImage` +Inside this namespace you'll find simple class names: `PreImage`, `PostImage`, and `ActionWrapper` ### Using the LocalPluginContext wrapper (Legacy) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 0f93937..0b88e47 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -40,6 +40,7 @@ public void Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; namespace TestNamespace { @@ -48,9 +49,9 @@ public class TestPlugin : Plugin // Only has a constructor WITH parameters - no parameterless constructor public TestPlugin(string config) { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name) - .Execute((service, preImage) => service.Process(preImage)); + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -59,8 +60,8 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle } } - public interface ITestService { void Process(object image); } - public class TestService : ITestService { public void Process(object image) { } } + public interface ITestService { void Process(PreImage preImage); } + public class TestService : ITestService { public void Process(PreImage preImage) { } } }"; var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); @@ -69,7 +70,7 @@ public class TestService : ITestService { public void Process(object image) { } var result = GeneratorTestHelper.RunGenerator( CompilationHelper.CreateCompilation(source)); - // Assert - should report XPC4001 (was XPC4002, now renamed) + // Assert - should report XPC4001 var errorDiagnostics = result.GeneratorDiagnostics .Where(d => d.Id == "XPC4001") .ToArray(); @@ -105,9 +106,9 @@ public void Should_Handle_XPC5000_Generation_Error_Gracefully() } [Fact] - public void Should_Report_XPC4002_When_Execute_Not_Called_After_WithImage() + public void Should_Report_XPC4002_When_Handler_Method_Not_Found() { - // Arrange - plugin with WithPreImage but NO Execute() call + // Arrange - method reference points to NonExistentMethod but service has Process var pluginSource = @" using XrmPluginCore; using XrmPluginCore.Enums; @@ -120,10 +121,9 @@ public class TestPlugin : Plugin { public TestPlugin() { - // This registration has WithPreImage but NO Execute() - incomplete! - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name, x => x.Revenue); - // Missing: .Execute((service, preImage) => service.Process(preImage)); + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.NonExistentMethod) + .WithPreImage(x => x.Name); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -132,8 +132,15 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle } } - public interface ITestService { void Process(object image); } - public class TestService : ITestService { public void Process(object image) { } } + public interface ITestService + { + void Process(); + } + + public class TestService : ITestService + { + public void Process() { } + } }"; var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); @@ -142,12 +149,438 @@ public class TestService : ITestService { public void Process(object image) { } var result = GeneratorTestHelper.RunGenerator( CompilationHelper.CreateCompilation(source)); - // Assert - should report XPC4002 + // Assert var errorDiagnostics = result.GeneratorDiagnostics .Where(d => d.Id == "XPC4002") .ToArray(); - errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when Execute() is not called after WithPreImage/WithPostImage"); - errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when handler method is not found"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void Should_Report_XPC4003_When_Handler_Missing_PreImage_Parameter() + { + // Arrange - WithPreImage is registered but handler takes no parameters + var 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.Process) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No PreImage parameter! + } + + public class TestService : ITestService + { + public void Process() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4003") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4003 should be reported when handler is missing PreImage parameter"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void Should_Report_XPC4003_When_Handler_Missing_PostImage_Parameter() + { + // Arrange - WithPostImage is registered but handler takes no parameters + var 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.Process) + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No PostImage parameter! + } + + public class TestService : ITestService + { + public void Process() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4003") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4003 should be reported when handler is missing PostImage parameter"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void Should_Report_XPC4003_When_Handler_Missing_Both_Image_Parameters() + { + // Arrange - Both WithPreImage and WithPostImage but handler takes no parameters + var 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.Process) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No parameters! + } + + public class TestService : ITestService + { + public void Process() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4003") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4003 should be reported when handler is missing both image parameters"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void Should_Report_XPC4003_When_Handler_Has_Wrong_Parameter_Order() + { + // Arrange - WithPreImage and WithPostImage but handler has parameters in wrong order + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(PostImage post, PreImage pre); // Wrong order! Should be PreImage, PostImage + } + + public class TestService : ITestService + { + public void Process(PostImage post, PreImage pre) { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var errorDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4003") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4003 should be reported when handler has wrong parameter order"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void Should_Report_XPC4004_When_WithPreImage_Used_With_Invocation_Syntax() + { + // Arrange - WithPreImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) + var 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, + s => s.DoSomething()) // Invocation syntax - NOT method reference + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var warningDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4004") + .ToArray(); + + warningDiagnostics.Should().NotBeEmpty("XPC4004 should be reported when WithPreImage is used with invocation syntax"); + warningDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + } + + [Fact] + public void Should_Report_XPC4004_When_WithPostImage_Used_With_Invocation_Syntax() + { + // Arrange - WithPostImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) + var 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, + s => s.DoSomething()) // Invocation syntax - NOT method reference + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var warningDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4004") + .ToArray(); + + warningDiagnostics.Should().NotBeEmpty("XPC4004 should be reported when WithPostImage is used with invocation syntax"); + warningDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); + } + + [Fact] + public void Should_Not_Report_XPC4004_When_Using_Method_Reference_Syntax() + { + // Arrange - Method reference syntax (correct usage) + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + s => s.HandleUpdate) // Method reference - correct syntax + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(PreImage preImage); + } + + public class TestService : ITestService + { + public void HandleUpdate(PreImage preImage) { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var warningDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4004") + .ToArray(); + + warningDiagnostics.Should().BeEmpty("XPC4004 should NOT be reported when using method reference syntax"); + } + + [Fact] + public void Should_Not_Report_XPC4004_When_Old_Api_Used_Without_Images() + { + // Arrange - Invocation syntax but without WithPreImage/WithPostImage (no images registered) + var 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, + s => s.DoSomething()) // Invocation syntax - but no images + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var warningDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC4004") + .ToArray(); + + warningDiagnostics.Should().BeEmpty("XPC4004 should NOT be reported when old API is used without images"); } } diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs index 3470eb8..c14e580 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs @@ -80,16 +80,17 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace() result.GeneratedTrees.Should().NotBeEmpty(); var generatedSource = result.GeneratedTrees[0].GetText().ToString(); - // Both classes should be in the same namespace + // All classes should be in the same namespace var namespaceCount = System.Text.RegularExpressions.Regex.Matches( generatedSource, - @"namespace\s+TestNamespace\.PluginImages\.TestPlugin\.AccountUpdatePostOperation").Count; + @"namespace\s+TestNamespace\.PluginRegistrations\.TestPlugin\.AccountUpdatePostOperation").Count; - namespaceCount.Should().Be(1, "both classes should be in the same namespace"); + namespaceCount.Should().Be(1, "all classes should be in the same namespace"); - // Both classes should exist + // All classes should exist generatedSource.Should().Contain("public class PreImage"); generatedSource.Should().Contain("public class PostImage"); + generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper"); } [Fact] @@ -99,8 +100,10 @@ public void Should_Generate_Properties_With_Correct_Types() var pluginSource = @" using XrmPluginCore; using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; namespace TestNamespace { @@ -108,9 +111,9 @@ public class TestPlugin : Plugin { public TestPlugin() { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name, x => x.Revenue, x => x.IndustryCode, x => x.PrimaryContactId) - .Execute((service, preImage) => service.Process(preImage)); + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue, x => x.IndustryCode, x => x.PrimaryContactId); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -119,8 +122,8 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle } } - public interface ITestService { void Process(object image); } - public class TestService : ITestService { public void Process(object image) { } } + public interface ITestService { void Process(PreImage preImage); } + public class TestService : ITestService { public void Process(PreImage preImage) { } } }"; var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); @@ -201,4 +204,231 @@ public void Should_Implement_IEntityImageWrapper_Interface() generatedSource.Should().Contain(": IEntityImageWrapper"); } + + [Fact] + public void Should_Generate_ActionWrapper_Class_For_New_Api() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper class structure (now implements IActionWrapper interface) + generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper"); + generatedSource.Should().Contain("public Action CreateAction()"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_PreImage_Call() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify PreImage handling (now inline instead of using PluginImageHelper) + generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(preImage)"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_PostImage_Call() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify PostImage handling (now inline instead of using PluginImageHelper) + generatedSource.Should().Contain("var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(postImage)"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_Both_Images() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify both images are handled (now inline instead of using PluginImageHelper) + generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + generatedSource.Should().Contain("var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(preImage, postImage)"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_Service_Resolution() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify service is resolved from service provider + generatedSource.Should().Contain("var service = serviceProvider.GetRequiredService<"); + generatedSource.Should().Contain("ITestService"); + } + + [Fact] + public void Should_Generate_ActionWrapper_For_Handler_Without_Images() + { + // Arrange - Plugin with method reference syntax but NO images + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithHandlerNoImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper class structure is generated + generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper"); + generatedSource.Should().Contain("public Action CreateAction()"); + + // Verify service method is called WITHOUT any image parameters + generatedSource.Should().Contain("service.HandleUpdate()"); + + // Verify NO PreImage or PostImage classes are generated + generatedSource.Should().NotContain("public class PreImage"); + generatedSource.Should().NotContain("public class PostImage"); + + // Verify NO image entity retrieval is generated + generatedSource.Should().NotContain("PreEntityImages"); + generatedSource.Should().NotContain("PostEntityImages"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_PreImage_Only() + { + // Arrange - Plugin with PreImage only (no PostImage) + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper calls service with preImage parameter + generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(preImage)"); + + // Verify PreImage class IS generated + generatedSource.Should().Contain("public class PreImage"); + + // Verify NO PostImage class is generated + generatedSource.Should().NotContain("public class PostImage"); + + // Verify NO PostEntityImages retrieval + generatedSource.Should().NotContain("PostEntityImages"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_PostImage_Only() + { + // Arrange - Plugin with PostImage only (no PreImage) + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPostImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper calls service with postImage parameter + generatedSource.Should().Contain("var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(postImage)"); + + // Verify PostImage class IS generated + generatedSource.Should().Contain("public class PostImage"); + + // Verify NO PreImage class is generated + generatedSource.Should().NotContain("public class PreImage"); + + // Verify NO PreEntityImages retrieval + generatedSource.Should().NotContain("PreEntityImages"); + } + + [Fact] + public void Should_Generate_ActionWrapper_With_Both_PreImage_And_PostImage() + { + // Arrange - Plugin with both PreImage and PostImage + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper calls service with both image parameters + generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + generatedSource.Should().Contain("var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(preImage, postImage)"); + + // Verify both PreImage and PostImage classes ARE generated + generatedSource.Should().Contain("public class PreImage"); + generatedSource.Should().Contain("public class PostImage"); + } } diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs index 144468c..a0d0f9a 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs @@ -42,6 +42,7 @@ private static IEnumerable GetMetadataReferences() yield return MetadataReference.CreateFromFile(typeof(Console).Assembly.Location); yield return MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location); // System.Linq.Expressions yield return MetadataReference.CreateFromFile(typeof(System.ComponentModel.DescriptionAttribute).Assembly.Location); // System.ComponentModel + yield return MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location); // IServiceProvider yield return MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location); yield return MetadataReference.CreateFromFile(Assembly.Load("System.Collections").Location); yield return MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location); diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs index 8976533..a613ec7 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -100,13 +100,15 @@ public string EmailAddress }"; /// - /// Plugin with PreImage only using WithPreImage. + /// Plugin with PreImage only. /// public static string GetPluginWithPreImage(string entityClass = "Account") => $@" using XrmPluginCore; using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.{entityClass}UpdatePostOperation; namespace TestNamespace {{ @@ -114,10 +116,10 @@ public class TestPlugin : Plugin {{ public TestPlugin() {{ - RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) - .WithPreImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}) - .Execute((service, preImage) => service.Process(preImage)); + .WithPreImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}); }} protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -128,23 +130,25 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle public interface ITestService {{ - void Process(object image); + void Process(PreImage preImage); }} public class TestService : ITestService {{ - public void Process(object image) {{ }} + public void Process(PreImage preImage) {{ }} }} }}"; /// - /// Plugin with PostImage only using WithPostImage. + /// Plugin with PostImage. /// public static string GetPluginWithPostImage(string entityClass = "Account") => $@" using XrmPluginCore; using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.{entityClass}UpdatePostOperation; namespace TestNamespace {{ @@ -152,10 +156,10 @@ public class TestPlugin : Plugin {{ public TestPlugin() {{ - RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) - .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}) - .Execute((service, postImage) => service.Process(postImage)); + .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}); }} protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -166,12 +170,12 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle public interface ITestService {{ - void Process(object image); + void Process(PostImage postImage); }} public class TestService : ITestService {{ - public void Process(object image) {{ }} + public void Process(PostImage postImage) {{ }} }} }}"; @@ -181,8 +185,10 @@ public void Process(object image) {{ }} public static string GetPluginWithBothImages(string entityClass = "Account") => $@" using XrmPluginCore; using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.{entityClass}UpdatePostOperation; namespace TestNamespace {{ @@ -190,11 +196,11 @@ public class TestPlugin : Plugin {{ public TestPlugin() {{ - RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation) + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) .AddFilteredAttributes(x => x.Name) .WithPreImage(x => x.Name, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}) - .WithPostImage(x => x.Name, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}) - .Execute((service, preImage, postImage) => service.Process(preImage, postImage)); + .WithPostImage(x => x.Name, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}); }} protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -205,12 +211,52 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle public interface ITestService {{ - void Process(object preImage, object postImage); + void Process(PreImage preImage, PostImage postImage); }} public class TestService : ITestService {{ - public void Process(object preImage, object postImage) {{ }} + public void Process(PreImage preImage, PostImage postImage) {{ }} + }} +}}"; + + /// + /// Plugin with handler method reference but without any images. + /// Tests that ActionWrapper is generated even when no images are registered. + /// + public static string GetPluginWithHandlerNoImages(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.{entityClass}UpdatePostOperation; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + service => service.HandleUpdate) + .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void HandleUpdate(); + }} + + public class TestService : ITestService + {{ + public void HandleUpdate() {{ }} }} }}"; @@ -271,11 +317,11 @@ public static string GetCompleteSource(string entitySource, string pluginSource) if (usesAccount) { - usingStatements.AppendLine("using TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation;"); + usingStatements.AppendLine($"using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;"); } if (usesContact) { - usingStatements.AppendLine("using TestNamespace.PluginImages.TestPlugin.ContactUpdatePostOperation;"); + usingStatements.AppendLine($"using TestNamespace.PluginRegistrations.TestPlugin.ContactUpdatePostOperation;"); } // Properly combine sources by merging namespaces diff --git a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs index 55a73db..126c861 100644 --- a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -48,7 +48,7 @@ public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); var preImageType = loadedAssembly.Assembly.GetType( - "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PreImage"); + "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation.PreImage"); preImageType.Should().NotBeNull("PreImage class should be generated"); @@ -89,7 +89,7 @@ public void Should_Access_Properties_And_Verify_Values() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); var postImageType = loadedAssembly.Assembly.GetType( - "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PostImage"); + "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation.PostImage"); var postImageInstance = Activator.CreateInstance(postImageType!, entity); @@ -126,7 +126,7 @@ public void Should_Work_With_Both_PreImage_And_PostImage() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); - var baseNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"; + var baseNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; var preImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PreImage"); var postImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PostImage"); @@ -161,7 +161,7 @@ public void Should_Handle_Null_Attribute_Values_Gracefully() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); var preImageType = loadedAssembly.Assembly.GetType( - "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation.PreImage"); + "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation.PreImage"); var preImageInstance = Activator.CreateInstance(preImageType!, entity); @@ -189,11 +189,40 @@ public void Should_Verify_Namespace_Isolation_Per_Registration() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); - // Assert - namespace should follow pattern: {Namespace}.PluginImages.{Plugin}.{Entity}{Operation}{Stage} - var expectedNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"; + // Assert - namespace should follow pattern: {Namespace}.PluginRegistrations.{Plugin}.{Entity}{Operation}{Stage} + var expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; var preImageType = loadedAssembly.Assembly.GetType($"{expectedNamespace}.PreImage"); preImageType.Should().NotBeNull("PreImage should be in the expected namespace"); preImageType!.Namespace.Should().Be(expectedNamespace); } + + [Fact] + public void Should_Generate_ActionWrapper_Class() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + var expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; + var actionWrapperType = loadedAssembly.Assembly.GetType($"{expectedNamespace}.ActionWrapper"); + + // Assert + actionWrapperType.Should().NotBeNull("ActionWrapper should be generated"); + + // Verify ActionWrapper implements IActionWrapper interface + var iactionWrapperInterface = actionWrapperType!.GetInterface("IActionWrapper"); + iactionWrapperInterface.Should().NotBeNull("ActionWrapper should implement IActionWrapper interface"); + + // Verify CreateAction method exists (now instance method, not static) + var createActionMethod = actionWrapperType.GetMethod("CreateAction"); + createActionMethod.Should().NotBeNull("CreateAction method should exist"); + createActionMethod!.IsStatic.Should().BeFalse("CreateAction should be an instance method since ActionWrapper implements IActionWrapper"); + } } diff --git a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs index 55cabe8..e305dab 100644 --- a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs @@ -78,9 +78,10 @@ public void Should_Parse_Both_PreImage_And_PostImage() } [Fact] - public void Should_Parse_Old_AddImage_Api_For_Backward_Compatibility() + public void Should_Not_Generate_Code_For_Old_AddImage_Api_Without_Method_Reference() { - // Arrange + // Arrange - Old API uses service => service.Process() which is a method invocation, + // not a method reference. The new generator requires a method reference. var source = TestFixtures.GetCompleteSource( TestFixtures.AccountEntity, TestFixtures.GetPluginWithOldImageApi()); @@ -89,12 +90,8 @@ public void Should_Parse_Old_AddImage_Api_For_Backward_Compatibility() var result = GeneratorTestHelper.RunGenerator( CompilationHelper.CreateCompilation(source)); - // Assert - result.GeneratedTrees.Should().NotBeEmpty(); - var generatedSource = result.GeneratedTrees[0].GetText().ToString(); - generatedSource.Should().Contain("class PreImage"); - generatedSource.Should().Contain("public string Name"); - generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); + // Assert - No code should be generated for old API without method reference + result.GeneratedTrees.Should().BeEmpty(); } [Fact] @@ -151,8 +148,8 @@ public void Should_Generate_Correct_Namespace_For_Registration() result.GeneratedTrees.Should().NotBeEmpty(); var generatedSource = result.GeneratedTrees[0].GetText().ToString(); - // Namespace pattern: {Namespace}.PluginImages.{PluginClassName}.{Entity}{Operation}{Stage} - generatedSource.Should().Contain("namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation"); + // Namespace pattern: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage} + generatedSource.Should().Contain("namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"); } [Fact] @@ -162,8 +159,10 @@ public void Should_Handle_Multiple_Attributes_In_Same_Image() var pluginSource = @" using XrmPluginCore; using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; namespace TestNamespace { @@ -171,9 +170,9 @@ public class TestPlugin : Plugin { public TestPlugin() { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation) - .WithPreImage(x => x.Name, x => x.AccountNumber, x => x.Revenue, x => x.IndustryCode) - .Execute((service, preImage) => service.Process(preImage)); + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name, x => x.AccountNumber, x => x.Revenue, x => x.IndustryCode); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) @@ -184,12 +183,12 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle public interface ITestService { - void Process(object image); + void Process(PreImage preImage); } public class TestService : ITestService { - public void Process(object image) { } + public void Process(PreImage preImage) { } } }"; @@ -208,4 +207,56 @@ public void Process(object image) { } generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.Money Revenue"); generatedSource.Should().Contain("public Microsoft.Xrm.Sdk.OptionSetValue IndustryCode"); } + + [Fact] + public void Should_Parse_Handler_Method_Name() + { + // Arrange - plugin with new API to verify method reference parsing + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.HandleAccountUpdate) + .AddImage(ImageType.PreImage, x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleAccountUpdate(PreImage preImage); + } + + public class TestService : ITestService + { + public void HandleAccountUpdate(PreImage preImage) { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - should generate ActionWrapper that calls HandleAccountUpdate + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + generatedSource.Should().Contain("service.HandleAccountUpdate(preImage)"); + } } diff --git a/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs index 417ef0f..457f0d4 100644 --- a/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs @@ -28,7 +28,7 @@ public void Should_Generate_PreImage_Class_With_Expected_Structure() // Verify essential structure elements var expectedElements = new[] { - "namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation", + "namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation", "public class PreImage : IEntityImageWrapper", "private readonly Entity entity;", "public PreImage(Entity entity)", @@ -64,7 +64,7 @@ public void Should_Generate_PostImage_Class_With_Expected_Structure() // Verify essential structure elements var expectedElements = new[] { - "namespace TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation", + "namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation", "public class PostImage : IEntityImageWrapper", "private readonly Entity entity;", "public PostImage(Entity entity)", @@ -111,14 +111,14 @@ public void Should_Follow_Namespace_Pattern() Source = TestFixtures.GetCompleteSource( TestFixtures.AccountEntity, TestFixtures.GetPluginWithPreImage()), - ExpectedNamespace = "TestNamespace.PluginImages.TestPlugin.AccountUpdatePostOperation" + ExpectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation" }, new { Source = TestFixtures.GetCompleteSource( TestFixtures.ContactEntity, TestFixtures.GetPluginWithPreImage("Contact")), - ExpectedNamespace = "TestNamespace.PluginImages.TestPlugin.ContactUpdatePostOperation" + ExpectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.ContactUpdatePostOperation" } }; @@ -131,7 +131,7 @@ public void Should_Follow_Namespace_Pattern() // Assert var generatedSource = result.GeneratedTrees[0].GetText().ToString(); generatedSource.Should().Contain($"namespace {testCase.ExpectedNamespace}", - $"namespace should follow pattern: {{Namespace}}.PluginImages.{{Plugin}}.{{Entity}}{{Operation}}{{Stage}}"); + $"namespace should follow pattern: {{Namespace}}.PluginRegistrations.{{Plugin}}.{{Entity}}{{Operation}}{{Stage}}"); } } @@ -156,8 +156,64 @@ public void Should_Mark_Classes_With_CompilerGenerated_Attribute() // Both classes should be marked generatedSource.Should().Contain("[CompilerGenerated]"); - // Count occurrences - should be at least 2 (one for each class) + // Count occurrences - should be at least 3 (PreImage, PostImage, and ActionWrapper) var matches = System.Text.RegularExpressions.Regex.Matches(generatedSource, @"\[CompilerGenerated\]"); - matches.Count.Should().BeGreaterOrEqualTo(2, "both PreImage and PostImage classes should be marked"); + matches.Count.Should().BeGreaterOrEqualTo(3, "PreImage, PostImage, and ActionWrapper classes should be marked"); + } + + [Fact] + public void Should_Generate_ActionWrapper_Class_For_New_Api() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper structure (now implements IActionWrapper interface with inline image construction) + var expectedElements = new[] + { + "internal sealed class ActionWrapper : IActionWrapper", + "public Action CreateAction()", + "serviceProvider =>", + "var service = serviceProvider.GetRequiredService<", + "var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();", + "var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;", + "service.Process(preImage)" + }; + + foreach (var element in expectedElements) + { + generatedSource.Should().Contain(element, $"generated code should contain: {element}"); + } + } + + [Fact] + public void Should_Generate_ActionWrapper_With_Both_Images() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify ActionWrapper handles both images (now inline instead of using PluginImageHelper) + generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + generatedSource.Should().Contain("var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + generatedSource.Should().Contain("service.Process(preImage, postImage)"); } } diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index a86e389..7c3ce6c 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -6,4 +6,6 @@ XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found -XPC4002 | XrmPluginCore.SourceGenerator | Warning | XPC4002 Missing Execute() call on plugin step registration +XPC4002 | XrmPluginCore.SourceGenerator | Error | XPC4002 Handler method not found +XPC4003 | XrmPluginCore.SourceGenerator | Error | XPC4003 Handler signature does not match registered images +XPC4004 | XrmPluginCore.SourceGenerator | Warning | XPC4004 Image registration without method reference diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index 59c8cc0..5db7ee0 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -12,19 +12,14 @@ internal static class WrapperClassGenerator { /// /// Generates a complete source file containing wrapper classes for a plugin step registration. - /// Only generates PreImage and PostImage wrappers (no Target wrapper). + /// Generates PreImage and PostImage wrappers, and ActionWrapper for the new method reference API. /// public static string GenerateWrapperClasses(PluginStepMetadata metadata) { - // Only generate if there are images with attributes var imagesWithAttributes = metadata.Images.Where(i => i.Attributes.Any()).ToList(); - if (!imagesWithAttributes.Any()) - { - return null; - } - // Estimate capacity: ~500 chars per image wrapper class - var estimatedCapacity = imagesWithAttributes.Count * 500; + // Estimate capacity: ~500 chars per image wrapper class + ~300 for ActionWrapper + var estimatedCapacity = (imagesWithAttributes.Count * 500) + 500; var sb = new StringBuilder(estimatedCapacity); // File header @@ -33,21 +28,27 @@ public static string GenerateWrapperClasses(PluginStepMetadata metadata) // Using directives sb.AppendLine("using System;"); + sb.AppendLine("using System.Linq;"); sb.AppendLine("using System.Runtime.CompilerServices;"); sb.AppendLine("using Microsoft.Xrm.Sdk;"); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); sb.AppendLine("using XrmPluginCore;"); sb.AppendLine(); - // Namespace declaration on the format {Namespace}.PluginImages.{PluginClassName}.{EntityTypeName}{EventOperation}{ExecutionStage} - sb.AppendLine($"namespace {metadata.ImageNamespace}"); + var namespaceToUse = metadata.RegistrationNamespace; + + // Namespace declaration + sb.AppendLine($"namespace {namespaceToUse}"); sb.AppendLine("{"); - // Generate Image wrapper classes + // Generate Image wrapper classes if we have images with attributes foreach (var image in imagesWithAttributes) { GenerateImageWrapperClass(sb, metadata, image); } + GenerateActionWrapperClass(sb, metadata, imagesWithAttributes); + // Close namespace sb.AppendLine("}"); @@ -133,6 +134,72 @@ private static void GenerateProperty(StringBuilder sb, AttributeMetadata attr) sb.AppendLine(); } + /// + /// Generates the ActionWrapper class that wraps the service method call. + /// This is used by the runtime to discover and invoke the plugin action. + /// + private static void GenerateActionWrapperClass(StringBuilder sb, PluginStepMetadata metadata, List images) + { + var hasPreImage = images.Any(i => i.ImageType == "PreImage"); + var hasPostImage = images.Any(i => i.ImageType == "PostImage"); + + // XML documentation + sb.AppendLine(" /// "); + sb.AppendLine($" /// Generated action wrapper for {metadata.ServiceTypeName}.{metadata.HandlerMethodName}"); + sb.AppendLine(" /// "); + + // CompilerGenerated attribute + sb.AppendLine(" [CompilerGenerated]"); + + // Class declaration + sb.AppendLine(" internal sealed class ActionWrapper : IActionWrapper"); + sb.AppendLine(" {"); + + // CreateAction method + sb.AppendLine(" /// "); + sb.AppendLine(" /// Creates the action delegate that invokes the service method with appropriate images."); + sb.AppendLine(" /// "); + sb.AppendLine(" public Action CreateAction()"); + sb.AppendLine(" {"); + sb.AppendLine(" return serviceProvider =>"); + sb.AppendLine(" {"); + + // Get the service + sb.AppendLine($" var service = serviceProvider.GetRequiredService<{metadata.ServiceTypeFullName}>();"); + + // Get images if needed + if (hasPreImage || hasPostImage) + { + sb.AppendLine(" var context = serviceProvider.GetService();"); + } + + var args = new List(); + if (hasPreImage) + { + sb.AppendLine(" var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); + sb.AppendLine(" var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + args.Add("preImage"); + } + if (hasPostImage) + { + sb.AppendLine(" var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); + sb.AppendLine(" var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + args.Add("postImage"); + } + + var argsString = string.Join(", ", args); + + // Call the service method + sb.AppendLine($" service.{metadata.HandlerMethodName}({argsString});"); + + sb.AppendLine(" };"); + sb.AppendLine(" }"); + + // Close class + sb.AppendLine(" }"); + sb.AppendLine(); + } + /// /// Generates a unique hint name for the source file /// @@ -161,6 +228,9 @@ public static PluginStepMetadata MergeMetadata(IEnumerable m ExecutionStage = list[0].ExecutionStage, Namespace = list[0].Namespace, PluginClassName = list[0].PluginClassName, + ServiceTypeName = list[0].ServiceTypeName, + ServiceTypeFullName = list[0].ServiceTypeFullName, + HandlerMethodName = list[0].HandlerMethodName, Images = [] }; diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 649bebc..990aa25 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -41,11 +41,27 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor MissingExecuteCall = new( + public static readonly DiagnosticDescriptor HandlerMethodNotFound = new( id: "XPC4002", - title: "Missing Execute() call on plugin step registration", - messageFormat: "Plugin step registration for '{0}' has image registrations but Execute() was never called. The registration is incomplete.", + title: "Handler method not found", + messageFormat: "Method '{0}' not found on service type '{1}'", category: Category, - DiagnosticSeverity.Warning, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor HandlerSignatureMismatch = new( + id: "XPC4003", + title: "Handler signature does not match registered images", + messageFormat: "Handler method '{0}' does not have expected signature. Expected parameters in order: {1}. PreImage must be the first parameter, followed by PostImage if both are used.", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ImageWithoutMethodReference = new( + id: "XPC4004", + title: "Image registration without method reference", + messageFormat: "WithPreImage/WithPostImage requires method reference syntax (e.g., 'service => service.HandleUpdate'). Using method invocation (e.g., 's => s.HandleUpdate()') will not generate type-safe wrappers.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); } diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 7754f6c..8488813 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -9,6 +9,7 @@ using XrmPluginCore.SourceGenerator.Helpers; using XrmPluginCore.SourceGenerator.Models; using XrmPluginCore.SourceGenerator.Parsers; +using XrmPluginCore.SourceGenerator.Validation; namespace XrmPluginCore.SourceGenerator.Generators; @@ -93,12 +94,26 @@ private static IEnumerable TransformToMetadata( // Merge multiple registrations for the same entity/operation/stage var mergedMetadata = WrapperClassGenerator.MergeMetadata(group); - // Include if there are images with attributes OR if there are diagnostics to report - if (mergedMetadata?.Images.Any(i => i.Attributes.Any()) == true || - mergedMetadata?.Diagnostics?.Any() == true) + if (mergedMetadata is null) + continue; + + // Store location for diagnostics + mergedMetadata.Location = location; + + // Validate handler method signature + HandlerMethodValidator.ValidateHandlerMethod( + mergedMetadata, + semanticModel.Compilation, + location); + + // Include if: + // - Has method reference (for ActionWrapper generation) + // - OR has images with attributes (for image wrapper generation) + // - OR has diagnostics to report + if (!string.IsNullOrEmpty(mergedMetadata.HandlerMethodName) || + mergedMetadata.Images.Any(i => i.Attributes.Any()) || + mergedMetadata.Diagnostics?.Any() == true) { - // Store location for diagnostics - mergedMetadata.Location = location; results.Add(mergedMetadata); } } @@ -127,7 +142,8 @@ private void GenerateSourceFromMetadata( } } - if (metadata?.Images.Any(i => i.Attributes.Any()) != true) + // Generate code if we have a handler method reference (ActionWrapper always needed) + if (string.IsNullOrEmpty(metadata?.HandlerMethodName)) return; try @@ -166,7 +182,7 @@ private void ReportGenerationSuccess( DiagnosticDescriptors.GenerationSuccess, metadata.Location ?? Location.None, 1, // wrapper class count - metadata.ImageNamespace); + metadata.RegistrationNamespace); // Uncomment to see generation info in build output context.ReportDiagnostic(diagnostic); diff --git a/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs index d83ca6b..81520cf 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs @@ -183,23 +183,4 @@ invocation.Expression is IdentifierNameSyntax identifier && return null; } - /// - /// Checks if Execute() is called anywhere in the builder chain starting from RegisterStep - /// - public static bool HasExecuteCall(InvocationExpressionSyntax registerStepInvocation) - { - var current = registerStepInvocation.Parent; - while (current is not null) - { - if (current is InvocationExpressionSyntax invocation && - invocation.Expression is MemberAccessExpressionSyntax memberAccess) - { - var methodName = memberAccess.Name.Identifier.Text; - if (methodName == "Execute") - return true; - } - current = current.Parent; - } - return false; - } } diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index 1edc555..3ff74b3 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -15,7 +15,21 @@ internal sealed class PluginStepMetadata public List Images { get; set; } = []; public string Namespace { get; set; } public string PluginClassName { get; set; } - public bool HasExecuteCall { get; set; } + + /// + /// Gets or sets the service type name (short name) for action wrapper generation. + /// + public string ServiceTypeName { get; set; } + + /// + /// Gets or sets the fully qualified service type name for action wrapper generation. + /// + public string ServiceTypeFullName { get; set; } + + /// + /// Gets or sets the handler method name on the service. + /// + public string HandlerMethodName { get; set; } /// /// Source location for diagnostic reporting. Not included in equality comparison. @@ -28,11 +42,11 @@ internal sealed class PluginStepMetadata public List Diagnostics { get; set; } = []; /// - /// Gets the namespace for generated image wrapper classes. - /// Format: {OriginalNamespace}.PluginImages.{PluginClassName}.{Entity}{Op}{Stage} + /// Gets the namespace for generated wrapper classes. + /// Format: {OriginalNamespace}.PluginRegistrations.{PluginClassName}.{Entity}{Op}{Stage} /// - public string ImageNamespace => - $"{Namespace}.PluginImages.{PluginClassName}.{EntityTypeName}{EventOperation}{ExecutionStage}"; + public string RegistrationNamespace => + $"{Namespace}.PluginRegistrations.{PluginClassName}.{EntityTypeName}{EventOperation}{ExecutionStage}"; /// /// Gets a unique identifier for this registration. @@ -50,7 +64,10 @@ public override bool Equals(object obj) && EventOperation == other.EventOperation && ExecutionStage == other.ExecutionStage && Images.SequenceEqual(other.Images) - && Namespace == other.Namespace; + && Namespace == other.Namespace + && ServiceTypeName == other.ServiceTypeName + && ServiceTypeFullName == other.ServiceTypeFullName + && HandlerMethodName == other.HandlerMethodName; } return false; } @@ -65,6 +82,9 @@ public override int GetHashCode() hash = (hash * 31) + (EventOperation?.GetHashCode() ?? 0); hash = (hash * 31) + (ExecutionStage?.GetHashCode() ?? 0); hash = (hash * 31) + (Namespace?.GetHashCode() ?? 0); + hash = (hash * 31) + (ServiceTypeName?.GetHashCode() ?? 0); + hash = (hash * 31) + (ServiceTypeFullName?.GetHashCode() ?? 0); + hash = (hash * 31) + (HandlerMethodName?.GetHashCode() ?? 0); foreach (var img in Images) { hash = (hash * 31) + img.GetHashCode(); diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index 4f1913c..fc089a2 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -107,6 +107,14 @@ private static PluginStepMetadata ParseRegisterStepInvocation( PluginClassName = classDeclaration.Identifier.Text }; + // Extract service type from generic parameter TService (if present) + if (methodSymbol.TypeArguments.Length >= 2) + { + var serviceType = methodSymbol.TypeArguments[1]; + metadata.ServiceTypeName = serviceType.Name; + metadata.ServiceTypeFullName = serviceType.ToDisplayString(); + } + // Extract EventOperation and ExecutionStage from arguments var arguments = registerStepInvocation.ArgumentList.Arguments; if (arguments.Count >= 2) @@ -115,6 +123,12 @@ private static PluginStepMetadata ParseRegisterStepInvocation( metadata.ExecutionStage = ExtractEnumValue(arguments[1].Expression); } + // Extract method reference from 3rd argument if present + if (arguments.Count >= 3) + { + metadata.HandlerMethodName = ParseMethodReference(arguments[2].Expression, semanticModel); + } + // Find image calls foreach (var imageCall in SyntaxHelper.FindImageInvocations(registerStepInvocation)) { @@ -125,27 +139,51 @@ private static PluginStepMetadata ParseRegisterStepInvocation( } } - // Check if Execute() was called - metadata.HasExecuteCall = SyntaxHelper.HasExecuteCall(registerStepInvocation); - - // If images exist but Execute() is not called, add diagnostic - if (metadata.Images.Any(i => i.Attributes.Any()) && !metadata.HasExecuteCall) + // After parsing images, check if we have images but no method reference + // This indicates using old API (s => s.Method()) with new image methods (WithPreImage/WithPostImage) + if (metadata.Images.Any() && string.IsNullOrEmpty(metadata.HandlerMethodName)) { metadata.Diagnostics.Add(new DiagnosticInfo { - Descriptor = DiagnosticDescriptors.MissingExecuteCall, + Descriptor = DiagnosticDescriptors.ImageWithoutMethodReference, Location = registerStepInvocation.GetLocation(), - MessageArgs = [$"{metadata.EntityTypeName}.{metadata.EventOperation}"] + MessageArgs = [] }); } - // Only return metadata if we have images with attributes - return metadata.Images.Any(i => i.Attributes.Any()) ? metadata : null; + // Return metadata if we have a method reference (for code generation) + // OR if we have diagnostics to report + return !string.IsNullOrEmpty(metadata.HandlerMethodName) || metadata.Diagnostics.Any() ? metadata : null; + } + + /// + /// Parses a method reference expression like "service => service.HandleUpdate" + /// + private static string ParseMethodReference(ExpressionSyntax expression, SemanticModel semanticModel) + { + // Handle: service => service.HandleUpdate + if (expression is SimpleLambdaExpressionSyntax lambda) + { + if (lambda.Body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + } + + // Handle: (service) => service.HandleUpdate + if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) + { + if (parenLambda.Body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + } + + return null; } /// - /// Parses WithPreImage, WithPostImage, AddPreImage, AddPostImage, or AddImage call to extract image metadata. - /// Handles both the old API (AddImage with ImageType) and new API (WithPreImage/WithPostImage). + /// Parses WithPreImage, WithPostImage, or AddImage call to extract image metadata. /// private static ImageMetadata ParseImageInvocation( InvocationExpressionSyntax imageInvocation, @@ -201,8 +239,8 @@ private static ImageMetadata ParseImageInvocation( attributeStartIndex = 0; } - // For new API (WithPreImage/WithPostImage), all arguments are attributes - // For old API with AddImage, first string after ImageType might be image name + // For WithPreImage/WithPostImage, all arguments are attributes + // For AddImage, first string after ImageType might be image name bool allArgumentsAreAttributes = isGenericMethod || methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName; diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs new file mode 100644 index 0000000..0113aed --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -0,0 +1,113 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; +using System.Linq; +using XrmPluginCore.SourceGenerator.Models; + +namespace XrmPluginCore.SourceGenerator.Validation; + +internal static class HandlerMethodValidator +{ + public static void ValidateHandlerMethod( + PluginStepMetadata metadata, + Compilation compilation, + Location location) + { + if (string.IsNullOrEmpty(metadata.HandlerMethodName) || + string.IsNullOrEmpty(metadata.ServiceTypeFullName)) + return; + + var serviceType = compilation.GetTypeByMetadataName(metadata.ServiceTypeFullName); + if (serviceType is null) + return; + + var methods = GetAllMethodsIncludingInherited(serviceType, metadata.HandlerMethodName); + if (!methods.Any()) + { + metadata.Diagnostics.Add(new DiagnosticInfo + { + Descriptor = DiagnosticDescriptors.HandlerMethodNotFound, + Location = location, + MessageArgs = [metadata.HandlerMethodName, metadata.ServiceTypeName] + }); + return; + } + + var hasPreImage = metadata.Images.Any(i => i.ImageType == Constants.PreImageTypeName); + var hasPostImage = metadata.Images.Any(i => i.ImageType == Constants.PostImageTypeName); + var expectedSignature = BuildExpectedSignature(hasPreImage, hasPostImage); + + var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage)); + if (!hasMatchingOverload) + { + metadata.Diagnostics.Add(new DiagnosticInfo + { + Descriptor = DiagnosticDescriptors.HandlerSignatureMismatch, + Location = location, + MessageArgs = [metadata.HandlerMethodName, expectedSignature] + }); + } + } + + private static IEnumerable GetAllMethodsIncludingInherited(ITypeSymbol type, string methodName) + { + var currentType = type; + while (currentType is not null) + { + foreach (var member in currentType.GetMembers(methodName)) + { + if (member is IMethodSymbol method) + yield return method; + } + currentType = currentType.BaseType; + } + } + + private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage) + { + var parameters = method.Parameters; + var expectedParamCount = (hasPreImage ? 1 : 0) + (hasPostImage ? 1 : 0); + + if (parameters.Length != expectedParamCount) + return false; + + var paramIndex = 0; + + if (hasPreImage) + { + if (paramIndex >= parameters.Length) + return false; + if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName)) + return false; + paramIndex++; + } + + if (hasPostImage) + { + if (paramIndex >= parameters.Length) + return false; + if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName)) + return false; + } + + return true; + } + + private static bool IsImageParameter(IParameterSymbol parameter, string expectedImageType) + { + return parameter.Type.Name == expectedImageType; + } + + private static string BuildExpectedSignature(bool hasPreImage, bool hasPostImage) + { + var parts = new List(); + if (hasPreImage) + parts.Add(Constants.PreImageTypeName); + if (hasPostImage) + parts.Add(Constants.PostImageTypeName); + + if (parts.Count == 0) + return "no parameters"; + + return string.Join(", ", parts); + } +} diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs index 5f94b86..a41b825 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs @@ -3,7 +3,7 @@ using XrmPluginCore.Enums; // Import the generated PreImage/PostImage from the namespace -using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeAccountPlugin.AccountUpdatePreOperation; +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeAccountPlugin.AccountUpdatePreOperation; namespace XrmPluginCore.Tests.TestPlugins.TypeSafe { @@ -20,13 +20,12 @@ public class TypeSafeAccountPlugin : Plugin public TypeSafeAccountPlugin() { - // Type-safe API: Images are passed directly to the action - // Using WithPreImage/WithPostImage enforces that Execute receives both images - RegisterStep(EventOperation.Update, ExecutionStage.PreOperation) + // Type-safe API: Images are passed directly to the action via source-generated wrapper + RegisterStep(EventOperation.Update, ExecutionStage.PreOperation, + service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.Accountnumber) .WithPreImage(x => x.Name, x => x.Accountnumber, x => x.Revenue) - .WithPostImage(x => x.Name, x => x.Accountnumber) - .Execute((service, pre, post) => service.HandleUpdate(pre, post)); + .WithPostImage(x => x.Name, x => x.Accountnumber); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs index d68d436..256bb06 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs @@ -1,7 +1,7 @@ using System; // Import the generated PreImage/PostImage from the namespace -using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeAccountPlugin.AccountUpdatePreOperation; +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeAccountPlugin.AccountUpdatePreOperation; namespace XrmPluginCore.Tests.TestPlugins.TypeSafe { diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs index 2211198..a681016 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs @@ -3,7 +3,7 @@ using XrmPluginCore.Enums; // Import the generated PreImage from the namespace -using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeContactPlugin.ContactCreatePostOperation; +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeContactPlugin.ContactCreatePostOperation; namespace XrmPluginCore.Tests.TestPlugins.TypeSafe { @@ -19,12 +19,11 @@ public class TypeSafeContactPlugin : Plugin public TypeSafeContactPlugin() { - // Type-safe API: PreImage is passed directly to the action - // Using WithPreImage enforces that Execute receives PreImage - RegisterStep(EventOperation.Create, ExecutionStage.PostOperation) + // Type-safe API: PreImage is passed directly to the action via source-generated wrapper + RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, + service => service.HandleCreate) .AddFilteredAttributes(x => x.Firstname, x => x.Lastname, x => x.Emailaddress1) - .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone) - .Execute((service, pre) => service.HandleCreate(pre)); + .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs index 6fdddfd..738c482 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs @@ -1,7 +1,7 @@ using System; // Import the generated PreImage from the namespace -using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginImages.TypeSafeContactPlugin.ContactCreatePostOperation; +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeContactPlugin.ContactCreatePostOperation; namespace XrmPluginCore.Tests.TestPlugins.TypeSafe { diff --git a/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj b/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj index ea767ad..ca09e62 100644 --- a/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj +++ b/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj @@ -4,6 +4,7 @@ net462;net8.0 false true + 10.0 true diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 1ce88c2..65a0743 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,7 +1,5 @@ ### v1.2.0 - 21 November 2025 -* Add: Type-Safe Images feature with compile-time enforcement via `WithPreImage()` and `WithPostImage()` builder methods -* Add: `PluginStepBuilder` pattern that enforces image handling at compile time -* Deprecate: `AddImage()`, methods in favor of new `WithPreImage()`/`WithPostImage()` API +* Add: Type-Safe Images feature with compile-time enforcement via source generator ### v1.1.1 - 14 November 2025 * Add: IManagedIdentityService to service provider (#1) diff --git a/XrmPluginCore/IActionWrapper.cs b/XrmPluginCore/IActionWrapper.cs new file mode 100644 index 0000000..4194bbc --- /dev/null +++ b/XrmPluginCore/IActionWrapper.cs @@ -0,0 +1,15 @@ +using System; + +namespace XrmPluginCore +{ + /// + /// Interface for generated action wrappers that create plugin execution delegates. + /// + public interface IActionWrapper + { + /// + /// Creates the action delegate that invokes the service method with appropriate images. + /// + Action CreateAction(); + } +} diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index b1cb920..88321a9 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; using System.ServiceModel; using XrmPluginCore.CustomApis; using XrmPluginCore.Enums; @@ -70,20 +72,27 @@ public void Execute(IServiceProvider serviceProvider) var matchingRegistration = GetMatchingRegistration(context); var pluginAction = matchingRegistration?.Action; + // If action is null but we have a handler method name, try to discover generated action wrapper + if (pluginAction == null && matchingRegistration?.HandlerMethodName != null) + { + pluginAction = DiscoverGeneratedAction(matchingRegistration); + } + if (pluginAction == null) { - // Check if this is an incomplete builder chain (registration exists but Execute() was never called) - if (matchingRegistration?.ConfigBuilder != null) + // If we have a handler method but no generated wrapper, provide a clear error + if (matchingRegistration?.HandlerMethodName != null) { throw new InvalidPluginExecutionException( OperationStatus.Failed, string.Format( CultureInfo.InvariantCulture, - "Plugin step registration for Entity: {0}, Message: {1} in {2} is incomplete. " + - "Ensure Execute() is called on the builder to complete the registration.", + "Plugin step registration for Entity: {0}, Message: {1} in {2} uses method reference " + + "'{3}' but no generated ActionWrapper was found. Ensure the source generator is running.", context.PrimaryEntityName, context.MessageName, - ChildClassName + ChildClassName, + matchingRegistration.HandlerMethodName )); } @@ -264,53 +273,100 @@ protected PluginStepConfigBuilder RegisterStep( } /// - /// Register a plugin step for the given entity type with type-safe image support. - /// Use WithPreImage/WithPostImage to add images, then call Execute to complete registration. + /// Register a plugin step for the given entity type with a method reference. + /// The source generator will emit an ActionWrapper that calls the specified method. + /// Use WithPreImage/WithPostImage to add images - the method signature must match. /// /// The entity type to register the plugin for - /// The service type to pass to the action + /// The service type that contains the handler method /// The event operation to register the plugin for /// The execution stage of the plugin registration - /// A for configuring images and completing registration - protected PluginStepBuilder RegisterStep( - EventOperation eventOperation, ExecutionStage executionStage) + /// A lambda expression pointing to the handler method (e.g., service => service.HandleUpdate) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + EventOperation eventOperation, + ExecutionStage executionStage, + Expression> methodReference) where TEntity : Entity { - return RegisterStep(eventOperation.ToString(), executionStage); + return RegisterStep(eventOperation.ToString(), executionStage, methodReference); } /// - /// Register a plugin step for the given entity type with type-safe image support. - /// Use WithPreImage/WithPostImage to add images, then call Execute to complete registration. + /// Register a plugin step for the given entity type with a method reference. + /// The source generator will emit an ActionWrapper that calls the specified method. + /// Use WithPreImage/WithPostImage to add images - the method signature must match. ///
/// - /// NOTE: It is strongly advised to use the method instead if possible.
+ /// NOTE: It is strongly advised to use the method instead if possible.
/// Only use this method if you are registering for a non-standard message. ///
///
/// The entity type to register the plugin for - /// The service type to pass to the action + /// The service type that contains the handler method /// The event operation to register the plugin for /// The execution stage of the plugin registration - /// A for configuring images and completing registration - protected PluginStepBuilder RegisterStep( - string eventOperation, ExecutionStage executionStage) + /// A lambda expression pointing to the handler method (e.g., service => service.HandleUpdate) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + string eventOperation, + ExecutionStage executionStage, + Expression> methodReference) where TEntity : Entity { + var methodName = ExtractMethodName(methodReference); var builder = new PluginStepConfigBuilder(eventOperation, executionStage); - // Create registration immediately so XrmSync/DAXIF can find it via GetRegistrations() - // Action is set later when Execute() is called on the builder var registration = new PluginStepRegistration(builder, null) { EntityTypeName = typeof(TEntity).Name, EventOperation = eventOperation, ExecutionStage = executionStage.ToString(), - PluginClassName = ChildClassShortName + PluginClassName = ChildClassShortName, + ServiceTypeName = typeof(TService).Name, + ServiceTypeFullName = typeof(TService).FullName, + HandlerMethodName = methodName }; + RegisteredPluginSteps.Add(registration); + return builder; + } - return new PluginStepBuilder(builder, registration); + private static string ExtractMethodName(Expression> methodReference) + { + // Handle: service => service.HandleUpdate + // This compiles to a UnaryExpression (Convert) wrapping a CreateDelegate call + if (methodReference.Body is UnaryExpression unary && + unary.Operand is MethodCallExpression methodCall && + methodCall.Object is ConstantExpression constant && + constant.Value is MethodInfo methodInfo) + { + return methodInfo.Name; + } + + // Fallback for simple member access (property or field that is a delegate) + if (methodReference.Body is MemberExpression member) + { + return member.Member.Name; + } + + throw new ArgumentException("Could not extract method name from expression. Use format: service => service.MethodName"); + } + + private Action DiscoverGeneratedAction(PluginStepRegistration registration) + { + // Build the wrapper type name using naming convention + // Format: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage}.ActionWrapper + var wrapperTypeName = $"{GetType().Namespace}.PluginRegistrations.{registration.PluginClassName}." + + $"{registration.EntityTypeName}{registration.EventOperation}{registration.ExecutionStage}.ActionWrapper"; + + var wrapperType = GetType().Assembly.GetType(wrapperTypeName); + if (wrapperType == null) + return null; + + // Use interface instead of reflection + var wrapper = (IActionWrapper)Activator.CreateInstance(wrapperType); + return wrapper.CreateAction(); } /// diff --git a/XrmPluginCore/Plugins/PluginStepBuilders.cs b/XrmPluginCore/Plugins/PluginStepBuilders.cs deleted file mode 100644 index 199c12f..0000000 --- a/XrmPluginCore/Plugins/PluginStepBuilders.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Xrm.Sdk; -using System; -using System.Linq; -using System.Linq.Expressions; - -#pragma warning disable CS0618 // Type or member is obsolete - we use AddImage internally - -namespace XrmPluginCore.Plugins -{ - /// - /// Helper class for retrieving plugin images and wrapping actions. - /// - internal static class PluginImageHelper - { - internal static Action WrapAction(Action action) - { - return sp => action(sp.GetRequiredService()); - } - - internal static Action WrapActionWithPreImage( - Action action) - where TPreImage : class - { - return sp => action( - sp.GetRequiredService(), - GetPreImage(sp)); - } - - internal static Action WrapActionWithPostImage( - Action action) - where TPostImage : class - { - return sp => action( - sp.GetRequiredService(), - GetPostImage(sp)); - } - - internal static Action WrapActionWithBothImages( - Action action) - where TPreImage : class - where TPostImage : class - { - return sp => action( - sp.GetRequiredService(), - GetPreImage(sp), - GetPostImage(sp)); - } - - private static T GetPreImage(IExtendedServiceProvider sp) where T : class - { - var context = sp.GetService(); - var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault(); - if (preImageEntity == null) return null; - return (T)Activator.CreateInstance(typeof(T), preImageEntity); - } - - private static T GetPostImage(IExtendedServiceProvider sp) where T : class - { - var context = sp.GetService(); - var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault(); - if (postImageEntity == null) return null; - return (T)Activator.CreateInstance(typeof(T), postImageEntity); - } - } - - /// - /// Base builder for plugin steps that may have images. - /// Use WithPreImage/WithPostImage to add type-safe images, then call Execute to complete registration. - /// - public class PluginStepBuilder where TEntity : Entity - { - private readonly PluginStepRegistration registration; - - internal PluginStepBuilder( - PluginStepConfigBuilder configBuilder, - PluginStepRegistration registration) - { - ConfigBuilder = configBuilder; - this.registration = registration; - } - - /// - /// Gets the underlying config builder for additional configuration (filtered attributes, deployment, etc.) - /// - public PluginStepConfigBuilder ConfigBuilder { get; } - - /// - /// Add filtered attributes to this step. - /// - public PluginStepBuilder AddFilteredAttributes(params Expression>[] attributes) - { - ConfigBuilder.AddFilteredAttributes(attributes); - return this; - } - - /// - /// Add a PreImage to this step. Returns a builder that requires PreImage in Execute. - /// - public PluginStepBuilderWithPreImage WithPreImage(params Expression>[] attributes) - { - ConfigBuilder.AddImage(Enums.ImageType.PreImage, attributes); - return new PluginStepBuilderWithPreImage(ConfigBuilder, registration); - } - - /// - /// Add a PostImage to this step. Returns a builder that requires PostImage in Execute. - /// - public PluginStepBuilderWithPostImage WithPostImage(params Expression>[] attributes) - { - ConfigBuilder.AddImage(Enums.ImageType.PostImage, attributes); - return new PluginStepBuilderWithPostImage(ConfigBuilder, registration); - } - - /// - /// Complete registration with an action that receives only the service. - /// - public PluginStepConfigBuilder Execute(Action action) - { - registration.Action = PluginImageHelper.WrapAction(action); - return ConfigBuilder; - } - } - - /// - /// Builder for plugin steps with a PreImage. Execute requires accepting PreImage. - /// - public class PluginStepBuilderWithPreImage where TEntity : Entity - { - private readonly PluginStepRegistration registration; - - internal PluginStepBuilderWithPreImage( - PluginStepConfigBuilder configBuilder, - PluginStepRegistration registration) - { - ConfigBuilder = configBuilder; - this.registration = registration; - } - - /// - /// Gets the underlying config builder for additional configuration. - /// - public PluginStepConfigBuilder ConfigBuilder { get; } - - /// - /// Add filtered attributes to this step. - /// - public PluginStepBuilderWithPreImage AddFilteredAttributes(params Expression>[] attributes) - { - ConfigBuilder.AddFilteredAttributes(attributes); - return this; - } - - /// - /// Add a PostImage to this step. Returns a builder that requires both images in Execute. - /// - public PluginStepBuilderWithBothImages WithPostImage(params Expression>[] attributes) - { - ConfigBuilder.AddImage(Enums.ImageType.PostImage, attributes); - return new PluginStepBuilderWithBothImages(ConfigBuilder, registration); - } - - /// - /// Complete registration with an action that receives service and PreImage. - /// - /// The PreImage wrapper type (generated by source generator) - public PluginStepConfigBuilder Execute(Action action) - where TPreImage : class - { - registration.Action = PluginImageHelper.WrapActionWithPreImage(action); - return ConfigBuilder; - } - } - - /// - /// Builder for plugin steps with a PostImage. Execute requires accepting PostImage. - /// - public class PluginStepBuilderWithPostImage where TEntity : Entity - { - private readonly PluginStepRegistration registration; - - internal PluginStepBuilderWithPostImage( - PluginStepConfigBuilder configBuilder, - PluginStepRegistration registration) - { - ConfigBuilder = configBuilder; - this.registration = registration; - } - - /// - /// Gets the underlying config builder for additional configuration. - /// - public PluginStepConfigBuilder ConfigBuilder { get; } - - /// - /// Add filtered attributes to this step. - /// - public PluginStepBuilderWithPostImage AddFilteredAttributes(params Expression>[] attributes) - { - ConfigBuilder.AddFilteredAttributes(attributes); - return this; - } - - /// - /// Add a PreImage to this step. Returns a builder that requires both images in Execute. - /// - public PluginStepBuilderWithBothImages WithPreImage(params Expression>[] attributes) - { - ConfigBuilder.AddImage(Enums.ImageType.PreImage, attributes); - return new PluginStepBuilderWithBothImages(ConfigBuilder, registration); - } - - /// - /// Complete registration with an action that receives service and PostImage. - /// - /// The PostImage wrapper type (generated by source generator) - public PluginStepConfigBuilder Execute(Action action) - where TPostImage : class - { - registration.Action = PluginImageHelper.WrapActionWithPostImage(action); - return ConfigBuilder; - } - } - - /// - /// Builder for plugin steps with both PreImage and PostImage. Execute requires accepting both. - /// - public class PluginStepBuilderWithBothImages where TEntity : Entity - { - private readonly PluginStepRegistration registration; - - internal PluginStepBuilderWithBothImages( - PluginStepConfigBuilder configBuilder, - PluginStepRegistration registration) - { - ConfigBuilder = configBuilder; - this.registration = registration; - } - - /// - /// Gets the underlying config builder for additional configuration. - /// - public PluginStepConfigBuilder ConfigBuilder { get; } - - /// - /// Add filtered attributes to this step. - /// - public PluginStepBuilderWithBothImages AddFilteredAttributes(params Expression>[] attributes) - { - ConfigBuilder.AddFilteredAttributes(attributes); - return this; - } - - /// - /// Complete registration with an action that receives service, PreImage, and PostImage. - /// - /// The PreImage wrapper type (generated by source generator) - /// The PostImage wrapper type (generated by source generator) - public PluginStepConfigBuilder Execute(Action action) - where TPreImage : class - where TPostImage : class - { - registration.Action = PluginImageHelper.WrapActionWithBothImages(action); - return ConfigBuilder; - } - } -} diff --git a/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs b/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs index dfa34b4..a42111c 100644 --- a/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs +++ b/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs @@ -10,8 +10,8 @@ namespace XrmPluginCore.Plugins { /// - /// Class the help build the for a specific entity type.
- /// Should be initialized using the method. + /// Class to help build the for a specific entity type.
+ /// Should be initialized using one of the Plugin.RegisterStep methods. ///
public class PluginStepConfigBuilder : IPluginStepConfigBuilder where T : Entity { @@ -139,31 +139,26 @@ public PluginStepConfigBuilder AddFilteredAttributes(params string[] attribut return this; } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType) { return AddImage(imageType.ToString(), imageType.ToString(), imageType); } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType) { return AddImage(name, entityAlias, imageType, (string[])null); } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType, params string[] attributes) { return AddImage(imageType.ToString(), imageType.ToString(), imageType, attributes); } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(ImageType imageType, params Expression>[] attributes) { return AddImage(imageType.ToString(), imageType.ToString(), imageType, attributes); } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType, params string[] attributes) { Images.Add(new PluginStepImage(name, entityAlias, imageType, attributes)); @@ -171,12 +166,29 @@ public PluginStepConfigBuilder AddImage(string name, string entityAlias, Imag return this; } - [Obsolete("Use RegisterStep(operation, stage).WithPreImage(...) or .WithPostImage(...) for type-safe image handling")] public PluginStepConfigBuilder AddImage(string name, string entityAlias, ImageType imageType, params Expression>[] attributes) { Images.Add(PluginStepImage.Create(name, entityAlias, imageType, attributes)); return this; } + + /// + /// Add a PreImage with the specified attributes. + /// The source generator will create a type-safe PreImage wrapper. + /// + public PluginStepConfigBuilder WithPreImage(params Expression>[] attributes) + { + return AddImage(ImageType.PreImage, attributes); + } + + /// + /// Add a PostImage with the specified attributes. + /// The source generator will create a type-safe PostImage wrapper. + /// + public PluginStepConfigBuilder WithPostImage(params Expression>[] attributes) + { + return AddImage(ImageType.PostImage, attributes); + } } } diff --git a/XrmPluginCore/Plugins/PluginStepRegistration.cs b/XrmPluginCore/Plugins/PluginStepRegistration.cs index 73ffe75..4465479 100644 --- a/XrmPluginCore/Plugins/PluginStepRegistration.cs +++ b/XrmPluginCore/Plugins/PluginStepRegistration.cs @@ -2,7 +2,7 @@ namespace XrmPluginCore.Plugins { - internal class PluginStepRegistration + internal sealed class PluginStepRegistration { public PluginStepRegistration(IPluginStepConfigBuilder pluginStepConfig, Action action) { @@ -37,5 +37,23 @@ public PluginStepRegistration(IPluginStepConfigBuilder pluginStepConfig, Action< /// Used to compute wrapper class names by convention. ///
public string ExecutionStage { get; set; } + + /// + /// Gets or sets the service type name (short name) for action wrapper generation. + /// Used by the source generator to emit the correct service resolution. + /// + public string ServiceTypeName { get; set; } + + /// + /// Gets or sets the fully qualified service type name for action wrapper generation. + /// Used by the source generator to emit the correct using directive. + /// + public string ServiceTypeFullName { get; set; } + + /// + /// Gets or sets the handler method name on the service. + /// Used by the source generator to emit the action wrapper that calls this method. + /// + public string HandlerMethodName { get; set; } } } From 3a17af1f06657817f0e30fc8585fbaac0d911b19 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 08:40:38 +0100 Subject: [PATCH 06/22] REFACTOR: Use raw string literals --- .../CodeGeneration/WrapperClassGenerator.cs | 251 ++++++++++-------- XrmPluginCore/CHANGELOG.md | 2 +- 2 files changed, 139 insertions(+), 114 deletions(-) diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index 5db7ee0..30c74b7 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -22,18 +22,8 @@ public static string GenerateWrapperClasses(PluginStepMetadata metadata) var estimatedCapacity = (imagesWithAttributes.Count * 500) + 500; var sb = new StringBuilder(estimatedCapacity); - // File header - sb.AppendLine("// "); - sb.AppendLine(); - - // Using directives - sb.AppendLine("using System;"); - sb.AppendLine("using System.Linq;"); - sb.AppendLine("using System.Runtime.CompilerServices;"); - sb.AppendLine("using Microsoft.Xrm.Sdk;"); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine("using XrmPluginCore;"); - sb.AppendLine(); + // File header and using directives + sb.Append(GetFileHeader()); var namespaceToUse = metadata.RegistrationNamespace; @@ -60,78 +50,24 @@ public static string GenerateWrapperClasses(PluginStepMetadata metadata) ///
private static void GenerateImageWrapperClass(StringBuilder sb, PluginStepMetadata metadata, ImageMetadata image) { - // Simple class name - just "PreImage" or "PostImage" var className = image.WrapperClassName; - // XML documentation - sb.AppendLine(" /// "); - sb.AppendLine($" /// Type-safe wrapper for {metadata.EntityTypeName} {metadata.EventOperation} {metadata.ExecutionStage} {image.ImageType}"); - sb.AppendLine(" /// "); - - // CompilerGenerated attribute - sb.AppendLine(" [CompilerGenerated]"); - - // Class declaration with interface implementation - sb.AppendLine($" public class {className} : IEntityImageWrapper"); - sb.AppendLine(" {"); - - // Private entity field - sb.AppendLine(" private readonly Entity entity;"); - sb.AppendLine(); - - // Constructor - sb.AppendLine(" /// "); - sb.AppendLine($" /// Initializes a new instance of {className}"); - sb.AppendLine(" /// "); - sb.AppendLine(" /// The image entity"); - sb.AppendLine($" public {className}(Entity entity)"); - sb.AppendLine(" {"); - sb.AppendLine(" this.entity = entity ?? throw new ArgumentNullException(nameof(entity));"); - sb.AppendLine(" }"); - sb.AppendLine(); + // Class header with documentation, attribute, field, and constructor + sb.Append(GetImageClassHeader( + className, + metadata.EntityTypeName, + metadata.EventOperation, + metadata.ExecutionStage, + image.ImageType)); // Generate properties for each image attribute foreach (var attr in image.Attributes) { - GenerateProperty(sb, attr); + sb.Append(GetPropertyTemplate(attr.TypeName, attr.PropertyName, attr.LogicalName)); } - // ToEntity method - uses SDK's ToEntity() for early-bound access - sb.AppendLine(" /// "); - sb.AppendLine(" /// Converts the underlying Entity to an early-bound entity type"); - sb.AppendLine(" /// "); - sb.AppendLine(" /// The early-bound entity type"); - sb.AppendLine(" public T ToEntity() where T : Entity => entity.ToEntity();"); - sb.AppendLine(); - - // GetUnderlyingEntity method - provides access to the raw Entity - sb.AppendLine(" /// "); - sb.AppendLine(" /// Gets the underlying Entity object for direct attribute access or service operations"); - sb.AppendLine(" /// "); - sb.AppendLine(" public Entity GetUnderlyingEntity() => entity;"); - - // Close class - sb.AppendLine(" }"); - sb.AppendLine(); - } - - /// - /// Generates a property for an entity attribute. - /// GetAttributeValue<T> is already null-safe (returns default(T) for missing attributes), - /// so we don't need the Contains check. - /// - private static void GenerateProperty(StringBuilder sb, AttributeMetadata attr) - { - var propertyType = attr.TypeName; - - // XML documentation - sb.AppendLine(" /// "); - sb.AppendLine($" /// Gets the {attr.PropertyName} attribute"); - sb.AppendLine(" /// "); - - // Property declaration using expression body - GetAttributeValue is already null-safe - sb.AppendLine($" public {propertyType} {attr.PropertyName} => entity.GetAttributeValue<{propertyType}>(\"{attr.LogicalName}\");"); - sb.AppendLine(); + // Class footer with ToEntity and GetUnderlyingEntity methods + sb.Append(GetImageClassFooter()); } /// @@ -143,61 +79,38 @@ private static void GenerateActionWrapperClass(StringBuilder sb, PluginStepMetad var hasPreImage = images.Any(i => i.ImageType == "PreImage"); var hasPostImage = images.Any(i => i.ImageType == "PostImage"); - // XML documentation - sb.AppendLine(" /// "); - sb.AppendLine($" /// Generated action wrapper for {metadata.ServiceTypeName}.{metadata.HandlerMethodName}"); - sb.AppendLine(" /// "); - - // CompilerGenerated attribute - sb.AppendLine(" [CompilerGenerated]"); - - // Class declaration - sb.AppendLine(" internal sealed class ActionWrapper : IActionWrapper"); - sb.AppendLine(" {"); - - // CreateAction method - sb.AppendLine(" /// "); - sb.AppendLine(" /// Creates the action delegate that invokes the service method with appropriate images."); - sb.AppendLine(" /// "); - sb.AppendLine(" public Action CreateAction()"); - sb.AppendLine(" {"); - sb.AppendLine(" return serviceProvider =>"); - sb.AppendLine(" {"); + // ActionWrapper header with documentation, class declaration, and service retrieval + sb.Append(GetActionWrapperHeader( + metadata.ServiceTypeName, + metadata.HandlerMethodName, + metadata.ServiceTypeFullName)); - // Get the service - sb.AppendLine($" var service = serviceProvider.GetRequiredService<{metadata.ServiceTypeFullName}>();"); - - // Get images if needed + // Get context if images are needed if (hasPreImage || hasPostImage) { - sb.AppendLine(" var context = serviceProvider.GetService();"); + sb.AppendLine(); + sb.Append(GetContextRetrieval()); } var args = new List(); if (hasPreImage) { - sb.AppendLine(" var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();"); - sb.AppendLine(" var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;"); + sb.AppendLine(); + sb.Append(GetPreImageRetrieval()); args.Add("preImage"); } if (hasPostImage) { - sb.AppendLine(" var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault();"); - sb.AppendLine(" var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); + sb.AppendLine(); + sb.Append(GetPostImageRetrieval()); args.Add("postImage"); } var argsString = string.Join(", ", args); - // Call the service method - sb.AppendLine($" service.{metadata.HandlerMethodName}({argsString});"); - - sb.AppendLine(" };"); - sb.AppendLine(" }"); - - // Close class - sb.AppendLine(" }"); + // ActionWrapper footer with method invocation and closing braces sb.AppendLine(); + sb.Append(GetActionWrapperFooter(metadata.HandlerMethodName, argsString)); } /// @@ -255,4 +168,116 @@ public static PluginStepMetadata MergeMetadata(IEnumerable m return merged; } + + #region Template Methods + + private static string GetFileHeader() => +""" +// + +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.Xrm.Sdk; +using Microsoft.Extensions.DependencyInjection; +using XrmPluginCore; + +"""; + + private static string GetImageClassHeader( + string className, + string entityTypeName, + string eventOperation, + string executionStage, + string imageType) => +$$""" + /// + /// Type-safe wrapper for {{entityTypeName}} {{eventOperation}} {{executionStage}} {{imageType}} + /// + [CompilerGenerated] + public class {{className}} : IEntityImageWrapper + { + private readonly Entity entity; + + /// + /// Initializes a new instance of {{className}} + /// + /// The image entity + public {{className}}(Entity entity) + { + this.entity = entity ?? throw new ArgumentNullException(nameof(entity)); + } + +"""; + + private static string GetPropertyTemplate(string propertyType, string propertyName, string logicalName) => +$$""" + /// + /// Gets the {{propertyName}} attribute + /// + public {{propertyType}} {{propertyName}} => entity.GetAttributeValue<{{propertyType}}>("{{logicalName}}"); + +"""; + + private static string GetImageClassFooter() => +""" + /// + /// Converts the underlying Entity to an early-bound entity type + /// + /// The early-bound entity type + public T ToEntity() where T : Entity => entity.ToEntity(); + + /// + /// Gets the underlying Entity object for direct attribute access or service operations + /// + public Entity GetUnderlyingEntity() => entity; + } + +"""; + + private static string GetActionWrapperHeader(string serviceTypeName, string methodName, string serviceFullName) => +$$""" + /// + /// Generated action wrapper for {{serviceTypeName}}.{{methodName}} + /// + [CompilerGenerated] + internal sealed class ActionWrapper : IActionWrapper + { + /// + /// Creates the action delegate that invokes the service method with appropriate images. + /// + public Action CreateAction() + { + return serviceProvider => + { + var service = serviceProvider.GetRequiredService<{{serviceFullName}}>(); +"""; + + private static string GetContextRetrieval() => +""" + var context = serviceProvider.GetService(); +"""; + + private static string GetPreImageRetrieval() => +""" + var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault(); + var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null; +"""; + + private static string GetPostImageRetrieval() => +""" + var postImageEntity = context?.PostEntityImages?.Values?.FirstOrDefault(); + var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null; +"""; + + private static string GetActionWrapperFooter(string methodName, string argsString) => +$$""" + service.{{methodName}}({{argsString}}); + }; + } + } + +"""; + + #endregion } diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 65a0743..0614706 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,4 +1,4 @@ -### v1.2.0 - 21 November 2025 +### v1.2.0-preview.1 - 21 November 2025 * Add: Type-Safe Images feature with compile-time enforcement via source generator ### v1.1.1 - 14 November 2025 From 4b2f4c6beb2d84293e0f6fcca85f38eae8aed727 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 08:53:45 +0100 Subject: [PATCH 07/22] CHORE: Remove deprecated mentions of target generation --- CLAUDE.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1e3d4f..bbc06be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,13 @@ dotnet pack --configuration Release --no-build --output ./nupkg - `RegisterPluginStep(EventOperation, ExecutionStage, Action)` - Legacy approach (deprecated) - `RegisterAPI(string name, Action)` - For Custom APIs - When `AddFilteredAttributes()` or `AddImage()` are used, the source generator automatically creates wrapper classes that are discovered at runtime by naming convention. + When `AddImage()`, `WithPreImage()` or `WithPostImage()` are used, the source generator automatically creates wrapper classes that are discovered at runtime by naming convention. 3. **Service Provider Pattern**: - `ExtendedServiceProvider` wraps the Dynamics SDK's IServiceProvider - `ServiceProviderExtensions.BuildServiceProvider()` creates a scoped DI container per execution - Built-in services injected: IPluginExecutionContext, IOrganizationServiceFactory, ITracingService (as ExtendedTracingService), ILogger - - Type-safe registrations automatically register generated wrapper classes (Target, PreImage, PostImage) directly in DI + - Type-safe registrations automatically register generated wrapper classes (PreImage, PostImage) directly in DI - Custom services registered via `OnBeforeBuildServiceProvider()` override 4. **Configuration Builders**: @@ -81,7 +81,7 @@ dotnet pack --configuration Release --no-build --output ./nupkg - `ICustomApiDefinition.cs` - Interface for retrieving custom API configuration **XrmPluginCore.SourceGenerator/** (Compile-time code generation) -- `Generators/TargetEntityGenerator.cs` - Incremental source generator that scans for Plugin classes +- `Generators/PluginImageGenerator.cs` - Incremental source generator that scans for Plugin classes - `Parsers/RegistrationParser.cs` - Extracts metadata from RegisterStep invocations - `CodeGeneration/WrapperClassGenerator.cs` - Generates type-safe wrapper classes - `Helpers/SyntaxHelper.cs` - Roslyn syntax tree analysis utilities @@ -97,21 +97,33 @@ Use `WithPreImage`/`WithPostImage` (convenience methods for `AddImage`) to regis ```csharp // Basic plugin (no images) -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, s => s.DoSomething()) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + s => s.DoSomething()) .AddFilteredAttributes(x => x.Name); // PreImage only - handler method MUST accept PreImage parameter -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue); // PostImage only - handler method MUST accept PostImage parameter -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name) .WithPostImage(x => x.Name, x => x.AccountNumber); // Both images - handler method MUST accept both parameters -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + service => service.HandleUpdate) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) .WithPostImage(x => x.Name, x => x.AccountNumber); @@ -125,7 +137,7 @@ RegisterStep(EventOperation.Update, ExecutionStage.Post 2. **Metadata Extraction**: For each registration, it extracts: - Plugin class name - - Entity type (TEntity) + - Entity type (`TEntity`) - Event operation and execution stage - Filtered attributes from `AddFilteredAttributes()` calls - Pre/Post image attributes from `WithPreImage()`/`WithPostImage()`/`AddImage()` calls From d15a8c985e7f0fba3970d4c4951aad01d28b5a39 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 09:00:21 +0100 Subject: [PATCH 08/22] CHORE: Use .NET10 for build since we use C#14 features --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92de470..c814936 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: 🛠️ Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.x + dotnet-version: 10.x - name: ✏️ Set abstractions version from CHANGELOG.md shell: pwsh @@ -45,4 +45,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: packages - path: ./nupkg \ No newline at end of file + path: ./nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68cd775..9b7397f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release on: release: @@ -21,7 +21,7 @@ jobs: - name: 🛠️ Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.x + dotnet-version: 10.x - name: ✏️ Set abstractions version from CHANGELOG.md shell: pwsh From d17784066246cc7ecfc8cf6c71b6e53abf078117 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 09:04:01 +0100 Subject: [PATCH 09/22] Use GetRequiredService instead of GetService Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../CodeGeneration/WrapperClassGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index 30c74b7..6e2bbfb 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -255,7 +255,7 @@ public Action CreateAction() private static string GetContextRetrieval() => """ - var context = serviceProvider.GetService(); + var context = serviceProvider.GetRequiredService(); """; private static string GetPreImageRetrieval() => From a946610748677898571048615194f157cc7d987c Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 09:05:26 +0100 Subject: [PATCH 10/22] More resilient including of generator dll Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- XrmPluginCore/XrmPluginCore.csproj | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/XrmPluginCore/XrmPluginCore.csproj b/XrmPluginCore/XrmPluginCore.csproj index 7b5de27..0a74b3d 100644 --- a/XrmPluginCore/XrmPluginCore.csproj +++ b/XrmPluginCore/XrmPluginCore.csproj @@ -53,7 +53,14 @@ + + + + + + + - + \ No newline at end of file From 0ca8718bd0cbf200a9f9b7655b1bee9d0641b87e Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 09:18:09 +0100 Subject: [PATCH 11/22] Go back to copying from SourceGenerator outdir, project reference enforced build order so it will be there --- XrmPluginCore/XrmPluginCore.csproj | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/XrmPluginCore/XrmPluginCore.csproj b/XrmPluginCore/XrmPluginCore.csproj index 0a74b3d..58642d4 100644 --- a/XrmPluginCore/XrmPluginCore.csproj +++ b/XrmPluginCore/XrmPluginCore.csproj @@ -53,14 +53,11 @@ - - - - - - - - + \ No newline at end of file From 5716b70570f187b65fa5e942b7df033447f799b7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:33:40 +0100 Subject: [PATCH 12/22] Optimize handler method lookup to avoid repeated inheritance chain traversal (#3) * REFACTOR: Materialize inheritance chain results for better caching Co-authored-by: mkholt <4355246+mkholt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mkholt <4355246+mkholt@users.noreply.github.com> --- .../Validation/HandlerMethodValidator.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs index 0113aed..2e09d9f 100644 --- a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -48,18 +48,20 @@ public static void ValidateHandlerMethod( } } - private static IEnumerable GetAllMethodsIncludingInherited(ITypeSymbol type, string methodName) + private static IReadOnlyList GetAllMethodsIncludingInherited(ITypeSymbol type, string methodName) { + var methods = new List(); var currentType = type; while (currentType is not null) { foreach (var member in currentType.GetMembers(methodName)) { if (member is IMethodSymbol method) - yield return method; + methods.Add(method); } currentType = currentType.BaseType; } + return methods; } private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage) From e619b09af518e883e475cadb7339a1557bf65709 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 11:23:17 +0100 Subject: [PATCH 13/22] FIX: Add RegisterStep overload that allows parameterless handler methods without clashing with the Action overload --- .../ParsingTests/RegisterStepParsingTests.cs | 123 ++++++++++++++++++ XrmPluginCore/CHANGELOG.md | 2 +- XrmPluginCore/Plugin.cs | 47 +++++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs index e305dab..d4e9f1e 100644 --- a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs @@ -259,4 +259,127 @@ public void HandleAccountUpdate(PreImage preImage) { } var generatedSource = result.GeneratedTrees[0].GetText().ToString(); generatedSource.Should().Contain("service.HandleAccountUpdate(preImage)"); } + + [Fact] + public void Should_Parse_Parameterless_Method_Reference() + { + // Arrange - plugin with a parameterless handler method (no images) + // This tests the Expression> overload for parameterless methods + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountCreatePostOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, + service => service.HandleCreate) + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleCreate(); + } + + public class TestService : ITestService + { + public void HandleCreate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - should generate ActionWrapper that calls HandleCreate with no parameters + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify the method name was extracted correctly + generatedSource.Should().Contain("service.HandleCreate()"); + + // Verify ActionWrapper is generated + generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper"); + + // Verify correct namespace is used + generatedSource.Should().Contain("namespace TestNamespace.PluginRegistrations.TestPlugin.AccountCreatePostOperation"); + + // Verify NO image classes are generated since it's a parameterless method + generatedSource.Should().NotContain("public class PreImage"); + generatedSource.Should().NotContain("public class PostImage"); + } + + [Fact] + public void Should_Parse_Parameterless_Method_Reference_With_Custom_Method_Name() + { + // Arrange - plugin with a parameterless handler method with a unique name + // This ensures the method name extraction works for various naming conventions + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePreOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Delete, ExecutionStage.PreOperation, + service => service.OnAccountDeleting) + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void OnAccountDeleting(); + } + + public class TestService : ITestService + { + public void OnAccountDeleting() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - should generate ActionWrapper that calls OnAccountDeleting + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Verify the custom method name was extracted correctly + generatedSource.Should().Contain("service.OnAccountDeleting()"); + + // Verify correct namespace with Delete operation and PreOperation stage + generatedSource.Should().Contain("namespace TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePreOperation"); + } } diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 0614706..924b47f 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,4 +1,4 @@ -### v1.2.0-preview.1 - 21 November 2025 +### v1.2.0-preview.2 - 21 November 2025 * Add: Type-Safe Images feature with compile-time enforcement via source generator ### v1.1.1 - 14 November 2025 diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 88321a9..4f1ca16 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -332,6 +332,53 @@ protected PluginStepConfigBuilder RegisterStep( return builder; } + /// + /// Register a plugin step for the given entity type with a parameterless method reference. + /// The source generator will emit an ActionWrapper that calls the specified method. + /// + /// The entity type to register the plugin for + /// The service type that contains the handler method + /// The event operation to register the plugin for + /// The execution stage of the plugin registration + /// A lambda expression pointing to a parameterless handler method (e.g., service => service.HandleCreate) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + EventOperation eventOperation, + ExecutionStage executionStage, + Expression> methodReference) + where TEntity : Entity + { + return RegisterStep(eventOperation.ToString(), executionStage, methodReference); + } + + /// + /// Register a plugin step for the given entity type with a parameterless method reference. + /// The source generator will emit an ActionWrapper that calls the specified method. + ///
+ /// + /// NOTE: It is strongly advised to use the method instead if possible.
+ /// Only use this method if you are registering for a non-standard message. + ///
+ ///
+ /// The entity type to register the plugin for + /// The service type that contains the handler method + /// The event operation to register the plugin for + /// The execution stage of the plugin registration + /// A lambda expression pointing to a parameterless handler method (e.g., service => service.HandleCreate) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + string eventOperation, + ExecutionStage executionStage, + Expression> methodReference) + where TEntity : Entity + { + // Convert the Action expression to a Delegate expression and delegate to existing overload + var delegateExpression = Expression.Lambda>( + Expression.Convert(methodReference.Body, typeof(Delegate)), + methodReference.Parameters); + return RegisterStep(eventOperation, executionStage, delegateExpression); + } + private static string ExtractMethodName(Expression> methodReference) { // Handle: service => service.HandleUpdate From 592015f75875eba739ee2a0338cbfdce76c65cbd Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 13:27:41 +0100 Subject: [PATCH 14/22] Fix: The overload does not work as expected, parameterless calls do not benefit from generation so use the standard action instead. --- XrmPluginCore/Plugin.cs | 47 ----------------------------------------- 1 file changed, 47 deletions(-) diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 4f1ca16..88321a9 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -332,53 +332,6 @@ protected PluginStepConfigBuilder RegisterStep( return builder; } - /// - /// Register a plugin step for the given entity type with a parameterless method reference. - /// The source generator will emit an ActionWrapper that calls the specified method. - /// - /// The entity type to register the plugin for - /// The service type that contains the handler method - /// The event operation to register the plugin for - /// The execution stage of the plugin registration - /// A lambda expression pointing to a parameterless handler method (e.g., service => service.HandleCreate) - /// A for configuring images and filtered attributes - protected PluginStepConfigBuilder RegisterStep( - EventOperation eventOperation, - ExecutionStage executionStage, - Expression> methodReference) - where TEntity : Entity - { - return RegisterStep(eventOperation.ToString(), executionStage, methodReference); - } - - /// - /// Register a plugin step for the given entity type with a parameterless method reference. - /// The source generator will emit an ActionWrapper that calls the specified method. - ///
- /// - /// NOTE: It is strongly advised to use the method instead if possible.
- /// Only use this method if you are registering for a non-standard message. - ///
- ///
- /// The entity type to register the plugin for - /// The service type that contains the handler method - /// The event operation to register the plugin for - /// The execution stage of the plugin registration - /// A lambda expression pointing to a parameterless handler method (e.g., service => service.HandleCreate) - /// A for configuring images and filtered attributes - protected PluginStepConfigBuilder RegisterStep( - string eventOperation, - ExecutionStage executionStage, - Expression> methodReference) - where TEntity : Entity - { - // Convert the Action expression to a Delegate expression and delegate to existing overload - var delegateExpression = Expression.Lambda>( - Expression.Convert(methodReference.Body, typeof(Delegate)), - methodReference.Parameters); - return RegisterStep(eventOperation, executionStage, delegateExpression); - } - private static string ExtractMethodName(Expression> methodReference) { // Handle: service => service.HandleUpdate From 6b822d5c1c38f0446c412476569047deb4f197ba Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 14:14:28 +0100 Subject: [PATCH 15/22] FIX: Use nameof instead of the delegate, the compiler gets confused about the action -> delegate implicit cast --- CLAUDE.md | 40 ++++++++----- .../Helpers/TestFixtures.cs | 57 +++++++++++++++++-- .../IntegrationTests/CompilationTests.cs | 17 ++++++ .../Parsers/RegistrationParser.cs | 33 ++++++++++- .../TypeSafe/TypeSafeAccountPlugin.cs | 2 +- .../TypeSafe/TypeSafeContactPlugin.cs | 2 +- XrmPluginCore/Plugin.cs | 46 +++++---------- 7 files changed, 138 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bbc06be..090bbc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,37 +93,38 @@ The source generator provides compile-time type safety for plugin images (PreIma #### API Design -Use `WithPreImage`/`WithPostImage` (convenience methods for `AddImage`) to register images. The method reference pattern (`service => service.HandleUpdate`) enables the source generator to validate that your handler method signature matches the registered images: +Use `WithPreImage`/`WithPostImage` (convenience methods for `AddImage`) to register images. The `nameof()` pattern enables the source generator to validate that your handler method signature matches the registered images: ```csharp -// Basic plugin (no images) +// Basic plugin (no images) - use lambda invocation syntax RegisterStep( EventOperation.Update, - ExecutionStage.PostOperation, - s => s.DoSomething()) + ExecutionStage.PostOperation, + s => s.DoSomething()) .AddFilteredAttributes(x => x.Name); // PreImage only - handler method MUST accept PreImage parameter +// Use nameof() for compile-time safety when images are registered RegisterStep( EventOperation.Update, - ExecutionStage.PostOperation, - service => service.HandleUpdate) + ExecutionStage.PostOperation, + nameof(AccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue); // PostImage only - handler method MUST accept PostImage parameter RegisterStep( EventOperation.Update, - ExecutionStage.PostOperation, - service => service.HandleUpdate) + ExecutionStage.PostOperation, + nameof(AccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name) .WithPostImage(x => x.Name, x => x.AccountNumber); // Both images - handler method MUST accept both parameters RegisterStep( EventOperation.Update, - ExecutionStage.PostOperation, - service => service.HandleUpdate) + ExecutionStage.PostOperation, + nameof(AccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) .WithPostImage(x => x.Name, x => x.AccountNumber); @@ -162,8 +163,11 @@ public class AccountPlugin : Plugin { public AccountPlugin() { - // Type-safe API with compile-time enforcement via method reference - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) + // Type-safe API with compile-time enforcement via nameof() + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(AccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) .WithPostImage(x => x.Name, x => x.AccountNumber); @@ -340,16 +344,22 @@ RegisterStep("custom_CustomMessage", ExecutionStage.PostOperation, s = ### Plugin Step Images -Images are configured through the builder using `WithPreImage`, `WithPostImage`, or `AddImage`: +Images are configured through the builder using `WithPreImage`, `WithPostImage`, or `AddImage`. Use `nameof()` for compile-time safety when registering images: ```csharp // Using convenience methods (recommended) -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .WithPreImage(x => x.Name, x => x.Revenue) .WithPostImage(x => x.Name, x => x.AccountNumber); // Using AddImage directly -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, service => service.HandleUpdate) +RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue) .AddImage(ImageType.PostImage, x => x.Name, x => x.AccountNumber); diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs index a613ec7..77cb311 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -117,7 +117,7 @@ public class TestPlugin : Plugin public TestPlugin() {{ RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) + nameof(ITestService.Process)) .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) .WithPreImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}); }} @@ -157,7 +157,7 @@ public class TestPlugin : Plugin public TestPlugin() {{ RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) + nameof(ITestService.Process)) .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}); }} @@ -197,7 +197,7 @@ public class TestPlugin : Plugin public TestPlugin() {{ RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) + nameof(ITestService.Process)) .AddFilteredAttributes(x => x.Name) .WithPreImage(x => x.Name, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}) .WithPostImage(x => x.Name, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}); @@ -239,7 +239,7 @@ public class TestPlugin : Plugin public TestPlugin() {{ RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, - service => service.HandleUpdate) + nameof(ITestService.HandleUpdate)) .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}); }} @@ -306,6 +306,11 @@ public static string GetCompleteSource(string entitySource, string pluginSource) var usesAccount = pluginSource.Contains("RegisterStep + /// Plugin with method reference and PostImage parameter - mirrors XrmMockup's AccountPostImagePlugin. + /// Tests that method groups for methods with parameters can be used with Expression<Func<TService, Delegate>>. + ///
+ public static string GetPluginWithMethodReferenceAndPostImage(string entityClass = "Account") => $@" +using XrmPluginCore; +using XrmPluginCore.Abstractions; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.{entityClass}DeletePostOperation; + +namespace TestNamespace +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Delete, ExecutionStage.PostOperation, + nameof(ITestService.HandleDelete)) + .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void HandleDelete(PostImage postImage); + }} + + public class TestService : ITestService + {{ + public void HandleDelete(PostImage postImage) {{ }} + }} +}}"; } diff --git a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs index 126c861..ad67fdc 100644 --- a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -225,4 +225,21 @@ public void Should_Generate_ActionWrapper_Class() createActionMethod.Should().NotBeNull("CreateAction method should exist"); createActionMethod!.IsStatic.Should().BeFalse("CreateAction should be an instance method since ActionWrapper implements IActionWrapper"); } + + [Fact] + public void Should_Compile_Method_Reference_With_Image_Parameter() + { + // Arrange - Source code that mirrors XrmMockup's AccountPostImagePlugin pattern: + // service => service.HandleDelete where HandleDelete(PostImage postImage) + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithMethodReferenceAndPostImage()); + + // Act + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + + // Assert + result.Success.Should().BeTrue( + because: $"method reference with image parameter should compile. Errors: {string.Join(", ", result.Errors ?? Array.Empty())}"); + } } diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index fc089a2..b927e7f 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -157,11 +157,38 @@ private static PluginStepMetadata ParseRegisterStepInvocation( } /// - /// Parses a method reference expression like "service => service.HandleUpdate" + /// Parses a method reference from various expression forms: + /// - nameof(IService.HandleUpdate) + /// - "HandleUpdate" (string literal) + /// - service => service.HandleUpdate (lambda - legacy support) /// private static string ParseMethodReference(ExpressionSyntax expression, SemanticModel semanticModel) { - // Handle: service => service.HandleUpdate + // Handle nameof(): nameof(IService.HandleDelete) + if (expression is InvocationExpressionSyntax invocation && + invocation.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "nameof") + { + var argument = invocation.ArgumentList.Arguments.FirstOrDefault(); + if (argument?.Expression is MemberAccessExpressionSyntax nameofMemberAccess) + { + return nameofMemberAccess.Name.Identifier.Text; + } + // Handle simple nameof: nameof(HandleDelete) + if (argument?.Expression is IdentifierNameSyntax simpleIdentifier) + { + return simpleIdentifier.Identifier.Text; + } + } + + // Handle string literal: "HandleDelete" + if (expression is LiteralExpressionSyntax literal && + literal.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralExpression)) + { + return literal.Token.ValueText; + } + + // Handle lambda: service => service.HandleUpdate (legacy support) if (expression is SimpleLambdaExpressionSyntax lambda) { if (lambda.Body is MemberAccessExpressionSyntax memberAccess) @@ -170,7 +197,7 @@ private static string ParseMethodReference(ExpressionSyntax expression, Semantic } } - // Handle: (service) => service.HandleUpdate + // Handle: (service) => service.HandleUpdate (legacy support) if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) { if (parenLambda.Body is MemberAccessExpressionSyntax memberAccess) diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs index a41b825..4208884 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs @@ -22,7 +22,7 @@ public TypeSafeAccountPlugin() { // Type-safe API: Images are passed directly to the action via source-generated wrapper RegisterStep(EventOperation.Update, ExecutionStage.PreOperation, - service => service.HandleUpdate) + nameof(TypeSafeAccountService.HandleUpdate)) .AddFilteredAttributes(x => x.Name, x => x.Accountnumber) .WithPreImage(x => x.Name, x => x.Accountnumber, x => x.Revenue) .WithPostImage(x => x.Name, x => x.Accountnumber); diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs index a681016..3e87720 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs @@ -21,7 +21,7 @@ public TypeSafeContactPlugin() { // Type-safe API: PreImage is passed directly to the action via source-generated wrapper RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, - service => service.HandleCreate) + nameof(TypeSafeContactService.HandleCreate)) .AddFilteredAttributes(x => x.Firstname, x => x.Lastname, x => x.Emailaddress1) .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone); } diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 88321a9..97512a7 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Linq.Expressions; -using System.Reflection; using System.ServiceModel; using XrmPluginCore.CustomApis; using XrmPluginCore.Enums; @@ -273,32 +271,36 @@ protected PluginStepConfigBuilder RegisterStep( } /// - /// Register a plugin step for the given entity type with a method reference. + /// Register a plugin step for the given entity type with a handler method name. /// The source generator will emit an ActionWrapper that calls the specified method. /// Use WithPreImage/WithPostImage to add images - the method signature must match. + ///
+ /// Use nameof(IService.MethodName) for compile-time safety. ///
/// The entity type to register the plugin for /// The service type that contains the handler method /// The event operation to register the plugin for /// The execution stage of the plugin registration - /// A lambda expression pointing to the handler method (e.g., service => service.HandleUpdate) + /// The name of the handler method (use nameof(IService.MethodName)) /// A for configuring images and filtered attributes protected PluginStepConfigBuilder RegisterStep( EventOperation eventOperation, ExecutionStage executionStage, - Expression> methodReference) + string handlerMethodName) where TEntity : Entity { - return RegisterStep(eventOperation.ToString(), executionStage, methodReference); + return RegisterStep(eventOperation.ToString(), executionStage, handlerMethodName); } /// - /// Register a plugin step for the given entity type with a method reference. + /// Register a plugin step for the given entity type with a handler method name. /// The source generator will emit an ActionWrapper that calls the specified method. /// Use WithPreImage/WithPostImage to add images - the method signature must match. ///
+ /// Use nameof(IService.MethodName) for compile-time safety. + ///
/// - /// NOTE: It is strongly advised to use the method instead if possible.
+ /// NOTE: It is strongly advised to use the method instead if possible.
/// Only use this method if you are registering for a non-standard message. ///
///
@@ -306,15 +308,14 @@ protected PluginStepConfigBuilder RegisterStep( /// The service type that contains the handler method /// The event operation to register the plugin for /// The execution stage of the plugin registration - /// A lambda expression pointing to the handler method (e.g., service => service.HandleUpdate) + /// The name of the handler method (use nameof(IService.MethodName)) /// A for configuring images and filtered attributes protected PluginStepConfigBuilder RegisterStep( string eventOperation, ExecutionStage executionStage, - Expression> methodReference) + string handlerMethodName) where TEntity : Entity { - var methodName = ExtractMethodName(methodReference); var builder = new PluginStepConfigBuilder(eventOperation, executionStage); var registration = new PluginStepRegistration(builder, null) @@ -325,34 +326,13 @@ protected PluginStepConfigBuilder RegisterStep( PluginClassName = ChildClassShortName, ServiceTypeName = typeof(TService).Name, ServiceTypeFullName = typeof(TService).FullName, - HandlerMethodName = methodName + HandlerMethodName = handlerMethodName }; RegisteredPluginSteps.Add(registration); return builder; } - private static string ExtractMethodName(Expression> methodReference) - { - // Handle: service => service.HandleUpdate - // This compiles to a UnaryExpression (Convert) wrapping a CreateDelegate call - if (methodReference.Body is UnaryExpression unary && - unary.Operand is MethodCallExpression methodCall && - methodCall.Object is ConstantExpression constant && - constant.Value is MethodInfo methodInfo) - { - return methodInfo.Name; - } - - // Fallback for simple member access (property or field that is a delegate) - if (methodReference.Body is MemberExpression member) - { - return member.Member.Name; - } - - throw new ArgumentException("Could not extract method name from expression. Use format: service => service.MethodName"); - } - private Action DiscoverGeneratedAction(PluginStepRegistration registration) { // Build the wrapper type name using naming convention From 432844974f48a63b25ca2248aa95ea8b4faea72e Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 14:34:03 +0100 Subject: [PATCH 16/22] FIX: Do not report location in diagnostics, it fails across incremental compilations --- .../Generators/PluginImageGenerator.cs | 17 ++++++----------- .../Models/PluginStepMetadata.cs | 9 ++------- .../Parsers/RegistrationParser.cs | 3 --- .../Validation/HandlerMethodValidator.cs | 5 +---- 4 files changed, 9 insertions(+), 25 deletions(-) diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 8488813..ebda9a1 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -77,9 +77,6 @@ private static IEnumerable TransformToMetadata( if (!metadataList.Any()) return null; - // Store location for diagnostic reporting - var location = classDecl.GetLocation(); - // Group metadata by unique registration (EntityType + EventOperation + ExecutionStage) var groupedMetadata = metadataList.GroupBy(m => m.UniqueId); @@ -97,14 +94,10 @@ private static IEnumerable TransformToMetadata( if (mergedMetadata is null) continue; - // Store location for diagnostics - mergedMetadata.Location = location; - // Validate handler method signature HandlerMethodValidator.ValidateHandlerMethod( mergedMetadata, - semanticModel.Compilation, - location); + semanticModel.Compilation); // Include if: // - Has method reference (for ActionWrapper generation) @@ -130,13 +123,15 @@ private void GenerateSourceFromMetadata( SourceProductionContext context) { // Report any collected diagnostics first + // Note: We use Location.None because Location objects cannot be cached across + // incremental compilations (they reference SyntaxTrees from the original compilation) if (metadata?.Diagnostics != null) { foreach (var diagnosticInfo in metadata.Diagnostics) { var diagnostic = Diagnostic.Create( diagnosticInfo.Descriptor, - diagnosticInfo.Location, + Location.None, diagnosticInfo.MessageArgs); context.ReportDiagnostic(diagnostic); } @@ -180,7 +175,7 @@ private void ReportGenerationSuccess( { var diagnostic = Diagnostic.Create( DiagnosticDescriptors.GenerationSuccess, - metadata.Location ?? Location.None, + Location.None, 1, // wrapper class count metadata.RegistrationNamespace); @@ -198,7 +193,7 @@ private void ReportGenerationError( { var diagnostic = Diagnostic.Create( DiagnosticDescriptors.GenerationError, - metadata.Location ?? Location.None, + Location.None, exception.Message); context.ReportDiagnostic(diagnostic); diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index 3ff74b3..f392fef 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -31,11 +31,6 @@ internal sealed class PluginStepMetadata ///
public string HandlerMethodName { get; set; } - /// - /// Source location for diagnostic reporting. Not included in equality comparison. - /// - public Location Location { get; set; } - /// /// Diagnostics to report for this plugin step. Not included in equality comparison. /// @@ -170,11 +165,11 @@ public override int GetHashCode() } /// -/// Represents a diagnostic to be reported during source generation +/// Represents a diagnostic to be reported during source generation. +/// Note: Location is not stored to avoid caching stale SyntaxTree references across incremental compilations. /// internal sealed class DiagnosticInfo { public DiagnosticDescriptor Descriptor { get; set; } - public Location Location { get; set; } public object[] MessageArgs { get; set; } } diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index b927e7f..7c7989f 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -36,14 +36,12 @@ public static IEnumerable ParsePluginClass( { PluginClassName = classDeclaration.Identifier.Text, Namespace = classDeclaration.GetNamespace(), - Location = classDeclaration.GetLocation(), Images = [] // Empty - no generation }; diagnosticMetadata.Diagnostics.Add(new DiagnosticInfo { Descriptor = DiagnosticDescriptors.NoParameterlessConstructor, - Location = classDeclaration.Identifier.GetLocation(), MessageArgs = [classDeclaration.Identifier.Text] }); @@ -146,7 +144,6 @@ private static PluginStepMetadata ParseRegisterStepInvocation( metadata.Diagnostics.Add(new DiagnosticInfo { Descriptor = DiagnosticDescriptors.ImageWithoutMethodReference, - Location = registerStepInvocation.GetLocation(), MessageArgs = [] }); } diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs index 2e09d9f..590fca0 100644 --- a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -9,8 +9,7 @@ internal static class HandlerMethodValidator { public static void ValidateHandlerMethod( PluginStepMetadata metadata, - Compilation compilation, - Location location) + Compilation compilation) { if (string.IsNullOrEmpty(metadata.HandlerMethodName) || string.IsNullOrEmpty(metadata.ServiceTypeFullName)) @@ -26,7 +25,6 @@ public static void ValidateHandlerMethod( metadata.Diagnostics.Add(new DiagnosticInfo { Descriptor = DiagnosticDescriptors.HandlerMethodNotFound, - Location = location, MessageArgs = [metadata.HandlerMethodName, metadata.ServiceTypeName] }); return; @@ -42,7 +40,6 @@ public static void ValidateHandlerMethod( metadata.Diagnostics.Add(new DiagnosticInfo { Descriptor = DiagnosticDescriptors.HandlerSignatureMismatch, - Location = location, MessageArgs = [metadata.HandlerMethodName, expectedSignature] }); } From 039466af89cddedc06fbd22783f68e110be0536b Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 26 Nov 2025 14:39:46 +0100 Subject: [PATCH 17/22] CLEANUP: Do not report success diagnostic --- .../DiagnosticTests/DiagnosticReportingTests.cs | 7 +++---- .../Generators/PluginImageGenerator.cs | 3 --- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 0b88e47..9f88b08 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -11,7 +11,7 @@ namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests; public class DiagnosticReportingTests { [Fact] - public void Should_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() + public void Should_Not_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() { // Arrange var source = TestFixtures.GetCompleteSource( @@ -22,13 +22,12 @@ public void Should_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() var result = GeneratorTestHelper.RunGenerator( CompilationHelper.CreateCompilation(source)); - // Assert + // Assert - XPC1000 is no longer reported to avoid spamming the user var successDiagnostics = result.GeneratorDiagnostics .Where(d => d.Id == "XPC1000") .ToArray(); - successDiagnostics.Should().NotBeEmpty("XPC1000 should be reported on successful generation"); - successDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Info); + successDiagnostics.Should().BeEmpty("XPC1000 success diagnostic should not be reported to avoid spam"); } [Fact] diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index ebda9a1..ac7d217 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -155,9 +155,6 @@ private void GenerateSourceFromMetadata( // Add the source to the compilation // Use SourceText.From() to ensure language-agnostic parsing (Roslyn will use compilation's ParseOptions) context.AddSource(hintName, SourceText.From(sourceCode, Encoding.UTF8)); - - // Report diagnostic for successful generation (optional, for debugging) - ReportGenerationSuccess(context, metadata); } catch (System.Exception ex) { From 6adaa6cb1faaecffc260c1bdc83c51ba2d3e3fb8 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 27 Nov 2025 08:44:05 +0100 Subject: [PATCH 18/22] REFACTOR: Move diagnostics to Analyzer insterad of Generator to allow better location information, and automated code-fixes --- .../PreferNameofAnalyzerTests.cs | 257 +++++++++++++ .../PreferNameofCodeFixProviderTests.cs | 347 ++++++++++++++++++ .../DiagnosticReportingTests.cs | 105 +++--- ...XrmPluginCore.SourceGenerator.Tests.csproj | 4 + .../AnalyzerReleases.Unshipped.md | 3 +- .../HandlerMethodNotFoundAnalyzer.cs | 95 +++++ .../HandlerSignatureMismatchAnalyzer.cs | 159 ++++++++ .../ImageWithoutMethodReferenceAnalyzer.cs | 134 +++++++ .../NoParameterlessConstructorAnalyzer.cs | 65 ++++ .../Analyzers/PreferNameofAnalyzer.cs | 76 ++++ ...ParameterlessConstructorCodeFixProvider.cs | 95 +++++ .../CreateHandlerMethodCodeFixProvider.cs | 192 ++++++++++ .../FixHandlerSignatureCodeFixProvider.cs | 177 +++++++++ ...geWithoutMethodReferenceCodeFixProvider.cs | 85 +++++ .../CodeFixes/PreferNameofCodeFixProvider.cs | 85 +++++ .../DiagnosticDescriptors.cs | 21 +- .../Generators/PluginImageGenerator.cs | 4 + .../Helpers/RegisterStepHelper.cs | 160 ++++++++ .../Helpers/SyntaxFactoryHelper.cs | 76 ++++ .../Helpers/TypeHelper.cs | 33 ++ .../Models/PluginStepMetadata.cs | 6 + .../Parsers/RegistrationParser.cs | 84 +---- .../Validation/HandlerMethodValidator.cs | 65 +--- .../XrmPluginCore.SourceGenerator.csproj | 1 + 24 files changed, 2141 insertions(+), 188 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/HandlerMethodNotFoundAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/ImageWithoutMethodReferenceAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/NoParameterlessConstructorAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/AddParameterlessConstructorCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/CreateHandlerMethodCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/FixHandlerSignatureCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/ImageWithoutMethodReferenceCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/PreferNameofCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/TypeHelper.cs diff --git a/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs new file mode 100644 index 0000000..16b6eff --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs @@ -0,0 +1,257 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Analyzers; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.AnalyzerTests; + +/// +/// Tests for PreferNameofAnalyzer that warns when string literals are used for handler methods. +/// +public class PreferNameofAnalyzerTests +{ + [Fact] + public async Task Should_Report_XPC3001_When_String_Literal_Used_For_Handler_Method() + { + // Arrange + var 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, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + diagnostics.Should().ContainSingle(d => d.Id == "XPC3001"); + var diagnostic = diagnostics.Single(d => d.Id == "XPC3001"); + diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); + diagnostic.GetMessage().Should().Contain("nameof(ITestService.HandleUpdate)"); + diagnostic.GetMessage().Should().Contain("\"HandleUpdate\""); + } + + [Fact] + public async Task Should_Include_ServiceType_In_Diagnostic_Properties() + { + // Arrange + var 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, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + var diagnostic = diagnostics.Single(d => d.Id == "XPC3001"); + diagnostic.Properties.Should().ContainKey("ServiceType"); + diagnostic.Properties.Should().ContainKey("MethodName"); + diagnostic.Properties["ServiceType"].Should().Be("ITestService"); + diagnostic.Properties["MethodName"].Should().Be("HandleUpdate"); + } + + [Fact] + public async Task Should_Not_Report_XPC3001_When_Nameof_Used_For_Handler_Method() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.AccountEntity, + TestFixtures.GetPluginWithHandlerNoImages()); + + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + diagnostics.Should().NotContain(d => d.Id == "XPC3001"); + } + + [Fact] + public async Task Should_Not_Report_XPC3001_When_Lambda_Used_For_Handler_Method() + { + // Arrange + var 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, + s => s.HandleUpdate) + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + diagnostics.Should().NotContain(d => d.Id == "XPC3001"); + } + + [Fact] + public async Task Should_Report_XPC3001_For_String_Literal_With_Images() + { + // Arrange + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; +using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + +namespace TestNamespace +{ + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + ""Process"") + .AddFilteredAttributes(x => x.Name) + .WithPreImage(x => x.Name, x => x.Revenue); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(PreImage preImage); + } + + public class TestService : ITestService + { + public void Process(PreImage preImage) { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + diagnostics.Should().ContainSingle(d => d.Id == "XPC3001"); + } + + [Fact] + public async Task Should_Not_Report_For_Non_RegisterStep_Methods() + { + // Arrange - Source with a generic method call that is not RegisterStep + var source = @" +using System; + +namespace TestNamespace +{ + public class SomeClass + { + public void DoSomething() + { + SomeMethod(""value""); + } + + public void SomeMethod(string arg) { } + } +}"; + + var diagnostics = await GetDiagnosticsAsync(source); + + // Assert + diagnostics.Should().NotContain(d => d.Id == "XPC3001"); + } + + private static async Task> GetDiagnosticsAsync(string source) + { + var compilation = CompilationHelper.CreateCompilation(source); + var analyzer = new PreferNameofAnalyzer(); + + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzer)); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs b/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs new file mode 100644 index 0000000..5e23606 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs @@ -0,0 +1,347 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Analyzers; +using XrmPluginCore.SourceGenerator.CodeFixes; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.CodeFixTests; + +/// +/// Tests for PreferNameofCodeFixProvider that converts string literals to nameof() expressions. +/// +public class PreferNameofCodeFixProviderTests +{ + [Fact] + public async Task Should_Convert_String_Literal_To_Nameof_With_Service_Type() + { + // Arrange + var 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, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var fixedSource = await ApplyCodeFixAsync(source); + + // Assert + fixedSource.Should().Contain("nameof(ITestService.HandleUpdate)"); + fixedSource.Should().NotContain("\"HandleUpdate\""); + } + + [Fact] + public async Task Should_Preserve_Surrounding_Code_Structure() + { + // Arrange + var 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, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var fixedSource = await ApplyCodeFixAsync(source); + + // Assert - Verify structure is preserved + fixedSource.Should().Contain("RegisterStep"); + fixedSource.Should().Contain("EventOperation.Update"); + fixedSource.Should().Contain("ExecutionStage.PostOperation"); + fixedSource.Should().Contain(".AddFilteredAttributes"); + } + + [Fact] + public async Task Should_Fix_Multiple_String_Literals_When_FixAll_Applied() + { + // Arrange - Two plugins with string literals + var pluginSource = @" +using XrmPluginCore; +using XrmPluginCore.Enums; +using Microsoft.Extensions.DependencyInjection; +using TestNamespace; + +namespace TestNamespace +{ + public class TestPlugin1 : Plugin + { + public TestPlugin1() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public class TestPlugin2 : Plugin + { + public TestPlugin2() + { + RegisterStep(EventOperation.Create, ExecutionStage.PreOperation, + ""HandleCreate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + void HandleCreate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + public void HandleCreate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act - Apply all fixes + var fixedSource = await ApplyAllCodeFixesAsync(source); + + // Assert + fixedSource.Should().Contain("nameof(ITestService.HandleUpdate)"); + fixedSource.Should().Contain("nameof(ITestService.HandleCreate)"); + fixedSource.Should().NotContain("\"HandleUpdate\""); + fixedSource.Should().NotContain("\"HandleCreate\""); + } + + [Fact] + public async Task CodeFix_Should_Have_Correct_Title() + { + // Arrange + var 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, + ""HandleUpdate"") + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(); + } + + public class TestService : ITestService + { + public void HandleUpdate() { } + } +}"; + + var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + + // Act + var codeActions = await GetCodeActionsAsync(source); + + // Assert + codeActions.Should().ContainSingle(); + codeActions[0].Title.Should().Be("Use nameof(ITestService.HandleUpdate)"); + } + + private static async Task ApplyCodeFixAsync(string source) + { + var compilation = CompilationHelper.CreateCompilation(source); + var analyzer = new PreferNameofAnalyzer(); + var codeFixProvider = new PreferNameofCodeFixProvider(); + + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "XPC3001"); + + if (diagnostic == null) + { + return source; + } + + var document = CreateDocument(source); + var actions = new List(); + + var context = new CodeFixContext( + document, + diagnostic, + (action, _) => actions.Add(action), + CancellationToken.None); + + await codeFixProvider.RegisterCodeFixesAsync(context); + + if (actions.Count == 0) + { + return source; + } + + var operations = await actions[0].GetOperationsAsync(CancellationToken.None); + var changedSolution = operations.OfType().Single().ChangedSolution; + var changedDocument = changedSolution.GetDocument(document.Id); + var newText = await changedDocument!.GetTextAsync(); + + return newText.ToString(); + } + + private static async Task ApplyAllCodeFixesAsync(string source) + { + var currentSource = source; + var previousSource = string.Empty; + + // Keep applying fixes until no more changes + while (currentSource != previousSource) + { + previousSource = currentSource; + currentSource = await ApplyCodeFixAsync(currentSource); + } + + return currentSource; + } + + private static async Task> GetCodeActionsAsync(string source) + { + var compilation = CompilationHelper.CreateCompilation(source); + var analyzer = new PreferNameofAnalyzer(); + var codeFixProvider = new PreferNameofCodeFixProvider(); + + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "XPC3001"); + + if (diagnostic == null) + { + return new List(); + } + + var document = CreateDocument(source); + var actions = new List(); + + var context = new CodeFixContext( + document, + diagnostic, + (action, _) => actions.Add(action), + CancellationToken.None); + + await codeFixProvider.RegisterCodeFixesAsync(context); + + return actions; + } + + private static Document CreateDocument(string source) + { + var projectId = ProjectId.CreateNewId(); + var documentId = DocumentId.CreateNewId(projectId); + + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Plugin).Assembly.Location), + MetadataReference.CreateFromFile(typeof(XrmPluginCore.Enums.EventOperation).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.Xrm.Sdk.Entity).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.IServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions).Assembly.Location), + MetadataReference.CreateFromFile(System.Reflection.Assembly.Load("System.Runtime").Location), + MetadataReference.CreateFromFile(System.Reflection.Assembly.Load("netstandard").Location), + }; + + var solution = new AdhocWorkspace().CurrentSolution + .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp) + .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .WithProjectParseOptions(projectId, new CSharpParseOptions(LanguageVersion.CSharp11)) + .AddMetadataReferences(projectId, references) + .AddDocument(documentId, "Test.cs", source); + + return solution.GetDocument(documentId)!; + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 9f88b08..04b0852 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -1,12 +1,15 @@ using FluentAssertions; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Analyzers; using XrmPluginCore.SourceGenerator.Tests.Helpers; using Xunit; namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests; /// -/// Tests for verifying diagnostic reporting from the source generator. +/// Tests for verifying diagnostic reporting from the source generator and analyzers. /// public class DiagnosticReportingTests { @@ -31,7 +34,7 @@ public void Should_Not_Report_XPC1000_Success_Diagnostic_On_Successful_Generatio } [Fact] - public void Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() + public async Task Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() { // Arrange - plugin class with only a parameterized constructor (no parameterless) var pluginSource = @" @@ -65,12 +68,11 @@ public class TestService : ITestService { public void Process(PreImage preImage) var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new NoParameterlessConstructorAnalyzer()); // Assert - should report XPC4001 - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4001") .ToArray(); @@ -105,7 +107,7 @@ public void Should_Handle_XPC5000_Generation_Error_Gracefully() } [Fact] - public void Should_Report_XPC4002_When_Handler_Method_Not_Found() + public async Task Should_Report_XPC4002_When_Handler_Method_Not_Found() { // Arrange - method reference points to NonExistentMethod but service has Process var pluginSource = @" @@ -144,12 +146,11 @@ public void Process() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerMethodNotFoundAnalyzer()); // Assert - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4002") .ToArray(); @@ -158,7 +159,7 @@ public void Process() { } } [Fact] - public void Should_Report_XPC4003_When_Handler_Missing_PreImage_Parameter() + public async Task Should_Report_XPC4003_When_Handler_Missing_PreImage_Parameter() { // Arrange - WithPreImage is registered but handler takes no parameters var pluginSource = @" @@ -197,12 +198,11 @@ public void Process() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); // Assert - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4003") .ToArray(); @@ -211,7 +211,7 @@ public void Process() { } } [Fact] - public void Should_Report_XPC4003_When_Handler_Missing_PostImage_Parameter() + public async Task Should_Report_XPC4003_When_Handler_Missing_PostImage_Parameter() { // Arrange - WithPostImage is registered but handler takes no parameters var pluginSource = @" @@ -250,12 +250,11 @@ public void Process() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); // Assert - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4003") .ToArray(); @@ -264,7 +263,7 @@ public void Process() { } } [Fact] - public void Should_Report_XPC4003_When_Handler_Missing_Both_Image_Parameters() + public async Task Should_Report_XPC4003_When_Handler_Missing_Both_Image_Parameters() { // Arrange - Both WithPreImage and WithPostImage but handler takes no parameters var pluginSource = @" @@ -304,12 +303,11 @@ public void Process() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); // Assert - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4003") .ToArray(); @@ -318,7 +316,7 @@ public void Process() { } } [Fact] - public void Should_Report_XPC4003_When_Handler_Has_Wrong_Parameter_Order() + public async Task Should_Report_XPC4003_When_Handler_Has_Wrong_Parameter_Order() { // Arrange - WithPreImage and WithPostImage but handler has parameters in wrong order var pluginSource = @" @@ -359,12 +357,11 @@ public void Process(PostImage post, PreImage pre) { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); // Assert - var errorDiagnostics = result.GeneratorDiagnostics + var errorDiagnostics = diagnostics .Where(d => d.Id == "XPC4003") .ToArray(); @@ -373,7 +370,7 @@ public void Process(PostImage post, PreImage pre) { } } [Fact] - public void Should_Report_XPC4004_When_WithPreImage_Used_With_Invocation_Syntax() + public async Task Should_Report_XPC4004_When_WithPreImage_Used_With_Invocation_Syntax() { // Arrange - WithPreImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) var pluginSource = @" @@ -412,12 +409,11 @@ public void DoSomething() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); // Assert - var warningDiagnostics = result.GeneratorDiagnostics + var warningDiagnostics = diagnostics .Where(d => d.Id == "XPC4004") .ToArray(); @@ -426,7 +422,7 @@ public void DoSomething() { } } [Fact] - public void Should_Report_XPC4004_When_WithPostImage_Used_With_Invocation_Syntax() + public async Task Should_Report_XPC4004_When_WithPostImage_Used_With_Invocation_Syntax() { // Arrange - WithPostImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) var pluginSource = @" @@ -465,12 +461,11 @@ public void DoSomething() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); // Assert - var warningDiagnostics = result.GeneratorDiagnostics + var warningDiagnostics = diagnostics .Where(d => d.Id == "XPC4004") .ToArray(); @@ -479,7 +474,7 @@ public void DoSomething() { } } [Fact] - public void Should_Not_Report_XPC4004_When_Using_Method_Reference_Syntax() + public async Task Should_Not_Report_XPC4004_When_Using_Method_Reference_Syntax() { // Arrange - Method reference syntax (correct usage) var pluginSource = @" @@ -519,12 +514,11 @@ public void HandleUpdate(PreImage preImage) { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); // Assert - var warningDiagnostics = result.GeneratorDiagnostics + var warningDiagnostics = diagnostics .Where(d => d.Id == "XPC4004") .ToArray(); @@ -532,7 +526,7 @@ public void HandleUpdate(PreImage preImage) { } } [Fact] - public void Should_Not_Report_XPC4004_When_Old_Api_Used_Without_Images() + public async Task Should_Not_Report_XPC4004_When_Old_Api_Used_Without_Images() { // Arrange - Invocation syntax but without WithPreImage/WithPostImage (no images registered) var pluginSource = @" @@ -571,15 +565,24 @@ public void DoSomething() { } var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); - // Act - var result = GeneratorTestHelper.RunGenerator( - CompilationHelper.CreateCompilation(source)); + // Act - Run analyzer instead of generator + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); // Assert - var warningDiagnostics = result.GeneratorDiagnostics + var warningDiagnostics = diagnostics .Where(d => d.Id == "XPC4004") .ToArray(); warningDiagnostics.Should().BeEmpty("XPC4004 should NOT be reported when old API is used without images"); } + + private static async Task> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer) + { + var compilation = CompilationHelper.CreateCompilation(source); + + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzer)); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } } diff --git a/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj index 8b38225..f6b0e0a 100644 --- a/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj +++ b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj @@ -27,6 +27,10 @@ + + + + diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index 7c3ce6c..967f987 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -3,9 +3,10 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe wrapper classes -XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes +XPC3001 | XrmPluginCore.SourceGenerator | Warning | XPC3001 Prefer nameof over string literal for handler method XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found XPC4002 | XrmPluginCore.SourceGenerator | Error | XPC4002 Handler method not found XPC4003 | XrmPluginCore.SourceGenerator | Error | XPC4003 Handler signature does not match registered images XPC4004 | XrmPluginCore.SourceGenerator | Warning | XPC4004 Image registration without method reference +XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes diff --git a/XrmPluginCore.SourceGenerator/Analyzers/HandlerMethodNotFoundAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/HandlerMethodNotFoundAnalyzer.cs new file mode 100644 index 0000000..224d3b6 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/HandlerMethodNotFoundAnalyzer.cs @@ -0,0 +1,95 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Analyzer that reports an error when a handler method referenced in RegisterStep does not exist. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class HandlerMethodNotFoundAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.HandlerMethodNotFound); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a RegisterStep call + if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName)) + { + return; + } + + // Check if there are at least 2 type arguments (TEntity, TService) + if (genericName.TypeArgumentList.Arguments.Count < 2) + { + return; + } + + // Check if there's a 3rd argument (the handler method) + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return; + } + + var handlerArgument = arguments[2].Expression; + + // Get the method name from nameof or string literal + var methodName = RegisterStepHelper.GetMethodName(handlerArgument); + if (methodName == null) + { + return; + } + + // Get the service type symbol + var serviceTypeSyntax = genericName.TypeArgumentList.Arguments[1]; + var serviceTypeInfo = context.SemanticModel.GetTypeInfo(serviceTypeSyntax); + var serviceType = serviceTypeInfo.Type; + + if (serviceType == null) + { + return; + } + + // Check if the method exists on the service type (including inherited methods) + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName); + if (methods.Any()) + { + return; // Method exists, no error + } + + // Create diagnostic properties for the code fix + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("ServiceType", serviceType.Name); + properties.Add("MethodName", methodName); + + // Determine if there are images registered by checking the call chain + var (hasPreImage, hasPostImage) = RegisterStepHelper.CheckForImages(invocation); + properties.Add("HasPreImage", hasPreImage.ToString()); + properties.Add("HasPostImage", hasPostImage.ToString()); + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.HandlerMethodNotFound, + handlerArgument.GetLocation(), + properties.ToImmutable(), + methodName, + serviceType.Name); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs new file mode 100644 index 0000000..c367b39 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs @@ -0,0 +1,159 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Analyzer that reports an error when a handler method signature does not match the registered images. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class HandlerSignatureMismatchAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.HandlerSignatureMismatch); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a RegisterStep call + if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName)) + { + return; + } + + // Check if there are at least 2 type arguments (TEntity, TService) + if (genericName.TypeArgumentList.Arguments.Count < 2) + { + return; + } + + // Check if there's a 3rd argument (the handler method) + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return; + } + + var handlerArgument = arguments[2].Expression; + + // Get the method name from nameof or string literal + var methodName = RegisterStepHelper.GetMethodName(handlerArgument); + if (methodName == null) + { + return; + } + + // Get the service type symbol + var serviceTypeSyntax = genericName.TypeArgumentList.Arguments[1]; + var serviceTypeInfo = context.SemanticModel.GetTypeInfo(serviceTypeSyntax); + var serviceType = serviceTypeInfo.Type; + + if (serviceType == null) + { + return; + } + + // Check if the method exists on the service type + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName); + if (!methods.Any()) + { + return; // Method doesn't exist - XPC4002 handles this + } + + // Check for registered images + var (hasPreImage, hasPostImage) = RegisterStepHelper.CheckForImages(invocation); + + // If no images registered, no signature check needed + if (!hasPreImage && !hasPostImage) + { + return; + } + + // Check if any overload matches the expected signature + var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage)); + if (hasMatchingOverload) + { + return; + } + + // Build expected signature description + var expectedSignature = SyntaxFactoryHelper.BuildSignatureDescription(hasPreImage, hasPostImage); + + // Create diagnostic properties for the code fix + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("ServiceType", serviceType.Name); + properties.Add("MethodName", methodName); + properties.Add("HasPreImage", hasPreImage.ToString()); + properties.Add("HasPostImage", hasPostImage.ToString()); + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.HandlerSignatureMismatch, + handlerArgument.GetLocation(), + properties.ToImmutable(), + methodName, + expectedSignature); + + context.ReportDiagnostic(diagnostic); + } + + private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage) + { + var parameters = method.Parameters; + var expectedParamCount = (hasPreImage ? 1 : 0) + (hasPostImage ? 1 : 0); + + if (parameters.Length != expectedParamCount) + { + return false; + } + + var paramIndex = 0; + + if (hasPreImage) + { + if (paramIndex >= parameters.Length) + { + return false; + } + + if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName)) + { + return false; + } + + paramIndex++; + } + + if (hasPostImage) + { + if (paramIndex >= parameters.Length) + { + return false; + } + + if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName)) + { + return false; + } + } + + return true; + } + + private static bool IsImageParameter(IParameterSymbol parameter, string expectedImageType) + { + return parameter.Type.Name == expectedImageType; + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/ImageWithoutMethodReferenceAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/ImageWithoutMethodReferenceAnalyzer.cs new file mode 100644 index 0000000..b5c8c09 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/ImageWithoutMethodReferenceAnalyzer.cs @@ -0,0 +1,134 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Analyzer that warns when lambda invocation syntax (s => s.Method()) is used with image registrations +/// instead of method reference syntax (nameof(Service.Method)). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ImageWithoutMethodReferenceAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ImageWithoutMethodReference); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a RegisterStep call + if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName)) + { + return; + } + + // Check if there are at least 2 type arguments (TEntity, TService) + if (genericName.TypeArgumentList.Arguments.Count < 2) + { + return; + } + + // Check if there's a 3rd argument (the handler method) + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return; + } + + var handlerArgument = arguments[2].Expression; + + // Check if the 3rd argument is a lambda with an invocation body (s => s.Method()) + if (!IsLambdaWithInvocation(handlerArgument, out var methodName)) + { + return; + } + + // Check if the call chain has WithPreImage or WithPostImage + if (!HasImageRegistration(invocation)) + { + return; + } + + // Get the service type name (TService) + var serviceType = genericName.TypeArgumentList.Arguments[1].ToString(); + + // Create diagnostic properties for the code fix + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("ServiceType", serviceType); + properties.Add("MethodName", methodName); + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.ImageWithoutMethodReference, + handlerArgument.GetLocation(), + properties.ToImmutable()); + + context.ReportDiagnostic(diagnostic); + } + + private static bool IsLambdaWithInvocation(ExpressionSyntax expression, out string methodName) + { + methodName = null; + + // Check for simple lambda: s => s.Method() + if (expression is SimpleLambdaExpressionSyntax simpleLambda) + { + if (simpleLambda.Body is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + methodName = memberAccess.Name.Identifier.Text; + return true; + } + } + + // Check for parenthesized lambda: (s) => s.Method() + if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) + { + if (parenLambda.Body is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + methodName = memberAccess.Name.Identifier.Text; + return true; + } + } + + return false; + } + + private static bool HasImageRegistration(InvocationExpressionSyntax registerStepInvocation) + { + // Walk up to find the full fluent call chain + var current = registerStepInvocation.Parent; + + while (current != null) + { + // Check if this is a method call in the chain + if (current is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == Constants.WithPreImageMethodName || + methodName == Constants.WithPostImageMethodName || + methodName == Constants.AddImageMethodName) + { + return true; + } + } + + // Move up the syntax tree + current = current.Parent; + } + + return false; + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/NoParameterlessConstructorAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/NoParameterlessConstructorAnalyzer.cs new file mode 100644 index 0000000..bcf4819 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/NoParameterlessConstructorAnalyzer.cs @@ -0,0 +1,65 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Analyzer that warns when a plugin class has explicit constructors but no parameterless constructor. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class NoParameterlessConstructorAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.NoParameterlessConstructor); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration); + } + + private void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + // Check if the class inherits from Plugin + if (!SyntaxHelper.InheritsFromPlugin(classDeclaration, context.SemanticModel)) + { + return; + } + + // Get all constructors + var constructors = classDeclaration.Members + .OfType() + .ToList(); + + // If no explicit constructors, compiler provides a default parameterless one + if (constructors.Count == 0) + { + return; + } + + // Check if any constructor is parameterless + var hasParameterlessConstructor = constructors + .Any(c => c.ParameterList.Parameters.Count == 0); + + if (hasParameterlessConstructor) + { + return; + } + + // Report diagnostic at the class identifier + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.NoParameterlessConstructor, + classDeclaration.Identifier.GetLocation(), + classDeclaration.Identifier.Text); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs new file mode 100644 index 0000000..c1f9347 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Analyzer that warns when string literals are used instead of nameof() for handler methods in RegisterStep calls. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class PreferNameofAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.PreferNameofOverStringLiteral); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check if this is a RegisterStep call + if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName)) + { + return; + } + + // Check if there are at least 2 type arguments (TEntity, TService) + if (genericName.TypeArgumentList.Arguments.Count < 2) + { + return; + } + + // Check if there's a 3rd argument (the handler method) + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return; + } + + var handlerArgument = arguments[2].Expression; + + // Check if the 3rd argument is a string literal + if (handlerArgument is not LiteralExpressionSyntax literal || + !literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + return; + } + + // Get the service type name (TService) + var serviceType = genericName.TypeArgumentList.Arguments[1].ToString(); + var methodName = literal.Token.ValueText; + + // Create diagnostic properties for the code fix + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add("ServiceType", serviceType); + properties.Add("MethodName", methodName); + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.PreferNameofOverStringLiteral, + literal.GetLocation(), + properties.ToImmutable(), + serviceType, + methodName); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/AddParameterlessConstructorCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/AddParameterlessConstructorCodeFixProvider.cs new file mode 100644 index 0000000..3dce62d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/AddParameterlessConstructorCodeFixProvider.cs @@ -0,0 +1,95 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix provider that adds a parameterless constructor to plugin classes. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddParameterlessConstructorCodeFixProvider)), Shared] +public class AddParameterlessConstructorCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create("XPC4001"); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var classDeclaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (classDeclaration == null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Add parameterless constructor", + createChangedDocument: c => AddParameterlessConstructorAsync(context.Document, classDeclaration, c), + equivalenceKey: nameof(AddParameterlessConstructorCodeFixProvider)), + diagnostic); + } + + private static async Task AddParameterlessConstructorAsync( + Document document, + ClassDeclarationSyntax classDeclaration, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + // Find the last constructor to insert after + var constructors = classDeclaration.Members + .OfType() + .ToList(); + + // Create: public ClassName() { } + var newConstructor = SyntaxFactory.ConstructorDeclaration(classDeclaration.Identifier) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithBody(SyntaxFactory.Block()) + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed, SyntaxFactory.ElasticTab) + .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + + // Find where to insert: after the last constructor, or at the start of members + ClassDeclarationSyntax newClassDeclaration; + if (constructors.Count > 0) + { + var lastConstructor = constructors.Last(); + var insertIndex = classDeclaration.Members.IndexOf(lastConstructor) + 1; + newClassDeclaration = classDeclaration.WithMembers( + classDeclaration.Members.Insert(insertIndex, newConstructor)); + } + else + { + // Insert at the beginning of members + newClassDeclaration = classDeclaration.WithMembers( + classDeclaration.Members.Insert(0, newConstructor)); + } + + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/CreateHandlerMethodCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/CreateHandlerMethodCodeFixProvider.cs new file mode 100644 index 0000000..06d1112 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/CreateHandlerMethodCodeFixProvider.cs @@ -0,0 +1,192 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix provider that creates a missing handler method on a service interface. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CreateHandlerMethodCodeFixProvider)), Shared] +public class CreateHandlerMethodCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create("XPC4002"); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + + // Get properties from diagnostic + if (!diagnostic.Properties.TryGetValue("ServiceType", out var serviceType) || + !diagnostic.Properties.TryGetValue("MethodName", out var methodName)) + { + return; + } + + diagnostic.Properties.TryGetValue("HasPreImage", out var hasPreImageStr); + diagnostic.Properties.TryGetValue("HasPostImage", out var hasPostImageStr); + + var hasPreImage = bool.TryParse(hasPreImageStr, out var pre) && pre; + var hasPostImage = bool.TryParse(hasPostImageStr, out var post) && post; + + // Build the title showing expected signature + var signatureDescription = SyntaxFactoryHelper.BuildSignatureDescription(hasPreImage, hasPostImage); + var title = $"Create method '{methodName}({signatureDescription})'"; + + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedSolution: c => CreateMethodAsync(context.Document, diagnostic, serviceType!, methodName!, hasPreImage, hasPostImage, c), + equivalenceKey: nameof(CreateHandlerMethodCodeFixProvider)), + diagnostic); + } + + private static async Task CreateMethodAsync( + Document document, + Diagnostic diagnostic, + string serviceTypeName, + string methodName, + bool hasPreImage, + bool hasPostImage, + CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + + // Get semantic model to find the service type declaration + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel == null) + { + return solution; + } + + // Find the RegisterStep call to get the service type + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return solution; + } + + var diagnosticNode = root.FindNode(diagnostic.Location.SourceSpan); + var registerStepInvocation = diagnosticNode.AncestorsAndSelf() + .OfType() + .FirstOrDefault(i => RegisterStepHelper.IsRegisterStepCall(i, out _)); + + if (registerStepInvocation == null) + { + return solution; + } + + // Get service type from generic arguments + var genericName = RegisterStepHelper.GetGenericName(registerStepInvocation); + if (genericName == null || genericName.TypeArgumentList.Arguments.Count < 2) + { + return solution; + } + + var serviceTypeSyntax = genericName.TypeArgumentList.Arguments[1]; + var typeInfo = semanticModel.GetTypeInfo(serviceTypeSyntax, cancellationToken); + var serviceTypeSymbol = typeInfo.Type as INamedTypeSymbol; + + if (serviceTypeSymbol == null) + { + return solution; + } + + // Find the interface declaration in the solution + var interfaceDeclaration = await FindInterfaceDeclarationAsync(solution, serviceTypeSymbol, cancellationToken); + if (interfaceDeclaration == null) + { + return solution; + } + + // Create the method declaration + var methodDeclaration = CreateMethodDeclaration(methodName, hasPreImage, hasPostImage); + + // Add the method to the interface + var interfaceDocument = solution.GetDocument(interfaceDeclaration.SyntaxTree); + if (interfaceDocument == null) + { + return solution; + } + + var interfaceRoot = await interfaceDocument.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (interfaceRoot == null) + { + return solution; + } + + var newInterface = interfaceDeclaration.AddMembers(methodDeclaration); + var newRoot = interfaceRoot.ReplaceNode(interfaceDeclaration, newInterface); + + return solution.WithDocumentSyntaxRoot(interfaceDocument.Id, newRoot); + } + + private static MethodDeclarationSyntax CreateMethodDeclaration(string methodName, bool hasPreImage, bool hasPostImage) + { + return SyntaxFactory.MethodDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), + SyntaxFactory.Identifier(methodName)) + .WithParameterList(SyntaxFactoryHelper.CreateImageParameterList(hasPreImage, hasPostImage)) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed, SyntaxFactory.ElasticTab) + .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + } + + private static async Task FindInterfaceDeclarationAsync( + Solution solution, + INamedTypeSymbol typeSymbol, + CancellationToken cancellationToken) + { + foreach (var location in typeSymbol.Locations) + { + if (!location.IsInSource) + { + continue; + } + + var tree = location.SourceTree; + if (tree == null) + { + continue; + } + + var document = solution.GetDocument(tree); + if (document == null) + { + continue; + } + + var root = await tree.GetRootAsync(cancellationToken).ConfigureAwait(false); + var node = root.FindNode(location.SourceSpan); + + var interfaceDeclaration = node.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (interfaceDeclaration != null) + { + return interfaceDeclaration; + } + } + + return null; + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/FixHandlerSignatureCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/FixHandlerSignatureCodeFixProvider.cs new file mode 100644 index 0000000..1e873b9 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/FixHandlerSignatureCodeFixProvider.cs @@ -0,0 +1,177 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix provider that fixes handler method signatures to match registered images. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FixHandlerSignatureCodeFixProvider)), Shared] +public class FixHandlerSignatureCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create("XPC4003"); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + + // Get properties from diagnostic + if (!diagnostic.Properties.TryGetValue("ServiceType", out var serviceType) || + !diagnostic.Properties.TryGetValue("MethodName", out var methodName)) + { + return; + } + + diagnostic.Properties.TryGetValue("HasPreImage", out var hasPreImageStr); + diagnostic.Properties.TryGetValue("HasPostImage", out var hasPostImageStr); + + var hasPreImage = bool.TryParse(hasPreImageStr, out var pre) && pre; + var hasPostImage = bool.TryParse(hasPostImageStr, out var post) && post; + + // Build the title showing expected signature + var signatureDescription = SyntaxFactoryHelper.BuildSignatureDescription(hasPreImage, hasPostImage, includeParameterNames: true); + var title = $"Fix signature to '{methodName}({signatureDescription})'"; + + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedSolution: c => FixSignatureAsync(context.Document, diagnostic, serviceType!, methodName!, hasPreImage, hasPostImage, c), + equivalenceKey: nameof(FixHandlerSignatureCodeFixProvider)), + diagnostic); + } + + private static async Task FixSignatureAsync( + Document document, + Diagnostic diagnostic, + string serviceTypeName, + string methodName, + bool hasPreImage, + bool hasPostImage, + CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + + // Get semantic model to find the service type + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel == null) + { + return solution; + } + + // Find the RegisterStep call to get the service type symbol + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return solution; + } + + var diagnosticNode = root.FindNode(diagnostic.Location.SourceSpan); + var registerStepInvocation = diagnosticNode.AncestorsAndSelf() + .OfType() + .FirstOrDefault(i => RegisterStepHelper.IsRegisterStepCall(i, out _)); + + if (registerStepInvocation == null) + { + return solution; + } + + // Get service type from generic arguments + var genericName = RegisterStepHelper.GetGenericName(registerStepInvocation); + if (genericName == null || genericName.TypeArgumentList.Arguments.Count < 2) + { + return solution; + } + + var serviceTypeSyntax = genericName.TypeArgumentList.Arguments[1]; + var typeInfo = semanticModel.GetTypeInfo(serviceTypeSyntax, cancellationToken); + var serviceTypeSymbol = typeInfo.Type as INamedTypeSymbol; + + if (serviceTypeSymbol == null) + { + return solution; + } + + // Find the method declarations to fix (in interface and implementations) + solution = await FixMethodDeclarationsAsync(solution, serviceTypeSymbol, methodName, hasPreImage, hasPostImage, cancellationToken); + + return solution; + } + + private static async Task FixMethodDeclarationsAsync( + Solution solution, + INamedTypeSymbol serviceType, + string methodName, + bool hasPreImage, + bool hasPostImage, + CancellationToken cancellationToken) + { + // Find all method declarations with this name on the service type + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName); + + foreach (var method in methods) + { + foreach (var location in method.Locations) + { + if (!location.IsInSource) + { + continue; + } + + var tree = location.SourceTree; + if (tree == null) + { + continue; + } + + var methodDocument = solution.GetDocument(tree); + if (methodDocument == null) + { + continue; + } + + var methodRoot = await tree.GetRootAsync(cancellationToken).ConfigureAwait(false); + var node = methodRoot.FindNode(location.SourceSpan); + + var methodDeclaration = node.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (methodDeclaration == null) + { + continue; + } + + // Create new parameter list + var newParameters = SyntaxFactoryHelper.CreateImageParameterList(hasPreImage, hasPostImage); + var newMethodDeclaration = methodDeclaration.WithParameterList(newParameters); + + var newRoot = methodRoot.ReplaceNode(methodDeclaration, newMethodDeclaration); + solution = solution.WithDocumentSyntaxRoot(methodDocument.Id, newRoot); + + // Re-fetch the tree since we modified the solution + break; // Only fix the first declaration, we'll fix others on subsequent runs + } + } + + return solution; + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/ImageWithoutMethodReferenceCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/ImageWithoutMethodReferenceCodeFixProvider.cs new file mode 100644 index 0000000..c7547e4 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/ImageWithoutMethodReferenceCodeFixProvider.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix provider that converts lambda invocation syntax (s => s.Method()) to nameof() expressions +/// when images are registered. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ImageWithoutMethodReferenceCodeFixProvider)), Shared] +public class ImageWithoutMethodReferenceCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create("XPC4004"); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var lambdaNode = root.FindNode(diagnosticSpan); + + // Get service type and method name from diagnostic properties + if (!diagnostic.Properties.TryGetValue("ServiceType", out var serviceType) || + !diagnostic.Properties.TryGetValue("MethodName", out var methodName)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Use nameof({serviceType}.{methodName})", + createChangedDocument: c => ConvertToNameofAsync(context.Document, lambdaNode, serviceType, methodName, c), + equivalenceKey: nameof(ImageWithoutMethodReferenceCodeFixProvider)), + diagnostic); + } + + private static async Task ConvertToNameofAsync( + Document document, + SyntaxNode diagnosticNode, + string serviceType, + string methodName, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + // Find the lambda expression (either SimpleLambda or ParenthesizedLambda) + var lambda = diagnosticNode.DescendantNodesAndSelf() + .FirstOrDefault(n => n is SimpleLambdaExpressionSyntax || n is ParenthesizedLambdaExpressionSyntax) + ?? diagnosticNode; + + if (lambda is not (SimpleLambdaExpressionSyntax or ParenthesizedLambdaExpressionSyntax)) + { + return document; + } + + // Build: nameof(ServiceType.MethodName) + var nameofExpression = SyntaxFactoryHelper.CreateNameofExpression(serviceType, methodName) + .WithTriviaFrom(lambda); + + var newRoot = root.ReplaceNode(lambda, nameofExpression); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/PreferNameofCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/PreferNameofCodeFixProvider.cs new file mode 100644 index 0000000..1abbb8b --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/PreferNameofCodeFixProvider.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix provider that converts string literal handler method references to nameof() expressions. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferNameofCodeFixProvider)), Shared] +public class PreferNameofCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create("XPC3001"); + + public sealed override FixAllProvider GetFixAllProvider() => + WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var stringLiteral = root.FindNode(diagnosticSpan); + + // Get service type and method name from diagnostic properties + if (!diagnostic.Properties.TryGetValue("ServiceType", out var serviceType) || + !diagnostic.Properties.TryGetValue("MethodName", out var methodName)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Use nameof({serviceType}.{methodName})", + createChangedDocument: c => ConvertToNameofAsync(context.Document, stringLiteral, serviceType, methodName, c), + equivalenceKey: nameof(PreferNameofCodeFixProvider)), + diagnostic); + } + + private static async Task ConvertToNameofAsync( + Document document, + SyntaxNode diagnosticNode, + string serviceType, + string methodName, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + // Find the actual string literal expression + var stringLiteral = diagnosticNode.DescendantNodesAndSelf() + .OfType() + .FirstOrDefault(l => l.IsKind(SyntaxKind.StringLiteralExpression)) + ?? diagnosticNode as LiteralExpressionSyntax; + + if (stringLiteral == null) + { + return document; + } + + // Build: nameof(ServiceType.MethodName) + var nameofExpression = SyntaxFactoryHelper.CreateNameofExpression(serviceType, methodName) + .WithTriviaFrom(stringLiteral); + + var newRoot = root.ReplaceNode(stringLiteral, nameofExpression); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 990aa25..8a549e8 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -17,13 +17,14 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Info, isEnabledByDefault: true); - public static readonly DiagnosticDescriptor GenerationError = new( - id: "XPC5000", - title: "Failed to generate wrapper classes", - messageFormat: "Exception during generation: {0}", + public static readonly DiagnosticDescriptor PreferNameofOverStringLiteral = new( + id: "XPC3001", + title: "Prefer nameof over string literal for handler method", + messageFormat: "Use 'nameof({0}.{1})' instead of string literal \"{1}\" for compile-time safety", category: Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true); + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Using nameof() provides compile-time verification that the method exists and enables refactoring support."); public static readonly DiagnosticDescriptor SymbolResolutionFailed = new( id: "XPC4000", @@ -64,4 +65,12 @@ internal static class DiagnosticDescriptors category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GenerationError = new( + id: "XPC5000", + title: "Failed to generate wrapper classes", + messageFormat: "Exception during generation: {0}", + category: Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index ac7d217..10026cb 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -137,6 +137,10 @@ 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/Helpers/RegisterStepHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs new file mode 100644 index 0000000..0631fae --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs @@ -0,0 +1,160 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Shared utilities for analyzing RegisterStep invocations. +/// +internal static class RegisterStepHelper +{ + /// + /// Checks if an invocation is a RegisterStep call and extracts the generic name. + /// + public static bool IsRegisterStepCall(InvocationExpressionSyntax invocation, out GenericNameSyntax genericName) + { + genericName = null; + + // Handle: this.RegisterStep<...>(...) or RegisterStep<...>(...) + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + if (memberAccess.Name is GenericNameSyntax generic && + generic.Identifier.Text == Constants.RegisterStepMethodName) + { + genericName = generic; + return true; + } + } + + // Handle: RegisterStep<...>(...) without 'this.' + if (invocation.Expression is GenericNameSyntax directGeneric && + directGeneric.Identifier.Text == Constants.RegisterStepMethodName) + { + genericName = directGeneric; + return true; + } + + return false; + } + + /// + /// Gets the GenericNameSyntax from a RegisterStep call. + /// + public static GenericNameSyntax GetGenericName(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is GenericNameSyntax generic) + { + return generic; + } + + if (invocation.Expression is GenericNameSyntax directGeneric) + { + return directGeneric; + } + + return null; + } + + /// + /// Extracts method name from nameof(), string literal, or lambda expressions. + /// + public static string GetMethodName(ExpressionSyntax expression) + { + // Handle nameof(): nameof(IService.HandleDelete) + if (expression is InvocationExpressionSyntax invocation && + invocation.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "nameof") + { + var argument = invocation.ArgumentList.Arguments.FirstOrDefault(); + if (argument?.Expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + + if (argument?.Expression is IdentifierNameSyntax simpleIdentifier) + { + return simpleIdentifier.Identifier.Text; + } + } + + // Handle string literal: "HandleDelete" + if (expression is LiteralExpressionSyntax literal && + literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + return literal.Token.ValueText; + } + + // Handle lambda: service => service.HandleUpdate + if (expression is SimpleLambdaExpressionSyntax simpleLambda) + { + if (simpleLambda.Body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + } + + if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) + { + if (parenLambda.Body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + } + + return null; + } + + /// + /// Checks the call chain for WithPreImage/WithPostImage/AddImage registrations. + /// + public static (bool hasPreImage, bool hasPostImage) CheckForImages(InvocationExpressionSyntax registerStepInvocation) + { + var hasPreImage = false; + var hasPostImage = false; + + // Walk up to find the full fluent call chain + var current = registerStepInvocation.Parent; + + while (current != null) + { + if (current is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == Constants.WithPreImageMethodName) + { + hasPreImage = true; + } + else if (methodName == Constants.WithPostImageMethodName) + { + hasPostImage = true; + } + else if (methodName == Constants.AddImageMethodName) + { + // Need to check the ImageType argument + if (current.Parent is InvocationExpressionSyntax addImageInvocation) + { + var args = addImageInvocation.ArgumentList.Arguments; + if (args.Count > 0 && args[0].Expression is MemberAccessExpressionSyntax imageTypeAccess) + { + var imageTypeName = imageTypeAccess.Name.Identifier.Text; + if (imageTypeName == Constants.PreImageTypeName) + { + hasPreImage = true; + } + else if (imageTypeName == Constants.PostImageTypeName) + { + hasPostImage = true; + } + } + } + } + } + + current = current.Parent; + } + + return (hasPreImage, hasPostImage); + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs new file mode 100644 index 0000000..4208091 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Shared utilities for syntax generation. +/// +internal static class SyntaxFactoryHelper +{ + /// + /// Creates a nameof(ServiceType.MethodName) expression. + /// + public static InvocationExpressionSyntax CreateNameofExpression(string serviceType, string methodName) + { + return SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(serviceType), + SyntaxFactory.IdentifierName(methodName)))))); + } + + /// + /// Creates a parameter list for PreImage/PostImage parameters. + /// + public static ParameterListSyntax CreateImageParameterList(bool hasPreImage, bool hasPostImage) + { + var parameters = new List(); + + if (hasPreImage) + { + parameters.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("preImage")) + .WithType(SyntaxFactory.IdentifierName(Constants.PreImageTypeName))); + } + + if (hasPostImage) + { + parameters.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier("postImage")) + .WithType(SyntaxFactory.IdentifierName(Constants.PostImageTypeName))); + } + + return SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)); + } + + /// + /// Builds a signature description string for PreImage/PostImage parameters. + /// + /// Whether PreImage is included. + /// Whether PostImage is included. + /// If true, includes parameter names (e.g., "PreImage preImage"); if false, just type names. + public static string BuildSignatureDescription(bool hasPreImage, bool hasPostImage, bool includeParameterNames = false) + { + if (!hasPreImage && !hasPostImage) + { + return ""; + } + + var parts = new List(); + if (hasPreImage) + { + parts.Add(includeParameterNames ? "PreImage preImage" : "PreImage"); + } + + if (hasPostImage) + { + parts.Add(includeParameterNames ? "PostImage postImage" : "PostImage"); + } + + return string.Join(", ", parts); + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/TypeHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/TypeHelper.cs new file mode 100644 index 0000000..e841121 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/TypeHelper.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Shared utilities for type and symbol operations. +/// +internal static class TypeHelper +{ + /// + /// Gets all methods with the specified name, including inherited methods. + /// + public static IMethodSymbol[] GetAllMethodsIncludingInherited(ITypeSymbol type, string methodName) + { + var methods = new List(); + var currentType = type; + while (currentType != null) + { + foreach (var member in currentType.GetMembers(methodName)) + { + if (member is IMethodSymbol method) + { + methods.Add(method); + } + } + + currentType = currentType.BaseType; + } + + return methods.ToArray(); + } +} diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index f392fef..2d7f3d5 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -36,6 +36,12 @@ 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} diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index 7c7989f..aeac078 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -29,23 +29,10 @@ public static IEnumerable ParsePluginClass( .OfType() .Any(); - // If class has explicit constructors but no parameterless one, report diagnostic + // If class has explicit constructors but no parameterless one, abort generation + // Note: XPC4001 (NoParameterlessConstructor) is handled by a separate analyzer if (hasExplicitConstructors && !hasParameterlessConstructor) { - var diagnosticMetadata = new PluginStepMetadata - { - PluginClassName = classDeclaration.Identifier.Text, - Namespace = classDeclaration.GetNamespace(), - Images = [] // Empty - no generation - }; - - diagnosticMetadata.Diagnostics.Add(new DiagnosticInfo - { - Descriptor = DiagnosticDescriptors.NoParameterlessConstructor, - MessageArgs = [classDeclaration.Identifier.Text] - }); - - yield return diagnosticMetadata; yield break; } @@ -124,7 +111,7 @@ private static PluginStepMetadata ParseRegisterStepInvocation( // Extract method reference from 3rd argument if present if (arguments.Count >= 3) { - metadata.HandlerMethodName = ParseMethodReference(arguments[2].Expression, semanticModel); + metadata.HandlerMethodName = RegisterStepHelper.GetMethodName(arguments[2].Expression); } // Find image calls @@ -137,75 +124,12 @@ private static PluginStepMetadata ParseRegisterStepInvocation( } } - // After parsing images, check if we have images but no method reference - // This indicates using old API (s => s.Method()) with new image methods (WithPreImage/WithPostImage) - if (metadata.Images.Any() && string.IsNullOrEmpty(metadata.HandlerMethodName)) - { - metadata.Diagnostics.Add(new DiagnosticInfo - { - Descriptor = DiagnosticDescriptors.ImageWithoutMethodReference, - MessageArgs = [] - }); - } - // Return metadata if we have a method reference (for code generation) // OR if we have diagnostics to report + // Note: XPC4004 (ImageWithoutMethodReference) is handled by a separate analyzer return !string.IsNullOrEmpty(metadata.HandlerMethodName) || metadata.Diagnostics.Any() ? metadata : null; } - /// - /// Parses a method reference from various expression forms: - /// - nameof(IService.HandleUpdate) - /// - "HandleUpdate" (string literal) - /// - service => service.HandleUpdate (lambda - legacy support) - /// - private static string ParseMethodReference(ExpressionSyntax expression, SemanticModel semanticModel) - { - // Handle nameof(): nameof(IService.HandleDelete) - if (expression is InvocationExpressionSyntax invocation && - invocation.Expression is IdentifierNameSyntax identifier && - identifier.Identifier.Text == "nameof") - { - var argument = invocation.ArgumentList.Arguments.FirstOrDefault(); - if (argument?.Expression is MemberAccessExpressionSyntax nameofMemberAccess) - { - return nameofMemberAccess.Name.Identifier.Text; - } - // Handle simple nameof: nameof(HandleDelete) - if (argument?.Expression is IdentifierNameSyntax simpleIdentifier) - { - return simpleIdentifier.Identifier.Text; - } - } - - // Handle string literal: "HandleDelete" - if (expression is LiteralExpressionSyntax literal && - literal.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralExpression)) - { - return literal.Token.ValueText; - } - - // Handle lambda: service => service.HandleUpdate (legacy support) - if (expression is SimpleLambdaExpressionSyntax lambda) - { - if (lambda.Body is MemberAccessExpressionSyntax memberAccess) - { - return memberAccess.Name.Identifier.Text; - } - } - - // Handle: (service) => service.HandleUpdate (legacy support) - if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) - { - if (parenLambda.Body is MemberAccessExpressionSyntax memberAccess) - { - return memberAccess.Name.Identifier.Text; - } - } - - return null; - } - /// /// Parses WithPreImage, WithPostImage, or AddImage call to extract image metadata. /// diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs index 590fca0..933a027 100644 --- a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -1,12 +1,17 @@ using Microsoft.CodeAnalysis; -using System.Collections.Generic; 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: XPC4002 and XPC4003 diagnostics are handled by separate analyzers. + /// public static void ValidateHandlerMethod( PluginStepMetadata metadata, Compilation compilation) @@ -19,48 +24,27 @@ public static void ValidateHandlerMethod( if (serviceType is null) return; - var methods = GetAllMethodsIncludingInherited(serviceType, metadata.HandlerMethodName); + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, metadata.HandlerMethodName); if (!methods.Any()) { - metadata.Diagnostics.Add(new DiagnosticInfo - { - Descriptor = DiagnosticDescriptors.HandlerMethodNotFound, - MessageArgs = [metadata.HandlerMethodName, metadata.ServiceTypeName] - }); + // Method not found - abort generation for this registration + // XPC4002 diagnostic is handled by HandlerMethodNotFoundAnalyzer + metadata.HasValidationError = true; return; } var hasPreImage = metadata.Images.Any(i => i.ImageType == Constants.PreImageTypeName); var hasPostImage = metadata.Images.Any(i => i.ImageType == Constants.PostImageTypeName); - var expectedSignature = BuildExpectedSignature(hasPreImage, hasPostImage); var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage)); if (!hasMatchingOverload) { - metadata.Diagnostics.Add(new DiagnosticInfo - { - Descriptor = DiagnosticDescriptors.HandlerSignatureMismatch, - MessageArgs = [metadata.HandlerMethodName, expectedSignature] - }); + // Signature mismatch - abort generation for this registration + // XPC4003 diagnostic is handled by HandlerSignatureMismatchAnalyzer + metadata.HasValidationError = true; } } - private static IReadOnlyList GetAllMethodsIncludingInherited(ITypeSymbol type, string methodName) - { - var methods = new List(); - var currentType = type; - while (currentType is not null) - { - foreach (var member in currentType.GetMembers(methodName)) - { - if (member is IMethodSymbol method) - methods.Add(method); - } - currentType = currentType.BaseType; - } - return methods; - } - private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage) { var parameters = method.Parameters; @@ -75,7 +59,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo { if (paramIndex >= parameters.Length) return false; - if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName)) + if (parameters[paramIndex].Type.Name != Constants.PreImageTypeName) return false; paramIndex++; } @@ -84,29 +68,10 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo { if (paramIndex >= parameters.Length) return false; - if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName)) + if (parameters[paramIndex].Type.Name != Constants.PostImageTypeName) return false; } return true; } - - private static bool IsImageParameter(IParameterSymbol parameter, string expectedImageType) - { - return parameter.Type.Name == expectedImageType; - } - - private static string BuildExpectedSignature(bool hasPreImage, bool hasPostImage) - { - var parts = new List(); - if (hasPreImage) - parts.Add(Constants.PreImageTypeName); - if (hasPostImage) - parts.Add(Constants.PostImageTypeName); - - if (parts.Count == 0) - return "no parameters"; - - return string.Join(", ", parts); - } } diff --git a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj index a823197..7754e8c 100644 --- a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj +++ b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj @@ -9,6 +9,7 @@ + From 0448a6d709b3418356406596434221d76a9589de Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 27 Nov 2025 09:12:12 +0100 Subject: [PATCH 19/22] DOCS: Add documentation of the rules and link to it on GH --- .../DiagnosticDescriptors.cs | 21 +-- .../rules/XPC3001.md | 60 ++++++++ .../rules/XPC4001.md | 79 +++++++++++ .../rules/XPC4002.md | 90 ++++++++++++ .../rules/XPC4003.md | 132 ++++++++++++++++++ .../rules/XPC4004.md | 77 ++++++++++ 6 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC3001.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4001.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4002.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4003.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4004.md diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 8a549e8..cd04b87 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -24,7 +24,8 @@ internal static class DiagnosticDescriptors category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Using nameof() provides compile-time verification that the method exists and enables refactoring support."); + description: "Using nameof() provides compile-time verification that the method exists and enables refactoring support.", + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC3001.md"); public static readonly DiagnosticDescriptor SymbolResolutionFailed = new( id: "XPC4000", @@ -39,24 +40,27 @@ internal static class DiagnosticDescriptors title: "No parameterless constructor found", messageFormat: "Plugin class '{0}' has no parameterless constructor. Image wrappers will not be generated for this plugin.", category: Category, - DiagnosticSeverity.Warning, - isEnabledByDefault: true); + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC4001.md"); public static readonly DiagnosticDescriptor HandlerMethodNotFound = new( id: "XPC4002", title: "Handler method not found", messageFormat: "Method '{0}' not found on service type '{1}'", category: Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true); + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC4002.md"); public static readonly DiagnosticDescriptor HandlerSignatureMismatch = new( id: "XPC4003", title: "Handler signature does not match registered images", messageFormat: "Handler method '{0}' does not have expected signature. Expected parameters in order: {1}. PreImage must be the first parameter, followed by PostImage if both are used.", category: Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true); + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC4003.md"); public static readonly DiagnosticDescriptor ImageWithoutMethodReference = new( id: "XPC4004", @@ -64,7 +68,8 @@ internal static class DiagnosticDescriptors messageFormat: "WithPreImage/WithPostImage requires method reference syntax (e.g., 'service => service.HandleUpdate'). Using method invocation (e.g., 's => s.HandleUpdate()') will not generate type-safe wrappers.", category: Category, defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC4004.md"); public static readonly DiagnosticDescriptor GenerationError = new( id: "XPC5000", diff --git a/XrmPluginCore.SourceGenerator/rules/XPC3001.md b/XrmPluginCore.SourceGenerator/rules/XPC3001.md new file mode 100644 index 0000000..ab1436e --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC3001.md @@ -0,0 +1,60 @@ +# XPC3001: Prefer nameof over string literal for handler method + +## Severity + +Warning + +## Description + +This rule reports when a string literal is used as the handler method parameter in `RegisterStep()` instead of `nameof()`. String literals don't provide compile-time verification that the method exists and prevent IDE refactoring support. + +## ❌ Example of violation + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC3001: Use 'nameof(IAccountService.HandleUpdate)' instead of string literal + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + "HandleUpdate") // String literal - no compile-time verification + .AddFilteredAttributes(x => x.Name); + } +} +``` + +## ✅ How to fix + +Use `nameof()` to reference the handler method: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) // Compile-time verified + .AddFilteredAttributes(x => x.Name); + } +} +``` + +## Why this matters + +Using `nameof()` provides several benefits: + +1. **Compile-time verification**: The compiler verifies the method exists on the service type +2. **Refactoring support**: Renaming the method automatically updates the reference +3. **IntelliSense**: IDE provides autocomplete for method names +4. **Reduced typos**: Eliminates the risk of misspelling method names + +When images are registered with `WithPreImage()` or `WithPostImage()`, using `nameof()` is especially important because the source generator validates that the handler method signature matches the registered images. + +## See also + +- [XPC4002: Handler method not found](XPC4002.md) +- [XPC4003: Handler signature does not match registered images](XPC4003.md) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4001.md b/XrmPluginCore.SourceGenerator/rules/XPC4001.md new file mode 100644 index 0000000..b8385e7 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4001.md @@ -0,0 +1,79 @@ +# XPC4001: No parameterless constructor found + +## Severity + +Warning + +## Description + +This rule reports when a plugin class that inherits from `Plugin` has explicit constructors but no parameterless constructor. The Dynamics 365/Dataverse framework instantiates plugins using parameterless constructors, so plugins must have one to function correctly at runtime. + +## ❌ Example of violation + +```csharp +public class AccountPlugin : Plugin +{ + // XPC4001: Plugin class 'AccountPlugin' has no parameterless constructor + public AccountPlugin(string config) // Only has constructor with parameters + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name); + } +} +``` + +## ✅ How to fix + +Add a parameterless constructor to the plugin class: + +```csharp +public class AccountPlugin : Plugin +{ + // Parameterless constructor required by Dynamics 365 + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name); + } + + // Additional constructor for testing or configuration is allowed + public AccountPlugin(string config) : this() + { + // Additional configuration + } +} +``` + +Alternatively, if you only need one constructor, use a parameterless one: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name); + } +} +``` + +## Why this matters + +1. **Runtime requirement**: The Dynamics 365/Dataverse plugin execution pipeline creates plugin instances using `Activator.CreateInstance()`, which requires a parameterless constructor +2. **Code generation**: When this rule is violated, the source generator will not generate type-safe image wrapper classes for the plugin +3. **Deployment failure**: Plugins without parameterless constructors will fail to execute at runtime with an error about missing constructor + +If your class has no explicit constructors defined, the C# compiler automatically provides a default parameterless constructor, so this rule will not be triggered. + +## See also + +- [Microsoft Docs: Write a plug-in](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/write-plug-in) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4002.md b/XrmPluginCore.SourceGenerator/rules/XPC4002.md new file mode 100644 index 0000000..3feff8d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4002.md @@ -0,0 +1,90 @@ +# XPC4002: Handler method not found + +## Severity + +Error + +## Description + +This rule reports when the handler method referenced in a `RegisterStep()` call does not exist on the specified service type. The source generator validates that the method exists to ensure the plugin will work correctly at runtime. + +## ❌ Example of violation + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC4002: Method 'HandleUpdate' not found on service type 'IAccountService' + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) // Method doesn't exist! + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void Process(); // Only has 'Process', not 'HandleUpdate' +} +``` + +## ✅ How to fix + +Add the missing method to the service interface: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void HandleUpdate(PreImage preImage); // Method now exists with correct signature +} +``` + +Or update the registration to reference an existing method: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.Process)) // Reference the existing method + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void Process(PreImage preImage); +} +``` + +## Why this matters + +1. **Compile-time safety**: This error catches typos and missing methods at compile time rather than runtime +2. **Code generation**: The source generator cannot generate type-safe wrapper classes when the handler method doesn't exist +3. **Runtime failures**: If this error is ignored (e.g., by using a string literal), the plugin will fail at runtime when it attempts to invoke the non-existent method + +## Code fix available + +Visual Studio and other IDEs supporting Roslyn analyzers will offer a code fix to create the missing method on the service interface with the correct signature based on any registered images. + +## See also + +- [XPC3001: Prefer nameof over string literal](XPC3001.md) +- [XPC4003: Handler signature does not match registered images](XPC4003.md) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4003.md b/XrmPluginCore.SourceGenerator/rules/XPC4003.md new file mode 100644 index 0000000..9fae12f --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4003.md @@ -0,0 +1,132 @@ +# XPC4003: Handler signature does not match registered images + +## Severity + +Error + +## Description + +This rule reports when a handler method's signature does not match the images registered with `WithPreImage()`, `WithPostImage()`, or `AddImage()`. The handler method must accept parameters in a specific order: `PreImage` first (if registered), then `PostImage` (if registered). + +## ❌ Examples of violations + +### ❌ Missing PreImage parameter + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC4003: Handler method 'HandleUpdate' does not have expected signature. + // Expected parameters in order: PreImage + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void HandleUpdate(); // Missing PreImage parameter! +} +``` + +### ❌ Missing PostImage parameter + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC4003: Handler method 'HandleUpdate' does not have expected signature. + // Expected parameters in order: PostImage + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPostImage(x => x.Name); + } +} + +public interface IAccountService +{ + void HandleUpdate(); // Missing PostImage parameter! +} +``` + +### ❌ Wrong parameter order + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC4003: Handler method 'HandleUpdate' does not have expected signature. + // Expected parameters in order: PreImage, PostImage + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } +} + +public interface IAccountService +{ + void HandleUpdate(PostImage post, PreImage pre); // Wrong order! +} +``` + +## ✅ How to fix + +Update the handler method signature to match the registered images: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } +} + +public interface IAccountService +{ + // Correct order: PreImage first, then PostImage + void HandleUpdate(PreImage preImage, PostImage postImage); +} +``` + +### Parameter order rules + +| Registered Images | Expected Signature | +|-------------------|-------------------| +| PreImage only | `void Method(PreImage preImage)` | +| PostImage only | `void Method(PostImage postImage)` | +| Both images | `void Method(PreImage preImage, PostImage postImage)` | +| No images | `void Method()` | + +## Why this matters + +1. **Type-safe code generation**: The source generator creates strongly-typed `PreImage` and `PostImage` wrapper classes that are injected into your handler method. If the signature doesn't match, the generated code won't compile or work correctly. + +2. **Compile-time enforcement**: This error prevents developers from accidentally ignoring registered images, which could lead to subtle bugs where image data is never used. + +3. **Clear contract**: The handler signature explicitly declares what data the method expects, making the code more readable and maintainable. + +## Code fix available + +Visual Studio and other IDEs supporting Roslyn analyzers will offer a code fix to update the method signature to match the registered images. + +## See also + +- [XPC4002: Handler method not found](XPC4002.md) +- [XPC4004: Image registration without method reference](XPC4004.md) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4004.md b/XrmPluginCore.SourceGenerator/rules/XPC4004.md new file mode 100644 index 0000000..069ca3b --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4004.md @@ -0,0 +1,77 @@ +# XPC4004: Image registration without method reference + +## Severity + +Warning + +## Description + +This rule reports when `WithPreImage()`, `WithPostImage()`, or `AddImage()` is used with a lambda invocation expression (e.g., `s => s.HandleUpdate()`) instead of a method reference expression (e.g., `s => s.HandleUpdate`). When images are registered, the source generator needs to know the method name to generate type-safe wrapper classes and validate the handler signature. Method invocation syntax prevents the generator from extracting this information. + +## ❌ Example of violation + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // XPC4004: WithPreImage/WithPostImage requires method reference syntax + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + s => s.HandleUpdate()) // Method invocation with parentheses - BAD + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void HandleUpdate(PreImage preImage); +} +``` + +## ✅ How to fix + +Use `nameof()` to reference the handler method when registering images: + +```csharp +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // Correct: Use nameof() for method reference when images are registered + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + nameof(IAccountService.HandleUpdate)) // nameof() for compile-time safety + .WithPreImage(x => x.Name); + } +} + +public interface IAccountService +{ + void HandleUpdate(PreImage preImage); +} +``` + +### When to use each syntax + +| Registration Type | Recommended Syntax | Example | +|-------------------|-------------------|---------| +| With images | `nameof()` | `nameof(IService.Method)` | +| Without images | Lambda invocation | `s => s.Method()` | + +## Why this matters + +1. **Type-safe wrapper generation**: The source generator creates strongly-typed `PreImage` and `PostImage` wrapper classes based on the registered attributes. It needs to know the handler method name to generate and inject these wrappers correctly. + +2. **Signature validation**: When using `nameof()`, the source generator can validate that the handler method's signature matches the registered images (XPC4003). With lambda invocation syntax, this validation cannot be performed. + +3. **Runtime behavior**: Without the method reference, the source generator cannot generate the wrapper classes. This means your handler method will not receive the type-safe image wrappers, and you'll need to manually extract images from the execution context. + +4. **Consistency**: Using `nameof()` when images are registered and lambda invocation when no images are needed creates a clear pattern that indicates what each registration expects. + +## See also + +- [XPC3001: Prefer nameof over string literal](XPC3001.md) +- [XPC4003: Handler signature does not match registered images](XPC4003.md) From ac21284b6132ac002d46bc02933bb967f578dbee Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 27 Nov 2025 10:10:17 +0100 Subject: [PATCH 20/22] CHORE: Clean up code-style warnings --- .../.editorconfig | 1 + .../PreferNameofAnalyzerTests.cs | 9 +- .../PreferNameofCodeFixProviderTests.cs | 45 +- .../DiagnosticReportingTests.cs | 746 +++++++++--------- .../WrapperClassGenerationTests.cs | 118 ++- .../Helpers/CompilationHelper.cs | 2 +- .../Helpers/GeneratorTestHelper.cs | 8 +- .../Helpers/TestFixtures.cs | 182 ++--- .../IntegrationTests/CompilationTests.cs | 36 +- .../ParsingTests/RegisterStepParsingTests.cs | 337 ++++---- .../GeneratedCodeSnapshotTests.cs | 33 +- .../Generators/PluginImageGenerator.cs | 20 +- .../Validation/HandlerMethodValidator.cs | 2 + .../TypeSafe/TypeSafeAccountPlugin.cs | 115 ++- .../TypeSafe/TypeSafeAccountService.cs | 43 +- .../TypeSafe/TypeSafeContactPlugin.cs | 119 ++- .../TypeSafe/TypeSafeContactService.cs | 43 +- XrmPluginCore/Plugin.cs | 10 +- .../Plugins/PluginStepConfigBuilder.cs | 2 +- 19 files changed, 927 insertions(+), 944 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/.editorconfig b/XrmPluginCore.SourceGenerator.Tests/.editorconfig index 431cbab..2f9c0f4 100644 --- a/XrmPluginCore.SourceGenerator.Tests/.editorconfig +++ b/XrmPluginCore.SourceGenerator.Tests/.editorconfig @@ -3,3 +3,4 @@ dotnet_diagnostic.CS0618.severity = none # Suppress 'obsolete' warnings for test project dotnet_diagnostic.CA1707.severity = none # Suppress 'identifiers should not contain underscores' warnings for test project +dotnet_diagnostic.CA2007.severity = none # Suppress ConfigureAwait warnings for test project diff --git a/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs index 16b6eff..4bed751 100644 --- a/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs @@ -52,7 +52,7 @@ public void HandleUpdate() { } } }"; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); var diagnostics = await GetDiagnosticsAsync(source); // Assert @@ -101,7 +101,7 @@ public void HandleUpdate() { } } }"; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); var diagnostics = await GetDiagnosticsAsync(source); // Assert @@ -117,7 +117,6 @@ public async Task Should_Not_Report_XPC3001_When_Nameof_Used_For_Handler_Method( { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, TestFixtures.GetPluginWithHandlerNoImages()); var diagnostics = await GetDiagnosticsAsync(source); @@ -164,7 +163,7 @@ public void HandleUpdate() { } } }"; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); var diagnostics = await GetDiagnosticsAsync(source); // Assert @@ -211,7 +210,7 @@ public void Process(PreImage preImage) { } } }"; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); var diagnostics = await GetDiagnosticsAsync(source); // Assert diff --git a/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs b/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs index 5e23606..6e0d6ed 100644 --- a/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs @@ -4,7 +4,6 @@ using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; -using System.Collections.Immutable; using XrmPluginCore.SourceGenerator.Analyzers; using XrmPluginCore.SourceGenerator.CodeFixes; using XrmPluginCore.SourceGenerator.Tests.Helpers; @@ -21,7 +20,7 @@ public class PreferNameofCodeFixProviderTests public async Task Should_Convert_String_Literal_To_Nameof_With_Service_Type() { // Arrange - var pluginSource = @" + const string pluginSource = """ using XrmPluginCore; using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; @@ -34,7 +33,7 @@ public class TestPlugin : Plugin public TestPlugin() { RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - ""HandleUpdate"") + "HandleUpdate") .AddFilteredAttributes(x => x.Name); } @@ -53,9 +52,10 @@ public class TestService : ITestService { public void HandleUpdate() { } } -}"; +} +"""; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var fixedSource = await ApplyCodeFixAsync(source); @@ -69,7 +69,7 @@ public void HandleUpdate() { } public async Task Should_Preserve_Surrounding_Code_Structure() { // Arrange - var pluginSource = @" + const string pluginSource = """ using XrmPluginCore; using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; @@ -82,7 +82,7 @@ public class TestPlugin : Plugin public TestPlugin() { RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - ""HandleUpdate"") + "HandleUpdate") .AddFilteredAttributes(x => x.Name); } @@ -101,9 +101,10 @@ public class TestService : ITestService { public void HandleUpdate() { } } -}"; +} +"""; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var fixedSource = await ApplyCodeFixAsync(source); @@ -119,7 +120,7 @@ public void HandleUpdate() { } public async Task Should_Fix_Multiple_String_Literals_When_FixAll_Applied() { // Arrange - Two plugins with string literals - var pluginSource = @" + const string pluginSource = """ using XrmPluginCore; using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; @@ -132,7 +133,7 @@ public class TestPlugin1 : Plugin public TestPlugin1() { RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - ""HandleUpdate"") + "HandleUpdate") .AddFilteredAttributes(x => x.Name); } @@ -147,7 +148,7 @@ public class TestPlugin2 : Plugin public TestPlugin2() { RegisterStep(EventOperation.Create, ExecutionStage.PreOperation, - ""HandleCreate"") + "HandleCreate") .AddFilteredAttributes(x => x.Name); } @@ -168,9 +169,10 @@ public class TestService : ITestService public void HandleUpdate() { } public void HandleCreate() { } } -}"; +} +"""; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Apply all fixes var fixedSource = await ApplyAllCodeFixesAsync(source); @@ -186,7 +188,7 @@ public void HandleCreate() { } public async Task CodeFix_Should_Have_Correct_Title() { // Arrange - var pluginSource = @" + const string pluginSource = """ using XrmPluginCore; using XrmPluginCore.Enums; using Microsoft.Extensions.DependencyInjection; @@ -199,7 +201,7 @@ public class TestPlugin : Plugin public TestPlugin() { RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - ""HandleUpdate"") + "HandleUpdate") .AddFilteredAttributes(x => x.Name); } @@ -218,9 +220,10 @@ public class TestService : ITestService { public void HandleUpdate() { } } -}"; +} +"""; - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var codeActions = await GetCodeActionsAsync(source); @@ -237,7 +240,7 @@ private static async Task ApplyCodeFixAsync(string source) var codeFixProvider = new PreferNameofCodeFixProvider(); var compilationWithAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(analyzer)); + [analyzer]); var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "XPC3001"); @@ -293,14 +296,14 @@ private static async Task> GetCodeActionsAsync(string source) var codeFixProvider = new PreferNameofCodeFixProvider(); var compilationWithAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(analyzer)); + [analyzer]); var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "XPC3001"); if (diagnostic == null) { - return new List(); + return []; } var document = CreateDocument(source); diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 04b0852..dbd9e98 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -18,8 +18,7 @@ public void Should_Not_Report_XPC1000_Success_Diagnostic_On_Successful_Generatio { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -36,37 +35,39 @@ public void Should_Not_Report_XPC1000_Success_Diagnostic_On_Successful_Generatio [Fact] public async Task Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() { - // Arrange - plugin class with only a parameterized constructor (no parameterless) - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - // Only has a constructor WITH parameters - no parameterless constructor - public TestPlugin(string config) - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) - .AddImage(ImageType.PreImage, x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService { void Process(PreImage preImage); } - public class TestService : ITestService { public void Process(PreImage preImage) { } } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - plugin class with only a parameterized constructor (no parameterless) + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + // Only has a constructor WITH parameters - no parameterless constructor + public TestPlugin(string config) + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(PreImage preImage); } + public class TestService : ITestService { public void Process(PreImage preImage) { } } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new NoParameterlessConstructorAnalyzer()); @@ -88,8 +89,7 @@ public void Should_Handle_XPC5000_Generation_Error_Gracefully() // Arrange - complex but valid source var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -109,42 +109,44 @@ public void Should_Handle_XPC5000_Generation_Error_Gracefully() [Fact] public async Task Should_Report_XPC4002_When_Handler_Method_Not_Found() { - // Arrange - method reference points to NonExistentMethod but service has Process - var 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(); - } - - public class TestService : ITestService - { - public void Process() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - method reference points to NonExistentMethod but service has Process + 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(); + } + + public class TestService : ITestService + { + public void Process() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerMethodNotFoundAnalyzer()); @@ -161,42 +163,44 @@ public void Process() { } [Fact] public async Task Should_Report_XPC4003_When_Handler_Missing_PreImage_Parameter() { - // Arrange - WithPreImage is registered but handler takes no parameters - var 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.Process) - .WithPreImage(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void Process(); // No PreImage parameter! - } - - public class TestService : ITestService - { - public void Process() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - WithPreImage is registered but handler takes no parameters + 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.Process) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No PreImage parameter! + } + + public class TestService : ITestService + { + public void Process() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); @@ -213,42 +217,44 @@ public void Process() { } [Fact] public async Task Should_Report_XPC4003_When_Handler_Missing_PostImage_Parameter() { - // Arrange - WithPostImage is registered but handler takes no parameters - var 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.Process) - .WithPostImage(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void Process(); // No PostImage parameter! - } - - public class TestService : ITestService - { - public void Process() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - WithPostImage is registered but handler takes no parameters + 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.Process) + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No PostImage parameter! + } + + public class TestService : ITestService + { + public void Process() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); @@ -265,43 +271,45 @@ public void Process() { } [Fact] public async Task Should_Report_XPC4003_When_Handler_Missing_Both_Image_Parameters() { - // Arrange - Both WithPreImage and WithPostImage but handler takes no parameters - var 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.Process) - .WithPreImage(x => x.Name) - .WithPostImage(x => x.AccountNumber); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void Process(); // No parameters! - } - - public class TestService : ITestService - { - public void Process() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - Both WithPreImage and WithPostImage but handler takes no parameters + 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.Process) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(); // No parameters! + } + + public class TestService : ITestService + { + public void Process() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); @@ -318,44 +326,46 @@ public void Process() { } [Fact] public async Task Should_Report_XPC4003_When_Handler_Has_Wrong_Parameter_Order() { - // Arrange - WithPreImage and WithPostImage but handler has parameters in wrong order - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) - .WithPreImage(x => x.Name) - .WithPostImage(x => x.AccountNumber); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void Process(PostImage post, PreImage pre); // Wrong order! Should be PreImage, PostImage - } - - public class TestService : ITestService - { - public void Process(PostImage post, PreImage pre) { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - WithPreImage and WithPostImage but handler has parameters in wrong order + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.AccountNumber); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(PostImage post, PreImage pre); // Wrong order! Should be PreImage, PostImage + } + + public class TestService : ITestService + { + public void Process(PostImage post, PreImage pre) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); @@ -372,42 +382,44 @@ public void Process(PostImage post, PreImage pre) { } [Fact] public async Task Should_Report_XPC4004_When_WithPreImage_Used_With_Invocation_Syntax() { - // Arrange - WithPreImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) - var 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, - s => s.DoSomething()) // Invocation syntax - NOT method reference - .WithPreImage(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void DoSomething(); - } - - public class TestService : ITestService - { - public void DoSomething() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - WithPreImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) + 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, + s => s.DoSomething()) // Invocation syntax - NOT method reference + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); @@ -424,42 +436,44 @@ public void DoSomething() { } [Fact] public async Task Should_Report_XPC4004_When_WithPostImage_Used_With_Invocation_Syntax() { - // Arrange - WithPostImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) - var 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, - s => s.DoSomething()) // Invocation syntax - NOT method reference - .WithPostImage(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void DoSomething(); - } - - public class TestService : ITestService - { - public void DoSomething() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - WithPostImage used with s => s.DoSomething() (invocation) instead of s => s.DoSomething (method reference) + 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, + s => s.DoSomething()) // Invocation syntax - NOT method reference + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); @@ -476,43 +490,45 @@ public void DoSomething() { } [Fact] public async Task Should_Not_Report_XPC4004_When_Using_Method_Reference_Syntax() { - // Arrange - Method reference syntax (correct usage) - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - s => s.HandleUpdate) // Method reference - correct syntax - .WithPreImage(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void HandleUpdate(PreImage preImage); - } - - public class TestService : ITestService - { - public void HandleUpdate(PreImage preImage) { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - Method reference syntax (correct usage) + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + s => s.HandleUpdate) // Method reference - correct syntax + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(PreImage preImage); + } + + public class TestService : ITestService + { + public void HandleUpdate(PreImage preImage) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); @@ -528,42 +544,44 @@ public void HandleUpdate(PreImage preImage) { } [Fact] public async Task Should_Not_Report_XPC4004_When_Old_Api_Used_Without_Images() { - // Arrange - Invocation syntax but without WithPreImage/WithPostImage (no images registered) - var 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, - s => s.DoSomething()) // Invocation syntax - but no images - .AddFilteredAttributes(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void DoSomething(); - } - - public class TestService : ITestService - { - public void DoSomething() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - Invocation syntax but without WithPreImage/WithPostImage (no images registered) + 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, + s => s.DoSomething()) // Invocation syntax - but no images + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void DoSomething(); + } + + public class TestService : ITestService + { + public void DoSomething() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act - Run analyzer instead of generator var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer()); @@ -581,7 +599,7 @@ private static async Task> GetAnalyzerDiagnosticsAsyn var compilation = CompilationHelper.CreateCompilation(source); var compilationWithAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(analyzer)); + [analyzer]); return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); } diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs index c14e580..f6bd394 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs @@ -7,15 +7,14 @@ namespace XrmPluginCore.SourceGenerator.Tests.GenerationTests; /// /// Tests for verifying wrapper class code generation structure and content. /// -public class WrapperClassGenerationTests +public partial class WrapperClassGenerationTests { [Fact] public void Should_Generate_PreImage_Class_With_Properties() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -43,8 +42,7 @@ public void Should_Generate_PostImage_Class_With_Properties() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -69,8 +67,7 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -81,9 +78,7 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace() var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // All classes should be in the same namespace - var namespaceCount = System.Text.RegularExpressions.Regex.Matches( - generatedSource, - @"namespace\s+TestNamespace\.PluginRegistrations\.TestPlugin\.AccountUpdatePostOperation").Count; + var namespaceCount = IsAccountUpdatePostOperationNamespace().Matches(generatedSource).Count; namespaceCount.Should().Be(1, "all classes should be in the same namespace"); @@ -96,37 +91,39 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace() [Fact] public void Should_Generate_Properties_With_Correct_Types() { - // Arrange - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) - .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue, x => x.IndustryCode, x => x.PrimaryContactId); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService { void Process(PreImage preImage); } - public class TestService : ITestService { public void Process(PreImage preImage) { } } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Abstractions; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name, x => x.Revenue, x => x.IndustryCode, x => x.PrimaryContactId); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService { void Process(PreImage preImage); } + public class TestService : ITestService { public void Process(PreImage preImage) { } } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var result = GeneratorTestHelper.RunGenerator( @@ -154,8 +151,7 @@ public void Should_Include_ToEntity_Method() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -173,8 +169,7 @@ public void Should_Include_GetUnderlyingEntity_Method() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -192,8 +187,7 @@ public void Should_Implement_IEntityImageWrapper_Interface() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -210,8 +204,7 @@ public void Should_Generate_ActionWrapper_Class_For_New_Api() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -230,8 +223,7 @@ public void Should_Generate_ActionWrapper_With_PreImage_Call() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -251,8 +243,7 @@ public void Should_Generate_ActionWrapper_With_PostImage_Call() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -272,8 +263,7 @@ public void Should_Generate_ActionWrapper_With_Both_Images() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -295,8 +285,7 @@ public void Should_Generate_ActionWrapper_With_Service_Resolution() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -315,8 +304,7 @@ public void Should_Generate_ActionWrapper_For_Handler_Without_Images() { // Arrange - Plugin with method reference syntax but NO images var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithHandlerNoImages()); + TestFixtures.GetPluginWithHandlerNoImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -347,8 +335,7 @@ public void Should_Generate_ActionWrapper_With_PreImage_Only() { // Arrange - Plugin with PreImage only (no PostImage) var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -378,8 +365,7 @@ public void Should_Generate_ActionWrapper_With_PostImage_Only() { // Arrange - Plugin with PostImage only (no PreImage) var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -409,8 +395,7 @@ public void Should_Generate_ActionWrapper_With_Both_PreImage_And_PostImage() { // Arrange - Plugin with both PreImage and PostImage var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -431,4 +416,7 @@ public void Should_Generate_ActionWrapper_With_Both_PreImage_And_PostImage() generatedSource.Should().Contain("public class PreImage"); generatedSource.Should().Contain("public class PostImage"); } + + [System.Text.RegularExpressions.GeneratedRegex(@"namespace\s+TestNamespace\.PluginRegistrations\.TestPlugin\.AccountUpdatePostOperation")] + private static partial System.Text.RegularExpressions.Regex IsAccountUpdatePostOperationNamespace(); } diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs index a0d0f9a..e4e0b58 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs @@ -24,7 +24,7 @@ public static CSharpCompilation CreateCompilation(string source, string? assembl return CSharpCompilation.Create( assemblyName ?? $"TestAssembly_{Guid.NewGuid():N}", - new[] { syntaxTree }, + [syntaxTree], references, new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs index 248c571..bde6fe4 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs @@ -19,7 +19,7 @@ public static GeneratorRunResult RunGenerator(CSharpCompilation compilation) var generator = new PluginImageGenerator(); // Pass the compilation's parse options to the driver so generated syntax trees use the same language version var driver = CSharpGeneratorDriver.Create( - generators: new[] { generator.AsSourceGenerator() }, + generators: [generator.AsSourceGenerator()], parseOptions: (CSharpParseOptions?)compilation.SyntaxTrees.FirstOrDefault()?.Options); driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( @@ -38,10 +38,10 @@ public static GeneratorRunResult RunGenerator(CSharpCompilation compilation) return new GeneratorRunResult { OutputCompilation = (CSharpCompilation)outputCompilation, - Diagnostics = diagnostics.ToArray(), + Diagnostics = [.. diagnostics], GeneratedTrees = generatedTrees, - GeneratorDiagnostics = runResult.Results[0].Diagnostics.ToArray() - }; + GeneratorDiagnostics = [.. runResult.Results[0].Diagnostics] + }; } /// diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs index 77cb311..2051ca1 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -8,96 +8,100 @@ public static class TestFixtures /// /// Sample Account entity class with common attributes. /// - public const string AccountEntity = @" + public const string AccountEntity = """ + using System; using System.ComponentModel; using Microsoft.Xrm.Sdk; namespace TestNamespace { - [EntityLogicalName(""account"")] + [EntityLogicalName("account")] public class Account : Entity { - public const string EntityLogicalName = ""account""; + public const string EntityLogicalName = "account"; public Account() : base(EntityLogicalName) { } - [AttributeLogicalName(""name"")] + [AttributeLogicalName("name")] public string Name { - get => GetAttributeValue(""name""); - set => SetAttributeValue(""name"", value); + get => GetAttributeValue("name"); + set => SetAttributeValue("name", value); } - [AttributeLogicalName(""accountnumber"")] + [AttributeLogicalName("accountnumber")] public string AccountNumber { - get => GetAttributeValue(""accountnumber""); - set => SetAttributeValue(""accountnumber"", value); + get => GetAttributeValue("accountnumber"); + set => SetAttributeValue("accountnumber", value); } - [AttributeLogicalName(""revenue"")] + [AttributeLogicalName("revenue")] public Money Revenue { - get => GetAttributeValue(""revenue""); - set => SetAttributeValue(""revenue"", value); + get => GetAttributeValue("revenue"); + set => SetAttributeValue("revenue", value); } - [AttributeLogicalName(""industrycode"")] + [AttributeLogicalName("industrycode")] public OptionSetValue IndustryCode { - get => GetAttributeValue(""industrycode""); - set => SetAttributeValue(""industrycode"", value); + get => GetAttributeValue("industrycode"); + set => SetAttributeValue("industrycode", value); } - [AttributeLogicalName(""primarycontactid"")] + [AttributeLogicalName("primarycontactid")] public EntityReference PrimaryContactId { - get => GetAttributeValue(""primarycontactid""); - set => SetAttributeValue(""primarycontactid"", value); + get => GetAttributeValue("primarycontactid"); + set => SetAttributeValue("primarycontactid", value); } } -}"; +} +"""; /// /// Sample Contact entity class with common attributes. /// - public const string ContactEntity = @" + public const string ContactEntity = """ + using System; using System.ComponentModel; using Microsoft.Xrm.Sdk; namespace TestNamespace { - [EntityLogicalName(""contact"")] + [EntityLogicalName("contact")] public class Contact : Entity { - public const string EntityLogicalName = ""contact""; + public const string EntityLogicalName = "contact"; public Contact() : base(EntityLogicalName) { } - [AttributeLogicalName(""firstname"")] + [AttributeLogicalName("firstname")] public string FirstName { - get => GetAttributeValue(""firstname""); - set => SetAttributeValue(""firstname"", value); + get => GetAttributeValue("firstname"); + set => SetAttributeValue("firstname", value); } - [AttributeLogicalName(""lastname"")] + [AttributeLogicalName("lastname")] public string LastName { - get => GetAttributeValue(""lastname""); - set => SetAttributeValue(""lastname"", value); + get => GetAttributeValue("lastname"); + set => SetAttributeValue("lastname", value); } - [AttributeLogicalName(""emailaddress1"")] + [AttributeLogicalName("emailaddress1")] public string EmailAddress { - get => GetAttributeValue(""emailaddress1""); - set => SetAttributeValue(""emailaddress1"", value); + get => GetAttributeValue("emailaddress1"); + set => SetAttributeValue("emailaddress1", value); } } -}"; +} +"""; /// /// Plugin with PreImage only. @@ -300,8 +304,8 @@ public void Process() {{ }} /// /// Gets a complete compilable source with entity and plugin. /// - public static string GetCompleteSource(string entitySource, string pluginSource) - { + public static string GetCompleteSource(string pluginSource) + { // Determine which entity is being used by checking if pluginSource contains RegisterStep GetAttributeValue(""name""); - set => SetAttributeValue(""name"", value); - }} + { + get => GetAttributeValue("name"); + set => SetAttributeValue("name", value); + } - [AttributeLogicalName(""accountnumber"")] + [AttributeLogicalName("accountnumber")] public string AccountNumber - {{ - get => GetAttributeValue(""accountnumber""); - set => SetAttributeValue(""accountnumber"", value); - }} + { + get => GetAttributeValue("accountnumber"); + set => SetAttributeValue("accountnumber", value); + } - [AttributeLogicalName(""revenue"")] + [AttributeLogicalName("revenue")] public Money Revenue - {{ - get => GetAttributeValue(""revenue""); - set => SetAttributeValue(""revenue"", value); - }} + { + get => GetAttributeValue("revenue"); + set => SetAttributeValue("revenue", value); + } - [AttributeLogicalName(""industrycode"")] + [AttributeLogicalName("industrycode")] public OptionSetValue IndustryCode - {{ - get => GetAttributeValue(""industrycode""); - set => SetAttributeValue(""industrycode"", value); - }} + { + get => GetAttributeValue("industrycode"); + set => SetAttributeValue("industrycode", value); + } - [AttributeLogicalName(""primarycontactid"")] + [AttributeLogicalName("primarycontactid")] public EntityReference PrimaryContactId - {{ - get => GetAttributeValue(""primarycontactid""); - set => SetAttributeValue(""primarycontactid"", value); - }} - }} + { + get => GetAttributeValue("primarycontactid"); + set => SetAttributeValue("primarycontactid", value); + } + } - [Microsoft.Xrm.Sdk.Client.EntityLogicalName(""contact"")] + [Microsoft.Xrm.Sdk.Client.EntityLogicalName("contact")] public class Contact : Entity - {{ - public const string EntityLogicalName = ""contact""; + { + public const string EntityLogicalName = "contact"; - public Contact() : base(EntityLogicalName) {{ }} + public Contact() : base(EntityLogicalName) { } - [AttributeLogicalName(""firstname"")] + [AttributeLogicalName("firstname")] public string FirstName - {{ - get => GetAttributeValue(""firstname""); - set => SetAttributeValue(""firstname"", value); - }} + { + get => GetAttributeValue("firstname"); + set => SetAttributeValue("firstname", value); + } - [AttributeLogicalName(""lastname"")] + [AttributeLogicalName("lastname")] public string LastName - {{ - get => GetAttributeValue(""lastname""); - set => SetAttributeValue(""lastname"", value); - }} + { + get => GetAttributeValue("lastname"); + set => SetAttributeValue("lastname", value); + } - [AttributeLogicalName(""emailaddress1"")] + [AttributeLogicalName("emailaddress1")] public string EmailAddress - {{ - get => GetAttributeValue(""emailaddress1""); - set => SetAttributeValue(""emailaddress1"", value); - }} - }} + { + get => GetAttributeValue("emailaddress1"); + set => SetAttributeValue("emailaddress1", value); + } + } - {StripNamespaceAndUsings(pluginSource)} -}}"; + {{StripNamespaceAndUsings(pluginSource)}} +} +"""; } /// diff --git a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs index ad67fdc..5334f32 100644 --- a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -15,15 +15,14 @@ public void Should_Compile_Generated_Code_Without_Errors() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGeneratorAndCompile(source); // Assert result.Success.Should().BeTrue( - because: $"compilation should succeed. Errors: {string.Join(", ", result.Errors ?? Array.Empty())}"); + because: $"compilation should succeed. Errors: {string.Join(", ", result.Errors ?? [])}"); result.AssemblyBytes.Should().NotBeNull(); } @@ -32,8 +31,7 @@ public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); @@ -74,8 +72,7 @@ public void Should_Access_Properties_And_Verify_Values() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); @@ -106,8 +103,7 @@ public void Should_Work_With_Both_PreImage_And_PostImage() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); @@ -126,7 +122,7 @@ public void Should_Work_With_Both_PreImage_And_PostImage() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); - var baseNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; + const string baseNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; var preImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PreImage"); var postImageType = loadedAssembly.Assembly.GetType($"{baseNamespace}.PostImage"); @@ -149,8 +145,7 @@ public void Should_Handle_Null_Attribute_Values_Gracefully() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); @@ -180,8 +175,7 @@ public void Should_Verify_Namespace_Isolation_Per_Registration() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); @@ -189,8 +183,8 @@ public void Should_Verify_Namespace_Isolation_Per_Registration() // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); - // Assert - namespace should follow pattern: {Namespace}.PluginRegistrations.{Plugin}.{Entity}{Operation}{Stage} - var expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; + // Assert - namespace should follow pattern: {Namespace}.PluginRegistrations.{Plugin}.{Entity}{Operation}{Stage} + const string expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; var preImageType = loadedAssembly.Assembly.GetType($"{expectedNamespace}.PreImage"); preImageType.Should().NotBeNull("PreImage should be in the expected namespace"); @@ -202,15 +196,14 @@ public void Should_Generate_ActionWrapper_Class() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); var result = GeneratorTestHelper.RunGeneratorAndCompile(source); result.Success.Should().BeTrue(); // Act using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); - var expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; + const string expectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"; var actionWrapperType = loadedAssembly.Assembly.GetType($"{expectedNamespace}.ActionWrapper"); // Assert @@ -232,14 +225,13 @@ public void Should_Compile_Method_Reference_With_Image_Parameter() // Arrange - Source code that mirrors XrmMockup's AccountPostImagePlugin pattern: // service => service.HandleDelete where HandleDelete(PostImage postImage) var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithMethodReferenceAndPostImage()); + TestFixtures.GetPluginWithMethodReferenceAndPostImage()); // Act var result = GeneratorTestHelper.RunGeneratorAndCompile(source); // Assert result.Success.Should().BeTrue( - because: $"method reference with image parameter should compile. Errors: {string.Join(", ", result.Errors ?? Array.Empty())}"); + because: $"method reference with image parameter should compile. Errors: {string.Join(", ", result.Errors ?? [])}"); } } diff --git a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs index d4e9f1e..6167847 100644 --- a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs @@ -14,8 +14,7 @@ public void Should_Parse_WithPreImage_Registration() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -34,8 +33,7 @@ public void Should_Parse_WithPostImage_Registration() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -54,8 +52,7 @@ public void Should_Parse_Both_PreImage_And_PostImage() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -83,8 +80,7 @@ public void Should_Not_Generate_Code_For_Old_AddImage_Api_Without_Method_Referen // Arrange - Old API uses service => service.Process() which is a method invocation, // not a method reference. The new generator requires a method reference. var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithOldImageApi()); + TestFixtures.GetPluginWithOldImageApi()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -99,8 +95,7 @@ public void Should_Parse_Lambda_Syntax_For_Attributes() { // Arrange - GetPluginWithPreImage uses lambda syntax: x => x.Name var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -117,8 +112,7 @@ public void Should_Handle_Contact_Entity() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.ContactEntity, - TestFixtures.GetPluginWithPreImage("Contact")); + TestFixtures.GetPluginWithPreImage("Contact")); // Act var result = GeneratorTestHelper.RunGenerator( @@ -137,8 +131,7 @@ public void Should_Generate_Correct_Namespace_For_Registration() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -155,44 +148,46 @@ public void Should_Generate_Correct_Namespace_For_Registration() [Fact] public void Should_Handle_Multiple_Attributes_In_Same_Image() { - // Arrange - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.Process) - .AddImage(ImageType.PreImage, x => x.Name, x => x.AccountNumber, x => x.Revenue, x => x.IndustryCode); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void Process(PreImage preImage); - } - - public class TestService : ITestService - { - public void Process(PreImage preImage) { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Abstractions; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.Process) + .AddImage(ImageType.PreImage, x => x.Name, x => x.AccountNumber, x => x.Revenue, x => x.IndustryCode); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(PreImage preImage); + } + + public class TestService : ITestService + { + public void Process(PreImage preImage) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var result = GeneratorTestHelper.RunGenerator( @@ -211,44 +206,46 @@ public void Process(PreImage preImage) { } [Fact] public void Should_Parse_Handler_Method_Name() { - // Arrange - plugin with new API to verify method reference parsing - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, - service => service.HandleAccountUpdate) - .AddImage(ImageType.PreImage, x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void HandleAccountUpdate(PreImage preImage); - } - - public class TestService : ITestService - { - public void HandleAccountUpdate(PreImage preImage) { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - plugin with new API to verify method reference parsing + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Abstractions; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + service => service.HandleAccountUpdate) + .AddImage(ImageType.PreImage, x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleAccountUpdate(PreImage preImage); + } + + public class TestService : ITestService + { + public void HandleAccountUpdate(PreImage preImage) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var result = GeneratorTestHelper.RunGenerator( @@ -263,45 +260,47 @@ public void HandleAccountUpdate(PreImage preImage) { } [Fact] public void Should_Parse_Parameterless_Method_Reference() { - // Arrange - plugin with a parameterless handler method (no images) - // This tests the Expression> overload for parameterless methods - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountCreatePostOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, - service => service.HandleCreate) - .AddFilteredAttributes(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void HandleCreate(); - } - - public class TestService : ITestService - { - public void HandleCreate() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - plugin with a parameterless handler method (no images) + // This tests the Expression> overload for parameterless methods + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Abstractions; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountCreatePostOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, + service => service.HandleCreate) + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleCreate(); + } + + public class TestService : ITestService + { + public void HandleCreate() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var result = GeneratorTestHelper.RunGenerator( @@ -328,45 +327,47 @@ public void HandleCreate() { } [Fact] public void Should_Parse_Parameterless_Method_Reference_With_Custom_Method_Name() { - // Arrange - plugin with a parameterless handler method with a unique name - // This ensures the method name extraction works for various naming conventions - var pluginSource = @" -using XrmPluginCore; -using XrmPluginCore.Abstractions; -using XrmPluginCore.Enums; -using Microsoft.Extensions.DependencyInjection; -using TestNamespace; -using TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePreOperation; - -namespace TestNamespace -{ - public class TestPlugin : Plugin - { - public TestPlugin() - { - RegisterStep(EventOperation.Delete, ExecutionStage.PreOperation, - service => service.OnAccountDeleting) - .AddFilteredAttributes(x => x.Name); - } - - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - return services.AddScoped(); - } - } - - public interface ITestService - { - void OnAccountDeleting(); - } - - public class TestService : ITestService - { - public void OnAccountDeleting() { } - } -}"; - - var source = TestFixtures.GetCompleteSource(TestFixtures.AccountEntity, pluginSource); + // Arrange - plugin with a parameterless handler method with a unique name + // This ensures the method name extraction works for various naming conventions + const string pluginSource = """ + + using XrmPluginCore; + using XrmPluginCore.Abstractions; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using TestNamespace; + using TestNamespace.PluginRegistrations.TestPlugin.AccountDeletePreOperation; + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Delete, ExecutionStage.PreOperation, + service => service.OnAccountDeleting) + .AddFilteredAttributes(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void OnAccountDeleting(); + } + + public class TestService : ITestService + { + public void OnAccountDeleting() { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); // Act var result = GeneratorTestHelper.RunGenerator( diff --git a/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs index 457f0d4..90d0524 100644 --- a/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs @@ -8,15 +8,14 @@ namespace XrmPluginCore.SourceGenerator.Tests.SnapshotTests; /// Snapshot tests that verify the exact structure of generated code. /// These tests ensure consistency in code generation patterns. /// -public class GeneratedCodeSnapshotTests +public partial class GeneratedCodeSnapshotTests { [Fact] public void Should_Generate_PreImage_Class_With_Expected_Structure() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -51,8 +50,7 @@ public void Should_Generate_PostImage_Class_With_Expected_Structure() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPostImage()); + TestFixtures.GetPluginWithPostImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -85,8 +83,7 @@ public void Should_Include_XML_Documentation_Comments() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -109,15 +106,13 @@ public void Should_Follow_Namespace_Pattern() new { Source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()), + TestFixtures.GetPluginWithPreImage()), ExpectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation" }, new { Source = TestFixtures.GetCompleteSource( - TestFixtures.ContactEntity, - TestFixtures.GetPluginWithPreImage("Contact")), + TestFixtures.GetPluginWithPreImage("Contact")), ExpectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.ContactUpdatePostOperation" } }; @@ -131,7 +126,7 @@ public void Should_Follow_Namespace_Pattern() // Assert var generatedSource = result.GeneratedTrees[0].GetText().ToString(); generatedSource.Should().Contain($"namespace {testCase.ExpectedNamespace}", - $"namespace should follow pattern: {{Namespace}}.PluginRegistrations.{{Plugin}}.{{Entity}}{{Operation}}{{Stage}}"); + "namespace should follow pattern: {Namespace}.PluginRegistrations.{Plugin}.{Entity}{Operation}{Stage}"); } } @@ -140,8 +135,7 @@ public void Should_Mark_Classes_With_CompilerGenerated_Attribute() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -157,7 +151,7 @@ public void Should_Mark_Classes_With_CompilerGenerated_Attribute() generatedSource.Should().Contain("[CompilerGenerated]"); // Count occurrences - should be at least 3 (PreImage, PostImage, and ActionWrapper) - var matches = System.Text.RegularExpressions.Regex.Matches(generatedSource, @"\[CompilerGenerated\]"); + var matches = IsCompilerGenerated().Matches(generatedSource); matches.Count.Should().BeGreaterOrEqualTo(3, "PreImage, PostImage, and ActionWrapper classes should be marked"); } @@ -166,8 +160,7 @@ public void Should_Generate_ActionWrapper_Class_For_New_Api() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithPreImage()); + TestFixtures.GetPluginWithPreImage()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -199,8 +192,7 @@ public void Should_Generate_ActionWrapper_With_Both_Images() { // Arrange var source = TestFixtures.GetCompleteSource( - TestFixtures.AccountEntity, - TestFixtures.GetPluginWithBothImages()); + TestFixtures.GetPluginWithBothImages()); // Act var result = GeneratorTestHelper.RunGenerator( @@ -216,4 +208,7 @@ public void Should_Generate_ActionWrapper_With_Both_Images() generatedSource.Should().Contain("var postImage = postImageEntity != null ? new PostImage(postImageEntity) : null;"); generatedSource.Should().Contain("service.Process(preImage, postImage)"); } + + [System.Text.RegularExpressions.GeneratedRegex(@"\[CompilerGenerated\]")] + private static partial System.Text.RegularExpressions.Regex IsCompilerGenerated(); } diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 10026cb..c7f16a0 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -163,33 +163,15 @@ private void GenerateSourceFromMetadata( catch (System.Exception ex) { // Report diagnostic error - ReportGenerationError(context, metadata, ex); + ReportGenerationError(context, ex); } } - /// - /// Reports a diagnostic for successful code generation - /// - private void ReportGenerationSuccess( - SourceProductionContext context, - PluginStepMetadata metadata) - { - var diagnostic = Diagnostic.Create( - DiagnosticDescriptors.GenerationSuccess, - Location.None, - 1, // wrapper class count - metadata.RegistrationNamespace); - - // Uncomment to see generation info in build output - context.ReportDiagnostic(diagnostic); - } - /// /// Reports a diagnostic error when code generation fails /// private void ReportGenerationError( SourceProductionContext context, - PluginStepMetadata metadata, System.Exception exception) { var diagnostic = Diagnostic.Create( diff --git a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs index 933a027..eb4dafb 100644 --- a/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -18,7 +18,9 @@ public static void ValidateHandlerMethod( { if (string.IsNullOrEmpty(metadata.HandlerMethodName) || string.IsNullOrEmpty(metadata.ServiceTypeFullName)) + { return; + } var serviceType = compilation.GetTypeByMetadataName(metadata.ServiceTypeFullName); if (serviceType is null) diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs index 4208884..0ba874f 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs @@ -5,70 +5,69 @@ // Import the generated PreImage/PostImage from the namespace using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeAccountPlugin.AccountUpdatePreOperation; -namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe; + +/// +/// Test plugin using the type-safe image API. +/// PreImage and PostImage wrappers are generated by the source generator +/// and passed directly to the action callback. +/// +public class TypeSafeAccountPlugin : Plugin { - /// - /// Test plugin using the type-safe image API. - /// PreImage and PostImage wrappers are generated by the source generator - /// and passed directly to the action callback. - /// - public class TypeSafeAccountPlugin : Plugin - { - public bool UpdateExecuted { get; private set; } - public PreImage LastPreImage { get; private set; } - public PostImage LastPostImage { get; private set; } + public bool UpdateExecuted { get; private set; } + public PreImage LastPreImage { get; private set; } + public PostImage LastPostImage { get; private set; } - public TypeSafeAccountPlugin() - { - // Type-safe API: Images are passed directly to the action via source-generated wrapper - RegisterStep(EventOperation.Update, ExecutionStage.PreOperation, - nameof(TypeSafeAccountService.HandleUpdate)) - .AddFilteredAttributes(x => x.Name, x => x.Accountnumber) - .WithPreImage(x => x.Name, x => x.Accountnumber, x => x.Revenue) - .WithPostImage(x => x.Name, x => x.Accountnumber); - } + public TypeSafeAccountPlugin() + { + // Type-safe API: Images are passed directly to the action via source-generated wrapper + RegisterStep(EventOperation.Update, ExecutionStage.PreOperation, + nameof(TypeSafeAccountService.HandleUpdate)) + .AddFilteredAttributes(x => x.Name, x => x.Accountnumber) + .WithPreImage(x => x.Name, x => x.Accountnumber, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.Accountnumber); + } - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - services.AddScoped(_ => new TypeSafeAccountService(this)); - return base.OnBeforeBuildServiceProvider(services); - } + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(_ => new TypeSafeAccountService(this)); + return base.OnBeforeBuildServiceProvider(services); + } - internal void SetExecutionResult(PreImage preImage, PostImage postImage) - { - UpdateExecuted = true; - LastPreImage = preImage; - LastPostImage = postImage; - } - } + internal void SetExecutionResult(PreImage preImage, PostImage postImage) + { + UpdateExecuted = true; + LastPreImage = preImage; + LastPostImage = postImage; + } +} - /// - /// Simple Account entity class for testing - /// - [Microsoft.Xrm.Sdk.Client.EntityLogicalName("account")] - public class Account : Entity - { - public Account() : base("account") { } +/// +/// Simple Account entity class for testing +/// +[Microsoft.Xrm.Sdk.Client.EntityLogicalName("account")] +public class Account : Entity +{ + public Account() : base("account") { } - [AttributeLogicalName("name")] - public string Name - { - get => GetAttributeValue("name"); - set => SetAttributeValue("name", value); - } + [AttributeLogicalName("name")] + public string Name + { + get => GetAttributeValue("name"); + set => SetAttributeValue("name", value); + } - [AttributeLogicalName("accountnumber")] - public string Accountnumber - { - get => GetAttributeValue("accountnumber"); - set => SetAttributeValue("accountnumber", value); - } + [AttributeLogicalName("accountnumber")] + public string Accountnumber + { + get => GetAttributeValue("accountnumber"); + set => SetAttributeValue("accountnumber", value); + } - [AttributeLogicalName("revenue")] - public Money Revenue - { - get => GetAttributeValue("revenue"); - set => SetAttributeValue("revenue", value); - } - } + [AttributeLogicalName("revenue")] + public Money Revenue + { + get => GetAttributeValue("revenue"); + set => SetAttributeValue("revenue", value); + } } diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs index 256bb06..b82c59c 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs @@ -3,30 +3,29 @@ // Import the generated PreImage/PostImage from the namespace using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeAccountPlugin.AccountUpdatePreOperation; -namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe; + +/// +/// Service for TypeSafeAccountPlugin that receives images directly +/// +public class TypeSafeAccountService { - /// - /// Service for TypeSafeAccountPlugin that receives images directly - /// - public class TypeSafeAccountService - { - private readonly TypeSafeAccountPlugin plugin; + private readonly TypeSafeAccountPlugin plugin; - public TypeSafeAccountService(TypeSafeAccountPlugin plugin) - { - this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); - } + public TypeSafeAccountService(TypeSafeAccountPlugin plugin) + { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); + } - public void HandleUpdate(PreImage preImage, PostImage postImage) - { - if (preImage != null) - { - _ = preImage.Name; - _ = preImage.Accountnumber; - _ = preImage.Revenue; - } + public void HandleUpdate(PreImage preImage, PostImage postImage) + { + if (preImage != null) + { + _ = preImage.Name; + _ = preImage.Accountnumber; + _ = preImage.Revenue; + } - plugin.SetExecutionResult(preImage, postImage); - } - } + plugin.SetExecutionResult(preImage, postImage); + } } diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs index 3e87720..b4334c6 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs @@ -5,73 +5,72 @@ // Import the generated PreImage from the namespace using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeContactPlugin.ContactCreatePostOperation; -namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe; + +/// +/// Test plugin using the type-safe image API. +/// PreImage wrapper is generated by the source generator +/// and passed directly to the action callback. +/// +public class TypeSafeContactPlugin : Plugin { - /// - /// Test plugin using the type-safe image API. - /// PreImage wrapper is generated by the source generator - /// and passed directly to the action callback. - /// - public class TypeSafeContactPlugin : Plugin - { - public bool CreateExecuted { get; private set; } - public PreImage LastPreImage { get; private set; } + public bool CreateExecuted { get; private set; } + public PreImage LastPreImage { get; private set; } - public TypeSafeContactPlugin() - { - // Type-safe API: PreImage is passed directly to the action via source-generated wrapper - RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, - nameof(TypeSafeContactService.HandleCreate)) - .AddFilteredAttributes(x => x.Firstname, x => x.Lastname, x => x.Emailaddress1) - .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone); - } + public TypeSafeContactPlugin() + { + // Type-safe API: PreImage is passed directly to the action via source-generated wrapper + RegisterStep(EventOperation.Create, ExecutionStage.PostOperation, + nameof(TypeSafeContactService.HandleCreate)) + .AddFilteredAttributes(x => x.Firstname, x => x.Lastname, x => x.Emailaddress1) + .WithPreImage(x => x.Firstname, x => x.Lastname, x => x.Mobilephone); + } - protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) - { - services.AddScoped(_ => new TypeSafeContactService(this)); - return base.OnBeforeBuildServiceProvider(services); - } + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(_ => new TypeSafeContactService(this)); + return base.OnBeforeBuildServiceProvider(services); + } - internal void SetExecutionResult(PreImage preImage) - { - CreateExecuted = true; - LastPreImage = preImage; - } - } + internal void SetExecutionResult(PreImage preImage) + { + CreateExecuted = true; + LastPreImage = preImage; + } +} - /// - /// Simple Contact entity class for testing - /// - public class Contact : Entity - { - public Contact() : base("contact") { } +/// +/// Simple Contact entity class for testing +/// +public class Contact : Entity +{ + public Contact() : base("contact") { } - [AttributeLogicalName("firstname")] - public string Firstname - { - get => GetAttributeValue("firstname"); - set => SetAttributeValue("firstname", value); - } + [AttributeLogicalName("firstname")] + public string Firstname + { + get => GetAttributeValue("firstname"); + set => SetAttributeValue("firstname", value); + } - [AttributeLogicalName("lastname")] - public string Lastname - { - get => GetAttributeValue("lastname"); - set => SetAttributeValue("lastname", value); - } + [AttributeLogicalName("lastname")] + public string Lastname + { + get => GetAttributeValue("lastname"); + set => SetAttributeValue("lastname", value); + } - [AttributeLogicalName("emailaddress1")] - public string Emailaddress1 - { - get => GetAttributeValue("emailaddress1"); - set => SetAttributeValue("emailaddress1", value); - } + [AttributeLogicalName("emailaddress1")] + public string Emailaddress1 + { + get => GetAttributeValue("emailaddress1"); + set => SetAttributeValue("emailaddress1", value); + } - [AttributeLogicalName("mobilephone")] - public string Mobilephone - { - get => GetAttributeValue("mobilephone"); - set => SetAttributeValue("mobilephone", value); - } - } + [AttributeLogicalName("mobilephone")] + public string Mobilephone + { + get => GetAttributeValue("mobilephone"); + set => SetAttributeValue("mobilephone", value); + } } diff --git a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs index 738c482..fc2412f 100644 --- a/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs @@ -3,30 +3,29 @@ // Import the generated PreImage from the namespace using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.TypeSafeContactPlugin.ContactCreatePostOperation; -namespace XrmPluginCore.Tests.TestPlugins.TypeSafe +namespace XrmPluginCore.Tests.TestPlugins.TypeSafe; + +/// +/// Service for TypeSafeContactPlugin that receives PreImage directly +/// +public class TypeSafeContactService { - /// - /// Service for TypeSafeContactPlugin that receives PreImage directly - /// - public class TypeSafeContactService - { - private readonly TypeSafeContactPlugin plugin; + private readonly TypeSafeContactPlugin plugin; - public TypeSafeContactService(TypeSafeContactPlugin plugin) - { - this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); - } + public TypeSafeContactService(TypeSafeContactPlugin plugin) + { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin)); + } - public void HandleCreate(PreImage preImage) - { - if (preImage != null) - { - _ = preImage.Firstname; - _ = preImage.Lastname; - _ = preImage.Mobilephone; - } + public void HandleCreate(PreImage preImage) + { + if (preImage != null) + { + _ = preImage.Firstname; + _ = preImage.Lastname; + _ = preImage.Mobilephone; + } - plugin.SetExecutionResult(preImage); - } - } + plugin.SetExecutionResult(preImage); + } } diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 97512a7..3978998 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -44,10 +44,10 @@ protected virtual IServiceCollection OnBeforeBuildServiceProvider(IServiceCollec /// /// The service provider. /// - /// For improved performance, Microsoft Dynamics CRM caches plug-in instances. - /// The plug-in's Execute method should be written to be stateless as the constructor - /// is not called for every invocation of the plug-in. Also, multiple system threads - /// could execute the plug-in at the same time. All per invocation state information + /// For improved performance, Microsoft Dynamics CRM caches plug-in instances. + /// The plug-in's Execute method should be written to be stateless as the constructor + /// is not called for every invocation of the plug-in. Also, multiple system threads + /// could execute the plug-in at the same time. All per invocation state information /// is stored in the context. This means that you should not use global variables in plug-ins. /// public void Execute(IServiceProvider serviceProvider) @@ -384,7 +384,7 @@ protected CustomApiConfigBuilder RegisterAPI(string name, Action public bool Matches(IPluginExecutionContext pluginExecutionContext) { return (int)ExecutionStage == pluginExecutionContext.Stage && - EventOperation.ToString() == pluginExecutionContext.MessageName && + EventOperation == pluginExecutionContext.MessageName && (string.IsNullOrWhiteSpace(EntityLogicalName) || EntityLogicalName == pluginExecutionContext.PrimaryEntityName); } From 627d151511ebb4e976b422b3a6cf4bfb2492356a Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 27 Nov 2025 10:27:29 +0100 Subject: [PATCH 21/22] CHORE: Update CHANGELOG and AnalyzerReleases to prepare for merge --- .../AnalyzerReleases.Shipped.md | 10 +++++++++- .../AnalyzerReleases.Unshipped.md | 9 +-------- XrmPluginCore/CHANGELOG.md | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md index 39071b5..c8dda4f 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md @@ -1,6 +1,14 @@ -## Release 1.0 +## Release 1.2.0 ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- +XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe wrapper classes +XPC3001 | XrmPluginCore.SourceGenerator | Warning | XPC3001 Prefer nameof over string literal for handler method +XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol +XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found +XPC4002 | XrmPluginCore.SourceGenerator | Error | XPC4002 Handler method not found +XPC4003 | XrmPluginCore.SourceGenerator | Error | XPC4003 Handler signature does not match registered images +XPC4004 | XrmPluginCore.SourceGenerator | Warning | XPC4004 Image registration without method reference +XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index 967f987..6db033d 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -2,11 +2,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- -XPC1000 | XrmPluginCore.SourceGenerator | Info | XPC1000 Generated type-safe wrapper classes -XPC3001 | XrmPluginCore.SourceGenerator | Warning | XPC3001 Prefer nameof over string literal for handler method -XPC4000 | XrmPluginCore.SourceGenerator | Warning | XPC4000 Failed to resolve symbol -XPC4001 | XrmPluginCore.SourceGenerator | Warning | XPC4001 No parameterless constructor found -XPC4002 | XrmPluginCore.SourceGenerator | Error | XPC4002 Handler method not found -XPC4003 | XrmPluginCore.SourceGenerator | Error | XPC4003 Handler signature does not match registered images -XPC4004 | XrmPluginCore.SourceGenerator | Warning | XPC4004 Image registration without method reference -XPC5000 | XrmPluginCore.SourceGenerator | Error | XPC5000 Failed to generate wrapper classes + diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 924b47f..e4c6118 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,5 +1,6 @@ -### v1.2.0-preview.2 - 21 November 2025 +### v1.2.0 - 27 November 2025 * Add: Type-Safe Images feature with compile-time enforcement via source generator +* Add: Source analyzer rules with hotfixes and documentation to help use the Type-Safe Images feature correctly ### v1.1.1 - 14 November 2025 * Add: IManagedIdentityService to service provider (#1) From 1b48cf9af7930bb5ab87532fb06c3a958f7671f3 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 27 Nov 2025 10:39:31 +0100 Subject: [PATCH 22/22] CHORE: Set version of SourceGenerator --- .github/workflows/ci.yml | 72 ++++++++++--------- .github/workflows/release.yml | 4 ++ XrmPluginCore.SourceGenerator/CHANGELOG.md | 4 ++ .../XrmPluginCore.SourceGenerator.csproj | 1 + 4 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c814936..ebc1a77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,52 @@ name: Check PR on: - pull_request: - push: - branches: - - main + pull_request: + push: + branches: + - main jobs: - run-ci: - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: windows-latest + run-ci: + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: windows-latest - steps: - - name: 🚚 Get latest code - uses: actions/checkout@v4 + steps: + - name: 🚚 Get latest code + uses: actions/checkout@v4 - - name: 🛠️ Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.x + - name: 🛠️ Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x - - name: ✏️ Set abstractions version from CHANGELOG.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore.Abstractions/CHANGELOG.md -CsprojPath XrmPluginCore.Abstractions/XrmPluginCore.Abstractions.csproj + - name: ✏️ Set abstractions version from CHANGELOG.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore.Abstractions/CHANGELOG.md -CsprojPath XrmPluginCore.Abstractions/XrmPluginCore.Abstractions.csproj - - name: ✏️ Set implementations version from CHANGELOG.md - shell: pwsh - run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore/CHANGELOG.md -CsprojPath XrmPluginCore/XrmPluginCore.csproj + - name: ✏️ Set source generator version from CHANGELOG.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore.SourceGenerator/CHANGELOG.md -CsprojPath XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj - - name: 📦 Install dependencies - run: dotnet restore + - name: ✏️ Set implementations version from CHANGELOG.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore/CHANGELOG.md -CsprojPath XrmPluginCore/XrmPluginCore.csproj - - name: 🔨 Build solution - run: dotnet build --configuration Release --no-restore + - name: 📦 Install dependencies + run: dotnet restore - - name: ✅ Run tests - run: dotnet test --configuration Release --no-build --verbosity normal + - name: 🔨 Build solution + run: dotnet build --configuration Release --no-restore - - name: 📦 Pack - run: dotnet pack --configuration Release --no-build --output ./nupkg + - name: ✅ Run tests + run: dotnet test --configuration Release --no-build --verbosity normal - - name: 📤 Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: packages - path: ./nupkg + - name: 📦 Pack + run: dotnet pack --configuration Release --no-build --output ./nupkg + + - name: 📤 Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: packages + path: ./nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b7397f..9fdfb14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,10 @@ jobs: shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore.Abstractions/CHANGELOG.md -CsprojPath XrmPluginCore.Abstractions/XrmPluginCore.Abstractions.csproj + - name: ✏️ Set source generator version from CHANGELOG.md + shell: pwsh + run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore.SourceGenerator/CHANGELOG.md -CsprojPath XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj + - name: ✏️ Set implementations version from CHANGELOG.md shell: pwsh run: ./scripts/Set-VersionFromChangelog.ps1 -ChangelogPath XrmPluginCore/CHANGELOG.md -CsprojPath XrmPluginCore/XrmPluginCore.csproj diff --git a/XrmPluginCore.SourceGenerator/CHANGELOG.md b/XrmPluginCore.SourceGenerator/CHANGELOG.md new file mode 100644 index 0000000..4fcb28d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CHANGELOG.md @@ -0,0 +1,4 @@ +### v1.0.0 - 27 November 2025 +Initial release of XrmPluginCore SourceGenerator +* Add: Type-Safe Images feature with compile-time enforcement via source generator +* Add: Source analyzer rules with hotfixes and documentation to help use the Type-Safe Images feature correctly diff --git a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj index 7754e8c..1746e2f 100644 --- a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj +++ b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj @@ -5,6 +5,7 @@ 14 true true + 1.0.0-local