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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ dvx plugin sync --project <path> [options]
| `--client-id` | | env var / config | Service principal client ID |
| `--client-secret` | | env var / config | Service principal client secret |
| `--solution-unique-name` | | from config | Add all registered steps to this Dataverse solution |
| `--delete-orphaned` | | | Delete steps in Dataverse no longer present in code. Steps backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first |
| `--dry-run` | | | Print what would change without writing to Dataverse |
| `--config` | | auto-discovered | Path to config file |
| `--verbose` | | | Log upload details + inner exception details on error |
Expand All @@ -348,7 +349,7 @@ dvx plugin sync --project <path> [options]
3. Uploads the new `.nupkg` by updating the `pluginpackage` `content` column via the Dataverse SDK
4. Queries the child `pluginassembly` record for the assembly ID
5. Reflects the `.dll` for `[PluginStep]` attributes
6. Fully syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, deletes orphan steps
6. Syncs `sdkmessageprocessingstep` records — creates new steps, updates changed steps, and warns about orphan steps (removed only when `--delete-orphaned` is passed)
7. Syncs `sdkmessageprocessingstepimage` records (pre/post images) for each step

> **Note:** `sync` and `deploy` only support **updating** an existing plugin package.
Expand Down Expand Up @@ -426,6 +427,7 @@ dvx plugin register (--project <path> | --assembly-name <name>) [options]
| `--client-id` | | Service principal client ID |
| `--client-secret` | | Service principal client secret |
| `--solution-unique-name` | | Add all registered steps to this Dataverse solution. Falls back to `solutionUniqueName` in config |
| `--delete-orphaned` | | Delete steps in Dataverse no longer present in code. Steps backing Custom APIs and Custom Actions are never removed. Destructive — run with `--dry-run` first |
| `--dry-run` | | Print what would change without writing to Dataverse |
| `--verbose` | | Log solution validation and step-assignment details |
| `--config` | | Path to config file |
Expand Down
167 changes: 166 additions & 1 deletion src/dvx.Tests/StepRegistrarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ private static IOrganizationService BuildDefaultSvc(
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "sdkmessageprocessingstepimage"))
.Returns(existingImages ?? new EntityCollection());

// custom APIs (none by default)
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "customapi"))
.Returns(new EntityCollection());

// custom actions / workflows (none by default)
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "workflow"))
.Returns(new EntityCollection());

// Create returns a fresh Guid
svc.Create(Arg.Any<Entity>()).Returns(_ => Guid.NewGuid());

Expand Down Expand Up @@ -216,15 +224,123 @@ public void OrphanStep_DeletedFromDataverse_ResultDeletedIsOne()
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "sdkmessageprocessingstepimage"))
.Returns(new EntityCollection());

svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "customapi"))
.Returns(new EntityCollection());
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "workflow"))
.Returns(new EntityCollection());

svc.Create(Arg.Any<Entity>()).Returns(_ => Guid.NewGuid());

// definitions don't include the old plugin type
var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { MakeDef() });
var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { MakeDef() }, deleteOrphaned: true);

result.Deleted.ShouldBe(1);
svc.Received(1).Delete("sdkmessageprocessingstep", orphanId);
}

[Fact]
public void OrphanStep_DeleteOrphanedFalse_Warns_NotDeleted()
{
// Deletion is opt-in: by default an orphan is reported as a warning, not removed.
var orphanId = Guid.NewGuid();
var orphan = new Entity("sdkmessageprocessingstep", orphanId)
{
["name"] = "Ns.OldPlugin | account | create | PreOperation",
["plugintypeid"] = new EntityReference("plugintype", PluginTypeId),
["sdkmessageid"] = new EntityReference("sdkmessage", MsgId),
};

var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { orphan }));
var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty<PluginStepDefinition>());

result.Deleted.ShouldBe(0);
result.Warnings.ShouldContain(w => w.Contains("Ns.OldPlugin | account | create | PreOperation"));
svc.DidNotReceive().Delete("sdkmessageprocessingstep", orphanId);
}

