-
Notifications
You must be signed in to change notification settings - Fork 3
Plugin Analysis
This document provides comprehensive documentation for the Generator's static code analysis system for Dataverse plugins. This system performs Roslyn-based static analysis of C# source code to detect which entity attributes are used by plugins, without requiring runtime execution.
Note
This analysis is separate from PluginAnalyzer.cs, which uses the SDK Step registrations from Dataverse metadata. This documentation covers the code-based static analysis approach enabled via feature features: static code analysis.
Caution
The analysis expects you to use XrmContext for early-bound .NET types and same pluginregistration method as XrmFramework or XrmBedrock.
The plugin analysis system consists of 5 core components that work together:
┌─────────────────────────────────────────────────────────────────────────────┐
│ DataverseService.cs │
│ (Orchestrator / Entry Point) │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ EntityMetadataParser │ │ EntityMetadataParser │
│ (Parses XrmContext.cs for │ │ (Shared metadata source) │
│ entity/attribute mappings) │ │ │
└───────────────────────────────────┘ └───────────────────────────────────┘
│ │
▼ ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ PluginRegistrationAnalyzer │ │ BusinessLogicAnalyzer │
│ (Analyzes Plugin classes for │ │ (Analyzes Manager/Service │
│ step registrations & logic) │ │ classes for attribute access) │
└───────────────────────────────────┘ └───────────────────────────────────┘
│ │
└───────────────┬───────────────┘
▼
┌───────────────────────────────────┐
│ AttributeAccessVisitor │
│ (Roslyn syntax walker for │
│ detecting attribute accesses) │
└───────────────────────────────────┘
- Static Analysis Only: No runtime execution; purely syntax-based detection
- Cached Parsing: XrmContext.cs parsing is cached to avoid repeated expensive operations
-
Strong Typing: Uses
XrmContext.csto map C# property names → Dataverse logical names - Pattern Recognition: Detects multiple patterns for entity/attribute access
Purpose: Parses the auto-generated XrmContext.cs file to build a mapping between:
- C# class names → Entity logical names (e.g.,
Account→account) - C# property names → Attribute logical names (e.g.,
Name→name,AccountNumber→accountnumber) - Relationship navigation properties → Schema names
Key Features:
-
SHA256-based caching: Computes file hash and caches results to
.xrmcontext-metadata.cache - Extracts
[EntityLogicalName]and[AttributeLogicalName]decorators - Extracts
[RelationshipSchemaName]for navigation properties
Key Methods:
// Parse XrmContext.cs (uses cache if unchanged)
await parser.ParseAsync("path/to/XrmContext.cs");
// Get entity by C# class name
EntityInfo? entity = parser.GetEntityByClassName("Account");
// Get entity by Dataverse logical name
EntityInfo? entity = parser.GetEntityByLogicalName("account");
// Map property to logical name
string? logicalName = parser.GetAttributeLogicalName("Account", "Name"); // Returns "name"Data Structures:
public record EntityInfo(
string ClassName, // "Account"
string LogicalName, // "account"
int? EntityTypeCode // 1
)
{
public Dictionary<string, AttributeInfo> Attributes { get; set; }
public Dictionary<string, RelationshipInfo> Relationships { get; set; }
}
public record AttributeInfo(
string PropertyName, // "Name"
string LogicalName, // "name"
string? Type, // "string"
string? DisplayName // "Account Name"
);
public record RelationshipInfo(
string PropertyName, // "Contacts"
string SchemaName, // "account_contacts"
string? RelatedEntityClassName, // "Contact"
string? EntityRole, // "Referenced" or "Referencing"
bool IsCollection // true for 1:N
);Purpose: Analyzes plugin class files to extract:
-
Step Registrations: From
RegisterPluginStep<T>()(note that is from Bedrock's or Framework's Plugin.cs extending interface IPlugin) -
Filtered Attributes: From
.AddFilteredAttributes([...]) -
Image Attributes: From
.AddImage(ImageType.PreImage, [...]) - Execute Logic: Attributes accessed in the handler method/lambda
Key Method:
var analyzer = new PluginRegistrationAnalyzer(metadataParser, verbose: true);
List<PluginStepInfo> steps = await analyzer.AnalyzeDirectoryAsync("path/to/Plugins");Detected Patterns:
// Pattern 1: Direct call
RegisterPluginStep<Account>(EventOperation.Update, ExecutionStage.PreOperation, Execute);
// Pattern 2: Chained with 'this'
this.RegisterPluginStep<Account>(EventOperation.Create, ExecutionStage.PostOperation, ctx => { });// Collection expression syntax (C# 12)
.AddFilteredAttributes([acc => acc.Name, acc => acc.AccountNumber])
// Individual lambdas
.AddFilteredAttributes(acc => acc.Name, acc => acc.AccountNumber).AddImage(ImageType.PreImage, [acc => acc.Name, acc => acc.Revenue])
.AddImage(ImageType.PostImage, acc => acc.OwnerId)The analyzer traces:
-
GetTargetEntity<T>(),GetPreImage<T>(),GetPostImage<T>()calls - Variable assignments from these calls
- All member accesses on those variables
public void Execute(IPluginContext context)
{
var target = context.GetTargetEntity<Account>(); // Tracked: target -> Account
var preImage = context.GetPreImage<Account>(); // Tracked: preImage -> Account
target.Name = "New Name"; // Detected: account.name (Write)
var revenue = preImage.Revenue; // Detected: account.revenue (Read)
}Output Data Structure:
public record PluginStepInfo(
string ClassName, // "UpdateAccountPlugin"
string FullName, // "MyPlugins.Accounts.UpdateAccountPlugin"
string FilePath, // "C:/Plugins/UpdateAccountPlugin.cs"
string EntityLogicalName, // "account"
string EntityClassName, // "Account"
string EventOperation, // "Update"
string ExecutionStage, // "PreOperation"
string ExecutionMode, // "Synchronous" or "Asynchronous"
string? UsesManager, // "AccountManager" (if CreatePluginManager<T> detected)
string? UsesService // "AccountService" (if GetRequiredService<T> detected)
)
{
public List<string> FilteredAttributes { get; set; } // ["name", "accountnumber"]
public List<ImageInfo> Images { get; set; } // Image configurations
public List<string> UsedAttributesInLogic { get; set; } // ["name", "revenue"]
}Purpose: Analyzes Manager and Service classes (business logic layer) for entity attribute access patterns. Uses AttributeAccessVisitor internally.
Key Method:
var analyzer = new BusinessLogicAnalyzer(metadataParser);
int fileCount = await analyzer.AnalyzeDirectoryAsync("path/to/BusinessLogic", attributeUsages);Behavior:
- Recursively scans all
.csfiles (excludingbin/andobj/) - For each class, invokes
AttributeAccessVisitorto detect attribute accesses - Results are added directly to the shared
attributeUsagesdictionary
Purpose: A Roslyn CSharpSyntaxWalker that walks the entire syntax tree to detect all patterns of entity attribute access.
Detected Access Patterns (enumerated in AccessPatternType):
| Pattern | Description | Example |
|---|---|---|
PropertyRead |
Direct property read | account.Name |
PropertyWrite |
Direct property assignment | account.Name = "Test" |
LinqWhere |
LINQ filter condition | .Where(a => a.Status == 1) |
LinqSelect |
LINQ projection | .Select(a => a.Name) |
GetAttributeValue |
Dynamic attribute read | entity.GetAttributeValue<string>("name") |
SetAttributeValue |
Dynamic attribute write | entity.SetAttributeValue("name", "Test") |
AnonymousObject |
Anonymous type projection | new { a.Id, a.Name } |
ObjectInitializerWrite |
Object initializer | new Account { Name = "Test" } |
ObjectInitializerRead |
Initializer right-side read | new Contact { ParentAccount = acc.Id } |
Operation Type Detection:
The visitor attempts to determine if an access is for Create, Read, Update, Delete, or List based on:
- Method name heuristics: Method containing "Create", "Update", "Delete", etc.
-
Object construction:
new Entity()without ID = Create, with ID = Update -
LINQ context:
Where,Any= List;FirstOrDefault= Read;Select= Read/projection
Lambda Parameter Tracking:
The visitor maintains a dictionary of lambda parameters to their entity types:
// When visiting: context.AccountSet.Where(a => a.Name == "Test")
// The visitor tracks: _lambdaParameterTypes["a"] = "Account"
// So when visiting a.Name, it knows "a" is an Account entityPurpose: Shared utility methods for Roslyn syntax analysis.
Key Method:
// Gets fully qualified type name including namespace
string fullName = SyntaxHelpers.GetFullTypeName(classDecl);
// Returns: "MyProject.Plugins.Accounts.UpdateAccountPlugin"1. Configuration
├── CodeAnalysis:XrmContextPath = "../XrmContext.cs"
├── CodeAnalysis:PluginProjectPaths = "../Plugins;../CustomAPIs"
└── CodeAnalysis:LogicPaths = "../BusinessLogic"
2. XrmContext Parsing
└── EntityMetadataParser.ParseAsync()
├── Computes SHA256 hash
├── Checks cache (.xrmcontext-metadata.cache)
├── If cache miss: Parse with Roslyn
└── Returns: Dictionary<ClassName, EntityInfo>
3. Plugin Analysis (per directory)
└── PluginRegistrationAnalyzer.AnalyzeDirectoryAsync()
├── Find all .cs files (exclude bin/obj)
├── For each file:
│ ├── Parse with Roslyn
│ ├── Find classes inheriting Plugin/CustomAPI
│ ├── Find RegisterPluginStep<T>() calls
│ ├── Parse fluent builder chain
│ └── Analyze Execute method
└── Returns: List<PluginStepInfo>
4. Business Logic Analysis (per directory)
└── BusinessLogicAnalyzer.AnalyzeDirectoryAsync()
├── Find all .cs files (exclude bin/obj)
└── For each file:
└── AttributeAccessVisitor.Analyze()
├── Walk entire syntax tree
├── Track lambda parameter types
├── Record all attribute accesses
└── Add to attributeUsages dictionary
5. Output Aggregation
└── attributeUsages: Dictionary<EntityLogicalName, Dictionary<AttributeLogicalName, List<AttributeUsage>>>
Example:
{
"account": {
"name": [
AttributeUsage("UpdateAccountPlugin", "Filtered (Update)", Update, Plugin, true),
AttributeUsage("AccountManager", "AccountManager: LINQ Where", List, Plugin, true)
],
"revenue": [
AttributeUsage("UpdateAccountPlugin", "PreImage Image", Read, Plugin, true)
]
}
}
The analyzer uses syntax-only analysis (no Roslyn compilation). This means:
// ❌ CANNOT DETECT - Type inference through var
var entity = GetSomeEntity(); // Type unknown!
entity.Name = "Test"; // Won't be detected
// ✅ DETECTED - Explicit type or tracked patterns
Account entity = GetSomeEntity();
var entity = context.GetTargetEntity<Account>(); // TrackedWorkaround: Use explicit generic methods like GetTargetEntity<Account>() instead of untyped patterns.
Only variables assigned from specific patterns are tracked:
// ✅ TRACKED
var target = context.GetTargetEntity<Account>();
var preImage = context.GetPreImage<Account>();
Account acc = (Account)entity;
var acc = entity as Account;
// ❌ NOT TRACKED
var target = GetTarget(); // Non-generic method
var acc = accountService.GetAccount(); // Service method
var acc = _accounts.First(); // LINQ result assignmentEach file is analyzed in isolation:
// ❌ CANNOT DETECT - Method in different file
// File: AccountManager.cs
public void UpdateName(Account acc, string name) {
acc.Name = name; // Won't know 'acc' is Account unless tracked in THIS file
}
// File: Plugin.cs
var manager = new AccountManager();
manager.UpdateName(target, "Test"); // Call not followed// ❌ CANNOT DETECT
IEntity entity = target;
entity.Name = "Test"; // IEntity is not a known entity class
// ❌ CANNOT DETECT - Generic constraints
public void Process<T>(T entity) where T : Entity {
entity.Name = "Test"; // T is not resolved to concrete type
}// ✅ DETECTED - String literal
entity.GetAttributeValue<string>("name");
// ❌ NOT DETECTED - Variable/constant
const string NameField = "name";
entity.GetAttributeValue<string>(NameField);
// ❌ NOT DETECTED - Interpolation/concatenation
entity.GetAttributeValue<string>($"prefix_{fieldName}");// ❌ NOT DETECTED
entity["name"] = "Test"; // Indexer access
entity.Attributes["name"] = "Test"; // Dictionary access
typeof(Account).GetProperty("Name"); // Reflection
dynamic dyn = entity; dyn.Name = "Test"; // DynamicThe analyzer cannot determine if an attribute is conditionally accessed:
// ⚠️ DETECTED AS USED (even though conditionally)
if (someCondition)
{
target.Name = "Test"; // Detected, but condition not tracked
}
// ⚠️ BOTH DETECTED (no branch analysis)
target.Name = flag ? "A" : "B"; // Simple case, detected
target.Name = GetValue(target.Revenue); // Both Name and Revenue detectedOnly the fluent RegisterPluginStep<T> pattern is detected:
// ✅ SUPPORTED
RegisterPluginStep<Account>(EventOperation.Update, ExecutionStage.PreOperation, Execute)
.AddFilteredAttributes([a => a.Name])
.AddImage(ImageType.PreImage, [a => a.Revenue]);
// ❌ NOT SUPPORTED - Attribute-based registration
[CrmPluginRegistration("Update", "account", ...)]
public class MyPlugin : IPlugin { }
// ❌ NOT SUPPORTED - Manual registration
var step = new SdkMessageProcessingStep { ... };If XrmContext.cs doesn't contain an entity, it won't be tracked:
// ❌ NOT DETECTED - Entity not in XrmContext.cs
var custom = context.GetTargetEntity<my_customentity>(); // Class not generated
custom.my_field = "Test";// Base class
public class BaseAccountPlugin : Plugin {
protected void UpdateName(Account acc) {
acc.Name = "Test"; // Detected in base class analysis
}
}
// Derived class
public class SpecificPlugin : BaseAccountPlugin {
public void Execute() {
UpdateName(target); // Base class method not re-analyzed
}
}// ✅ All accesses will be detected
public class UpdateAccountPlugin : Plugin
{
public UpdateAccountPlugin()
{
RegisterPluginStep<Account>(
EventOperation.Update,
ExecutionStage.PreOperation,
Execute)
.AddFilteredAttributes([a => a.Name, a => a.AccountNumber]) // ✅ Filtered
.AddImage(ImageType.PreImage, [a => a.Revenue, a => a.OwnerId]); // ✅ Image
}
private void Execute(IPluginContext context)
{
var target = context.GetTargetEntity<Account>(); // ✅ Variable tracked
var preImage = context.GetPreImage<Account>(); // ✅ Variable tracked
// ✅ All these are detected
if (target.Name != preImage.Name) // Read on target.Name, preImage.Name
{
target.Description = "Name changed"; // Write on target.Description
}
var oldRevenue = preImage.Revenue; // Read on preImage.Revenue
}
}Detected Usages:
| Attribute | Usage | Operation |
|---|---|---|
name |
Filtered (Update) | Update |
accountnumber |
Filtered (Update) | Update |
revenue |
PreImage Image | Read |
ownerid |
PreImage Image | Read |
name |
Used in Logic (Update) | Update |
description |
Used in Logic (Update) | Update |
revenue |
Used in Logic (Update) | Read |
public class ProblematicPlugin : Plugin
{
public ProblematicPlugin()
{
RegisterPluginStep<Account>(
EventOperation.Update,
ExecutionStage.PreOperation,
Execute);
}
private void Execute(IPluginContext context)
{
// ❌ NOT TRACKED - var with non-generic method
var target = context.GetTarget();
target.Name = "Test"; // ❌ Won't be detected (target type unknown)
// ❌ NOT TRACKED - Service method
var account = _accountService.GetById(id);
account.Revenue = 100; // ❌ Won't be detected
// ❌ NOT TRACKED - Variable attribute name
string field = "name";
target.SetAttributeValue(field, "Test"); // ❌ Won't be detected
// ✅ TRACKED - Direct generic call
var proper = context.GetTargetEntity<Account>();
proper.Description = "Test"; // ✅ Detected
}
}// File: AccountManager.cs
public class AccountManager
{
private readonly XrmContext _context;
public List<Account> GetActiveAccounts()
{
// ✅ DETECTED - LINQ Where
return _context.AccountSet
.Where(a => a.StateCode == 0) // ✅ account.statecode (List)
.Where(a => a.Name.Contains("Corp")) // ✅ account.name (List)
.OrderBy(a => a.CreatedOn) // ✅ account.createdon (List)
.Select(a => new Account { // Object initializer
Id = a.Id, // ✅ account.accountid (Read)
Name = a.Name, // ✅ account.name (Read)
Revenue = a.Revenue // ✅ account.revenue (Read)
})
.ToList();
}
public void UpdateRevenue(Account account, decimal amount)
{
account.Revenue = amount; // ✅ account.revenue (Update)
account.ModifiedOn = DateTime.Now; // ✅ account.modifiedon (Update)
}
}public class ContactPlugin : Plugin
{
private void Execute(IPluginContext context)
{
var contact = context.GetTargetEntity<Contact>();
// ✅ DETECTED - Navigation property access
var parentAccount = contact.ParentCustomerId; // contact.parentcustomerid
// ⚠️ PARTIALLY DETECTED - Depends on relationship mapping
// If XrmContext has RelationshipSchemaName attribute:
var primaryContact = contact.Account.PrimaryContactId;
// contact → Account (via relationship) → primarycontactid
}
}-
Use Strongly-Typed Generic Methods
// ✅ Good var target = context.GetTargetEntity<Account>(); // ❌ Avoid var target = context.GetTarget();
-
Use String Literals for Dynamic Access
// ✅ Detectable entity.GetAttributeValue<string>("name"); // ❌ Not detectable entity.GetAttributeValue<string>(FieldConstants.Name);
-
Register Filtered Attributes Explicitly
// ✅ Fully tracked .AddFilteredAttributes([a => a.Name, a => a.Revenue])
-
Use Standard Variable Names for Fallback
// ✅ Fallback detection for common names var target = ...; // Assumed to be registered entity type var preImage = ...; // Assumed to be registered entity type var entity = ...; // Assumed to be registered entity type
-
Keep Plugin Logic in the Plugin Class
// ✅ Fully analyzed public class MyPlugin : Plugin { private void Execute(IPluginContext ctx) { // All logic here } } // ⚠️ Separate analysis (may miss connections) public class MyPlugin : Plugin { ... } public class MyManager { ... } // Analyzed separately
In appsettings.json:
{
"CodeAnalysis": {
"XrmContextPath": "/../XrmContext/XrmContext.cs",
"PluginProjectPaths": "/../Plugins;/../CustomAPIs",
"LogicPaths": "/../BusinessLogic/Managers;/../BusinessLogic/Services"
}
}-
XrmContextPath: Path to the XrmContext.cs file (relative to Generator directory) -
PluginProjectPaths: Semicolon-separated paths to plugin projects -
LogicPaths: Semicolon-separated paths to business logic directories
| Component | Purpose | Key Limitation |
|---|---|---|
EntityMetadataParser |
Maps C# names → logical names | Only parses XrmContext.cs |
PluginRegistrationAnalyzer |
Extracts step registrations | Only RegisterPluginStep<T> pattern |
BusinessLogicAnalyzer |
Analyzes Manager/Service classes | Per-file isolation |
AttributeAccessVisitor |
Detects attribute access patterns | Syntax-only (no type inference) |
SyntaxHelpers |
Utility methods | - |
Overall Accuracy: ~70-85% for well-structured codebases following best practices. Lower accuracy for dynamic, reflection-heavy, or loosely-typed code.