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
35 changes: 35 additions & 0 deletions src/dvx.Tests/SolutionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,40 @@ public void GetSolutionWebResources_FiltersByComponentType61AndSolutionUniqueNam
se.LinkToEntityName == "solution" &&
se.LinkCriteria.Conditions.Any(c => c.AttributeName == "uniquename" && c.Values.Contains("MySolution"))))));
}

// ── GetSolutionStepIds ─────────────────────────────────────────────────

[Fact]
public void GetSolutionStepIds_ReturnsObjectIds()
{
var svc = Substitute.For<IOrganizationService>();
var id = Guid.NewGuid();
svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "solutioncomponent"))
.Returns(new EntityCollection(new List<Entity>
{
new Entity("solutioncomponent", Guid.NewGuid()) { ["objectid"] = id }
}));

var result = new SolutionService(svc).GetSolutionStepIds("MySolution");

result.Count.ShouldBe(1);
result.ShouldContain(id);
}

[Fact]
public void GetSolutionStepIds_FiltersByComponentType92AndSolutionUniqueName()
{
var svc = Substitute.For<IOrganizationService>();
svc.RetrieveMultiple(Arg.Any<QueryExpression>()).Returns(new EntityCollection());

new SolutionService(svc).GetSolutionStepIds("MySolution");

svc.Received(1).RetrieveMultiple(Arg.Is<QueryExpression>(q =>
q.EntityName == "solutioncomponent" &&
q.Criteria.Conditions.Any(c => c.AttributeName == "componenttype" && c.Values.Contains(92)) &&
q.LinkEntities.Any(le =>
le.LinkToEntityName == "solution" &&
le.LinkCriteria.Conditions.Any(c => c.AttributeName == "uniquename" && c.Values.Contains("MySolution")))));
}
}
}
36 changes: 36 additions & 0 deletions src/dvx.Tests/StepRegistrarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ public void SolutionProvided_ExistingStep_AddStepToSolutionCalled()
};
var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { existingStepEntity }));
var solutionService = Substitute.For<SolutionService>(svc);
solutionService.GetSolutionStepIds("MySolution", Arg.Any<bool>()).Returns(new HashSet<Guid>());
var registrar = MakeRegistrarWithSolution(svc, solutionService);

registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution");
Expand Down Expand Up @@ -955,5 +956,40 @@ public void SolutionProvided_VerboseTrue_PassedThroughToSolutionService()
solutionService.Received(1).ValidateSolutionExists("MySolution", true);
solutionService.Received(1).AddStepToSolution(Arg.Any<Guid>(), "MySolution", true);
}

[Fact]
public void SolutionProvided_ExistingStepAlreadyInSolution_AddStepToSolutionNotCalled()
{
// The optimization: a matched step the solution already contains must not be re-added.
// AddSolutionComponent is an idempotent server round-trip, so re-adding is pure latency.
var def = MakeDef();
var stepId = Guid.NewGuid();
var existingStepEntity = new Entity("sdkmessageprocessingstep", stepId)
{
["name"] = def.StepName
};
var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List<Entity> { existingStepEntity }));
var solutionService = Substitute.For<SolutionService>(svc);
solutionService.GetSolutionStepIds("MySolution", Arg.Any<bool>())
.Returns(new HashSet<Guid> { stepId });
var registrar = MakeRegistrarWithSolution(svc, solutionService);

registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution");

solutionService.DidNotReceive().AddStepToSolution(stepId, "MySolution", Arg.Any<bool>());
}

[Fact]
public void SolutionProvided_QueriesSolutionStepIdsOnce()
{
// Solution membership is read once per sync (before the loop), not once per step.
var svc = BuildDefaultSvc();
var solutionService = Substitute.For<SolutionService>(svc);
var registrar = MakeRegistrarWithSolution(svc, solutionService);

registrar.Sync(AssemblyId, new[] { MakeDef() }, solutionUniqueName: "MySolution");

solutionService.Received(1).GetSolutionStepIds("MySolution", Arg.Any<bool>());
}
}
}
34 changes: 34 additions & 0 deletions src/dvx/Services/SolutionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

var query = new QueryExpression("solution")
{
ColumnSet = new ColumnSet("solutionid"),

Check warning on line 16 in src/dvx/Services/SolutionService.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'solutionid' 5 times.

Check warning on line 16 in src/dvx/Services/SolutionService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'solutionid' 5 times.

See more on https://sonarcloud.io/project/issues?id=beyro_dvx-cli&issues=AZ75mh0onJ-7HuGow99e&open=AZ75mh0onJ-7HuGow99e&pullRequest=12
Criteria = new FilterExpression()
{
Conditions =
Expand Down Expand Up @@ -83,5 +83,39 @@

return results;
}

/// <summary>
/// Returns the ids of every plugin step (sdkmessageprocessingstep) already a component of
/// the given solution. Lets the registrar skip redundant AddSolutionComponent calls for
/// steps the solution already contains. Mirrors <see cref="GetSolutionWebResources"/>.
/// </summary>
public virtual HashSet<Guid> GetSolutionStepIds(string solutionUniqueName, bool verbose = false)
{
if (verbose)
Out.Dim($" Querying plugin steps in solution '{solutionUniqueName}'...");

var query = new QueryExpression("solutioncomponent")
{
ColumnSet = new ColumnSet("objectid"),
Criteria = new FilterExpression
{
Conditions = { new ConditionExpression("componenttype", ConditionOperator.Equal, 92) }
}
};
var solution = query.AddLink("solution", "solutionid", "solutionid");
solution.LinkCriteria.AddCondition("uniquename", ConditionOperator.Equal, solutionUniqueName);

var ids = new HashSet<Guid>();
foreach (var e in svc.RetrieveMultiple(query).Entities)
{
var id = e.GetAttributeValue<Guid>("objectid");
if (id != Guid.Empty) ids.Add(id);
}

if (verbose)
Out.Dim($" Found {ids.Count} plugin step(s) in solution.");

return ids;
}
}
}
9 changes: 8 additions & 1 deletion src/dvx/Services/StepRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
private readonly SolutionService _solutionService = solutionService ?? new SolutionService(svc);
private Guid _systemUserId;

