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
29 changes: 29 additions & 0 deletions src/dvx.Tests/StepImporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryExpression>(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()
{
Expand Down
17 changes: 17 additions & 0 deletions src/dvx.Tests/StepRegistrarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ public void NewStep_CreatesStep_ResultCreatedIsOne()
svc.Received(1).Create(Arg.Any<Entity>());
}

[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<QueryExpression>(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()
{
Expand Down
135 changes: 109 additions & 26 deletions src/dvx/Services/SdkMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@

namespace dvx.Services
{
/// <summary>
/// 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.
/// </summary>
internal readonly record struct MessageEntityKey
{
public Guid MessageId { get; }
public string EntityLogicalName { get; }

public MessageEntityKey(Guid messageId, string entityLogicalName)
{
MessageId = messageId;
EntityLogicalName = entityLogicalName.ToLowerInvariant();
}
}

/// <summary>
/// Loads SDK metadata lookups (messages, message filters, plugin types) shared by step
/// registration (forward: name → id) and adoption (reverse: id → name). Raw message and
Expand All @@ -11,7 +28,6 @@ namespace dvx.Services
internal class SdkMetadata(IOrganizationService svc)
{
private List<Entity>? _messages;
private List<Entity>? _filters;
private List<Entity>? _customApis;
private Guid? _systemUserId;

Expand All @@ -37,73 +53,124 @@ public Guid SystemUserId()
return _systemUserId.Value;
}

private IReadOnlyList<Entity> Messages =>
/// <summary>
/// Gets a collection of SDK messages cached within the instance.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private IReadOnlyList<Entity> SdkMessages =>
_messages ??= svc.RetrieveMultiple(new QueryExpression("sdkmessage")
{
ColumnSet = new ColumnSet("sdkmessageid", "name")
}).Entities.ToList();

private IReadOnlyList<Entity> Filters =>
_filters ??= svc.RetrieveMultiple(new QueryExpression("sdkmessagefilter")
{
ColumnSet = new ColumnSet("sdkmessagefilterid", "sdkmessageid",
"primaryobjecttypecode", "iscustomprocessingstepallowed")
}).Entities.ToList();

/// <summary>
/// Gets a collection of Custom API entities cached within the instance.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private IReadOnlyList<Entity> CustomApis =>
_customApis ??= svc.RetrieveMultiple(new QueryExpression("customapi")
{
ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid")
}).Entities.ToList();

/// <summary>Message name → id (case-insensitive).</summary>
/// <summary>
/// Maps message names to their corresponding GUIDs.
/// </summary>
/// <returns>A dictionary where keys are sdk message names (case-insensitive) and values are their respective GUIDs.</returns>
public Dictionary<string, Guid> MessageIdByName()
{
var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var e in Messages)
foreach (var e in SdkMessages)
{
var name = e.GetAttributeValue<string>("name");
if (name is not null) map[name] = e.Id;
}
return map;
}

/// <summary>Message id → name.</summary>
/// <summary>
/// Maps message GUIDs to their corresponding names.
/// </summary>
/// <returns>A dictionary where keys are message GUIDs and values are their case-sensitive names.</returns>
public Dictionary<Guid, string> MessageNameById()
{
var map = new Dictionary<Guid, string>();
foreach (var e in Messages)
foreach (var e in SdkMessages)
{
var name = e.GetAttributeValue<string>("name");
if (name is not null) map[e.Id] = name;
}
return map;
}

/// <summary>(messageId, entity logical name) → filterId. First entry wins on duplicates.</summary>
public Dictionary<(Guid, string), Guid> FilterIdByKey()
/// <summary>
/// Retrieves a mapping of SDK message filters keyed by <see cref="MessageEntityKey"/>
/// (the message ID and entity logical name), where the value is the SDK message filter ID.
/// </summary>
/// <param name="entityNames">A collection of entity logical names to filter SDK message filters by.</param>
/// <returns>
/// A dictionary where the key is a <see cref="MessageEntityKey"/> of the SDK message ID and entity
/// logical name, and the value is the SDK message filter ID.
/// </returns>
public Dictionary<MessageEntityKey, Guid> SdkFilterIdsByEntityNames(IReadOnlyCollection<string> entityNames)
{
var map = new Dictionary<(Guid, string), Guid>();
foreach (var e in Filters)
var map = new Dictionary<MessageEntityKey, Guid>();
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<object>().ToArray());

foreach (var e in svc.RetrieveMultiple(query).Entities)
{
var msgRef = e.GetAttributeValue<EntityReference>("sdkmessageid");
if (msgRef is null) continue;
var entity = (e.GetAttributeValue<string>("primaryobjecttypecode") ?? string.Empty).ToLowerInvariant();
map.TryAdd((msgRef.Id, entity), e.Id);
var entity = e.GetAttributeValue<string>("primaryobjecttypecode") ?? string.Empty;
map.TryAdd(new MessageEntityKey(msgRef.Id, entity), e.Id);
}
return map;
}

/// <summary>filterId → entity logical name (primaryobjecttypecode).</summary>
public Dictionary<Guid, string> FilterEntityById()
/// <summary>
/// Retrieves a mapping between SDK message filter IDs and their associated primary object type codes.
/// </summary>
/// <param name="filterIds">A collection of GUIDs representing the SDK message filter IDs to query.</param>
/// <returns>A dictionary where keys are the SDK message filter IDs and values are their corresponding primary object type codes.</returns>
public Dictionary<Guid, string> FilterEntityById(IReadOnlyCollection<Guid> filterIds)
{
var map = new Dictionary<Guid, string>();
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<object>().ToArray());

foreach (var e in svc.RetrieveMultiple(query).Entities)
map[e.Id] = e.GetAttributeValue<string>("primaryobjecttypecode") ?? string.Empty;

return map;
}

/// <summary>plugintype typename → id for the given assembly (case-insensitive).</summary>
/// <summary>
/// Retrieves a dictionary mapping plugin type names to their corresponding GUIDs within a specified plugin assembly.
/// </summary>
/// <param name="assemblyId">The unique identifier of the plugin assembly.</param>
/// <returns>A dictionary where keys are plugin type names (case-insensitive) and values are their respective GUIDs.</returns>
public Dictionary<string, Guid> PluginTypeIdByName(Guid assemblyId)
{
var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
Expand All @@ -112,10 +179,14 @@ public Dictionary<string, Guid> PluginTypeIdByName(Guid assemblyId)
var name = e.GetAttributeValue<string>("typename");
if (name is not null) map[name] = e.Id;
}

return map;
}

/// <summary>plugintype ids referenced by a Custom API as its main-operation implementation.</summary>
/// <summary>
/// Retrieves the unique identifiers of plugin types associated with custom APIs in the system.
/// </summary>
/// <returns>A set of GUIDs representing the plugin types linked to custom APIs.</returns>
public HashSet<Guid> CustomApiPluginTypeIds()
{
var set = new HashSet<Guid>();
Expand All @@ -127,7 +198,10 @@ public HashSet<Guid> CustomApiPluginTypeIds()
return set;
}

/// <summary>sdkmessage ids of the messages created for Custom APIs.</summary>
/// <summary>
/// Retrieves the set of GUIDs corresponding to SDK messages associated with custom APIs.
/// </summary>
/// <returns>A hash set containing the unique identifiers for SDK messages linked to custom APIs.</returns>
public HashSet<Guid> CustomApiMessageIds()
{
var set = new HashSet<Guid>();
Expand All @@ -139,7 +213,11 @@ public HashSet<Guid> CustomApiMessageIds()
return set;
}

/// <summary>plugintype id → typename for the given assembly.</summary>
/// <summary>
/// Retrieves a dictionary mapping plugin type IDs to their respective type names for a given assembly.
/// </summary>
/// <param name="assemblyId">The unique identifier of the assembly for which plugin type records are being retrieved.</param>
/// <returns>A dictionary where keys are plugin type GUIDs and values are their associated type names.</returns>
public Dictionary<Guid, string> PluginTypeNameById(Guid assemblyId)
{
var map = new Dictionary<Guid, string>();
Expand All @@ -151,6 +229,11 @@ public Dictionary<Guid, string> PluginTypeNameById(Guid assemblyId)
return map;
}

/// <summary>
/// Retrieves a list of plugin types associated with a specific plugin assembly.
/// </summary>
/// <param name="assemblyId">The unique identifier of the plugin assembly.</param>
/// <returns>A list of entities representing plugin types, including their IDs and type names.</returns>
private List<Entity> PluginTypes(Guid assemblyId)
{
var query = new QueryExpression("plugintype")
Expand Down
27 changes: 24 additions & 3 deletions src/dvx/Services/StepImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("name") ?? step.Id.ToString();

Expand Down Expand Up @@ -125,7 +127,7 @@ private List<Entity> LoadSteps(IEnumerable<Guid> pluginTypeIds)
query.Criteria.AddCondition("plugintypeid", ConditionOperator.In, ids);
return svc.RetrieveMultiple(query).Entities.ToList();
}

private List<Entity> LoadImages(Guid stepId)
{
var query = new QueryExpression("sdkmessageprocessingstepimage")
Expand All @@ -149,6 +151,25 @@ private List<Entity> LoadImages(Guid stepId)
return impersonating.Id == Guid.Empty ? null : impersonating.Id;
}

/// <summary>
/// Extracts and returns a distinct list of SDK message filter IDs referenced
/// in the provided collection of entities.
/// </summary>
/// <param name="steps">
/// A collection of entities representing SDK message processing steps, from which
/// the SDK message filter IDs are extracted.
/// </param>
/// <returns>
/// 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.
/// </returns>
private static List<Guid> ReferencedFilterIds(IEnumerable<Entity> steps) =>
steps.Select(s => s.GetAttributeValue<EntityReference>("sdkmessagefilterid")?.Id)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
.ToList();

private static string[] SplitCsv(string? value)
=> string.IsNullOrWhiteSpace(value)
? Array.Empty<string>()
Expand Down
12 changes: 10 additions & 2 deletions src/dvx/Services/StepRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList<PluginStepDefinition> 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)
Expand Down Expand Up @@ -70,7 +70,7 @@ public SyncResult Sync(Guid assemblyId, IReadOnlyList<PluginStepDefinition> 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(
Expand Down Expand Up @@ -359,6 +359,14 @@ private bool ImagesUnchanged(Guid stepId, PluginStepDefinition def)

private static string? NullIfEmpty(string? s) => string.IsNullOrEmpty(s) ? null : s;

/// <summary>The distinct, non-global entity logical names targeted by the definitions.</summary>
private static List<string> ReferencedEntityNames(IReadOnlyList<PluginStepDefinition> 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)
Expand Down
2 changes: 1 addition & 1 deletion 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.6.2</Version>
<Version>1.6.3</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 Down
Loading