diff --git a/src/dvx.Tests/SolutionServiceTests.cs b/src/dvx.Tests/SolutionServiceTests.cs index 2e5e4e1..3f6da58 100644 --- a/src/dvx.Tests/SolutionServiceTests.cs +++ b/src/dvx.Tests/SolutionServiceTests.cs @@ -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(); + var id = Guid.NewGuid(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "solutioncomponent")) + .Returns(new EntityCollection(new List + { + 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(); + svc.RetrieveMultiple(Arg.Any()).Returns(new EntityCollection()); + + new SolutionService(svc).GetSolutionStepIds("MySolution"); + + svc.Received(1).RetrieveMultiple(Arg.Is(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"))))); + } } } diff --git a/src/dvx.Tests/StepRegistrarTests.cs b/src/dvx.Tests/StepRegistrarTests.cs index 03cea09..37a7924 100644 --- a/src/dvx.Tests/StepRegistrarTests.cs +++ b/src/dvx.Tests/StepRegistrarTests.cs @@ -912,6 +912,7 @@ public void SolutionProvided_ExistingStep_AddStepToSolutionCalled() }; var svc = BuildDefaultSvc(existingSteps: new EntityCollection(new List { existingStepEntity })); var solutionService = Substitute.For(svc); + solutionService.GetSolutionStepIds("MySolution", Arg.Any()).Returns(new HashSet()); var registrar = MakeRegistrarWithSolution(svc, solutionService); registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution"); @@ -955,5 +956,40 @@ public void SolutionProvided_VerboseTrue_PassedThroughToSolutionService() solutionService.Received(1).ValidateSolutionExists("MySolution", true); solutionService.Received(1).AddStepToSolution(Arg.Any(), "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 { existingStepEntity })); + var solutionService = Substitute.For(svc); + solutionService.GetSolutionStepIds("MySolution", Arg.Any()) + .Returns(new HashSet { stepId }); + var registrar = MakeRegistrarWithSolution(svc, solutionService); + + registrar.Sync(AssemblyId, new[] { def }, solutionUniqueName: "MySolution"); + + solutionService.DidNotReceive().AddStepToSolution(stepId, "MySolution", Arg.Any()); + } + + [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(svc); + var registrar = MakeRegistrarWithSolution(svc, solutionService); + + registrar.Sync(AssemblyId, new[] { MakeDef() }, solutionUniqueName: "MySolution"); + + solutionService.Received(1).GetSolutionStepIds("MySolution", Arg.Any()); + } } } diff --git a/src/dvx/Services/SolutionService.cs b/src/dvx/Services/SolutionService.cs index 04f0826..779eefb 100644 --- a/src/dvx/Services/SolutionService.cs +++ b/src/dvx/Services/SolutionService.cs @@ -83,5 +83,39 @@ public virtual void AddWebResourceToSolution(Guid webResourceId, string solution return results; } + + /// + /// 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 . + /// + public virtual HashSet 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(); + foreach (var e in svc.RetrieveMultiple(query).Entities) + { + var id = e.GetAttributeValue("objectid"); + if (id != Guid.Empty) ids.Add(id); + } + + if (verbose) + Out.Dim($" Found {ids.Count} plugin step(s) in solution."); + + return ids; + } } } \ No newline at end of file diff --git a/src/dvx/Services/StepRegistrar.cs b/src/dvx/Services/StepRegistrar.cs index 0a26093..d1c35cb 100644 --- a/src/dvx/Services/StepRegistrar.cs +++ b/src/dvx/Services/StepRegistrar.cs @@ -54,6 +54,13 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi 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(); + // ── Upsert loop ──────────────────────────────────────────────────── var consumed = new HashSet(); @@ -123,7 +130,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi 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 diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index c699cbb..1ed1631 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.7.0 + 1.7.1 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default @@ -17,10 +17,8 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 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 -> Id, SetState -> EntityMoniker, etc.); messages with no valid image property skip the image with a warning. + 1.7.1: +- Improve performance of sync/register by only making a AddSolutionComponent request if steps are not already part of the solution.