public SyncResult Sync(Guid assemblyId, IReadOnlyList<PluginStepDefinition> definitions,

Check warning on line 17 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this method to reduce its Cognitive Complexity from 51 to the 15 allowed.
bool dryRun = false, string? solutionUniqueName = null,
bool deleteOrphaned = false, bool verbose = false)
{
Expand Down Expand Up @@ -54,6 +54,13 @@
byIdentity.TryAdd(s.Identity, s);
}

// Steps already in the target solution. Re-adding a component is an idempotent
// server round-trip, so skip AddSolutionComponent for steps the solution already
// contains — a no-op re-sync would otherwise re-add every step, one call each.
var solutionStepIds = solutionUniqueName is not null && !dryRun
? _solutionService.GetSolutionStepIds(solutionUniqueName, verbose)
: new HashSet<Guid>();

// ── Upsert loop ────────────────────────────────────────────────────
var consumed = new HashSet<Guid>();

Expand Down Expand Up @@ -123,7 +130,7 @@
else
UpdateStep(def, existing.StepId,
msgId, filterId, pluginTypeId, result);
if (solutionUniqueName is not null && !dryRun)
if (solutionUniqueName is not null && !dryRun && !solutionStepIds.Contains(existing.StepId))
_solutionService.AddStepToSolution(existing.StepId, solutionUniqueName, verbose);
}
else
Expand Down Expand Up @@ -163,7 +170,7 @@
if (deleteOrphaned)
{
logger.LogInformation("Deleting orphan step: {Name}", orphan.Name);
_svc.Delete("sdkmessageprocessingstep", orphan.StepId);

Check warning on line 173 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'sdkmessageprocessingstep' 4 times.
result.Deleted++;
}
else
Expand All @@ -188,7 +195,7 @@
private readonly record struct StepIdentity(
Guid PluginTypeId, Guid MessageId, Guid FilterId, int Stage, int Mode);

private record ExistingStep(

Check warning on line 198 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Private record classes which are not derived in the current assembly should be marked as 'sealed'.
Guid StepId, string Name,
Guid PluginTypeId, Guid MessageId, Guid FilterId, int Stage, int Mode,
int Rank, string? Description, string? FilteringAttributes, string? Configuration,
Expand All @@ -206,8 +213,8 @@

var query = new QueryExpression("sdkmessageprocessingstep")
{
ColumnSet = new ColumnSet("sdkmessageprocessingstepid", "name",

Check warning on line 216 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'sdkmessageprocessingstepid' 4 times.
"plugintypeid", "sdkmessageid",

Check warning on line 217 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'plugintypeid' 4 times.
"sdkmessagefilterid", "stage", "mode",
"rank", "description", "filteringattributes", "configuration", "impersonatinguserid"),
Criteria = new FilterExpression()
Expand Down Expand Up @@ -355,9 +362,9 @@

private HashSet<(ImageType, string, string?)> LoadExistingImagesWithAttributes(Guid stepId)
{
var query = new QueryExpression("sdkmessageprocessingstepimage")

Check warning on line 365 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'sdkmessageprocessingstepimage' 5 times.
{
ColumnSet = new ColumnSet("imagetype", "entityalias", "attributes"),

Check warning on line 367 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'entityalias' 5 times.

Check warning on line 367 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'imagetype' 5 times.
Criteria = new FilterExpression()
};
query.Criteria.AddCondition("sdkmessageprocessingstepid", ConditionOperator.Equal, stepId);
Expand Down Expand Up @@ -496,7 +503,7 @@
/// </summary>
private static string? ImageMessagePropertyName(string message) => message switch
{
"Assign" => "Target",

Check warning on line 506 in src/dvx/Services/StepRegistrar.cs

View workflow job for this annotation

GitHub Actions / build

Define a constant instead of using this literal 'Target' 6 times.
"Create" => "Id",
"CreateMultiple" => "Ids",
"Delete" => "Target",
Expand Down
8 changes: 3 additions & 5 deletions src/dvx/dvx.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<PackAsTool>true</PackAsTool>
<ToolCommandName>dvx</ToolCommandName>
<PackageId>dvx.cli</PackageId>
<Version>1.7.0</Version>
<Version>1.7.1</Version>
<Authors>Byron Matus</Authors>
<Description>CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources.</Description>
<LangVersion>default</LangVersion>
Expand All @@ -17,10 +17,8 @@
<RepositoryUrl>https://github.com/beyro/dvx-cli</RepositoryUrl>
<PackageIcon>package-icon.png</PackageIcon>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReleaseNotes>1.7.0:
- Orphaned plugin step deletion is now opt-in: pass --delete-orphaned to `plugin sync` / `plugin register` to remove steps that exist in Dataverse but are no longer in code. By default, orphans are reported as warnings only.
- Bug fix: Steps backing Custom APIs and Custom Actions are now never removed as orphans.
- Bug fix: entity images on Create steps (and other non-Update messages) failed to register because the image property name was always 'Target'. It is now derived per message (Create -&gt; Id, SetState -&gt; EntityMoniker, etc.); messages with no valid image property skip the image with a warning.</PackageReleaseNotes>
<PackageReleaseNotes>1.7.1:
- Improve performance of sync/register by only making a AddSolutionComponent request if steps are not already part of the solution.</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading