Skip to content

Commit f2e2a71

Browse files
authored
Add shared image interfaces (IPluginImage/IPluginPreImage/IPluginPostImage) (#13)
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.
1 parent a4c10b1 commit f2e2a71

11 files changed

Lines changed: 519 additions & 58 deletions

File tree

CLAUDE.md

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -193,44 +193,82 @@ public class AccountService
193193

194194
#### Generated Code Example
195195

196-
The source generator creates wrapper classes in isolated namespaces:
196+
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):
197197

198198
```csharp
199199
// Generated in: {Namespace}.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation
200200
namespace YourNamespace.PluginRegistrations.AccountPlugin.AccountUpdatePostOperation
201201
{
202-
public sealed class PreImage
202+
public sealed class PreImage : IPluginPreImage<YourNamespace.Account>
203203
{
204-
private readonly Entity entity;
205-
206204
public PreImage(Entity entity)
207205
{
208-
this.entity = entity ?? throw new ArgumentNullException(nameof(entity));
206+
Entity = entity.ToEntity<YourNamespace.Account>();
209207
}
210208

211-
public string Name => entity.GetAttributeValue<string>("name");
212-
public decimal? Revenue => entity.GetAttributeValue<decimal?>("revenue");
209+
public YourNamespace.Account Entity { get; }
210+
211+
Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity;
212+
213+
public System.Guid Id => Entity.Id;
214+
public string LogicalName => Entity.LogicalName;
213215

214-
public T ToEntity<T>() where T : Entity => entity.ToEntity<T>();
216+
public string Name => Entity.Name;
217+
public decimal? Revenue => Entity.Revenue;
215218
}
216219

217-
public sealed class PostImage
220+
public sealed class PostImage : IPluginPostImage<YourNamespace.Account>
218221
{
219-
private readonly Entity entity;
220-
221222
public PostImage(Entity entity)
222223
{
223-
this.entity = entity ?? throw new ArgumentNullException(nameof(entity));
224+
Entity = entity.ToEntity<YourNamespace.Account>();
224225
}
225226

226-
public string Name => entity.GetAttributeValue<string>("name");
227-
public string Accountnumber => entity.GetAttributeValue<string>("accountnumber");
227+
public YourNamespace.Account Entity { get; }
228+
229+
Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity;
230+
231+
public System.Guid Id => Entity.Id;
232+
public string LogicalName => Entity.LogicalName;
228233

229-
public T ToEntity<T>() where T : Entity => entity.ToEntity<T>();
234+
public string Name => Entity.Name;
235+
public string AccountNumber => Entity.AccountNumber;
230236
}
231237
}
232238
```
233239

