From 8c5bdb8f896ebf2c8fef46bfa4cdfff2369e6e30 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 19 Jun 2026 15:07:31 +0200 Subject: [PATCH 1/2] Add shared image interfaces (IPluginImage/IPluginPreImage/IPluginPostImage) Replace IEntityImageWrapper with a richer interface hierarchy for generated plugin images so handler methods can share functionality across the per-registration concrete image types. - Add IPluginImage (non-generic) and IPluginImage with type-safe Entity access, plus IPluginPreImage/IPluginPostImage marker variants. - Always expose Id and LogicalName on images (available on every Entity), surfaced on the non-generic IPluginImage for fully generic helpers. - Generated PreImage/PostImage implement the new interfaces; skip any attribute mapping to Id/LogicalName to avoid duplicate members. - HandlerSignatureMismatchAnalyzer now accepts interface-typed parameters, validating image kind (pre/post) and entity type for generic variants. - Update tests, CHANGELOG (v1.3.0), and CLAUDE.md docs. BREAKING: IEntityImageWrapper removed; use IPluginImage. Co-Authored-By: Claude via Conducktor --- CLAUDE.md | 69 ++++-- .../DiagnosticReportingTests.cs | 204 ++++++++++++++++++ .../WrapperClassGenerationTests.cs | 19 +- .../IntegrationTests/CompilationTests.cs | 76 ++++++- XrmPluginCore.SourceGenerator.Tests/README.md | 4 +- .../HandlerSignatureMismatchAnalyzer.cs | 53 ++++- .../CodeGeneration/WrapperClassGenerator.cs | 49 +++-- XrmPluginCore.SourceGenerator/Constants.cs | 7 +- XrmPluginCore/CHANGELOG.md | 5 + XrmPluginCore/IEntityImageWrapper.cs | 8 - XrmPluginCore/IPluginImage.cs | 73 +++++++ 11 files changed, 513 insertions(+), 54 deletions(-) delete mode 100644 XrmPluginCore/IEntityImageWrapper.cs create mode 100644 XrmPluginCore/IPluginImage.cs diff --git a/CLAUDE.md b/CLAUDE.md index 31de6db..246b94d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,44 +193,82 @@ public class AccountService #### Generated Code Example -The source generator creates wrapper classes in isolated namespaces: +The source generator creates wrapper classes in isolated namespaces. Each wrapper holds the strongly-typed entity and implements the shared image interfaces (see [Image Interfaces](#image-interfaces) below): ```csharp // Generated in: {Namespace}.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation namespace YourNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation { - public sealed class PreImage + public sealed class PreImage : IPluginPreImage { - private readonly Entity entity; - public PreImage(Entity entity) { - this.entity = entity ?? throw new ArgumentNullException(nameof(entity)); + Entity = entity.ToEntity(); } - public string Name => entity.GetAttributeValue("name"); - public decimal? Revenue => entity.GetAttributeValue("revenue"); + public YourNamespace.Account Entity { get; } + + Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity; + + public System.Guid Id => Entity.Id; + public string LogicalName => Entity.LogicalName; - public T ToEntity() where T : Entity => entity.ToEntity(); + public string Name => Entity.Name; + public decimal? Revenue => Entity.Revenue; } - public sealed class PostImage + public sealed class PostImage : IPluginPostImage { - private readonly Entity entity; - public PostImage(Entity entity) { - this.entity = entity ?? throw new ArgumentNullException(nameof(entity)); + Entity = entity.ToEntity(); } - public string Name => entity.GetAttributeValue("name"); - public string Accountnumber => entity.GetAttributeValue("accountnumber"); + public YourNamespace.Account Entity { get; } + + Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity; + + public System.Guid Id => Entity.Id; + public string LogicalName => Entity.LogicalName; - public T ToEntity() where T : Entity => entity.ToEntity(); + public string Name => Entity.Name; + public string AccountNumber => Entity.AccountNumber; } } ``` +#### Image Interfaces + +Every generated image implements a small interface hierarchy declared in `XrmPluginCore`: + +| Interface | `Entity` property type | Purpose | +| --- | --- | --- | +| `IPluginImage` | `Microsoft.Xrm.Sdk.Entity` | Non-generic base; lowest common denominator for fully generic helpers | +| `IPluginImage` | `TEntity` (early-bound) | Type-safe access to the entity | +| `IPluginPreImage` / `IPluginPreImage` | (inherited) | Identifies a pre-image | +| `IPluginPostImage` / `IPluginPostImage` | (inherited) | Identifies a post-image | + +The non-generic `IPluginImage` also exposes the members that are always available on any entity image, regardless of which attributes were registered: `Id` (`Guid`, the primary key) and `LogicalName` (`string`). + +Because each registration generates its **own** `PreImage`/`PostImage` type in its own namespace, these interfaces let you write shared logic that works across multiple registrations. A handler method may declare its parameters using any of the matching interfaces instead of the concrete generated type — the source generator accepts them and validates the kind (pre vs post) and, for the generic variants, the entity type: + +```csharp +public class AccountService +{ + // Concrete generated types (most specific) + public void HandleUpdate(PreImage pre, PostImage post) { } +} + +public static class AuditHelper +{ + // Works for the Pre or Post image of ANY registration on Account + public static void Log(IPluginImage image) { /* image.Entity is an Account */ } + + // Works for any image of any entity + public static void LogRaw(IPluginImage image) { /* image.Entity is an Entity */ } +} +``` + #### Image Registration Methods The following methods are available for registering images: @@ -249,6 +287,7 @@ All three methods are valid and supported. `WithPreImage` and `WithPostImage` ar - **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 +- **Shared interfaces**: `IPluginImage`/`IPluginPreImage`/`IPluginPostImage` (and generic variants) let handler methods share logic across the per-registration concrete image types ### Dependency Injection diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index b5caa78..64a0974 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -1114,6 +1114,210 @@ public async Task Should_Not_Report_XPC3005_When_WithPostImage_Has_Arguments() warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPostImage() is called with specific attributes"); } + [Theory] + [InlineData("IPluginPreImage preImage")] + [InlineData("IPluginPreImage preImage")] + [InlineData("IPluginImage preImage")] + [InlineData("IPluginImage preImage")] + public async Task Should_Not_Report_Signature_Mismatch_When_Handler_Uses_Shared_PreImage_Interface(string parameter) + { + // Arrange - handler accepts a shared image interface instead of the concrete PreImage + 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, + nameof(ITestService.HandleUpdate)) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate({{parameter}}); + } + + public class TestService : ITestService + { + public void HandleUpdate({{parameter}}) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + // Act + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); + + // Assert - no signature mismatch should be reported for valid shared interfaces + diagnostics + .Where(d => d.Id == "XPC4002" || d.Id == "XPC4003") + .Should().BeEmpty($"a handler parameter of type '{parameter}' should be accepted"); + } + + [Fact] + public async Task Should_Not_Report_Signature_Mismatch_When_Both_Images_Use_Shared_Interfaces() + { + // Arrange - both images registered, handler uses the typed Pre/Post interfaces + 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, + nameof(ITestService.HandleUpdate)) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(IPluginPreImage pre, IPluginPostImage post); + } + + public class TestService : ITestService + { + public void HandleUpdate(IPluginPreImage pre, IPluginPostImage post) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); + + diagnostics + .Where(d => d.Id == "XPC4002" || d.Id == "XPC4003") + .Should().BeEmpty("typed Pre/Post image interfaces should be accepted for both images"); + } + + [Fact] + public async Task Should_Report_Signature_Mismatch_When_Generic_Image_Interface_Has_Wrong_Entity() + { + // Arrange - generic interface with an entity type that does not match the registered TEntity + 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, + nameof(ITestService.HandleUpdate)) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(IPluginPreImage preImage); + } + + public class TestService : ITestService + { + public void HandleUpdate(IPluginPreImage preImage) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); + + diagnostics + .Where(d => d.Id == "XPC4002" || d.Id == "XPC4003") + .Should().NotBeEmpty("a generic image interface with the wrong entity type should be rejected"); + } + + [Fact] + public async Task Should_Report_Signature_Mismatch_When_PostImage_Interface_Used_For_PreImage() + { + // Arrange - PreImage registered but handler asks for IPluginPostImage (wrong kind) + 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, + nameof(ITestService.HandleUpdate)) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(IPluginPostImage postImage); + } + + public class TestService : ITestService + { + public void HandleUpdate(IPluginPostImage postImage) { } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); + + diagnostics + .Where(d => d.Id == "XPC4002" || d.Id == "XPC4003") + .Should().NotBeEmpty("the post-image interface should not satisfy a registered pre-image"); + } + private static async Task> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer) { var compilation = CompilationHelper.CreateCompilation(source); diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs index 9e08f7e..a83762a 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs @@ -27,7 +27,7 @@ public void Should_Generate_PreImage_Class_With_Properties() var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // Verify class structure - generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>"); generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}"); generatedSource.Should().Contain("public PreImage(Entity entity)"); generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();"); @@ -55,7 +55,7 @@ public void Should_Generate_PostImage_Class_With_Properties() var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // Verify class structure - generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>"); generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}"); generatedSource.Should().Contain("public PostImage(Entity entity)"); generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();"); @@ -93,8 +93,8 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace() namespaceCount.Should().Be(1, "all classes should be in the same namespace"); // All classes should exist - generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>"); - generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>"); generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper"); } @@ -129,7 +129,7 @@ public void Should_Generate_Properties_With_Correct_Types(string entityType) } [Fact] - public void Should_Implement_IEntityWrapper_interface() + public void Should_Implement_IPluginImage_interface() { // Arrange var source = TestFixtures.GetCompleteSource( @@ -143,8 +143,11 @@ public void Should_Implement_IEntityWrapper_interface() var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // Entity property should be public and of the early-bound type - generatedSource.Should().Contain($": IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($": IPluginPreImage<{ContextNamespace}.Account>"); generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}"); + generatedSource.Should().Contain("Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity;"); + generatedSource.Should().Contain("public System.Guid Id => Entity.Id;"); + generatedSource.Should().Contain("public string LogicalName => Entity.LogicalName;"); generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();"); } @@ -346,7 +349,7 @@ public void Should_Generate_PreImage_With_All_Entity_Properties_When_No_Attribut var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // Verify PreImage class is generated - generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>"); // Verify that multiple entity properties are present (full entity = all properties) generatedSource.Should().Contain("public string? Name => Entity.Name;"); @@ -373,7 +376,7 @@ public void Should_Generate_PostImage_With_All_Entity_Properties_When_No_Attribu var generatedSource = result.GeneratedTrees[0].GetText().ToString(); // Verify PostImage class is generated - generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>"); + generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>"); // Verify that multiple entity properties are present (full entity = all properties) generatedSource.Should().Contain("public string? Name => Entity.Name;"); diff --git a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs index 32e3d34..4abe11b 100644 --- a/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/IntegrationTests/CompilationTests.cs @@ -37,7 +37,8 @@ public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() result.Success.Should().BeTrue(); // Create test entity - var entity = new Entity("account") + var accountId = Guid.NewGuid(); + var entity = new Entity("account", accountId) { ["name"] = "Test Account", ["revenue"] = new Money(100000) @@ -64,6 +65,18 @@ public void Should_Instantiate_Generated_PreImage_Class_Via_Reflection() revenueProperty.Should().NotBeNull(); var revenueValue = revenueProperty!.GetValue(preImageInstance) as decimal?; revenueValue!.Should().Be(100000); + + // Id is always exposed (from the Entity base type) + var idProperty = preImageType.GetProperty("Id"); + idProperty.Should().NotBeNull("Id should always be exposed on generated images"); + idProperty!.PropertyType.Should().Be(typeof(Guid)); + idProperty.GetValue(preImageInstance).Should().Be(accountId); + + // LogicalName is always exposed (from the Entity base type) + var logicalNameProperty = preImageType.GetProperty("LogicalName"); + logicalNameProperty.Should().NotBeNull("LogicalName should always be exposed on generated images"); + logicalNameProperty!.PropertyType.Should().Be(typeof(string)); + logicalNameProperty.GetValue(preImageInstance).Should().Be("account"); } [Fact] @@ -217,4 +230,65 @@ public void Should_Generate_ActionWrapper_Class() createActionMethod.Should().NotBeNull("CreateAction method should exist"); createActionMethod!.IsStatic.Should().BeFalse("CreateAction should be an instance method since ActionWrapper implements IActionWrapper"); } + + [Fact] + public void Should_Compile_When_Handler_Uses_Shared_Image_Interfaces() + { + // Arrange - the generated ActionWrapper must still compile when it passes the concrete + // PreImage/PostImage to a handler that declares the shared image interfaces as 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, + nameof(ITestService.HandleUpdate)) + .WithPreImage(x => x.Name) + .WithPostImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void HandleUpdate(IPluginPreImage pre, IPluginPostImage post); + } + + public class TestService : ITestService + { + public void HandleUpdate(IPluginPreImage pre, IPluginPostImage post) + { + // Access via the type-safe interface + var before = pre.Entity.Name; + var after = post.Entity.Name; + + // Access via the non-generic base interface (shared helper scenario) + IPluginImage shared = pre; + var raw = shared.Entity; + } + } + } + """; + + var source = TestFixtures.GetCompleteSource(pluginSource); + + // Act + var result = GeneratorTestHelper.RunGeneratorAndCompile(source); + + // Assert + result.Success.Should().BeTrue( + because: $"compilation should succeed. Errors: {string.Join(", ", result.Errors ?? [])}"); + } } diff --git a/XrmPluginCore.SourceGenerator.Tests/README.md b/XrmPluginCore.SourceGenerator.Tests/README.md index 945ea4a..24249e4 100644 --- a/XrmPluginCore.SourceGenerator.Tests/README.md +++ b/XrmPluginCore.SourceGenerator.Tests/README.md @@ -62,7 +62,7 @@ Tests for code generation structure and content: - Property generation with correct types - ToEntity() method - GetUnderlyingEntity() method - - IEntityImageWrapper interface implementation + - IPluginImage / IPluginPreImage / IPluginPostImage interface implementation ### IntegrationTests/ End-to-end tests that verify generated code compiles and runs: @@ -213,7 +213,7 @@ Current test coverage includes: - ✅ Property types (string, Money, OptionSetValue, EntityReference) - ✅ ToEntity() method - ✅ GetUnderlyingEntity() method -- ✅ IEntityImageWrapper interface +- ✅ IPluginImage / IPluginPreImage / IPluginPostImage interfaces **Integration (~6 tests)** - ✅ Compilation success diff --git a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs index 21be74c..3a31a31 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs @@ -85,8 +85,12 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } + // Resolve the registered entity type name (TEntity) for validating typed image interfaces + var entityTypeSyntax = genericName.TypeArgumentList.Arguments[0]; + var entityTypeName = context.SemanticModel.GetTypeInfo(entityTypeSyntax).Type?.Name; + // Check if any overload matches the expected signature - var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage)); + var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage, entityTypeName)); if (hasMatchingOverload) { return; @@ -165,7 +169,7 @@ private static bool DoGeneratedTypesExist( return true; } - private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage) + private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage, string entityTypeName) { var parameters = method.Parameters; var expectedParamCount = (hasPreImage ? 1 : 0) + (hasPostImage ? 1 : 0); @@ -184,7 +188,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName)) + if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName, Constants.PreImageInterfaceName, entityTypeName)) { return false; } @@ -199,7 +203,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName)) + if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName, Constants.PostImageInterfaceName, entityTypeName)) { return false; } @@ -208,8 +212,45 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return true; } - private static bool IsImageParameter(IParameterSymbol parameter, string expectedImageType) + /// + /// Determines whether a handler parameter is a valid image parameter. A parameter is valid when it is: + /// + /// the concrete generated wrapper type (PreImage / PostImage), or + /// one of the shared XrmPluginCore image interfaces matching the image kind + /// (IPluginImage / IPluginPreImage / IPluginPostImage, generic or non-generic). + /// + /// For the generic interfaces, the type argument must match the registered entity type, otherwise the + /// generated ActionWrapper would fail to compile when passing the concrete image. + /// + private static bool IsImageParameter(IParameterSymbol parameter, string expectedWrapperType, string expectedInterfaceName, string entityTypeName) { - return parameter.Type.Name == expectedImageType; + var type = parameter.Type; + + // Concrete generated wrapper (PreImage / PostImage) - entity correctness is guaranteed by its namespace. + if (type.Name == expectedWrapperType) + { + return true; + } + + // Shared image interfaces - must be declared in XrmPluginCore. + if (type.ContainingNamespace?.ToString() != Constants.PluginNamespace) + { + return false; + } + + // The non-generic base interface matches the kind, but not a more specific opposite kind. + if (type.Name != Constants.PluginImageInterfaceName && type.Name != expectedInterfaceName) + { + return false; + } + + // For generic interfaces, the entity type argument must match the registered entity. + if (type is INamedTypeSymbol named && named.IsGenericType) + { + var argument = named.TypeArguments.Length == 1 ? named.TypeArguments[0] : null; + return argument != null && entityTypeName != null && argument.Name == entityTypeName; + } + + return true; } } diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index 00b0706..2708265 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -62,9 +62,16 @@ private static void GenerateImageWrapperClass(StringBuilder sb, PluginStepMetada metadata.ExecutionStage, image.ImageType)); - // Generate properties for each image attribute + // Generate properties for each image attribute. + // Skip attributes that map to "Id"/"LogicalName" - they are always exposed via the synthetic + // IPluginImage members (forwarding to the Entity base type). foreach (var attr in image.Attributes) { + if (attr.PropertyName == "Id" || attr.PropertyName == "LogicalName") + { + continue; + } + sb.Append(GetPropertyTemplate(attr.TypeName, attr.PropertyName, attr.XmlDocumentation)); } @@ -145,18 +152,34 @@ private static string GetImageClassHeader( string entityTypeFullName, string eventOperation, string executionStage, - string imageType) => - $"{L1}/// \n" + - $"{L1}/// Type-safe wrapper for {entityTypeName} {eventOperation} {executionStage} {imageType}\n" + - $"{L1}/// \n" + - $"{L1}[CompilerGenerated]\n" + - $"{L1}public sealed class {className} : IEntityImageWrapper<{entityTypeFullName}>\n" + - $"{L1}{{\n" + - $"{L2}public {className}(Entity entity)\n" + - $"{L2}{{\n" + - $"{L3}Entity = entity.ToEntity<{entityTypeFullName}>();\n" + - $"{L2}}}\n\n" + - $"{L2}public {entityTypeFullName} Entity {{ get; }}\n\n"; + string imageType) + { + var interfaceName = imageType == Constants.PostImageTypeName + ? "IPluginPostImage" + : "IPluginPreImage"; + + return + $"{L1}/// \n" + + $"{L1}/// Type-safe wrapper for {entityTypeName} {eventOperation} {executionStage} {imageType}\n" + + $"{L1}/// \n" + + $"{L1}[CompilerGenerated]\n" + + $"{L1}public sealed class {className} : {interfaceName}<{entityTypeFullName}>\n" + + $"{L1}{{\n" + + $"{L2}public {className}(Entity entity)\n" + + $"{L2}{{\n" + + $"{L3}Entity = entity.ToEntity<{entityTypeFullName}>();\n" + + $"{L2}}}\n\n" + + $"{L2}public {entityTypeFullName} Entity {{ get; }}\n\n" + + $"{L2}Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity;\n\n" + + $"{L2}/// \n" + + $"{L2}/// The unique identifier (primary key) of the record the image was captured for.\n" + + $"{L2}/// \n" + + $"{L2}public System.Guid Id => Entity.Id;\n\n" + + $"{L2}/// \n" + + $"{L2}/// The logical name of the entity captured in the image.\n" + + $"{L2}/// \n" + + $"{L2}public string LogicalName => Entity.LogicalName;\n\n"; + } private static string GetPropertyTemplate(string propertyType, string propertyName, string xmlDoc) { diff --git a/XrmPluginCore.SourceGenerator/Constants.cs b/XrmPluginCore.SourceGenerator/Constants.cs index 685c122..ad5586d 100644 --- a/XrmPluginCore.SourceGenerator/Constants.cs +++ b/XrmPluginCore.SourceGenerator/Constants.cs @@ -16,10 +16,15 @@ internal static class Constants public const string WithPostImageMethodName = "WithPostImage"; public const string AddImageMethodName = "AddImage"; - // Image types + // Image types (concrete generated wrapper class names) public const string PreImageTypeName = "PreImage"; public const string PostImageTypeName = "PostImage"; + // Shared image interfaces (declared in XrmPluginCore) + public const string PluginImageInterfaceName = "IPluginImage"; + public const string PreImageInterfaceName = "IPluginPreImage"; + public const string PostImageInterfaceName = "IPluginPostImage"; + // Diagnostic property keys (passed from analyzers to code fix providers) public const string PropertyServiceType = "ServiceType"; public const string PropertyMethodName = "MethodName"; diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 5eed448..a9126bf 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,8 @@ +### v1.3.0 - 19 June 2026 +* Add: `IPluginImage`, `IPluginImage`, `IPluginPreImage`/`IPluginPreImage` and `IPluginPostImage`/`IPluginPostImage` interfaces for generated images. Handler methods can now accept these interface types so functionality can be shared across the per-registration concrete image types. The generic variants expose a type-safe `Entity` property. +* Add: Generated images (and `IPluginImage`) now always expose the record's `Id` (primary key) and `LogicalName`, since they are available on every entity image. +* Breaking: Removed `IEntityImageWrapper`; generated images now implement `IPluginPreImage`/`IPluginPostImage` instead. Replace any usage of `IEntityImageWrapper` with `IPluginImage`. + ### v1.2.8 - 30 April 2026 * Fix: Set ServiceProvider property on LocalPluginContext * Fix: XPC3004: Detect and report usage of LocalPluginContext when implicitly passed diff --git a/XrmPluginCore/IEntityImageWrapper.cs b/XrmPluginCore/IEntityImageWrapper.cs deleted file mode 100644 index 00d0630..0000000 --- a/XrmPluginCore/IEntityImageWrapper.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.Xrm.Sdk; - -namespace XrmPluginCore; - -public interface IEntityImageWrapper where T : Entity -{ - T Entity { get; } -} diff --git a/XrmPluginCore/IPluginImage.cs b/XrmPluginCore/IPluginImage.cs new file mode 100644 index 0000000..2c47d94 --- /dev/null +++ b/XrmPluginCore/IPluginImage.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.Xrm.Sdk; + +namespace XrmPluginCore; + +/// +/// Non-generic base interface implemented by all generated plugin image wrappers +/// (PreImage/PostImage). Exposes the members that are always available on an image, +/// regardless of the concrete entity type. +/// +/// Use this (or one of the more specific interfaces) as a parameter type on a service +/// method to share functionality across the per-registration concrete image types. +/// +/// +public interface IPluginImage +{ + /// + /// The unique identifier (primary key) of the record the image was captured for. + /// + Guid Id { get; } + + /// + /// The logical name of the entity captured in the image. + /// + string LogicalName { get; } + + /// + /// The underlying entity captured in the image. + /// + Entity Entity { get; } +} + +/// +/// Plugin image wrapper with type-safe access to the underlying entity. +/// +/// The early-bound entity type captured in the image. +public interface IPluginImage : IPluginImage where TEntity : Entity +{ + /// + /// The strongly-typed entity captured in the image. + /// + new TEntity Entity { get; } +} + +/// +/// Marker interface for a pre-image (the entity state before the operation). +/// +public interface IPluginPreImage : IPluginImage +{ +} + +/// +/// Type-safe pre-image (the entity state before the operation). +/// +/// The early-bound entity type captured in the image. +public interface IPluginPreImage : IPluginImage, IPluginPreImage where TEntity : Entity +{ +} + +/// +/// Marker interface for a post-image (the entity state after the operation). +/// +public interface IPluginPostImage : IPluginImage +{ +} + +/// +/// Type-safe post-image (the entity state after the operation). +/// +/// The early-bound entity type captured in the image. +public interface IPluginPostImage : IPluginImage, IPluginPostImage where TEntity : Entity +{ +} From 57374dbd1555d9ebe29e6aa4a588c2e326c6b457 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Mon, 22 Jun 2026 09:15:21 +0200 Subject: [PATCH 2/2] Address review feedback on image interfaces - Use Constants.Pre/PostImageInterfaceName in the generator instead of hard-coded interface name literals. - Validate generic image-interface entity type with SymbolEqualityComparer.Default rather than comparing simple names, avoiding false matches between same-named types in different namespaces. - Fix misleading test README coverage list (ToEntity/GetUnderlyingEntity do not exist) to reflect actual coverage. - Reformat IPluginImage.cs to tabs per .editorconfig. Co-Authored-By: Claude via Conducktor --- XrmPluginCore.SourceGenerator.Tests/README.md | 8 ++--- .../HandlerSignatureMismatchAnalyzer.cs | 18 ++++++----- .../CodeGeneration/WrapperClassGenerator.cs | 4 +-- XrmPluginCore/IPluginImage.cs | 32 +++++++++---------- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/README.md b/XrmPluginCore.SourceGenerator.Tests/README.md index 24249e4..5953125 100644 --- a/XrmPluginCore.SourceGenerator.Tests/README.md +++ b/XrmPluginCore.SourceGenerator.Tests/README.md @@ -60,8 +60,8 @@ 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 + - Strongly-typed `Entity` property + - Always-available `Id` and `LogicalName` members - IPluginImage / IPluginPreImage / IPluginPostImage interface implementation ### IntegrationTests/ @@ -211,8 +211,8 @@ Current test coverage includes: - ✅ PostImage class structure - ✅ Both classes in same namespace - ✅ Property types (string, Money, OptionSetValue, EntityReference) -- ✅ ToEntity() method -- ✅ GetUnderlyingEntity() method +- ✅ Strongly-typed `Entity` property +- ✅ Always-available `Id` and `LogicalName` members - ✅ IPluginImage / IPluginPreImage / IPluginPostImage interfaces **Integration (~6 tests)** diff --git a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs index 3a31a31..5811ca6 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs @@ -85,12 +85,12 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - // Resolve the registered entity type name (TEntity) for validating typed image interfaces + // Resolve the registered entity type (TEntity) for validating typed image interfaces var entityTypeSyntax = genericName.TypeArgumentList.Arguments[0]; - var entityTypeName = context.SemanticModel.GetTypeInfo(entityTypeSyntax).Type?.Name; + var entityType = context.SemanticModel.GetTypeInfo(entityTypeSyntax).Type; // Check if any overload matches the expected signature - var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage, entityTypeName)); + var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage, entityType)); if (hasMatchingOverload) { return; @@ -169,7 +169,7 @@ private static bool DoGeneratedTypesExist( return true; } - private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage, string entityTypeName) + private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage, ITypeSymbol entityType) { var parameters = method.Parameters; var expectedParamCount = (hasPreImage ? 1 : 0) + (hasPostImage ? 1 : 0); @@ -188,7 +188,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName, Constants.PreImageInterfaceName, entityTypeName)) + if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName, Constants.PreImageInterfaceName, entityType)) { return false; } @@ -203,7 +203,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName, Constants.PostImageInterfaceName, entityTypeName)) + if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName, Constants.PostImageInterfaceName, entityType)) { return false; } @@ -222,7 +222,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo /// For the generic interfaces, the type argument must match the registered entity type, otherwise the /// generated ActionWrapper would fail to compile when passing the concrete image. /// - private static bool IsImageParameter(IParameterSymbol parameter, string expectedWrapperType, string expectedInterfaceName, string entityTypeName) + private static bool IsImageParameter(IParameterSymbol parameter, string expectedWrapperType, string expectedInterfaceName, ITypeSymbol entityType) { var type = parameter.Type; @@ -248,7 +248,9 @@ private static bool IsImageParameter(IParameterSymbol parameter, string expected if (type is INamedTypeSymbol named && named.IsGenericType) { var argument = named.TypeArguments.Length == 1 ? named.TypeArguments[0] : null; - return argument != null && entityTypeName != null && argument.Name == entityTypeName; + return argument != null + && entityType != null + && SymbolEqualityComparer.Default.Equals(argument, entityType); } return true; diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index 2708265..f547d02 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -155,8 +155,8 @@ private static string GetImageClassHeader( string imageType) { var interfaceName = imageType == Constants.PostImageTypeName - ? "IPluginPostImage" - : "IPluginPreImage"; + ? Constants.PostImageInterfaceName + : Constants.PreImageInterfaceName; return $"{L1}/// \n" + diff --git a/XrmPluginCore/IPluginImage.cs b/XrmPluginCore/IPluginImage.cs index 2c47d94..e60dab5 100644 --- a/XrmPluginCore/IPluginImage.cs +++ b/XrmPluginCore/IPluginImage.cs @@ -14,20 +14,20 @@ namespace XrmPluginCore; /// public interface IPluginImage { - /// - /// The unique identifier (primary key) of the record the image was captured for. - /// - Guid Id { get; } + /// + /// The unique identifier (primary key) of the record the image was captured for. + /// + Guid Id { get; } - /// - /// The logical name of the entity captured in the image. - /// - string LogicalName { get; } + /// + /// The logical name of the entity captured in the image. + /// + string LogicalName { get; } - /// - /// The underlying entity captured in the image. - /// - Entity Entity { get; } + /// + /// The underlying entity captured in the image. + /// + Entity Entity { get; } } /// @@ -36,10 +36,10 @@ public interface IPluginImage /// The early-bound entity type captured in the image. public interface IPluginImage : IPluginImage where TEntity : Entity { - /// - /// The strongly-typed entity captured in the image. - /// - new TEntity Entity { get; } + /// + /// The strongly-typed entity captured in the image. + /// + new TEntity Entity { get; } } ///