Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<YourNamespace.Account>
{
private readonly Entity entity;

public PreImage(Entity entity)
{
this.entity = entity ?? throw new ArgumentNullException(nameof(entity));
Entity = entity.ToEntity<YourNamespace.Account>();
}

public string Name => entity.GetAttributeValue<string>("name");
public decimal? Revenue => entity.GetAttributeValue<decimal?>("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<T>() where T : Entity => entity.ToEntity<T>();
public string Name => Entity.Name;
public decimal? Revenue => Entity.Revenue;
}

public sealed class PostImage
public sealed class PostImage : IPluginPostImage<YourNamespace.Account>
{
private readonly Entity entity;

public PostImage(Entity entity)
{
this.entity = entity ?? throw new ArgumentNullException(nameof(entity));
Entity = entity.ToEntity<YourNamespace.Account>();
}

public string Name => entity.GetAttributeValue<string>("name");
public string Accountnumber => entity.GetAttributeValue<string>("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<T>() where T : Entity => entity.ToEntity<T>();
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>` | `TEntity` (early-bound) | Type-safe access to the entity |
| `IPluginPreImage` / `IPluginPreImage<TEntity>` | (inherited) | Identifies a pre-image |
| `IPluginPostImage` / `IPluginPostImage<TEntity>` | (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<Account> 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:
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account> preImage")]
[InlineData("IPluginPreImage preImage")]
[InlineData("IPluginImage<Account> 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<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
nameof(ITestService.HandleUpdate))
.WithPreImage(x => x.Name);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

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<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
nameof(ITestService.HandleUpdate))
.WithPreImage(x => x.Name)
.WithPostImage(x => x.Name);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

public interface ITestService
{
void HandleUpdate(IPluginPreImage<Account> pre, IPluginPostImage<Account> post);
}

public class TestService : ITestService
{
public void HandleUpdate(IPluginPreImage<Account> pre, IPluginPostImage<Account> 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<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
nameof(ITestService.HandleUpdate))
.WithPreImage(x => x.Name);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

public interface ITestService
{
void HandleUpdate(IPluginPreImage<Contact> preImage);
}

public class TestService : ITestService
{
public void HandleUpdate(IPluginPreImage<Contact> 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<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
nameof(ITestService.HandleUpdate))
.WithPreImage(x => x.Name);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
{
return services.AddScoped<ITestService, TestService>();
}
}

public interface ITestService
{
void HandleUpdate(IPluginPostImage<Account> postImage);
}

public class TestService : ITestService
{
public void HandleUpdate(IPluginPostImage<Account> 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<ImmutableArray<Diagnostic>> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)
{
var compilation = CompilationHelper.CreateCompilation(source);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>();");
Expand Down Expand Up @@ -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>();");
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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(
Expand All @@ -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>();");
}

Expand Down Expand Up @@ -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;");
Expand All @@ -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;");
Expand Down
Loading
Loading