From 9f334515c40e049aea79254edcd910c30539cf85 Mon Sep 17 00:00:00 2001 From: Imran Akram Date: Fri, 9 Oct 2026 11:58:06 +0200 Subject: [PATCH] Import N:N relationships from a table to itself In a self-referencing N:N relationship the intersect columns are named idone and
idtwo. The intersect import worked out each side's table by removing a trailing "id", so it asked for a table called
idone and every record failed. TableOfIntersectColumn now removes idone and idtwo as well. Xrm.Utils.Core's Associate already sets PrimaryEntityRole when both sides are the same table, so with the right names the association goes through. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + XTB/ShuffleDeployer.nuspec | 1 + XTB/ShuffleRunner.nuspec | 1 + shared/Xrm.Shuffle.Core/ShuffleDataImport.cs | 19 +++- .../Layer2/IntersectImportTests.cs | 96 +++++++++++++++++++ .../Xrm.Shuffle.Core.Tests.csproj | 1 + 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/Xrm.Shuffle.Core.Tests/Layer2/IntersectImportTests.cs diff --git a/README.md b/README.md index 58c8325..8209dc9 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ creates each record and updates it if it already exists. - Multi-select choice columns (`OptionSetValueCollection`) export and import correctly - Floating point (`Double`) columns can be imported from every format, not only Full - Records with date columns are skipped as `(Identical)` when nothing changed. A date read from a file and the same date read from the target used to compare as different, so these records were always updated, and a `Match` on a date column created a duplicate +- An N:N relationship from a table to itself (intersect columns `
idone` and `
idtwo`) imports. Every record used to fail, because the table name was taken as `
idone` ### Solutions - A zip without `solution.xml` fails with a clear `FileNotFoundException` diff --git a/XTB/ShuffleDeployer.nuspec b/XTB/ShuffleDeployer.nuspec index 007856d..628e590 100644 --- a/XTB/ShuffleDeployer.nuspec +++ b/XTB/ShuffleDeployer.nuspec @@ -31,6 +31,7 @@ Import - Multi-select choice columns (OptionSetValueCollection) export and import correctly. - Floating point (Double) columns can be imported from every format, not only Full. - Records with date columns are now skipped as identical when nothing changed, and a Match on a date column finds the existing record instead of creating a duplicate. +- An N:N relationship from a table to itself imports; every record used to fail. Logs - Values instead of SDK type names, no more "[START of ToString]" noise, and correctly nested sections, also after an error. diff --git a/XTB/ShuffleRunner.nuspec b/XTB/ShuffleRunner.nuspec index 891a9ae..71b04b9 100644 --- a/XTB/ShuffleRunner.nuspec +++ b/XTB/ShuffleRunner.nuspec @@ -31,6 +31,7 @@ Import - Multi-select choice columns (OptionSetValueCollection) export and import correctly. - Floating point (Double) columns can be imported from every format, not only Full. - Records with date columns are now skipped as identical when nothing changed, and a Match on a date column finds the existing record instead of creating a duplicate. +- An N:N relationship from a table to itself imports; every record used to fail. Runner - The import no longer waits for the window to repaint after every record, which made large blocks several times slower. diff --git a/shared/Xrm.Shuffle.Core/ShuffleDataImport.cs b/shared/Xrm.Shuffle.Core/ShuffleDataImport.cs index 2c914ea..f457f0d 100644 --- a/shared/Xrm.Shuffle.Core/ShuffleDataImport.cs +++ b/shared/Xrm.Shuffle.Core/ShuffleDataImport.cs @@ -786,12 +786,27 @@ private EntityReference GetAttributeEntityReference(KeyValuePair { id = guidmap[id]; } - var logicname = x.Key.EndsWith("id") ? x.Key.Substring(0, x.Key.Length - 2) : x.Key; - return new EntityReference(logicname, id); + return new EntityReference(TableOfIntersectColumn(x.Key), id); } return null; } + /// + /// The table an intersect column points at: <table>id, or <table>idone and + /// <table>idtwo for an N:N relationship from a table to itself. + /// + private static string TableOfIntersectColumn(string column) + { + foreach (var suffix in new[] { "idone", "idtwo", "id" }) + { + if (column.Length > suffix.Length && column.EndsWith(suffix, StringComparison.Ordinal)) + { + return column.Substring(0, column.Length - suffix.Length); + } + } + return column; + } + /// /// Determines whether the record holds a reference to a record that is still waiting in the /// create batch. Such a reference cannot be remapped by yet, because diff --git a/tests/Xrm.Shuffle.Core.Tests/Layer2/IntersectImportTests.cs b/tests/Xrm.Shuffle.Core.Tests/Layer2/IntersectImportTests.cs new file mode 100644 index 0000000..436149f --- /dev/null +++ b/tests/Xrm.Shuffle.Core.Tests/Layer2/IntersectImportTests.cs @@ -0,0 +1,96 @@ +namespace Cinteros.Crm.Utils.Shuffle.Tests.Layer2 +{ + using System; + using System.IO; + using System.Linq; + using System.Xml.Serialization; + using Cinteros.Crm.Utils.Shuffle.Tests.Helpers; + using FakeXrmEasy; + using Microsoft.Xrm.Sdk; + using Microsoft.Xrm.Sdk.Messages; + using NUnit.Framework; + + /// + /// An intersect block associates the two records each row names. The table on each side is + /// worked out from the intersect column - and for a relationship from a table to itself those + /// columns are <table>idone and <table>idtwo, not <table>id. + /// + [TestFixture] + public class IntersectImportTests : FakeOrgTestBase + { + private static Types.DataBlock IntersectBlock(string intersect, string column1, string column2) + { + var xml = + "" + + "" + + "" + + "" + + ""; + using (var reader = new StringReader(xml)) + { + var definition = (Types.ShuffleDefinition)new XmlSerializer(typeof(Types.ShuffleDefinition)).Deserialize(reader); + return (Types.DataBlock)definition.Blocks.Items[0]; + } + } + + /// One intersect row as the data file holds it: the two ids as bare Guids. + private static EntityCollection Rows(string intersect, string column1, string column2, params Tuple[] pairs) + { + var rows = new EntityCollection { EntityName = intersect }; + foreach (var pair in pairs) + { + var row = new Entity(intersect); + row[column1] = pair.Item1; + row[column2] = pair.Item2; + rows.Entities.Add(row); + } + return rows; + } + + private AssociateRequest[] Associations() => + Service.Requests.OfType().ToArray(); + + [Test] + public void A_relationship_from_a_table_to_itself_associates_the_two_records() + { + Online().WithEntity(Record("new_category", Id(1), "Gold"), Record("new_category", Id(2), "Silver"), Record("new_category", Id(3), "Bronze")); + Org.Faked.AddRelationship("new_category_incompatible", new XrmFakedRelationship( + "new_category_incompatible", "new_categoryidone", "new_categoryidtwo", "new_category", "new_category")); + + var outcome = NewShuffler().TestImportDataBlock( + IntersectBlock("new_category_incompatible", "new_categoryidone", "new_categoryidtwo"), + Rows("new_category_incompatible", "new_categoryidone", "new_categoryidtwo", + Tuple.Create(Id(1), Id(2)), Tuple.Create(Id(1), Id(3)))); + + Assert.That(outcome.Failed, Is.EqualTo(0), DumpAll()); + Assert.That(outcome.Created, Is.EqualTo(2), DumpAll()); + var associations = Associations(); + Assert.That(associations.Select(a => a.Target.LogicalName), Is.All.EqualTo("new_category"), DumpAll()); + Assert.That(associations.SelectMany(a => a.RelatedEntities).Select(r => r.LogicalName), Is.All.EqualTo("new_category"), DumpAll()); + Assert.That(associations.Select(a => a.Relationship.PrimaryEntityRole), Is.All.EqualTo(Microsoft.Xrm.Sdk.EntityRole.Referencing), + "Dataverse rejects an associate on a reflexive relationship that does not say which side the target is on"); + Assert.That(associations.Select(a => a.Target.Id), Is.EqualTo(new[] { Id(1), Id(1) })); + Assert.That(associations.Select(a => a.RelatedEntities.Single().Id), Is.EqualTo(new[] { Id(2), Id(3) })); + } + + /// The usual case, two different tables with <table>id columns, is unchanged. + [Test] + public void A_relationship_between_two_tables_associates_them_without_a_role() + { + Online().WithEntity(Record("account", Id(1), "Contoso"), Record("contact", Id(2), "Pat")); + Org.Faked.AddRelationship("new_account_contact", new XrmFakedRelationship( + "new_account_contact", "accountid", "contactid", "account", "contact")); + + var outcome = NewShuffler().TestImportDataBlock( + IntersectBlock("new_account_contact", "accountid", "contactid"), + Rows("new_account_contact", "accountid", "contactid", Tuple.Create(Id(1), Id(2)))); + + Assert.That(outcome.Failed, Is.EqualTo(0), DumpAll()); + Assert.That(outcome.Created, Is.EqualTo(1), DumpAll()); + var association = Associations().Single(); + Assert.That(association.Target.LogicalName, Is.EqualTo("account")); + Assert.That(association.RelatedEntities.Single().LogicalName, Is.EqualTo("contact")); + Assert.That(association.Relationship.PrimaryEntityRole, Is.Null); + } + } +} diff --git a/tests/Xrm.Shuffle.Core.Tests/Xrm.Shuffle.Core.Tests.csproj b/tests/Xrm.Shuffle.Core.Tests/Xrm.Shuffle.Core.Tests.csproj index 81dd3c6..3464d6a 100644 --- a/tests/Xrm.Shuffle.Core.Tests/Xrm.Shuffle.Core.Tests.csproj +++ b/tests/Xrm.Shuffle.Core.Tests/Xrm.Shuffle.Core.Tests.csproj @@ -72,6 +72,7 @@ +