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
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,56 @@ public void HandleUpdate() { }
"Namespace2 source should not contain Namespace1 namespace declaration");
}

[Fact]
public async Task Should_Report_XPC3005_When_WithPreImage_Has_No_Arguments()
{
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithFullEntityPreImage);

var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());

var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();

warnings.Should().NotBeEmpty("XPC3005 should be reported when WithPreImage() is called with no arguments");
warnings.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning);
}

[Fact]
public async Task Should_Report_XPC3005_When_WithPostImage_Has_No_Arguments()
{
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithFullEntityPostImage);

var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());

var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();

warnings.Should().NotBeEmpty("XPC3005 should be reported when WithPostImage() is called with no arguments");
warnings.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning);
}

[Fact]
public async Task Should_Not_Report_XPC3005_When_WithPreImage_Has_Arguments()
{
var source = TestFixtures.GetCompleteSource(TestFixtures.GetPluginWithPreImage());

var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());

var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();

warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPreImage() is called with specific attributes");
}

[Fact]
public async Task Should_Not_Report_XPC3005_When_WithPostImage_Has_Arguments()
{
var source = TestFixtures.GetCompleteSource(TestFixtures.PluginWithPostImage);

var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new FullEntityImageAnalyzer());

var warnings = diagnostics.Where(d => d.Id == "XPC3005").ToArray();

warnings.Should().BeEmpty("XPC3005 should NOT be reported when WithPostImage() is called with specific attributes");
}

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 @@ -330,6 +330,84 @@ public void Should_Parse_Parameterless_Method_Reference()
generatedSource.Should().Contain("service.HandleUpdate()");
}

[Fact]
public void Should_Generate_PreImage_With_All_Entity_Properties_When_No_Attributes_Specified()
{
// Arrange - WithPreImage() called with no arguments
var source = TestFixtures.GetCompleteSource(
TestFixtures.PluginWithFullEntityPreImage);

// Act
var result = GeneratorTestHelper.RunGenerator(
CompilationHelper.CreateCompilation(source));

// Assert
result.GeneratedTrees.Should().NotBeEmpty("code should be generated for full entity PreImage");
var generatedSource = result.GeneratedTrees[0].GetText().ToString();

// Verify PreImage class is generated
generatedSource.Should().Contain($"public sealed class PreImage : IEntityImageWrapper<{ContextNamespace}.Account>");

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

// Verify NO PostImage class is generated
generatedSource.Should().NotContain("public sealed class PostImage");
}

[Fact]
public void Should_Generate_PostImage_With_All_Entity_Properties_When_No_Attributes_Specified()
{
// Arrange - WithPostImage() called with no arguments
var source = TestFixtures.GetCompleteSource(
TestFixtures.PluginWithFullEntityPostImage);

// Act
var result = GeneratorTestHelper.RunGenerator(
CompilationHelper.CreateCompilation(source));

// Assert
result.GeneratedTrees.Should().NotBeEmpty("code should be generated for full entity PostImage");
var generatedSource = result.GeneratedTrees[0].GetText().ToString();

// Verify PostImage class is generated
generatedSource.Should().Contain($"public sealed class PostImage : IEntityImageWrapper<{ContextNamespace}.Account>");

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

// Verify NO PreImage class is generated
generatedSource.Should().NotContain("public sealed class PreImage");
}

[Fact]
public void Should_Generate_ActionWrapper_With_Full_Entity_PreImage_Call()
{
// Arrange - WithPreImage() called with no arguments
var source = TestFixtures.GetCompleteSource(
TestFixtures.PluginWithFullEntityPreImage);

// Act
var result = GeneratorTestHelper.RunGenerator(
CompilationHelper.CreateCompilation(source));

// Assert
var generatedSource = result.GeneratedTrees[0].GetText().ToString();

// Verify ActionWrapper is generated and handles the PreImage
generatedSource.Should().Contain("internal sealed class ActionWrapper : IActionWrapper");
generatedSource.Should().Contain("var preImageEntity = context?.PreEntityImages?.Values?.FirstOrDefault();");
generatedSource.Should().Contain("var preImage = preImageEntity != null ? new PreImage(preImageEntity) : null;");
generatedSource.Should().Contain("service.HandleAccountUpdate(preImage)");

// Should NOT have PostImage retrieval
generatedSource.Should().NotContain("PostEntityImages");
}

[System.Text.RegularExpressions.GeneratedRegex(@"namespace\s+TestNamespace\.PluginRegistrations\.TestPlugin\.AccountUpdatePostOperation")]
private static partial System.Text.RegularExpressions.Regex IsAccountUpdatePostOperationNamespace();

Expand Down
84 changes: 84 additions & 0 deletions XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,90 @@ public void HandleUpdate() { }
}
""";

/// <summary>
/// Plugin with full entity PreImage (no attributes specified — captures all entity attributes).
/// </summary>
public const string PluginWithFullEntityPreImage = """

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

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

public interface ITestService
{
void HandleAccountUpdate(PreImage preImage);
}

public class TestService : ITestService
{
public void HandleAccountUpdate(PreImage preImage) { }
}
}
""";

/// <summary>
/// Plugin with full entity PostImage (no attributes specified — captures all entity attributes).
/// </summary>
public const string PluginWithFullEntityPostImage = """

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

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

public interface ITestService
{
void HandleAccountUpdate(PostImage postImage);
}