240+
#### Image Interfaces
241+
242+
Every generated image implements a small interface hierarchy declared in `XrmPluginCore`:
243+
244+
| Interface | `Entity` property type | Purpose |
245+
| --- | --- | --- |
246+
| `IPluginImage` | `Microsoft.Xrm.Sdk.Entity` | Non-generic base; lowest common denominator for fully generic helpers |
247+
| `IPluginImage<TEntity>` | `TEntity` (early-bound) | Type-safe access to the entity |
248+
| `IPluginPreImage` / `IPluginPreImage<TEntity>` | (inherited) | Identifies a pre-image |
249+
| `IPluginPostImage` / `IPluginPostImage<TEntity>` | (inherited) | Identifies a post-image |
250+
251+
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`).
252+
253+
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:
254+
255+
```csharp
256+
public class AccountService
257+
{
258+
// Concrete generated types (most specific)
259+
public void HandleUpdate(PreImage pre, PostImage post) { }
260+
}
261+
262+
public static class AuditHelper
263+
{
264+
// Works for the Pre or Post image of ANY registration on Account
265+
public static void Log(IPluginImage<Account> image) { /* image.Entity is an Account */ }
266+
267+
// Works for any image of any entity
268+
public static void LogRaw(IPluginImage image) { /* image.Entity is an Entity */ }
269+
}
270+
```
271+
234272
#### Image Registration Methods
235273

236274
The following methods are available for registering images:
@@ -249,6 +287,7 @@ All three methods are valid and supported. `WithPreImage` and `WithPostImage` ar
249287
- **No runtime overhead**: Simple property accessors, no reflection at access time
250288
- **Null safety**: Missing attributes return null instead of throwing exceptions
251289
- **Namespace isolation**: Each step gets its own namespace, preventing naming conflicts
290+
- **Shared interfaces**: `IPluginImage`/`IPluginPreImage`/`IPluginPostImage` (and generic variants) let handler methods share logic across the per-registration concrete image types
252291

253292
### Dependency Injection
254293

XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,210 @@ public async Task Should_Not_Report_XPC3005_When_WithPostImage_Has_Arguments()
11771177
warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPostImage() is called with specific attributes");
11781178
}
11791179

1180+
[Theory]
1181+
[InlineData("IPluginPreImage<Account> preImage")]
1182+
[InlineData("IPluginPreImage preImage")]
1183+
[InlineData("IPluginImage<Account> preImage")]
1184+
[InlineData("IPluginImage preImage")]
1185+
public async Task Should_Not_Report_Signature_Mismatch_When_Handler_Uses_Shared_PreImage_Interface(string parameter)
1186+
{
1187+
// Arrange - handler accepts a shared image interface instead of the concrete PreImage
1188+
var pluginSource = $$"""
1189+
1190+
using XrmPluginCore;
1191+
using XrmPluginCore.Enums;
1192+
using Microsoft.Extensions.DependencyInjection;
1193+
using TestNamespace;
1194+
using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;
1195+
1196+
namespace TestNamespace
1197+
{
1198+
public class TestPlugin : Plugin
1199+
{
1200+
public TestPlugin()
1201+
{
1202+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
1203+
nameof(ITestService.HandleUpdate))
1204+
.WithPreImage(x => x.Name);
1205+
}
1206+
1207+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
1208+
{
1209+
return services.AddScoped<ITestService, TestService>();
1210+
}
1211+
}
1212+
1213+
public interface ITestService
1214+
{
1215+
void HandleUpdate({{parameter}});
1216+
}
1217+
1218+
public class TestService : ITestService
1219+
{
1220+
public void HandleUpdate({{parameter}}) { }
1221+
}
1222+
}
1223+
""";
1224+
1225+
var source = TestFixtures.GetCompleteSource(pluginSource);
1226+
1227+
// Act
1228+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer());
1229+
1230+
// Assert - no signature mismatch should be reported for valid shared interfaces
1231+
diagnostics
1232+
.Where(d => d.Id == "XPC4002" || d.Id == "XPC4003")
1233+
.Should().BeEmpty($"a handler parameter of type '{parameter}' should be accepted");
1234+
}
1235+
1236+
[Fact]
1237+
public async Task Should_Not_Report_Signature_Mismatch_When_Both_Images_Use_Shared_Interfaces()
1238+
{
1239+
// Arrange - both images registered, handler uses the typed Pre/Post interfaces
1240+
const string pluginSource = """
1241+
1242+
using XrmPluginCore;
1243+
using XrmPluginCore.Enums;
1244+
using Microsoft.Extensions.DependencyInjection;
1245+
using TestNamespace;
1246+
1247+
namespace TestNamespace
1248+
{
1249+
public class TestPlugin : Plugin
1250+
{
1251+
public TestPlugin()
1252+
{
1253+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
1254+
nameof(ITestService.HandleUpdate))
1255+
.WithPreImage(x => x.Name)
1256+
.WithPostImage(x => x.Name);
1257+
}
1258+
1259+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
1260+
{
1261+
return services.AddScoped<ITestService, TestService>();
1262+
}
1263+
}
1264+
1265+
public interface ITestService
1266+
{
1267+
void HandleUpdate(IPluginPreImage<Account> pre, IPluginPostImage<Account> post);
1268+
}
1269+
1270+
public class TestService : ITestService
1271+
{
1272+
public void HandleUpdate(IPluginPreImage<Account> pre, IPluginPostImage<Account> post) { }
1273+
}
1274+
}
1275+
""";
1276+
1277+
var source = TestFixtures.GetCompleteSource(pluginSource);
1278+
1279+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer());
1280+
1281+
diagnostics
1282+
.Where(d => d.Id == "XPC4002" || d.Id == "XPC4003")
1283+
.Should().BeEmpty("typed Pre/Post image interfaces should be accepted for both images");
1284+
}
1285+
1286+
[Fact]
1287+
public async Task Should_Report_Signature_Mismatch_When_Generic_Image_Interface_Has_Wrong_Entity()
1288+
{
1289+
// Arrange - generic interface with an entity type that does not match the registered TEntity
1290+
const string pluginSource = """
1291+
1292+
using XrmPluginCore;
1293+
using XrmPluginCore.Enums;
1294+
using Microsoft.Extensions.DependencyInjection;
1295+
using TestNamespace;
1296+
1297+
namespace TestNamespace
1298+
{
1299+
public class TestPlugin : Plugin
1300+
{
1301+
public TestPlugin()
1302+
{
1303+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
1304+
nameof(ITestService.HandleUpdate))
1305+
.WithPreImage(x => x.Name);
1306+
}
1307+
1308+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
1309+
{
1310+
return services.AddScoped<ITestService, TestService>();
1311+
}
1312+
}
1313+
1314+
public interface ITestService
1315+
{
1316+
void HandleUpdate(IPluginPreImage<Contact> preImage);
1317+
}
1318+
1319+
public class TestService : ITestService
1320+
{
1321+
public void HandleUpdate(IPluginPreImage<Contact> preImage) { }
1322+
}
1323+
}
1324+
""";
1325+
1326+
var source = TestFixtures.GetCompleteSource(pluginSource);
1327+
1328+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer());
1329+
1330+
diagnostics
1331+
.Where(d => d.Id == "XPC4002" || d.Id == "XPC4003")
1332+
.Should().NotBeEmpty("a generic image interface with the wrong entity type should be rejected");
1333+
}
1334+
1335+
[Fact]
1336+
public async Task Should_Report_Signature_Mismatch_When_PostImage_Interface_Used_For_PreImage()
1337+
{
1338+
// Arrange - PreImage registered but handler asks for IPluginPostImage (wrong kind)
1339+
const string pluginSource = """
1340+
1341+
using XrmPluginCore;
1342+
using XrmPluginCore.Enums;
1343+
using Microsoft.Extensions.DependencyInjection;
1344+
using TestNamespace;
1345+
1346+
namespace TestNamespace
1347+
{
1348+
public class TestPlugin : Plugin
1349+
{
1350+
public TestPlugin()
1351+
{
1352+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
1353+
nameof(ITestService.HandleUpdate))
1354+
.WithPreImage(x => x.Name);
1355+
}
1356+
1357+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
1358+
{
1359+
return services.AddScoped<ITestService, TestService>();
1360+
}
1361+
}
1362+
1363+
public interface ITestService
1364+
{
1365+
void HandleUpdate(IPluginPostImage<Account> postImage);
1366+
}
1367+
1368+
public class TestService : ITestService
1369+
{
1370+
public void HandleUpdate(IPluginPostImage<Account> postImage) { }
1371+
}
1372+
}
1373+
""";
1374+
1375+
var source = TestFixtures.GetCompleteSource(pluginSource);
1376+
1377+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer());
1378+
1379+
diagnostics
1380+
.Where(d => d.Id == "XPC4002" || d.Id == "XPC4003")
1381+
.Should().NotBeEmpty("the post-image interface should not satisfy a registered pre-image");
1382+
}
1383+
11801384
private static async Task<ImmutableArray<Diagnostic>> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)
11811385
{
11821386
var compilation = CompilationHelper.CreateCompilation(source);

XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public void Should_Generate_PreImage_Class_With_Properties()
2727
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
2828

2929
// Verify class structure
30-
generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>");
30+
generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>");
3131
generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}");
3232
generatedSource.Should().Contain("public PreImage(Entity entity)");
3333
generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();");
@@ -55,7 +55,7 @@ public void Should_Generate_PostImage_Class_With_Properties()
5555
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
5656

5757
// Verify class structure
58-
generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>");
58+
generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>");
5959
generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}");
6060
generatedSource.Should().Contain("public PostImage(Entity entity)");
6161
generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();");
@@ -93,8 +93,8 @@ public void Should_Generate_Both_Image_Classes_In_Same_Namespace()
9393
namespaceCount.Should().Be(1, "all classes should be in the same namespace");
9494

9595
// All classes should exist
96-
generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>");
97-
generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>");
96+
generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>");
97+
generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>");
9898
generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper");
9999
}
100100

@@ -129,7 +129,7 @@ public void Should_Generate_Properties_With_Correct_Types(string entityType)
129129
}
130130

131131
[Fact]
132-
public void Should_Implement_IEntityWrapper_interface()
132+
public void Should_Implement_IPluginImage_interface()
133133
{
134134
// Arrange
135135
var source = TestFixtures.GetCompleteSource(
@@ -143,8 +143,11 @@ public void Should_Implement_IEntityWrapper_interface()
143143
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
144144

145145
// Entity property should be public and of the early-bound type
146-
generatedSource.Should().Contain($": IEntityImageWrapper<{ContextNamespace}.Account>");
146+
generatedSource.Should().Contain($": IPluginPreImage<{ContextNamespace}.Account>");
147147
generatedSource.Should().Contain($"public {ContextNamespace}.Account Entity {{ get; }}");
148+
generatedSource.Should().Contain("Microsoft.Xrm.Sdk.Entity IPluginImage.Entity => this.Entity;");
149+
generatedSource.Should().Contain("public System.Guid Id => Entity.Id;");
150+
generatedSource.Should().Contain("public string LogicalName => Entity.LogicalName;");
148151
generatedSource.Should().Contain($"Entity = entity.ToEntity<{ContextNamespace}.Account>();");
149152
}
150153

@@ -346,7 +349,7 @@ public void Should_Generate_PreImage_With_All_Entity_Properties_When_No_Attribut
346349
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
347350

348351
// Verify PreImage class is generated
349-
generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>");
352+
generatedSource.Should().Contain($"public sealed class PreImage : IPluginPreImage<{ContextNamespace}.Account>");
350353

351354
// Verify that multiple entity properties are present (full entity = all properties)
352355
generatedSource.Should().Contain("public string? Name => Entity.Name;");
@@ -373,7 +376,7 @@ public void Should_Generate_PostImage_With_All_Entity_Properties_When_No_Attribu
373376
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
374377

375378
// Verify PostImage class is generated
376-
generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>");
379+
generatedSource.Should().Contain($"public sealed class PostImage : IPluginPostImage<{ContextNamespace}.Account>");
377380

378381
// Verify that multiple entity properties are present (full entity = all properties)
379382
generatedSource.Should().Contain("public string? Name => Entity.Name;");

0 commit comments

Comments
 (0)