Skip to content

Commit 94df1c7

Browse files
authored
Add: When specifying a Pre or Post image without filtering the attributes, we now generate a Pre or Post image wrapper with all attributes that have a logical name spacified. (#9)
Add: Warning to avoid image registrations without attributes
1 parent 36d8e12 commit 94df1c7

8 files changed

Lines changed: 372 additions & 0 deletions

File tree

XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,56 @@ public void HandleUpdate() { }
10641064
"Namespace2 source should not contain Namespace1 namespace declaration");
10651065
}
10661066

1067+
[Fact]
1068+
public async Task Should_Report_XPC3005_When_WithPreImage_Has_No_Arguments()
1069+
{
1070+
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithFullEntityPreImage);
1071+
1072+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());
1073+
1074+
var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();
1075+
1076+
warnings.Should().NotBeEmpty("XPC3005 should be reported when WithPreImage() is called with no arguments");
1077+
warnings.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning);
1078+
}
1079+
1080+
[Fact]
1081+
public async Task Should_Report_XPC3005_When_WithPostImage_Has_No_Arguments()
1082+
{
1083+
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithFullEntityPostImage);
1084+
1085+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());
1086+
1087+
var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();
1088+
1089+
warnings.Should().NotBeEmpty("XPC3005 should be reported when WithPostImage() is called with no arguments");
1090+
warnings.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning);
1091+
}
1092+
1093+
[Fact]
1094+
public async Task Should_Not_Report_XPC3005_When_WithPreImage_Has_Arguments()
1095+
{
1096+
var source = TestFixtures.GetCompleteSource(TestFixtures.GetPluginWithPreImage());
1097+
1098+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());
1099+
1100+
var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();
1101+
1102+
warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPreImage() is called with specific attributes");
1103+
}
1104+
1105+
[Fact]
1106+
public async Task Should_Not_Report_XPC3005_When_WithPostImage_Has_Arguments()
1107+
{
1108+
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithPostImage);
1109+
1110+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());
1111+
1112+
var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();
1113+
1114+
warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPostImage() is called with specific attributes");
1115+
}
1116+
10671117
private static async Task<ImmutableArray<Diagnostic>> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)
10681118
{
10691119
var compilation = CompilationHelper.CreateCompilation(source);

XrmPluginCore.SourceGenerator.Tests/GenerationTests/WrapperClassGenerationTests.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,84 @@ public void Should_Parse_Parameterless_Method_Reference()
330330
generatedSource.Should().Contain("service.HandleUpdate()");
331331
}
332332

