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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92de470..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: 8.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 \ No newline at end of file + - 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 68cd775..9fdfb14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release on: release: @@ -21,12 +21,16 @@ 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 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/CLAUDE.md b/CLAUDE.md index bc8445b..090bbc5 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 `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 (PreImage, PostImage) directly in DI - Custom services registered via `OnBeforeBuildServiceProvider()` override 4. **Configuration Builders**: @@ -76,20 +80,244 @@ 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/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 +- `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` (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) - use lambda invocation syntax +RegisterStep( + EventOperation.Update, + 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, + 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, + 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, + nameof(AccountService.HandleUpdate)) + .AddFilteredAttributes(x => x.Name, x => x.AccountNumber) + .WithPreImage(x => x.Name, x => x.Revenue) + .WithPostImage(x => x.Name, x => x.AccountNumber); +``` + +**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()`, `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()`/`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) + +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. + +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 MyNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation; + +public class AccountPlugin : Plugin +{ + public AccountPlugin() + { + // 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); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } +} + +public class AccountService +{ + // 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 + var previousRevenue = preImage.Revenue; + var newName = postImage.Name; + } +} +``` + +#### Generated Code Example + +The source generator creates wrapper classes in isolated namespaces: + +```csharp +// Generated in: {Namespace}.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation +namespace YourNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation +{ + public sealed class PreImage + { + private readonly Entity entity; + + public PreImage(Entity entity) + { + this.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 sealed class PostImage + { + private readonly Entity entity; + + public PostImage(Entity entity) + { + this.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(); + } +} +``` + +#### Image Registration Methods + +The following methods are available for registering images: + +- `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 + +All three methods are valid and supported. `WithPreImage` and `WithPostImage` are convenience wrappers around `AddImage`. + +#### Benefits + +- **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 +- **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 @@ -116,12 +344,25 @@ 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`. Use `nameof()` for compile-time safety when registering images: ```csharp -RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, s => s.Process()) +// Using convenience methods (recommended) +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, + nameof(IAccountService.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 35919b6..1c26e10 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,88 @@ namespace Some.Namespace { } ``` -#### Using the LocalPluginContext wrapper +### 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. -**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.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation; + +namespace MyPlugin { + public class AccountUpdatePlugin : Plugin { + public AccountUpdatePlugin() { + // 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); + // Source generator validates that Process accepts PreImage parameter + } + } + + 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** - 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 + +#### Working with Both Images + +```csharp +using MyPlugin.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation; + +public class AccountUpdatePlugin : Plugin { + public AccountUpdatePlugin() { + 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); + // Handler method 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}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage} +``` +Example: `MyPlugin.PluginRegistrations.AccountUpdatePlugin.AccountUpdatePostOperation` + +Inside this namespace you'll find simple class names: `PreImage`, `PostImage`, and `ActionWrapper` + +### 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 +212,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 +235,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.Tests/.editorconfig b/XrmPluginCore.SourceGenerator.Tests/.editorconfig new file mode 100644 index 0000000..2f9c0f4 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/.editorconfig @@ -0,0 +1,6 @@ +# 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 +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 new file mode 100644 index 0000000..4bed751 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/AnalyzerTests/PreferNameofAnalyzerTests.cs @@ -0,0 +1,256 @@ +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(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(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.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(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(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..6e0d6ed --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/CodeFixTests/PreferNameofCodeFixProviderTests.cs @@ -0,0 +1,350 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +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 + 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, + "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(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 + 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, + "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(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 + const string 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(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 + 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, + "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(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( + [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( + [analyzer]); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "XPC3001"); + + if (diagnostic == null) + { + return []; + } + + 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 new file mode 100644 index 0000000..dbd9e98 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -0,0 +1,606 @@ +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 and analyzers. +/// +public class DiagnosticReportingTests +{ + [Fact] + public void Should_Not_Report_XPC1000_Success_Diagnostic_On_Successful_Generation() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - XPC1000 is no longer reported to avoid spamming the user + var successDiagnostics = result.GeneratorDiagnostics + .Where(d => d.Id == "XPC1000") + .ToArray(); + + successDiagnostics.Should().BeEmpty("XPC1000 success diagnostic should not be reported to avoid spam"); + } + + [Fact] + public async Task Should_Report_XPC4001_When_Plugin_Has_No_Parameterless_Constructor() + { + // 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()); + + // Assert - should report XPC4001 + var errorDiagnostics = diagnostics + .Where(d => d.Id == "XPC4001") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4001 should be reported when plugin class 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.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"); + } + + [Fact] + public async Task Should_Report_XPC4002_When_Handler_Method_Not_Found() + { + // 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()); + + // Assert + var errorDiagnostics = diagnostics + .Where(d => d.Id == "XPC4002") + .ToArray(); + + errorDiagnostics.Should().NotBeEmpty("XPC4002 should be reported when handler method is not found"); + errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public async Task Should_Report_XPC4003_When_Handler_Missing_PreImage_Parameter() + { + // 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()); + + // Assert + var errorDiagnostics = diagnostics + .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 async Task Should_Report_XPC4003_When_Handler_Missing_PostImage_Parameter() + { + // 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()); + + // Assert + var errorDiagnostics = diagnostics + .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 async Task Should_Report_XPC4003_When_Handler_Missing_Both_Image_Parameters() + { + // 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()); + + // Assert + var errorDiagnostics = diagnostics + .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 async Task Should_Report_XPC4003_When_Handler_Has_Wrong_Parameter_Order() + { + // 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()); + + // Assert + var errorDiagnostics = diagnostics + .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 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) + 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()); + + // Assert + var warningDiagnostics = diagnostics + .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 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) + 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()); + + // Assert + var warningDiagnostics = diagnostics + .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 async Task Should_Not_Report_XPC4004_When_Using_Method_Reference_Syntax() + { + // 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()); + + // Assert + var warningDiagnostics = diagnostics + .Where(d => d.Id == "XPC4004") + .ToArray(); + + warningDiagnostics.Should().BeEmpty("XPC4004 should NOT be reported when using method reference syntax"); + } + + [Fact] + public async Task Should_Not_Report_XPC4004_When_Old_Api_Used_Without_Images() + { + // 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()); + + // Assert + 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( + [analyzer]); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs new file mode 100644 index 0000000..f6bd394 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs @@ -0,0 +1,422 @@ +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 partial class WrapperClassGenerationTests +{ + [Fact] + public void Should_Generate_PreImage_Class_With_Properties() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + 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.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.GetPluginWithBothImages()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // All classes should be in the same namespace + var namespaceCount = IsAccountUpdatePostOperationNamespace().Matches(generatedSource).Count; + + namespaceCount.Should().Be(1, "all classes should be in the same namespace"); + + // 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] + public void Should_Generate_Properties_With_Correct_Types() + { + // 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( + 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.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.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.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + generatedSource.Should().Contain(": IEntityImageWrapper"); + } + + [Fact] + public void Should_Generate_ActionWrapper_Class_For_New_Api() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + 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.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.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.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.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.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.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.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.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"); + } + + [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 new file mode 100644 index 0000000..e4e0b58 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs @@ -0,0 +1,62 @@ +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}", + [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(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); + + // 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..bde6fe4 --- /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: [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], + GeneratedTrees = generatedTrees, + GeneratorDiagnostics = [.. runResult.Results[0].Diagnostics] + }; + } + + /// + /// 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..2051ca1 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -0,0 +1,504 @@ +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. + /// + 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 +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + nameof(ITestService.Process)) + .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) + .WithPreImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "Revenue" : "EmailAddress")}); + }} + + 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) {{ }} + }} +}}"; + + /// + /// 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 +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + nameof(ITestService.Process)) + .AddFilteredAttributes(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}) + .WithPostImage(x => x.{(entityClass == "Account" ? "Name" : "FirstName")}, x => x.{(entityClass == "Account" ? "AccountNumber" : "LastName")}); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(PostImage postImage); + }} + + public class TestService : ITestService + {{ + public void Process(PostImage postImage) {{ }} + }} +}}"; + + /// + /// Plugin with both PreImage and PostImage using WithPreImage and WithPostImage. + /// + 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 +{{ + public class TestPlugin : Plugin + {{ + public TestPlugin() + {{ + RegisterStep<{entityClass}, ITestService>(EventOperation.Update, ExecutionStage.PostOperation, + 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")}); + }} + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + {{ + return services.AddScoped(); + }} + }} + + public interface ITestService + {{ + void Process(PreImage preImage, PostImage postImage); + }} + + public class TestService : ITestService + {{ + 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, + nameof(ITestService.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() {{ }} + }} +}}"; + + /// + /// 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 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(); + } + + /// + /// 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 new file mode 100644 index 0000000..5334f32 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -0,0 +1,237 @@ +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.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + + // Assert + result.Success.Should().BeTrue( + because: $"compilation should succeed. Errors: {string.Join(", ", result.Errors ?? [])}"); + result.AssemblyBytes.Should().NotBeNull(); + } + + [Fact] + public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + 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.PluginRegistrations.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.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.PluginRegistrations.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.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!); + const string baseNamespace = "TestNamespace.PluginRegistrations.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.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.PluginRegistrations.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.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + + // 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"); + preImageType!.Namespace.Should().Be(expectedNamespace); + } + + [Fact] + public void Should_Generate_ActionWrapper_Class() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + TestFixtures.GetPluginWithPreImage()); + + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + result.Success.Should().BeTrue(); + + // Act + using var loadedAssembly = GeneratorTestHelper.LoadAssembly(result.AssemblyBytes!); + const string 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"); + } + + [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.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 ?? [])}"); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs new file mode 100644 index 0000000..6167847 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/ParsingTests/RegisterStepParsingTests.cs @@ -0,0 +1,386 @@ +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.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.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.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_Not_Generate_Code_For_Old_AddImage_Api_Without_Method_Reference() + { + // 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.GetPluginWithOldImageApi()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert - No code should be generated for old API without method reference + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void Should_Parse_Lambda_Syntax_For_Attributes() + { + // Arrange - GetPluginWithPreImage uses lambda syntax: x => x.Name + var source = TestFixtures.GetCompleteSource( + 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.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.GetPluginWithPreImage()); + + // Act + var result = GeneratorTestHelper.RunGenerator( + CompilationHelper.CreateCompilation(source)); + + // Assert + result.GeneratedTrees.Should().NotBeEmpty(); + var generatedSource = result.GeneratedTrees[0].GetText().ToString(); + + // Namespace pattern: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage} + generatedSource.Should().Contain("namespace TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation"); + } + + [Fact] + public void Should_Handle_Multiple_Attributes_In_Same_Image() + { + // 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( + 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"); + } + + [Fact] + public void Should_Parse_Handler_Method_Name() + { + // 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( + 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)"); + } + + [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 + 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( + 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 + 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( + 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.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..90d0524 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/SnapshotTests/GeneratedCodeSnapshotTests.cs @@ -0,0 +1,214 @@ +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 partial class GeneratedCodeSnapshotTests +{ + [Fact] + public void Should_Generate_PreImage_Class_With_Expected_Structure() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + 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.PluginRegistrations.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.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.PluginRegistrations.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.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.GetPluginWithPreImage()), + ExpectedNamespace = "TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation" + }, + new + { + Source = TestFixtures.GetCompleteSource( + TestFixtures.GetPluginWithPreImage("Contact")), + ExpectedNamespace = "TestNamespace.PluginRegistrations.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}.PluginRegistrations.{Plugin}.{Entity}{Operation}{Stage}"); + } + } + + [Fact] + public void Should_Mark_Classes_With_CompilerGenerated_Attribute() + { + // Arrange + var source = TestFixtures.GetCompleteSource( + 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 3 (PreImage, PostImage, and ActionWrapper) + var matches = IsCompilerGenerated().Matches(generatedSource); + 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.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.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)"); + } + + [System.Text.RegularExpressions.GeneratedRegex(@"\[CompilerGenerated\]")] + private static partial System.Text.RegularExpressions.Regex IsCompilerGenerated(); +} diff --git a/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..f6b0e0a --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/XrmPluginCore.SourceGenerator.Tests.csproj @@ -0,0 +1,47 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..c8dda4f --- /dev/null +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1,14 @@ +## 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 new file mode 100644 index 0000000..6db033d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,5 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- + 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/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/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/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs new file mode 100644 index 0000000..6e2bbfb --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -0,0 +1,283 @@ +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. + /// Generates PreImage and PostImage wrappers, and ActionWrapper for the new method reference API. + /// + public static string GenerateWrapperClasses(PluginStepMetadata metadata) + { + var imagesWithAttributes = metadata.Images.Where(i => i.Attributes.Any()).ToList(); + + // Estimate capacity: ~500 chars per image wrapper class + ~300 for ActionWrapper + var estimatedCapacity = (imagesWithAttributes.Count * 500) + 500; + var sb = new StringBuilder(estimatedCapacity); + + // File header and using directives + sb.Append(GetFileHeader()); + + var namespaceToUse = metadata.RegistrationNamespace; + + // Namespace declaration + sb.AppendLine($"namespace {namespaceToUse}"); + sb.AppendLine("{"); + + // 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("}"); + + return sb.ToString(); + } + + /// + /// Generates an Image wrapper class (PreImage or PostImage) + /// + private static void GenerateImageWrapperClass(StringBuilder sb, PluginStepMetadata metadata, ImageMetadata image) + { + var className = image.WrapperClassName; + + // 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) + { + sb.Append(GetPropertyTemplate(attr.TypeName, attr.PropertyName, attr.LogicalName)); + } + + // Class footer with ToEntity and GetUnderlyingEntity methods + sb.Append(GetImageClassFooter()); + } + + /// + /// 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"); + + // ActionWrapper header with documentation, class declaration, and service retrieval + sb.Append(GetActionWrapperHeader( + metadata.ServiceTypeName, + metadata.HandlerMethodName, + metadata.ServiceTypeFullName)); + + // Get context if images are needed + if (hasPreImage || hasPostImage) + { + sb.AppendLine(); + sb.Append(GetContextRetrieval()); + } + + var args = new List(); + if (hasPreImage) + { + sb.AppendLine(); + sb.Append(GetPreImageRetrieval()); + args.Add("preImage"); + } + if (hasPostImage) + { + sb.AppendLine(); + sb.Append(GetPostImageRetrieval()); + args.Add("postImage"); + } + + var argsString = string.Join(", ", args); + + // ActionWrapper footer with method invocation and closing braces + sb.AppendLine(); + sb.Append(GetActionWrapperFooter(metadata.HandlerMethodName, argsString)); + } + + /// + /// 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, + ServiceTypeName = list[0].ServiceTypeName, + ServiceTypeFullName = list[0].ServiceTypeFullName, + HandlerMethodName = list[0].HandlerMethodName, + 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; + } + + #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.GetRequiredService(); +"""; + + 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.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..cd04b87 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -0,0 +1,81 @@ +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 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, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + 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", + 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 NoParameterlessConstructor = new( + 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, + 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, + 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, + 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", + 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, + helpLinkUri: "https://github.com/delegateas/XrmPluginCore/blob/main/XrmPluginCore.SourceGenerator/rules/XPC4004.md"); + + 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 new file mode 100644 index 0000000..c7f16a0 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -0,0 +1,184 @@ +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; +using XrmPluginCore.SourceGenerator.Validation; + +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; + + // 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); + + if (mergedMetadata is null) + continue; + + // Validate handler method signature + HandlerMethodValidator.ValidateHandlerMethod( + mergedMetadata, + semanticModel.Compilation); + + // Include if: + // - Has method reference (for ActionWrapper generation) + // - OR has images with attributes (for image wrapper generation) + // - OR has diagnostics to report + if (!string.IsNullOrEmpty(mergedMetadata.HandlerMethodName) || + mergedMetadata.Images.Any(i => i.Attributes.Any()) || + mergedMetadata.Diagnostics?.Any() == true) + { + 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) + { + // 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, + Location.None, + diagnosticInfo.MessageArgs); + context.ReportDiagnostic(diagnostic); + } + } + + // 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; + + 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)); + } + catch (System.Exception ex) + { + // Report diagnostic error + ReportGenerationError(context, ex); + } + } + + /// + /// Reports a diagnostic error when code generation fails + /// + private void ReportGenerationError( + SourceProductionContext context, + System.Exception exception) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.GenerationError, + Location.None, + exception.Message); + + context.ReportDiagnostic(diagnostic); + } +} 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/SyntaxHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs new file mode 100644 index 0000000..81520cf --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxHelper.cs @@ -0,0 +1,186 @@ +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/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 new file mode 100644 index 0000000..2d7f3d5 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -0,0 +1,181 @@ +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; } + + /// + /// 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; } + + /// + /// Diagnostics to report for this plugin step. Not included in equality comparison. + /// + 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} + /// + public string RegistrationNamespace => + $"{Namespace}.PluginRegistrations.{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 + && ServiceTypeName == other.ServiceTypeName + && ServiceTypeFullName == other.ServiceTypeFullName + && HandlerMethodName == other.HandlerMethodName; + } + 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); + 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(); + } + 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; + } + } +} + +/// +/// 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 object[] MessageArgs { get; set; } +} diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs new file mode 100644 index 0000000..aeac078 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -0,0 +1,340 @@ +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) + { + // 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, abort generation + // Note: XPC4001 (NoParameterlessConstructor) is handled by a separate analyzer + if (hasExplicitConstructors && !hasParameterlessConstructor) + { + yield break; + } + + // 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 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) + { + metadata.EventOperation = ExtractEnumValue(arguments[0].Expression); + metadata.ExecutionStage = ExtractEnumValue(arguments[1].Expression); + } + + // Extract method reference from 3rd argument if present + if (arguments.Count >= 3) + { + metadata.HandlerMethodName = RegisterStepHelper.GetMethodName(arguments[2].Expression); + } + + // Find image calls + foreach (var imageCall in SyntaxHelper.FindImageInvocations(registerStepInvocation)) + { + var imageMetadata = ParseImageInvocation(imageCall, entityType); + if (imageMetadata != null) + { + metadata.Images.Add(imageMetadata); + } + } + + // 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 WithPreImage, WithPostImage, or AddImage call to extract image metadata. + /// + 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 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; + + // 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/Validation/HandlerMethodValidator.cs b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs new file mode 100644 index 0000000..eb4dafb --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Validation/HandlerMethodValidator.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; + +namespace XrmPluginCore.SourceGenerator.Validation; + +internal static class HandlerMethodValidator +{ + /// + /// Validates handler method existence and signature. + /// Sets HasValidationError on metadata if validation fails. + /// Note: XPC4002 and XPC4003 diagnostics are handled by separate analyzers. + /// + public static void ValidateHandlerMethod( + PluginStepMetadata metadata, + Compilation compilation) + { + if (string.IsNullOrEmpty(metadata.HandlerMethodName) || + string.IsNullOrEmpty(metadata.ServiceTypeFullName)) + { + return; + } + + var serviceType = compilation.GetTypeByMetadataName(metadata.ServiceTypeFullName); + if (serviceType is null) + return; + + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, metadata.HandlerMethodName); + if (!methods.Any()) + { + // Method not found - abort generation for this registration + // 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 hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage)); + if (!hasMatchingOverload) + { + // Signature mismatch - abort generation for this registration + // XPC4003 diagnostic is handled by HandlerSignatureMismatchAnalyzer + metadata.HasValidationError = true; + } + } + + 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 (parameters[paramIndex].Type.Name != Constants.PreImageTypeName) + return false; + paramIndex++; + } + + if (hasPostImage) + { + if (paramIndex >= parameters.Length) + return false; + if (parameters[paramIndex].Type.Name != Constants.PostImageTypeName) + return false; + } + + return true; + } +} diff --git a/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj new file mode 100644 index 0000000..1746e2f --- /dev/null +++ b/XrmPluginCore.SourceGenerator/XrmPluginCore.SourceGenerator.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + 14 + true + true + 1.0.0-local + + + + + + + + + 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) 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..0ba874f --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountPlugin.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using XrmPluginCore.Enums; + +// Import the generated PreImage/PostImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.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 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); + } + + 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..b82c59c --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeAccountService.cs @@ -0,0 +1,31 @@ +using System; + +// Import the generated PreImage/PostImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.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..b4334c6 --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactPlugin.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using XrmPluginCore.Enums; + +// Import the generated PreImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.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 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); + } + + 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..fc2412f --- /dev/null +++ b/XrmPluginCore.Tests/TestPlugins/TypeSafe/TypeSafeContactService.cs @@ -0,0 +1,31 @@ +using System; + +// Import the generated PreImage from the namespace +using XrmPluginCore.Tests.TestPlugins.TypeSafe.PluginRegistrations.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..ca09e62 100644 --- a/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj +++ b/XrmPluginCore.Tests/XrmPluginCore.Tests.csproj @@ -1,9 +1,14 @@ - + net462;net8.0 false true + 10.0 + + + true + $(BaseIntermediateOutputPath)Generated @@ -32,6 +37,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/XrmPluginCore.sln b/XrmPluginCore.sln index f2a49ca..ea9dd70 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,10 @@ 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 +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 @@ -69,6 +73,30 @@ 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 + {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 diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 032becc..e4c6118 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,7 @@ +### 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) @@ -36,4 +40,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/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/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..3978998 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; } /// @@ -41,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) @@ -54,17 +57,43 @@ 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 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) { + // 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} uses method reference " + + "'{3}' but no generated ActionWrapper was found. Ensure the source generator is running.", + context.PrimaryEntityName, + context.MessageName, + ChildClassName, + matchingRegistration.HandlerMethodName + )); + } + localServiceProvider.Trace(string.Format( CultureInfo.InvariantCulture, "No registered event found for Entity: {0}, Message: {1} in {2}", @@ -229,10 +258,97 @@ 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 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 + /// The name of the handler method (use nameof(IService.MethodName)) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + EventOperation eventOperation, + ExecutionStage executionStage, + string handlerMethodName) + where TEntity : Entity + { + return RegisterStep(eventOperation.ToString(), executionStage, handlerMethodName); + } + + /// + /// 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.
+ /// 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 + /// The name of the handler method (use nameof(IService.MethodName)) + /// A for configuring images and filtered attributes + protected PluginStepConfigBuilder RegisterStep( + string eventOperation, + ExecutionStage executionStage, + string handlerMethodName) + where TEntity : Entity + { + var builder = new PluginStepConfigBuilder(eventOperation, executionStage); + + var registration = new PluginStepRegistration(builder, null) + { + EntityTypeName = typeof(TEntity).Name, + EventOperation = eventOperation, + ExecutionStage = executionStage.ToString(), + PluginClassName = ChildClassShortName, + ServiceTypeName = typeof(TService).Name, + ServiceTypeFullName = typeof(TService).FullName, + HandlerMethodName = handlerMethodName + }; + + RegisteredPluginSteps.Add(registration); + return builder; + } + + 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(); + } + /// /// /// Register a CustomAPI with the given name and action.
@@ -268,7 +384,7 @@ 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/PluginStepConfigBuilder.cs b/XrmPluginCore/Plugins/PluginStepConfigBuilder.cs index 084371f..b441174 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; @@ -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 { @@ -74,7 +74,7 @@ public IPluginStepConfig Build() => public bool Matches(IPluginExecutionContext pluginExecutionContext) { return (int)ExecutionStage == pluginExecutionContext.Stage && - EventOperation.ToString() == pluginExecutionContext.MessageName && + EventOperation == pluginExecutionContext.MessageName && (string.IsNullOrWhiteSpace(EntityLogicalName) || EntityLogicalName == pluginExecutionContext.PrimaryEntityName); } @@ -172,5 +172,23 @@ public PluginStepConfigBuilder AddImage(string name, string entityAlias, Imag 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); + } } -} \ No newline at end of file +} diff --git a/XrmPluginCore/Plugins/PluginStepRegistration.cs b/XrmPluginCore/Plugins/PluginStepRegistration.cs index 73b16d3..4465479 100644 --- a/XrmPluginCore/Plugins/PluginStepRegistration.cs +++ b/XrmPluginCore/Plugins/PluginStepRegistration.cs @@ -1,8 +1,8 @@ -using System; +using System; namespace XrmPluginCore.Plugins { - internal class PluginStepRegistration + internal sealed class PluginStepRegistration { public PluginStepRegistration(IPluginStepConfigBuilder pluginStepConfig, Action action) { @@ -11,7 +11,49 @@ 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; } + + /// + /// 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; } } } diff --git a/XrmPluginCore/XrmPluginCore.csproj b/XrmPluginCore/XrmPluginCore.csproj index d7fb13b..58642d4 100644 --- a/XrmPluginCore/XrmPluginCore.csproj +++ b/XrmPluginCore/XrmPluginCore.csproj @@ -49,5 +49,15 @@ + + + + + + \ No newline at end of file