diff --git a/src/dvx.Tests/StepImporterTests.cs b/src/dvx.Tests/StepImporterTests.cs index 59310e9..64eaab9 100644 --- a/src/dvx.Tests/StepImporterTests.cs +++ b/src/dvx.Tests/StepImporterTests.cs @@ -108,6 +108,35 @@ public void Import_MapsScalarFields() def.FilteringAttributes.ShouldBe(new[] { "name", "statuscode" }); } + [Fact] + public void Import_CustomEntityStep_AdoptsWithCorrectEntity() + { + // The reported bug: steps on custom entities were adopted with an empty entity (""). + var svc = BuildSvc(step: FullStep(), entity: "lhg_agency_revenue_id"); + + var def = Importer(svc).Import(AssemblyId).Definitions.ShouldHaveSingleItem(); + + def.Entity.ShouldBe("lhg_agency_revenue_id"); + } + + [Fact] + public void Import_ResolvesFiltersByStepFilterId_NotByScanningEveryFilter() + { + // Root cause of the empty-entity bug: the importer pulled every sdkmessagefilter in the + // org, and only the first page (~5000) came back, so filters past the cut-off were + // missed. It must instead query only the filter ids the loaded steps reference. + var svc = BuildSvc(step: FullStep()); + + Importer(svc).Import(AssemblyId); + + svc.Received().RetrieveMultiple(Arg.Is(q => + q.EntityName == "sdkmessagefilter" && + q.Criteria.Conditions.Any(c => + c.AttributeName == "sdkmessagefilterid" && + c.Operator == ConditionOperator.In && + c.Values.Contains(FilterId)))); + } + [Fact] public void Import_PreservesCustomImageAlias() { diff --git a/src/dvx.Tests/StepRegistrarTests.cs b/src/dvx.Tests/StepRegistrarTests.cs index 05fba84..e7c437d 100644 --- a/src/dvx.Tests/StepRegistrarTests.cs +++ b/src/dvx.Tests/StepRegistrarTests.cs @@ -140,6 +140,23 @@ public void NewStep_CreatesStep_ResultCreatedIsOne() svc.Received(1).Create(Arg.Any()); } + [Fact] + public void Sync_ResolvesFiltersByEntityName_NotByScanningEveryFilter() + { + // Symmetric to the adopt fix: registration must query sdkmessagefilter for the entities + // its definitions target, not pull every filter in the org (more than one page of them). + var svc = BuildDefaultSvc(); + + MakeRegistrar(svc).Sync(AssemblyId, new[] { MakeDef(entity: "account") }); + + svc.Received().RetrieveMultiple(Arg.Is(q => + q.EntityName == "sdkmessagefilter" && + q.Criteria.Conditions.Any(c => + c.AttributeName == "primaryobjecttypecode" && + c.Operator == ConditionOperator.In && + c.Values.Contains("account")))); + } + [Fact] public void ExistingStep_UpdatesCalled_ResultUpdatedIsOne() { diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index 073bf76..4349ba2 100644 --- a/src/dvx/Services/SdkMetadata.cs +++ b/src/dvx/Services/SdkMetadata.cs @@ -3,6 +3,23 @@ namespace dvx.Services { + /// + /// Represents a composite key that combines a Message ID and an Entity Logical Name. + /// This key is used for uniquely identifying SDK message filters associated with + /// specific entities. + /// + internal readonly record struct MessageEntityKey + { + public Guid MessageId { get; } + public string EntityLogicalName { get; } + + public MessageEntityKey(Guid messageId, string entityLogicalName) + { + MessageId = messageId; + EntityLogicalName = entityLogicalName.ToLowerInvariant(); + } + } + /// /// Loads SDK metadata lookups (messages, message filters, plugin types) shared by step /// registration (forward: name → id) and adoption (reverse: id → name). Raw message and @@ -11,7 +28,6 @@ namespace dvx.Services internal class SdkMetadata(IOrganizationService svc) { private List? _messages; - private List? _filters; private List? _customApis; private Guid? _systemUserId; @@ -37,30 +53,43 @@ public Guid SystemUserId() return _systemUserId.Value; } - private IReadOnlyList Messages => + /// + /// Gets a collection of SDK messages cached within the instance. + /// + /// + /// SDK messages represent operations or actions that can be performed using the system, + /// such as Create, Update, or Delete. The collection is retrieved from the CRM organization + /// service and includes both the unique identifiers (GUIDs) and names of the SDK messages. + /// + private IReadOnlyList SdkMessages => _messages ??= svc.RetrieveMultiple(new QueryExpression("sdkmessage") { ColumnSet = new ColumnSet("sdkmessageid", "name") }).Entities.ToList(); - private IReadOnlyList Filters => - _filters ??= svc.RetrieveMultiple(new QueryExpression("sdkmessagefilter") - { - ColumnSet = new ColumnSet("sdkmessagefilterid", "sdkmessageid", - "primaryobjecttypecode", "iscustomprocessingstepallowed") - }).Entities.ToList(); - + /// + /// Gets a collection of Custom API entities cached within the instance. + /// + /// + /// The Custom API entities represent custom-defined operations in the system. + /// These entities include attributes such as unique identifiers, unique names, + /// associated plugin types, and SDK messages. The data is retrieved from the + /// CRM organization service and cached for efficient access. + /// private IReadOnlyList CustomApis => _customApis ??= svc.RetrieveMultiple(new QueryExpression("customapi") { ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid") }).Entities.ToList(); - /// Message name → id (case-insensitive). + /// + /// Maps message names to their corresponding GUIDs. + /// + /// A dictionary where keys are sdk message names (case-insensitive) and values are their respective GUIDs. public Dictionary MessageIdByName() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var e in Messages) + foreach (var e in SdkMessages) { var name = e.GetAttributeValue("name"); if (name is not null) map[name] = e.Id; @@ -68,11 +97,14 @@ public Dictionary MessageIdByName() return map; } - /// Message id → name. + /// + /// Maps message GUIDs to their corresponding names. + /// + /// A dictionary where keys are message GUIDs and values are their case-sensitive names. public Dictionary MessageNameById() { var map = new Dictionary(); - foreach (var e in Messages) + foreach (var e in SdkMessages) { var name = e.GetAttributeValue("name"); if (name is not null) map[e.Id] = name; @@ -80,30 +112,65 @@ public Dictionary MessageNameById() return map; } - /// (messageId, entity logical name) → filterId. First entry wins on duplicates. - public Dictionary<(Guid, string), Guid> FilterIdByKey() + /// + /// Retrieves a mapping of SDK message filters keyed by + /// (the message ID and entity logical name), where the value is the SDK message filter ID. + /// + /// A collection of entity logical names to filter SDK message filters by. + /// + /// A dictionary where the key is a of the SDK message ID and entity + /// logical name, and the value is the SDK message filter ID. + /// + public Dictionary SdkFilterIdsByEntityNames(IReadOnlyCollection entityNames) { - var map = new Dictionary<(Guid, string), Guid>(); - foreach (var e in Filters) + var map = new Dictionary(); + if (entityNames.Count == 0) return map; + + var query = new QueryExpression("sdkmessagefilter") + { + ColumnSet = new ColumnSet("sdkmessagefilterid", "sdkmessageid", "primaryobjecttypecode"), + Criteria = new FilterExpression() + }; + query.Criteria.AddCondition("primaryobjecttypecode", ConditionOperator.In, entityNames.Cast().ToArray()); + + foreach (var e in svc.RetrieveMultiple(query).Entities) { var msgRef = e.GetAttributeValue("sdkmessageid"); if (msgRef is null) continue; - var entity = (e.GetAttributeValue("primaryobjecttypecode") ?? string.Empty).ToLowerInvariant(); - map.TryAdd((msgRef.Id, entity), e.Id); + var entity = e.GetAttributeValue("primaryobjecttypecode") ?? string.Empty; + map.TryAdd(new MessageEntityKey(msgRef.Id, entity), e.Id); } return map; } - /// filterId → entity logical name (primaryobjecttypecode). - public Dictionary FilterEntityById() + /// + /// Retrieves a mapping between SDK message filter IDs and their associated primary object type codes. + /// + /// A collection of GUIDs representing the SDK message filter IDs to query. + /// A dictionary where keys are the SDK message filter IDs and values are their corresponding primary object type codes. + public Dictionary FilterEntityById(IReadOnlyCollection filterIds) { var map = new Dictionary(); - foreach (var e in Filters) + if (filterIds.Count == 0) return map; + + var query = new QueryExpression("sdkmessagefilter") + { + ColumnSet = new ColumnSet("sdkmessagefilterid", "primaryobjecttypecode"), + Criteria = new FilterExpression() + }; + query.Criteria.AddCondition("sdkmessagefilterid", ConditionOperator.In, filterIds.Cast().ToArray()); + + foreach (var e in svc.RetrieveMultiple(query).Entities) map[e.Id] = e.GetAttributeValue("primaryobjecttypecode") ?? string.Empty; + return map; } - /// plugintype typename → id for the given assembly (case-insensitive). + /// + /// Retrieves a dictionary mapping plugin type names to their corresponding GUIDs within a specified plugin assembly. + /// + /// The unique identifier of the plugin assembly. + /// A dictionary where keys are plugin type names (case-insensitive) and values are their respective GUIDs. public Dictionary PluginTypeIdByName(Guid assemblyId) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -112,10 +179,14 @@ public Dictionary PluginTypeIdByName(Guid assemblyId) var name = e.GetAttributeValue("typename"); if (name is not null) map[name] = e.Id; } + return map; } - /// plugintype ids referenced by a Custom API as its main-operation implementation. + /// + /// Retrieves the unique identifiers of plugin types associated with custom APIs in the system. + /// + /// A set of GUIDs representing the plugin types linked to custom APIs. public HashSet CustomApiPluginTypeIds() { var set = new HashSet(); @@ -127,7 +198,10 @@ public HashSet CustomApiPluginTypeIds() return set; } - /// sdkmessage ids of the messages created for Custom APIs. + /// + /// Retrieves the set of GUIDs corresponding to SDK messages associated with custom APIs. + /// + /// A hash set containing the unique identifiers for SDK messages linked to custom APIs. public HashSet CustomApiMessageIds() { var set = new HashSet(); @@ -139,7 +213,11 @@ public HashSet CustomApiMessageIds() return set; } - /// plugintype id → typename for the given assembly. + /// + /// Retrieves a dictionary mapping plugin type IDs to their respective type names for a given assembly. + /// + /// The unique identifier of the assembly for which plugin type records are being retrieved. + /// A dictionary where keys are plugin type GUIDs and values are their associated type names. public Dictionary PluginTypeNameById(Guid assemblyId) { var map = new Dictionary(); @@ -151,6 +229,11 @@ public Dictionary PluginTypeNameById(Guid assemblyId) return map; } + /// + /// Retrieves a list of plugin types associated with a specific plugin assembly. + /// + /// The unique identifier of the plugin assembly. + /// A list of entities representing plugin types, including their IDs and type names. private List PluginTypes(Guid assemblyId) { var query = new QueryExpression("plugintype") diff --git a/src/dvx/Services/StepImporter.cs b/src/dvx/Services/StepImporter.cs index bd7836c..ba5ca6b 100644 --- a/src/dvx/Services/StepImporter.cs +++ b/src/dvx/Services/StepImporter.cs @@ -28,11 +28,13 @@ public ImportResult Import(Guid assemblyId, bool verbose = false) } var messageNameById = meta.MessageNameById(); - var filterEntityById = meta.FilterEntityById(); var customApiMessageIds = meta.CustomApiMessageIds(); var customApiPluginTypeIds = meta.CustomApiPluginTypeIds(); - foreach (var step in LoadSteps(typeNameById.Keys)) + var steps = LoadSteps(typeNameById.Keys); + var filterEntityById = meta.FilterEntityById(ReferencedFilterIds(steps)); + + foreach (var step in steps) { var stepName = step.GetAttributeValue("name") ?? step.Id.ToString(); @@ -125,7 +127,7 @@ private List LoadSteps(IEnumerable pluginTypeIds) query.Criteria.AddCondition("plugintypeid", ConditionOperator.In, ids); return svc.RetrieveMultiple(query).Entities.ToList(); } - + private List LoadImages(Guid stepId) { var query = new QueryExpression("sdkmessageprocessingstepimage") @@ -149,6 +151,25 @@ private List LoadImages(Guid stepId) return impersonating.Id == Guid.Empty ? null : impersonating.Id; } + /// + /// Extracts and returns a distinct list of SDK message filter IDs referenced + /// in the provided collection of entities. + /// + /// + /// A collection of entities representing SDK message processing steps, from which + /// the SDK message filter IDs are extracted. + /// + /// + /// A list of unique GUIDs corresponding to the SDK message filters referenced + /// by the given entities. If no filters are referenced, an empty list is returned. + /// + private static List ReferencedFilterIds(IEnumerable steps) => + steps.Select(s => s.GetAttributeValue("sdkmessagefilterid")?.Id) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .Distinct() + .ToList(); + private static string[] SplitCsv(string? value) => string.IsNullOrWhiteSpace(value) ? Array.Empty() diff --git a/src/dvx/Services/StepRegistrar.cs b/src/dvx/Services/StepRegistrar.cs index 32ec1ef..00f2bdf 100644 --- a/src/dvx/Services/StepRegistrar.cs +++ b/src/dvx/Services/StepRegistrar.cs @@ -32,7 +32,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi var meta = new SdkMetadata(_svc); _systemUserId = meta.SystemUserId(); var msgCache = meta.MessageIdByName(); - var filterCache = meta.FilterIdByKey(); + var filterCache = meta.SdkFilterIdsByEntityNames(ReferencedEntityNames(definitions)); var typeCache = meta.PluginTypeIdByName(assemblyId); if (typeCache.Count == 0) @@ -70,7 +70,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList defi Guid? filterId = null; if (!string.IsNullOrWhiteSpace(def.Entity)) { - var filterKey = (msgId, def.Entity.ToLowerInvariant()); + var filterKey = new MessageEntityKey(msgId, def.Entity); if (!filterCache.TryGetValue(filterKey, out var entityFilterId)) { result.Warnings.Add( @@ -359,6 +359,14 @@ private bool ImagesUnchanged(Guid stepId, PluginStepDefinition def) private static string? NullIfEmpty(string? s) => string.IsNullOrEmpty(s) ? null : s; + /// The distinct, non-global entity logical names targeted by the definitions. + private static List ReferencedEntityNames(IReadOnlyList definitions) => + definitions.Select(d => d.Entity) + .Where(e => !string.IsNullOrWhiteSpace(e)) + .Select(e => e.ToLowerInvariant()) + .Distinct() + .ToList(); + // ── Image sync ───────────────────────────────────────────────────────── private void SyncImages(Guid stepId, PluginStepDefinition def) diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index 5c2d34d..7f396bb 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.6.2 + 1.6.3 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default