[Fact]
public void OrphanStep_OnCustomApiMessage_NotDeleted()
{
// A Custom API's backing step lives on this assembly's plugin types but is owned by the
// Custom API definition — it must never be treated as an orphan, even when pruning.
var customApiMsgId = Guid.NewGuid();
var stepId = Guid.NewGuid();

var orphanOnCustomApi = new Entity("sdkmessageprocessingstep", stepId)
{
["name"] = "MyCustomApi.Handler",
["plugintypeid"] = new EntityReference("plugintype", PluginTypeId),
["sdkmessageid"] = new EntityReference("sdkmessage", customApiMsgId),
};

var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { orphanOnCustomApi }));
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "customapi"))
.Returns(new EntityCollection(new List<Entity>
{
new Entity("customapi", Guid.NewGuid())
{
["sdkmessageid"] = new EntityReference("sdkmessage", customApiMsgId)
}
}));

var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty<PluginStepDefinition>(), deleteOrphaned: true);

result.Deleted.ShouldBe(0);
svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId);
}

[Fact]
public void OrphanStep_OnCustomActionMessage_NotDeleted()
{
// A Custom Action (workflow, category = Action) owns an SDK message named after its
// uniquename. A step on that message backs the action and must never be orphan-deleted.
var actionMsgId = Guid.NewGuid();
const string actionUnique = "new_MyAction";
var stepId = Guid.NewGuid();

var orphanOnAction = new Entity("sdkmessageprocessingstep", stepId)
{
["name"] = "MyAction.Handler",
["plugintypeid"] = new EntityReference("plugintype", PluginTypeId),
["sdkmessageid"] = new EntityReference("sdkmessage", actionMsgId),
};

var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { orphanOnAction }));

// The action's message must be resolvable by name (workflow.uniquename → sdkmessage.name).
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "sdkmessage"))
.Returns(new EntityCollection(new List<Entity>
{
new Entity("sdkmessage", MsgId) { ["name"] = MessageName },
new Entity("sdkmessage", actionMsgId) { ["name"] = actionUnique },
}));
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "workflow"))
.Returns(new EntityCollection(new List<Entity>
{
new Entity("workflow", Guid.NewGuid()) { ["uniquename"] = actionUnique }
}));

var result = MakeRegistrar(svc).Sync(AssemblyId, Array.Empty<PluginStepDefinition>(), deleteOrphaned: true);

result.Deleted.ShouldBe(0);
svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId);
}

[Fact]
public void ConsumedStep_DeleteOrphanedTrue_NotDeleted()
{
// Pruning must only remove genuine orphans — a step matching a definition is never deleted.
var def = MakeDef();
var stepId = Guid.NewGuid();
var existing = new Entity("sdkmessageprocessingstep", stepId) { ["name"] = def.StepName };

var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { existing }));
var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { def }, deleteOrphaned: true);

result.Deleted.ShouldBe(0);
svc.DidNotReceive().Delete("sdkmessageprocessingstep", stepId);
}

[Fact]
public void UnknownMessage_AddsWarning_CreateNotCalled()
{
Expand Down Expand Up @@ -653,6 +769,55 @@ public void ImageAttributes_Removed_ClearedOnUpdate()
e.GetAttributeValue<string>("attributes") == null));
}

// ── Image message property name ────────────────────────────────────────

[Fact]
public void CreateStep_PostImage_UsesIdMessagePropertyName()
{
// The Create message binds post-images via 'Id'. dvx previously hardcoded 'Target',
// which Dataverse rejects ("Message property name 'Target' is not valid on message Create").
var def = MakeDef(stage: 40, postImage: true); // message defaults to "Create"
var svc = BuildDefaultSvc();

MakeRegistrar(svc).Sync(AssemblyId, new[] { def });

svc.Received().Create(Arg.Is<Entity>(e =>
e.LogicalName == "sdkmessageprocessingstepimage" &&
e.GetAttributeValue<string>("messagepropertyname") == "Id"));
}