333+
[Fact]
334+
public void Should_Generate_PreImage_With_All_Entity_Properties_When_No_Attributes_Specified()
335+
{
336+
// Arrange - WithPreImage() called with no arguments
337+
var source = TestFixtures.GetCompleteSource(
338+
TestFixtures.PluginWithFullEntityPreImage);
339+
340+
// Act
341+
var result = GeneratorTestHelper.RunGenerator(
342+
CompilationHelper.CreateCompilation(source));
343+
344+
// Assert
345+
result.GeneratedTrees.Should().NotBeEmpty("code should be generated for full entity PreImage");
346+
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
347+
348+
// Verify PreImage class is generated
349+
generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>");
350+
351+
// Verify that multiple entity properties are present (full entity = all properties)
352+
generatedSource.Should().Contain("public string? Name => Entity.Name;");
353+
generatedSource.Should().Contain("public decimal? Revenue => Entity.Revenue;");
354+
generatedSource.Should().Contain("public string? AccountNumber => Entity.AccountNumber;");
355+
356+
// Verify NO PostImage class is generated
357+
generatedSource.Should().NotContain("public sealed class PostImage");
358+
}
359+
360+
[Fact]
361+
public void Should_Generate_PostImage_With_All_Entity_Properties_When_No_Attributes_Specified()
362+
{
363+
// Arrange - WithPostImage() called with no arguments
364+
var source = TestFixtures.GetCompleteSource(
365+
TestFixtures.PluginWithFullEntityPostImage);
366+
367+
// Act
368+
var result = GeneratorTestHelper.RunGenerator(
369+
CompilationHelper.CreateCompilation(source));
370+
371+
// Assert
372+
result.GeneratedTrees.Should().NotBeEmpty("code should be generated for full entity PostImage");
373+
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
374+
375+
// Verify PostImage class is generated
376+
generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>");
377+
378+
// Verify that multiple entity properties are present (full entity = all properties)
379+
generatedSource.Should().Contain("public string? Name => Entity.Name;");
380+
generatedSource.Should().Contain("public decimal? Revenue => Entity.Revenue;");
381+
generatedSource.Should().Contain("public string? AccountNumber => Entity.AccountNumber;");
382+
383+
// Verify NO PreImage class is generated
384+
generatedSource.Should().NotContain("public sealed class PreImage");
385+
}
386+
387+
[Fact]
388+
public void Should_Generate_ActionWrapper_With_Full_Entity_PreImage_Call()
389+
{
390+
// Arrange - WithPreImage() called with no arguments
391+
var source = TestFixtures.GetCompleteSource(
392+
TestFixtures.PluginWithFullEntityPreImage);
393+
394+
// Act
395+
var result = GeneratorTestHelper.RunGenerator(
396+
CompilationHelper.CreateCompilation(source));
397+
398+
// Assert
399+
var generatedSource = result.GeneratedTrees[0].GetText().ToString();
400+
401+
// Verify ActionWrapper is generated and handles the PreImage
402+
generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper");
403+
generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();");
404+
generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;");
405+
generatedSource.Should().Contain("service.HandleAccountUpdate(preImage)");
406+
407+
// Should NOT have PostImage retrieval
408+
generatedSource.Should().NotContain("PostEntityImages");
409+
}
410+
333411
[System.Text.RegularExpressions.GeneratedRegex(@"namespace\s+TestNamespace\.PluginRegistrations\.TestPlugin\.AccountUpdatePostOperation")]
334412
private static partial System.Text.RegularExpressions.Regex IsAccountUpdatePostOperationNamespace();
335413

XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,90 @@ public void HandleUpdate() { }
198198
}
199199
""";
200200

201+
/// <summary>
202+
/// Plugin with full entity PreImage (no attributes specified — captures all entity attributes).
203+
/// </summary>
204+
public const string PluginWithFullEntityPreImage = """
205+
206+
using XrmPluginCore;
207+
using XrmPluginCore.Abstractions;
208+
using XrmPluginCore.Enums;
209+
using Microsoft.Extensions.DependencyInjection;
210+
using TestNamespace;
211+
using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;
212+
213+
namespace TestNamespace
214+
{
215+
public class TestPlugin : Plugin
216+
{
217+
public TestPlugin()
218+
{
219+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
220+
nameof(ITestService.HandleAccountUpdate))
221+
.AddFilteredAttributes(x => x.Name)
222+
.WithPreImage();
223+
}
224+
225+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
226+
{
227+
return services.AddScoped<ITestService, TestService>();
228+
}
229+
}
230+
231+
public interface ITestService
232+
{
233+
void HandleAccountUpdate(PreImage preImage);
234+
}
235+
236+
public class TestService : ITestService
237+
{
238+
public void HandleAccountUpdate(PreImage preImage) { }
239+
}
240+
}
241+
""";
242+
243+
/// <summary>
244+
/// Plugin with full entity PostImage (no attributes specified — captures all entity attributes).
245+
/// </summary>
246+
public const string PluginWithFullEntityPostImage = """
247+
248+
using XrmPluginCore;
249+
using XrmPluginCore.Abstractions;
250+
using XrmPluginCore.Enums;
251+
using Microsoft.Extensions.DependencyInjection;
252+
using TestNamespace;
253+
using TestNamespace.PluginRegistrations.TestPlugin.AccountUpdatePostOperation;
254+
255+
namespace TestNamespace
256+
{
257+
public class TestPlugin : Plugin
258+
{
259+
public TestPlugin()
260+
{
261+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
262+
nameof(ITestService.HandleAccountUpdate))
263+
.AddFilteredAttributes(x => x.Name)
264+
.WithPostImage();
265+
}
266+
267+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
268+
{
269+
return services.AddScoped<ITestService, TestService>();
270+
}
271+
}
272+
273+
public interface ITestService
274+
{
275+
void HandleAccountUpdate(PostImage postImage);
276+
}
277+
278+
public class TestService : ITestService
279+
{
280+
public void HandleAccountUpdate(PostImage postImage) { }
281+
}
282+
}
283+
""";
284+
201285
/// <summary>
202286
/// Plugin using old AddImage API for backward compatibility testing.
203287
/// </summary>

XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Rule ID | Category | Severity | Notes
44
--------|----------|----------|-------
55
XPC3004 | XrmPluginCore.SourceGenerator | Error | Do not use LocalPluginContext as TService in RegisterStep
6+
XPC3005 | XrmPluginCore.SourceGenerator | Warning | Full entity image registration without specifying attributes
67

78
### Removed Rules
89

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
using System.Collections.Immutable;
6+
7+
namespace XrmPluginCore.SourceGenerator.Analyzers;
8+
9+
/// <summary>
10+
/// Analyzer that reports XPC3005 when WithPreImage or WithPostImage is called
11+
/// without specifying any attributes, resulting in a full entity image registration.
12+
/// </summary>
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public class FullEntityImageAnalyzer : DiagnosticAnalyzer
15+
{
16+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
17+
ImmutableArray.Create(DiagnosticDescriptors.FullEntityImage);
18+
19+
public override void Initialize(AnalysisContext context)
20+
{
21+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
22+
context.EnableConcurrentExecution();
23+
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
24+
}
25+
26+
private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
27+
{
28+
var invocation = (InvocationExpressionSyntax)context.Node;
29+
30+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
31+
return;
32+
33+
var methodName = memberAccess.Name.Identifier.Text;
34+
35+
if (methodName != Constants.WithPreImageMethodName && methodName != Constants.WithPostImageMethodName)
36+
return;
37+
38+
// Only report when called with no arguments (full entity image)
39+
if (invocation.ArgumentList.Arguments.Count > 0)
40+
return;
41+
42+
context.ReportDiagnostic(Diagnostic.Create(
43+
DiagnosticDescriptors.FullEntityImage,
44+
invocation.GetLocation(),
45+
methodName));
46+
}
47+
}

XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ public static class DiagnosticDescriptors
106106
DiagnosticSeverity.Warning,
107107
isEnabledByDefault: true);
108108

109+
public static readonly DiagnosticDescriptor FullEntityImage = new(
110+
id: "XPC3005",
111+
title: "Full entity image registration",
112+
messageFormat: "'{0}' registered without specifying attributes will capture all entity attributes. Consider specifying only the attributes your handler needs for better performance.",
113+
category: Category,
114+
defaultSeverity: DiagnosticSeverity.Warning,
115+
isEnabledByDefault: true,
116+
description: "Registering an image without specifying attributes causes Dynamics 365 to serialize all entity attributes into the image, which may impact performance. Specify only the attributes your handler needs.",
117+
helpLinkUri: $"{HelpLinkBaseUri}/XPC3005.md");
118+
109119
public static readonly DiagnosticDescriptor GenerationError = new(
110120
id: "XPC5002",
111121
title: "Failed to generate wrapper classes",

XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,29 @@ private static ImageMetadata ParseImageInvocation(
244244
imageMetadata.ImageName = imageMetadata.ImageType;
245245
}
246246

247+
// For WithPreImage/WithPostImage with no attributes specified, capture all entity attributes
248+
if (!imageMetadata.Attributes.Any() &&
249+
(methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName))
250+
{
251+
imageMetadata.Attributes.AddRange(GetAllEntityAttributes(entityType));
252+
}
253+
247254
return imageMetadata.Attributes.Any() ? imageMetadata : null;
248255
}
249256

257+
/// <summary>
258+
/// Gets all attribute metadata for all entity properties that have an AttributeLogicalName attribute.
259+
/// Used for full entity images where no specific attributes are specified.
260+
/// </summary>
261+
private static IEnumerable<AttributeMetadata> GetAllEntityAttributes(ITypeSymbol entityType)
262+
{
263+
return entityType.GetMembers()
264+
.OfType<IPropertySymbol>()
265+
.Where(p => p.GetAttributes().Any(a => a.AttributeClass?.Name == Constants.LogicalNameAttributeName))
266+
.Select(p => GetAttributeMetadata(p.Name, entityType))
267+
.Where(a => a != null);
268+
}
269+
250270
/// <summary>
251271
/// Gets attribute metadata (property name, logical name, type) for a property
252272
/// </summary>

0 commit comments

Comments
 (0)