From 9fdae1ae38726b6c70fbf618552b3890484e8aaf Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Fri, 19 Jun 2026 09:13:25 +0200 Subject: [PATCH 1/3] fix(query): prefer OrderExpression.Alias over EntityName in CollectOrders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-level cross-entity ordering was broken when using the 3-arg OrderExpression constructor, which sets only Alias (EntityName stays null). The beta.11 partial fix read EntityName instead, so the sort key was never resolved and TopCount=1 returned by insertion order. CollectOrders now mirrors the precedence rule already used in CollectLinkOrders: Alias is checked first, EntityName is the fallback, null means root-entity attribute. Tests added (QueryBugsTests): - 3-arg constructor descending → TopCount=1 returns highest linked value - 3-arg constructor ascending → returns lowest linked value - 4-arg constructor (both Alias + EntityName set) → Alias wins - EntityName-only fallback (no Alias) → EntityName used as key prefix --- .../Logic/Queries/QueryProcessor.cs | 14 +- .../Logic/QueryBugsTests.cs | 190 ++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/src/Digitall.Dataverse.Testing/Logic/Queries/QueryProcessor.cs b/src/Digitall.Dataverse.Testing/Logic/Queries/QueryProcessor.cs index 66c6c3b..c5fce81 100644 --- a/src/Digitall.Dataverse.Testing/Logic/Queries/QueryProcessor.cs +++ b/src/Digitall.Dataverse.Testing/Logic/Queries/QueryProcessor.cs @@ -308,11 +308,15 @@ private static IQueryable OrderQuery(QueryExpression qe, IQueryable). - // In that case the attribute is stored on the result entity as an AliasedValue - // under the key "{EntityName}.{AttributeName}", so we pass EntityName as the alias. - var alias = string.IsNullOrWhiteSpace(order.EntityName) ? null : order.EntityName; + // A root-level OrderExpression may reference a linked entity via Alias (set by the + // 3-arg or 4-arg constructor) or EntityName (set by the 4-arg constructor as the + // entity logical name). Alias always takes precedence, matching the same rule used + // in CollectLinkOrders for link-entity orders. EntityName is only used as a fallback + // for the case where the link has no alias and is identified by its entity logical name. + // (FetchXML equivalent: ) + var alias = !string.IsNullOrWhiteSpace(order.Alias) ? order.Alias + : !string.IsNullOrWhiteSpace(order.EntityName) ? order.EntityName + : null; result.Add((order, alias)); } } diff --git a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs index 2f78e9b..bfccccc 100644 --- a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs +++ b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs @@ -194,4 +194,194 @@ public async Task CrossApply_FirstIsDeterministic_ReturnsSameResultsRegardlessOf await Assert.That(firstName1).IsEqualTo("Alice"); // Verified: the lower ID entity is returned } + /// + /// BUG #5 (partial fix in beta.11): Root-level cross-entity OrderExpression reads EntityName + /// instead of Alias. The 3-arg constructor sets only Alias; EntityName is null, so the sort + /// key was never resolved and ordering had no effect. + /// FIXED: CollectOrders now prefers Alias over EntityName (mirrors CollectLinkOrders). + /// + [Test] + public async Task RootLevelCrossEntityOrder_ThreeArgConstructorDescending_ReturnsHighestLinkedValue() + { + // Arrange – three accounts each linked to a contact with a distinct NumberOfChildren value + var accountLow = new Account(Guid.NewGuid()) { Name = "Low" }; + var accountMid = new Account(Guid.NewGuid()) { Name = "Mid" }; + var accountHigh = new Account(Guid.NewGuid()) { Name = "High" }; + + var contactLow = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 1, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountLow.Id) + }; + var contactMid = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 5, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountMid.Id) + }; + var contactHigh = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 10, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountHigh.Id) + }; + + var sut = new FakeOrganizationService(); + sut.AddRange([accountLow, accountMid, accountHigh, contactLow, contactMid, contactHigh]); + + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; + query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, + Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); + link.EntityAlias = "c"; + link.Columns = new ColumnSet(Contact.LogicalNames.NumberOfChildren); + + // 3-arg constructor: sets Alias="c", EntityName=null + query.Orders.Add(new OrderExpression(Contact.LogicalNames.NumberOfChildren, OrderType.Descending, "c")); + + // Act + var result = sut.RetrieveMultiple(query); + + // Assert – should return the account linked to the contact with the highest value (10) + await Assert.That(result.Entities).Count().IsEqualTo(1); + await Assert.That(result.Entities[0].GetAttributeValue(Account.LogicalNames.Name)) + .IsEqualTo("High"); + } + + [Test] + public async Task RootLevelCrossEntityOrder_ThreeArgConstructorAscending_ReturnsLowestLinkedValue() + { + // Arrange + var accountLow = new Account(Guid.NewGuid()) { Name = "Low" }; + var accountMid = new Account(Guid.NewGuid()) { Name = "Mid" }; + var accountHigh = new Account(Guid.NewGuid()) { Name = "High" }; + + var contactLow = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 1, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountLow.Id) + }; + var contactMid = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 5, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountMid.Id) + }; + var contactHigh = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 10, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountHigh.Id) + }; + + var sut = new FakeOrganizationService(); + sut.AddRange([accountLow, accountMid, accountHigh, contactLow, contactMid, contactHigh]); + + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; + query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, + Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); + link.EntityAlias = "c"; + link.Columns = new ColumnSet(Contact.LogicalNames.NumberOfChildren); + + // 3-arg ascending + query.Orders.Add(new OrderExpression(Contact.LogicalNames.NumberOfChildren, OrderType.Ascending, "c")); + + // Act + var result = sut.RetrieveMultiple(query); + + // Assert – should return the account linked to the contact with the lowest value (1) + await Assert.That(result.Entities).Count().IsEqualTo(1); + await Assert.That(result.Entities[0].GetAttributeValue(Account.LogicalNames.Name)) + .IsEqualTo("Low"); + } + + [Test] + public async Task RootLevelCrossEntityOrder_FourArgConstructor_UsesAliasNotEntityName() + { + // Arrange – verifies that when both Alias and EntityName are set (4-arg constructor), + // Alias is used as the key prefix, not EntityName. + var accountLow = new Account(Guid.NewGuid()) { Name = "Low" }; + var accountHigh = new Account(Guid.NewGuid()) { Name = "High" }; + + var contactLow = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 1, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountLow.Id) + }; + var contactHigh = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 10, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountHigh.Id) + }; + + var sut = new FakeOrganizationService(); + sut.AddRange([accountLow, accountHigh, contactLow, contactHigh]); + + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; + query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, + Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); + link.EntityAlias = "c"; + link.Columns = new ColumnSet(Contact.LogicalNames.NumberOfChildren); + + // 4-arg constructor: sets Alias="c", EntityName="contact" + // Attributes are stored as "c.numberofchildren" (alias prefix), NOT "contact.numberofchildren" + query.Orders.Add(new OrderExpression(Contact.LogicalNames.NumberOfChildren, OrderType.Descending, + "c", Contact.EntityLogicalName)); + + // Act + var result = sut.RetrieveMultiple(query); + + // Assert – Alias "c" was used → correctly found "c.numberofchildren" → returns highest + await Assert.That(result.Entities).Count().IsEqualTo(1); + await Assert.That(result.Entities[0].GetAttributeValue(Account.LogicalNames.Name)) + .IsEqualTo("High"); + } + + [Test] + public async Task RootLevelCrossEntityOrder_EntityNameOnlyNoAlias_UsesEntityNameAsFallbackPrefix() + { + // Arrange – link entity has an EntityAlias that equals the entity logical name ("contact"). + // The root-level OrderExpression carries only EntityName="contact" (no Alias set). + // CollectOrders must fall back to EntityName when Alias is empty/null so the key + // "contact.numberofchildren" is resolved correctly. + var accountLow = new Account(Guid.NewGuid()) { Name = "Low" }; + var accountHigh = new Account(Guid.NewGuid()) { Name = "High" }; + + var contactLow = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 1, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountLow.Id) + }; + var contactHigh = new Contact(Guid.NewGuid()) + { + NumberOfChildren = 10, + ParentCustomerId = new EntityReference(Account.EntityLogicalName, accountHigh.Id) + }; + + var sut = new FakeOrganizationService(); + sut.AddRange([accountLow, accountHigh, contactLow, contactHigh]); + + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; + query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, + Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); + // EntityAlias explicitly set to the entity logical name so the key prefix is "contact" + link.EntityAlias = Contact.EntityLogicalName; + link.Columns = new ColumnSet(Contact.LogicalNames.NumberOfChildren); + + // OrderExpression with EntityName only (Alias not set) → fallback to EntityName="contact" + query.Orders.Add(new OrderExpression + { + AttributeName = Contact.LogicalNames.NumberOfChildren, + OrderType = OrderType.Descending, + EntityName = Contact.EntityLogicalName + }); + + // Act + var result = sut.RetrieveMultiple(query); + + // Assert – EntityName "contact" used as key prefix → correctly resolved → returns highest + await Assert.That(result.Entities).Count().IsEqualTo(1); + await Assert.That(result.Entities[0].GetAttributeValue(Account.LogicalNames.Name)) + .IsEqualTo("High"); + } + } From 3c2734834abceaad39bb293c20afc439ba7be864 Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Fri, 19 Jun 2026 09:43:42 +0200 Subject: [PATCH 2/3] chore: resolve qodana issue --- .../Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs index bfccccc..33b5f06 100644 --- a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs +++ b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs @@ -358,9 +358,7 @@ public async Task RootLevelCrossEntityOrder_EntityNameOnlyNoAlias_UsesEntityName var sut = new FakeOrganizationService(); sut.AddRange([accountLow, accountHigh, contactLow, contactHigh]); - - var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; - query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1, ColumnSet = new ColumnSet(Account.LogicalNames.Name) }; var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); // EntityAlias explicitly set to the entity logical name so the key prefix is "contact" From be454ccd21ce5b7256010c82c510a3e2587851d1 Mon Sep 17 00:00:00 2001 From: Lars Bauer Date: Fri, 19 Jun 2026 09:49:12 +0200 Subject: [PATCH 3/3] chore: resolve remaining qodana issues --- .../Logic/QueryBugsTests.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs index 33b5f06..ab25ea6 100644 --- a/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs +++ b/tests/Digitall.Dataverse.Testing.Tests/Logic/QueryBugsTests.cs @@ -227,8 +227,7 @@ public async Task RootLevelCrossEntityOrder_ThreeArgConstructorDescending_Return var sut = new FakeOrganizationService(); sut.AddRange([accountLow, accountMid, accountHigh, contactLow, contactMid, contactHigh]); - var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; - query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1, ColumnSet = new ColumnSet(Account.LogicalNames.Name) }; var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); link.EntityAlias = "c"; @@ -273,8 +272,7 @@ public async Task RootLevelCrossEntityOrder_ThreeArgConstructorAscending_Returns var sut = new FakeOrganizationService(); sut.AddRange([accountLow, accountMid, accountHigh, contactLow, contactMid, contactHigh]); - var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; - query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1, ColumnSet = new ColumnSet(Account.LogicalNames.Name) }; var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); link.EntityAlias = "c"; @@ -314,8 +312,7 @@ public async Task RootLevelCrossEntityOrder_FourArgConstructor_UsesAliasNotEntit var sut = new FakeOrganizationService(); sut.AddRange([accountLow, accountHigh, contactLow, contactHigh]); - var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1 }; - query.ColumnSet = new ColumnSet(Account.LogicalNames.Name); + var query = new QueryExpression(Account.EntityLogicalName) { TopCount = 1, ColumnSet = new ColumnSet(Account.LogicalNames.Name) }; var link = query.AddLink(Contact.EntityLogicalName, Account.LogicalNames.AccountId, Contact.LogicalNames.ParentCustomerId, JoinOperator.Inner); link.EntityAlias = "c";