[Fact]
public void UpdateStep_PostImage_UsesTargetMessagePropertyName()
{
var def = MakeDef(message: "Update", stage: 40, postImage: true);
var svc = BuildDefaultSvc();
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "sdkmessage"))
.Returns(new EntityCollection(new List<Entity>
{
new Entity("sdkmessage", MsgId) { ["name"] = "Update" }
}));

MakeRegistrar(svc).Sync(AssemblyId, new[] { def });

svc.Received().Create(Arg.Is<Entity>(e =>
e.LogicalName == "sdkmessageprocessingstepimage" &&
e.GetAttributeValue<string>("messagepropertyname") == "Target"));
}

[Fact]
public void MessageWithoutImageSupport_SkipsImage_AddsWarning()
{
// Associate has no valid image property name. The image must be skipped (not registered
// with an invalid property name), and the user warned.
var def = MakeDef(entity: "", message: "Associate", stage: 40, postImage: true);
var svc = BuildEntitylessSvc("Associate");

var result = MakeRegistrar(svc).Sync(AssemblyId, new[] { def });

svc.DidNotReceive().Create(Arg.Is<Entity>(e => e.LogicalName == "sdkmessageprocessingstepimage"));
result.Warnings.ShouldContain(w => w.Contains("does not support entity images"));
}

// ── Change detection (skip unchanged) ──────────────────────────────────

[Fact]
Expand Down
7 changes: 5 additions & 2 deletions src/dvx/Commands/RegisterCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ public static Command Build(ILoggerFactory loggerFactory)
var dryRun = CommandOptions.DryRun();
var verbose = CommandOptions.Verbose();
var solutionUniqueName = CommandOptions.SolutionUniqueName();
var deleteOrphaned = CommandOptions.DeleteOrphanedSteps();

// --project and --assembly-name are mutually exclusive; both optional — validated at runtime

cmd.AddOptions(env, config, url, clientId, clientSecret, project, assemblyName,
dryRun, verbose, solutionUniqueName);
dryRun, verbose, solutionUniqueName, deleteOrphaned);

cmd.SetHandler((InvocationContext ctx) =>
{
Expand All @@ -42,6 +43,7 @@ public static Command Build(ILoggerFactory loggerFactory)
var isDryRun = ctx.ParseResult.GetValueForOption(dryRun);
var isVerbose = ctx.ParseResult.GetValueForOption(verbose);
var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName);
var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned);

if (!string.IsNullOrEmpty(projectPath) && !string.IsNullOrEmpty(asmName))
{
Expand Down Expand Up @@ -93,7 +95,8 @@ public static Command Build(ILoggerFactory loggerFactory)
Out.DryRun("— no changes will be made to Dataverse.");

var registrar = new StepRegistrar(svc, loggerFactory.CreateLogger<StepRegistrar>());
var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, isVerbose);
var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution,
deleteOrphaned: delOrphaned, verbose: isVerbose);

Out.SyncSummary(syncResult, isDryRun);

Expand Down
6 changes: 6 additions & 0 deletions src/dvx/Commands/Shared/CommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public static class CommandOptions
"Delete web resources that are in the target solution but not in the folder/manifest. " +
"Requires a solution. Destructive — run with --dry-run first.");

public static Option<bool> DeleteOrphanedSteps() => new Option<bool>(
"--delete-orphaned",
"Delete plugin steps registered in Dataverse but no longer present in code. " +
"Steps backing Custom APIs and Custom Actions are never removed. " +
"Destructive — run with --dry-run first.");

public static Option<bool> DryRun() => new Option<bool>(
"--dry-run",
"Print what would happen without making any changes to Dataverse.");
Expand Down
7 changes: 5 additions & 2 deletions src/dvx/Commands/SyncCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ public static Command Build(ILoggerFactory loggerFactory)
var dryRun = CommandOptions.DryRun();
var verbose = CommandOptions.Verbose();
var solutionUniqueName = CommandOptions.SolutionUniqueName();
var deleteOrphaned = CommandOptions.DeleteOrphanedSteps();

cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix,
dryRun, verbose, solutionUniqueName);
dryRun, verbose, solutionUniqueName, deleteOrphaned);

cmd.SetHandler((InvocationContext ctx) =>
{
Expand All @@ -39,6 +40,7 @@ public static Command Build(ILoggerFactory loggerFactory)
var isDryRun = ctx.ParseResult.GetValueForOption(dryRun);
var isVerbose = ctx.ParseResult.GetValueForOption(verbose);
var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName);
var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned);

try
{
Expand Down Expand Up @@ -78,7 +80,8 @@ public static Command Build(ILoggerFactory loggerFactory)
Out.DryRun("— no step changes will be made.");

var registrar = new StepRegistrar(svc, loggerFactory.CreateLogger<StepRegistrar>());
var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution, isVerbose);
var syncResult = registrar.Sync(assemblyId, definitions, isDryRun, solution,
deleteOrphaned: delOrphaned, verbose: isVerbose);

Out.SyncSummary(syncResult, isDryRun);

Expand Down
15 changes: 9 additions & 6 deletions src/dvx/Models/ImageDefinition.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
namespace dvx.Models
{
public enum ImageType { Pre = 0, Post = 1 }
public enum ImageType
{
Pre = 0,
Post = 1
}

public class ImageDefinition
{
public ImageType ImageType { get; set; }
public string Alias { get; set; } = string.Empty;
public string[] Attributes { get; set; } = Array.Empty<string>();
// messagepropertyname is always "Target" for standard messages
public ImageType ImageType { get; set; }
public string Alias { get; set; } = string.Empty;
public string[] Attributes { get; set; } = Array.Empty<string>();
}
}
}
40 changes: 40 additions & 0 deletions src/dvx/Services/SdkMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal class SdkMetadata(IOrganizationService svc)
{
private List<Entity>? _messages;
private List<Entity>? _customApis;
private List<Entity>? _customActions;
private Guid? _systemUserId;

public Guid SystemUserId()
Expand Down Expand Up @@ -82,6 +83,27 @@ public Guid SystemUserId()
ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid")
}).Entities.ToList();

/// <summary>
/// Gets the Custom Action (process) definitions cached within the instance.
/// </summary>
/// <remarks>
/// Custom Actions are <c>workflow</c> records with category Action (3) and type Definition (1).
/// Each registers an SDK message named after the action's <c>uniquename</c>.
/// </remarks>
private IReadOnlyList<Entity> CustomActions =>
_customActions ??= svc.RetrieveMultiple(new QueryExpression("workflow")
{
ColumnSet = new ColumnSet("workflowid", "uniquename"),
Criteria = new FilterExpression
{
Conditions =
{
new ConditionExpression("category", ConditionOperator.Equal, 3), // Action
new ConditionExpression("type", ConditionOperator.Equal, 1), // Definition
}
}
}).Entities.ToList();

/// <summary>
/// Maps message names to their corresponding GUIDs.
/// </summary>
Expand Down Expand Up @@ -213,6 +235,24 @@ public HashSet<Guid> CustomApiMessageIds()
return set;
}

/// <summary>
/// Retrieves the set of GUIDs for SDK messages owned by Custom Actions, resolved by matching
/// each action's <c>uniquename</c> to an <c>sdkmessage</c> name.
/// </summary>
/// <returns>A hash set containing the unique identifiers for SDK messages linked to Custom Actions.</returns>
public HashSet<Guid> CustomActionMessageIds()
{
var messageIdByName = MessageIdByName();
var set = new HashSet<Guid>();
foreach (var e in CustomActions)
{
var uniqueName = e.GetAttributeValue<string>("uniquename");
if (uniqueName is not null && messageIdByName.TryGetValue(uniqueName, out var id))
set.Add(id);
}
return set;
}

/// <summary>
/// Retrieves a dictionary mapping plugin type IDs to their respective type names for a given assembly.
/// </summary>
Expand Down
Loading
Loading