public class TestService : ITestService
{
public void HandleAccountUpdate(PostImage postImage) { }
}
}
""";

/// <summary>
/// Plugin using old AddImage API for backward compatibility testing.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
XPC3004 | XrmPluginCore.SourceGenerator | Error | Do not use LocalPluginContext as TService in RegisterStep
XPC3005 | XrmPluginCore.SourceGenerator | Warning | Full entity image registration without specifying attributes

### Removed Rules

Expand Down
47 changes: 47 additions & 0 deletions XrmPluginCore.SourceGenerator/Analyzers/FullEntityImageAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

namespace XrmPluginCore.SourceGenerator.Analyzers;

/// <summary>
/// Analyzer that reports XPC3005 when WithPreImage or WithPostImage is called
/// without specifying any attributes, resulting in a full entity image registration.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class FullEntityImageAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DiagnosticDescriptors.FullEntityImage);

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;

if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return;

var methodName = memberAccess.Name.Identifier.Text;

if (methodName != Constants.WithPreImageMethodName && methodName != Constants.WithPostImageMethodName)
return;

// Only report when called with no arguments (full entity image)
if (invocation.ArgumentList.Arguments.Count > 0)
return;

context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.FullEntityImage,
invocation.GetLocation(),
methodName));
}
Comment on lines +41 to +46
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FullEntityImageAnalyzer will report XPC3005 for any method named WithPreImage/WithPostImage anywhere in the codebase (including unrelated user-defined APIs), because it only checks the identifier text and does not verify the invocation is part of an XrmPluginCore RegisterStep fluent chain or that the target symbol is the expected method. This can create false-positive warnings in non-plugin code. Consider restricting analysis to invocations chained from a RegisterStep call (similar to ImageWithoutMethodReferenceAnalyzer) and/or using the semantic model to confirm the invoked IMethodSymbol belongs to the intended XrmPluginCore registration API type/namespace.

Suggested change
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.FullEntityImage,
invocation.GetLocation(),
methodName));
}
// Restrict matches to the XrmPluginCore registration fluent chain to avoid
// reporting on unrelated user-defined APIs with the same method name.
if (!IsRegisterStepChain(memberAccess.Expression))
return;
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.FullEntityImage,
invocation.GetLocation(),
methodName));
}
private static bool IsRegisterStepChain(ExpressionSyntax expression)
{
switch (expression)
{
case InvocationExpressionSyntax invocation:
return IsRegisterStepInvocation(invocation) || IsRegisterStepChain(invocation.Expression);
case MemberAccessExpressionSyntax memberAccess:
return IsRegisterStepChain(memberAccess.Expression);
default:
return false;
}
}
private static bool IsRegisterStepInvocation(InvocationExpressionSyntax invocation)
{
return invocation.Expression switch
{
MemberAccessExpressionSyntax memberAccess =>
memberAccess.Name.Identifier.Text == Constants.RegisterStepMethodName,
IdentifierNameSyntax identifier =>
identifier.Identifier.Text == Constants.RegisterStepMethodName,
_ => false
};
}

Copilot uses AI. Check for mistakes.
}
10 changes: 10 additions & 0 deletions XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ public static class DiagnosticDescriptors
DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public static readonly DiagnosticDescriptor FullEntityImage = new(
id: "XPC3005",
title: "Full entity image registration",
messageFormat: "'{0}' registered without specifying attributes will capture all entity attributes. Consider specifying only the attributes your handler needs for better performance.",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
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.",
helpLinkUri: $"{HelpLinkBaseUri}/XPC3005.md");

public static readonly DiagnosticDescriptor GenerationError = new(
id: "XPC5002",
title: "Failed to generate wrapper classes",
Expand Down
20 changes: 20 additions & 0 deletions XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,29 @@ private static ImageMetadata ParseImageInvocation(
imageMetadata.ImageName = imageMetadata.ImageType;
}

// For WithPreImage/WithPostImage with no attributes specified, capture all entity attributes
if (!imageMetadata.Attributes.Any() &&
(methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName))
{
imageMetadata.Attributes.AddRange(GetAllEntityAttributes(entityType));
}
Comment on lines +247 to +252
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this branch will "capture all entity attributes", but GetAllEntityAttributes only includes properties decorated with AttributeLogicalNameAttribute. To avoid confusion for future maintainers (and align with the actual behavior), consider rewording the comment to reflect that it captures all mapped entity properties/attributes (i.e., those with logical name metadata).

Copilot uses AI. Check for mistakes.

return imageMetadata.Attributes.Any() ? imageMetadata : null;
}

/// <summary>
/// Gets all attribute metadata for all entity properties that have an AttributeLogicalName attribute.
/// Used for full entity images where no specific attributes are specified.
/// </summary>
private static IEnumerable<AttributeMetadata> GetAllEntityAttributes(ITypeSymbol entityType)
{
return entityType.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetAttributes().Any(a => a.AttributeClass?.Name == Constants.LogicalNameAttributeName))
.Select(p => GetAttributeMetadata(p.Name, entityType))
.Where(a => a != null);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetAllEntityAttributes returns entity members in Roslyn’s default order, and later generation iterates image.Attributes as-is. This can make generated source unstable between compilations (and produces noisy diffs) if member enumeration order changes. Consider applying a deterministic ordering (e.g., order properties by Name and/or order the resulting AttributeMetadata by LogicalName) when building the full-entity attribute list.

Suggested change
.Where(a => a != null);
.Where(a => a != null)
.OrderBy(a => a.LogicalName, global::System.StringComparer.Ordinal)
.ThenBy(a => a.PropertyName, global::System.StringComparer.Ordinal);

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Gets attribute metadata (property name, logical name, type) for a property
/// </summary>
Expand Down
Loading
Loading