From 47129e9b85cfa2d9330daf407d238b08e4f68bfc Mon Sep 17 00:00:00 2001 From: Rob Wood Date: Sun, 29 Dec 2024 07:35:18 +0000 Subject: [PATCH] Feat: Set-DataverseRecord support for upserts using AK --- .../Commands/SetDataverseRecordCmdlet.cs | 84 +++++++++++++------ .../Rnwood.Dataverse.Data.PowerShell.csproj | 2 +- .../docs/Set-DataverseRecord.md | 4 +- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/Rnwood.Dataverse.Data.PowerShell.FrameworkSpecific/Commands/SetDataverseRecordCmdlet.cs b/Rnwood.Dataverse.Data.PowerShell.FrameworkSpecific/Commands/SetDataverseRecordCmdlet.cs index ceb9ece..6428887 100644 --- a/Rnwood.Dataverse.Data.PowerShell.FrameworkSpecific/Commands/SetDataverseRecordCmdlet.cs +++ b/Rnwood.Dataverse.Data.PowerShell.FrameworkSpecific/Commands/SetDataverseRecordCmdlet.cs @@ -41,7 +41,7 @@ public class SetDataverseRecordCmdlet : CustomLogicBypassableOrganizationService [Parameter(Mandatory = false, ValueFromPipelineByPropertyName = true, HelpMessage = "ID of record to be created or updated.")] public Guid Id { get; set; } - [Parameter(Mandatory = false, HelpMessage = "List of list of column names that identify an existing record to update based on the values of those columns in the InputObject. These are used if a record with and Id matching the value of the Id cannot be found. The first list that returns a match is used. e.g. (\"firstname\", \"lastname\"), \"fullname\" will try to find an existing record based on the firstname AND listname from the InputObject and if not found it will try by fullname. Not supported with -Upsert")] + [Parameter(Mandatory = false, HelpMessage = "List of list of column names that identify an existing record to update based on the values of those columns in the InputObject. For update/create these are used if a record with and Id matching the value of the Id cannot be found. The first list that returns a match is used. e.g. (\"firstname\", \"lastname\"), \"fullname\" will try to find an existing record based on the firstname AND listname from the InputObject and if not found it will try by fullname. For upsert only a single list is allowed and it must match the properties of an alternate key defined on the table.")] public string[][] MatchOn { get; set; } [Parameter(HelpMessage = "If specified, the InputObject is written to the pipeline with an Id property set indicating the primary key of the affected record (even if nothing was updated).")] @@ -110,7 +110,7 @@ private object FormatValue(object value) if (value is EntityReference er) { return $"{er.LogicalName}:{er.Id}"; - } + } if (value is Entity en) { @@ -432,13 +432,18 @@ private bool UpsertRecord(EntityMetadata entityMetadata, Entity target) { bool result = true; - if (NoCreate || NoUpdate || MatchOn != null) + if (NoCreate || NoUpdate) { - throw new ArgumentException("-NoCreate, -NoUpdate and -MatchOn are not supported with -Upsert"); + throw new ArgumentException("-NoCreate and -NoUpdate are not supported with -Upsert"); } if (entityMetadata.IsIntersect.GetValueOrDefault()) { + if (MatchOn != null) + { + throw new ArgumentException("-MatchOn is not supported for -Upsert of M:M"); + } + ManyToManyRelationshipMetadata manyToManyRelationshipMetadata = entityMetadata.ManyToManyRelationships[0]; EntityReference record1 = new EntityReference(manyToManyRelationshipMetadata.Entity1LogicalName, @@ -487,6 +492,28 @@ private bool UpsertRecord(EntityMetadata entityMetadata, Entity target) else { Entity targetUpdate = new Entity(target.LogicalName) { Id = target.Id }; + + if (MatchOn != null) + { + if (MatchOn.Length > 1) + { + throw new NotSupportedException("MatchOn must only have a single array when used with Upsert"); + } + + var key = entityMetadataFactory.GetMetadata(target.LogicalName).Keys.FirstOrDefault(k => k.KeyAttributes.Length == MatchOn[0].Length && k.KeyAttributes.All(a => MatchOn[0].Contains(a))); + if (key == null) + { + throw new ArgumentException($"MatchOn must match a key that is defined on the table"); + } + + targetUpdate.KeyAttributes = new KeyAttributeCollection(); + + foreach (var matchOnField in MatchOn[0]) + { + targetUpdate.KeyAttributes.Add(matchOnField, target.GetAttributeValue(matchOnField)); + } + } + targetUpdate.Attributes.AddRange(target.Attributes.Where(a => !dontUpdateDirectlyColumnNames.Contains(a.Key, StringComparer.OrdinalIgnoreCase))); string columnSummary = GetColumnSummary(targetUpdate); @@ -498,29 +525,29 @@ private bool UpsertRecord(EntityMetadata entityMetadata, Entity target) if (_nextBatchItems != null) { - if (target.Id == Guid.Empty) + if (targetUpdate.Id == Guid.Empty && targetUpdate.KeyAttributes.Count == 0) { targetUpdate.Id = Guid.NewGuid(); } var inputObject = InputObject; - WriteVerbose(string.Format("Added upsert of new record {0}:{1} to batch - columns:\n{2}", TableName, targetUpdate.Id, columnSummary)); - QueueBatchItem(new BatchItem(InputObject, request, (response) => { UpsertCompletion(targetUpdate, inputObject, columnSummary, (UpsertResponse)response); }), CallerId); + WriteVerbose(string.Format("Added upsert of new record {0}:{1} to batch - columns:\n{2}", TableName, GetKeySummary(targetUpdate), columnSummary)); + QueueBatchItem(new BatchItem(InputObject, request, (response) => { UpsertCompletion(targetUpdate, inputObject, (UpsertResponse)response); }), CallerId); } else { - if (ShouldProcess(string.Format("Upsert record {0}:{1} columns:\n{2}", TableName, targetUpdate.Id, columnSummary))) + if (ShouldProcess(string.Format("Upsert record {0}:{1} columns:\n{2}", TableName, GetKeySummary(targetUpdate), columnSummary))) { try { UpsertResponse response = (UpsertResponse)Connection.Execute(request); - UpsertCompletion(targetUpdate, InputObject, columnSummary, response); + UpsertCompletion(targetUpdate, InputObject, response); } catch (Exception e) { result = false; - WriteError(new ErrorRecord(new Exception(string.Format("Error creating record {0}:{1} {2}, columns: {3}", TableName, targetUpdate.Id, e.Message, columnSummary), e), null, ErrorCategory.InvalidResult, InputObject)); + WriteError(new ErrorRecord(new Exception(string.Format("Error creating record {0}:{1} {2}, columns: {3}", TableName, GetKeySummary(targetUpdate), e.Message, columnSummary), e), null, ErrorCategory.InvalidResult, InputObject)); } } } @@ -529,6 +556,21 @@ private bool UpsertRecord(EntityMetadata entityMetadata, Entity target) return result; } + private static string GetKeySummary(Entity record) + { + if (record.Id != Guid.Empty) + { + return record.Id.ToString(); + } + + if (record.KeyAttributes.Any()) + { + return string.Join(",", record.KeyAttributes.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + return ""; + } + private void AssociateUpsertGetIdCompletion(OrganizationResponse response, PSObject inputObject) { Guid id = ((RetrieveMultipleResponse)response).EntityCollection.Entities.Single().Id; @@ -542,24 +584,26 @@ private void AssociateUpsertGetIdCompletion(OrganizationResponse response, PSObj WriteObject(inputObject); } - private void UpsertCompletion(Entity targetUpdate, PSObject inputObject, string columnSummary, UpsertResponse response) + private void UpsertCompletion(Entity targetUpdate, PSObject inputObject, UpsertResponse response) { - Guid id = response.Target.Id; + targetUpdate.Id = response.Target.Id; if (inputObject.Properties.Any(p => p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase))) { inputObject.Properties.Remove("Id"); } - inputObject.Properties.Add(new PSNoteProperty("Id", id)); + inputObject.Properties.Add(new PSNoteProperty("Id", targetUpdate.Id)); + + string columnSummary = GetColumnSummary(targetUpdate); if (response.RecordCreated) { - WriteVerbose(string.Format("Upsert created new record {0}:{1} columns:\n{2}", TableName, targetUpdate.Id, columnSummary)); + WriteVerbose(string.Format("Upsert created new record {0}:{1} columns:\n{2}", TableName, GetKeySummary(targetUpdate), columnSummary)); } else { - WriteVerbose(string.Format("Upsert updated existing record {0}:{1} columns:\n{2}", TableName, targetUpdate.Id, columnSummary)); + WriteVerbose(string.Format("Upsert updated existing record {0}:{1} columns:\n{2}", TableName, GetKeySummary(targetUpdate), columnSummary)); } if (PassThru.IsPresent) @@ -898,16 +942,6 @@ private Entity GetExistingRecord(EntityMetadata entityMetadata, Entity target) QueryByAttribute matchOnQuery = new QueryByAttribute(TableName); matchOnQuery.TopCount = 2; - if (entityMetadata.Attributes.Any(a => string.Equals(a.LogicalName, "statecode", StringComparison.OrdinalIgnoreCase))) - { - matchOnQuery.AddAttributeValue("statecode", 0); - } - - if (entityMetadata.Attributes.Any(a => string.Equals(a.LogicalName, "isdisabled", StringComparison.OrdinalIgnoreCase))) - { - matchOnQuery.AddAttributeValue("isdisabled", false); - } - foreach (string matchOnColumn in matchOnColumnList) { object queryValue = target.GetAttributeValue(matchOnColumn); diff --git a/Rnwood.Dataverse.Data.PowerShell/Rnwood.Dataverse.Data.PowerShell.csproj b/Rnwood.Dataverse.Data.PowerShell/Rnwood.Dataverse.Data.PowerShell.csproj index 98047e3..12d78c9 100644 --- a/Rnwood.Dataverse.Data.PowerShell/Rnwood.Dataverse.Data.PowerShell.csproj +++ b/Rnwood.Dataverse.Data.PowerShell/Rnwood.Dataverse.Data.PowerShell.csproj @@ -43,7 +43,7 @@ - + diff --git a/Rnwood.Dataverse.Data.PowerShell/docs/Set-DataverseRecord.md b/Rnwood.Dataverse.Data.PowerShell/docs/Set-DataverseRecord.md index a57bfda..576ea75 100644 --- a/Rnwood.Dataverse.Data.PowerShell/docs/Set-DataverseRecord.md +++ b/Rnwood.Dataverse.Data.PowerShell/docs/Set-DataverseRecord.md @@ -163,11 +163,11 @@ Accept wildcard characters: False ### -MatchOn List of list of field names that identify an existing record to update based on the values of those fields in the InputObject. -These are used if a record with and Id matching the value of the Id cannot be found. +For create/update these are used if a record with and Id matching the value of the Id cannot be found. The first list that returns a match is used. e.g. ("firstname", "lastname"), "fullname" will try to find an existing record based on the firstname AND listname from the InputObject and if not found it will try by fullname. -Not supported with -Upsert +For upsert only a single list is allowed and it must match the properties of an alternate key defined on the table. ```yaml Type: String[][]