From 04adb37fad71cd98345dd3ec74b09729c26ed7bb Mon Sep 17 00:00:00 2001 From: Marcel Kummerow Date: Tue, 26 May 2026 09:55:53 +0200 Subject: [PATCH 1/2] fix: support reverse-direction (ManyToOne) nested deep insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessOneToMany now handles both directions: - Parent is Referenced side → sets FK on children (existing behavior) - Parent is Referencing side → creates child first, then sets FK on parent This fixes nested deep inserts like Calendar → CalendarRule → InnerCalendar where the inner relationship goes from the Referencing to the Referenced side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DeepInsertProcessor.cs | 40 +++++++++---- .../CreateFakeDeepInsertTests.cs | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs b/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs index 6be3dbf..68e99ef 100644 --- a/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs +++ b/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs @@ -68,21 +68,39 @@ private static void ProcessOneToMany( EntityCollection children, FakeOrganizationService state) { - if (metadata.ReferencedEntity != parentLogicalName) + if (metadata.ReferencedEntity == parentLogicalName) { - ErrorFactory.ThrowFault( - ErrorCodes.InvalidArgument, - $"Relationship '{metadata.SchemaName}' references entity '{metadata.ReferencedEntity}' " + - $"but the parent entity is '{parentLogicalName}'. The relationship metadata does not match the parent."); - } + // Parent is the "one" side → set FK on each child pointing to parent + var parentRef = new EntityReference(parentLogicalName, parentId); - var parentRef = new EntityReference(parentLogicalName, parentId); + foreach (var child in children.Entities) + { + child[metadata.ReferencingAttribute] = parentRef; + state.Create(child); + } + } + else if (metadata.ReferencingEntity == parentLogicalName) + { + // Parent is the "many" side → create child first, then set FK on parent pointing to child + foreach (var child in children.Entities) + { + var childId = state.Create(child); + var childRef = new EntityReference(metadata.ReferencedEntity, childId); - foreach (var child in children.Entities) + var parentUpdate = new Entity(parentLogicalName, parentId) + { + [metadata.ReferencingAttribute] = childRef + }; + state.Update(parentUpdate); + } + } + else { - // Set the foreign key (lookup) on the child pointing to the parent - child[metadata.ReferencingAttribute] = parentRef; - state.Create(child); + ErrorFactory.ThrowFault( + ErrorCodes.InvalidArgument, + $"Relationship '{metadata.SchemaName}' is between '{metadata.ReferencedEntity}' and " + + $"'{metadata.ReferencingEntity}' but the parent entity is '{parentLogicalName}'. " + + "The relationship metadata does not match the parent."); } } diff --git a/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs b/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs index 23fd4bd..50a55e5 100644 --- a/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs +++ b/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs @@ -5,6 +5,7 @@ using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using Microsoft.Xrm.Sdk.Metadata; +using Microsoft.Xrm.Sdk.Query; namespace Digitall.Dataverse.Testing.Tests.OrganizationRequests; @@ -216,4 +217,59 @@ account.RelatedEntities[new Relationship("systemuserroles_association")] = await Assert.That(act).Throws(); } + + [Test] + public async Task Create_WithReversedOneToMany_SetsForeignKeyOnParent() + { + // Scenario: calendarrule (Referencing) has a lookup to calendar (Referenced) + // Deep insert from calendarrule side should create the calendar and set FK on calendarrule + _sut.State.Relationships["calendarrule_innercalendar"] = new OneToManyRelationshipMetadata + { + SchemaName = "calendarrule_innercalendar", + ReferencedEntity = "calendar", + ReferencedAttribute = "calendarid", + ReferencingEntity = "calendarrule", + ReferencingAttribute = "innercalendarid" + }; + + // Also register the parent relationship: calendar → calendarrule + _sut.State.Relationships["calendar_calendar_rules"] = new OneToManyRelationshipMetadata + { + SchemaName = "calendar_calendar_rules", + ReferencedEntity = "calendar", + ReferencedAttribute = "calendarid", + ReferencingEntity = "calendarrule", + ReferencingAttribute = "calendarid" + }; + + var calendar = new Entity("calendar") { Id = Guid.NewGuid(), ["name"] = "Business Hours" }; + var innerCalendar = new Entity("calendar") { ["name"] = "Inner Schedule" }; + var calendarRule = new Entity("calendarrule") { ["description"] = "Rule 1" }; + + // Nested deep insert: calendar → calendarrule → innercalendar (reverse direction) + calendarRule.RelatedEntities[new Relationship("calendarrule_innercalendar")] = + new EntityCollection([innerCalendar]); + + calendar.RelatedEntities[new Relationship("calendar_calendar_rules")] = + new EntityCollection([calendarRule]); + + _sut.Execute(new CreateRequest { Target = calendar }); + + // Verify all entities were created + var calendars = _sut.CreateQuery("calendar").ToList(); + var rules = _sut.CreateQuery("calendarrule").ToList(); + + await Assert.That(calendars).Count().IsEqualTo(2); // parent + inner + await Assert.That(rules).Count().IsEqualTo(1); + + // Verify the FK on calendarrule points to the inner calendar + var createdRule = rules.Single(); + var innerCalRef = createdRule.GetAttributeValue("innercalendarid"); + await Assert.That(innerCalRef).IsNotNull(); + await Assert.That(innerCalRef!.LogicalName).IsEqualTo("calendar"); + + // The inner calendar should be the one named "Inner Schedule" + var innerCal = _sut.Retrieve("calendar", innerCalRef.Id, new ColumnSet(true)); + await Assert.That(innerCal.GetAttributeValue("name")).IsEqualTo("Inner Schedule"); + } } From 442045e3ac9847e345ce194621fea1daec00e3ea Mon Sep 17 00:00:00 2001 From: Marcel Kummerow Date: Tue, 26 May 2026 10:31:44 +0200 Subject: [PATCH 2/2] fix: enforce single-child and entity type validation for N:1 deep insert A lookup attribute can only reference one record. The N:1 (reverse) branch now throws an InvalidArgument fault when multiple children are provided, preventing silent data loss where only the last child would be linked. Also validates that the child's logical name matches metadata.ReferencedEntity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DeepInsertProcessor.cs | 34 +++++++++---- .../CreateFakeDeepInsertTests.cs | 48 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs b/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs index 68e99ef..6f8b99c 100644 --- a/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs +++ b/src/Digitall.Dataverse.Testing/OrganizationRequests/DeepInsertProcessor.cs @@ -81,18 +81,34 @@ private static void ProcessOneToMany( } else if (metadata.ReferencingEntity == parentLogicalName) { - // Parent is the "many" side → create child first, then set FK on parent pointing to child - foreach (var child in children.Entities) + // Parent is the "many" side (N:1) → lookup can only point to a single record + if (children.Entities.Count > 1) { - var childId = state.Create(child); - var childRef = new EntityReference(metadata.ReferencedEntity, childId); + ErrorFactory.ThrowFault( + ErrorCodes.InvalidArgument, + $"Relationship '{metadata.SchemaName}' is used from the referencing (N:1) side. " + + "A lookup attribute can only reference a single entity, but multiple related entities were provided."); + } - var parentUpdate = new Entity(parentLogicalName, parentId) - { - [metadata.ReferencingAttribute] = childRef - }; - state.Update(parentUpdate); + var child = children.Entities[0]; + + if (child.LogicalName != metadata.ReferencedEntity) + { + ErrorFactory.ThrowFault( + ErrorCodes.InvalidArgument, + $"Relationship '{metadata.SchemaName}' expects referenced entity '{metadata.ReferencedEntity}' " + + $"but the related entity is '{child.LogicalName}'."); } + + // Create child first, then set FK on parent pointing to child + var childId = state.Create(child); + var childRef = new EntityReference(metadata.ReferencedEntity, childId); + + var parentUpdate = new Entity(parentLogicalName, parentId) + { + [metadata.ReferencingAttribute] = childRef + }; + state.Update(parentUpdate); } else { diff --git a/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs b/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs index 50a55e5..3534d16 100644 --- a/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs +++ b/tests/Digitall.Dataverse.Testing.Tests/OrganizationRequests/CreateFakeDeepInsertTests.cs @@ -272,4 +272,52 @@ calendar.RelatedEntities[new Relationship("calendar_calendar_rules")] = var innerCal = _sut.Retrieve("calendar", innerCalRef.Id, new ColumnSet(true)); await Assert.That(innerCal.GetAttributeValue("name")).IsEqualTo("Inner Schedule"); } + + [Test] + public async Task Create_WithReversedOneToMany_MultipleChildren_ThrowsFault() + { + // N:1 direction: a lookup can only point to one record + _sut.State.Relationships["calendarrule_innercalendar"] = new OneToManyRelationshipMetadata + { + SchemaName = "calendarrule_innercalendar", + ReferencedEntity = "calendar", + ReferencedAttribute = "calendarid", + ReferencingEntity = "calendarrule", + ReferencingAttribute = "innercalendarid" + }; + + var rule = new Entity("calendarrule") { Id = Guid.NewGuid(), ["description"] = "Rule" }; + var cal1 = new Entity("calendar") { ["name"] = "Cal 1" }; + var cal2 = new Entity("calendar") { ["name"] = "Cal 2" }; + + rule.RelatedEntities[new Relationship("calendarrule_innercalendar")] = + new EntityCollection([cal1, cal2]); // Two children on N:1 → invalid + + var act = () => _sut.Execute(new CreateRequest { Target = rule }); + + await Assert.That(act).Throws(); + } + + [Test] + public async Task Create_WithReversedOneToMany_WrongChildLogicalName_ThrowsFault() + { + _sut.State.Relationships["calendarrule_innercalendar"] = new OneToManyRelationshipMetadata + { + SchemaName = "calendarrule_innercalendar", + ReferencedEntity = "calendar", + ReferencedAttribute = "calendarid", + ReferencingEntity = "calendarrule", + ReferencingAttribute = "innercalendarid" + }; + + var rule = new Entity("calendarrule") { Id = Guid.NewGuid(), ["description"] = "Rule" }; + var wrongEntity = new Entity("account") { ["name"] = "Not a calendar" }; + + rule.RelatedEntities[new Relationship("calendarrule_innercalendar")] = + new EntityCollection([wrongEntity]); + + var act = () => _sut.Execute(new CreateRequest { Target = rule }); + + await Assert.That(act).Throws(); + } }