From 9dc131427ce2be4a78003d8e48d6796a873fe20a Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 8 Jul 2026 16:10:06 +0200 Subject: [PATCH 1/2] Reject RetrieveMultiple queries filtering on non-existent attributes Mirror Dataverse: a filter condition referencing an attribute that does not exist on the targeted entity now faults with an OrganizationServiceFault instead of being silently ignored. Validation runs after alias-filling so link-criteria conditions resolve against the correct entity's metadata, and the attribute lookup is case-sensitive. Co-Authored-By: Claude via Conducktor --- .../RetrieveMultipleRequestHandler.cs | 80 +++++++++++++++++ .../XrmMockup365Test/TestRetrieveMultiple.cs | 86 +++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs index 1cd703d7..c606f0a7 100644 --- a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs +++ b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs @@ -40,6 +40,11 @@ internal override OrganizationResponse Execute(OrganizationRequest orgRequest, E FillAliasIfEmpty(queryExpr); + // Reject up-front any filter that references an attribute that does not exist on the + // targeted entity, mirroring Dataverse (which faults instead of silently ignoring the + // condition). Run after FillAliasIfEmpty so link-criteria conditions carry their alias. + ValidateFilterAttributes(queryExpr); + db.PrefillDBWithOnlineData(queryExpr); // Create a snapshot for thread-safe enumeration during calculated field execution var rows = db.GetDBEntityRows(queryExpr.EntityName).ToList(); @@ -366,6 +371,81 @@ private static ISet GetRequestedAttributes(ColumnSet columnSet) return new HashSet(columnSet.Columns, StringComparer.OrdinalIgnoreCase); } + // Validates that every attribute referenced in the query's filters exists on the entity it + // targets. Conditions carry an EntityName (the primary entity or a link alias) that decides + // which entity's metadata the attribute is looked up against; the lookup is case-sensitive, + // matching Dataverse. + private void ValidateFilterAttributes(QueryExpression query) + { + // alias/logical-name -> metadata for the primary entity and every (nested) link entity. + var scopes = new Dictionary(StringComparer.Ordinal); + if (metadata.EntityMetadata.TryGetValue(query.EntityName, out var primaryMeta)) + { + scopes[query.EntityName] = primaryMeta; + } + foreach (var link in query.LinkEntities) + { + CollectLinkScopes(link, scopes); + } + + ValidateFilterAttributes(query.Criteria, query.EntityName, scopes); + foreach (var link in query.LinkEntities) + { + ValidateLinkCriteria(link, scopes); + } + } + + private void CollectLinkScopes(LinkEntity link, Dictionary scopes) + { + if (!string.IsNullOrEmpty(link.EntityAlias) && !scopes.ContainsKey(link.EntityAlias) && + metadata.EntityMetadata.TryGetValue(link.LinkToEntityName, out var meta)) + { + scopes[link.EntityAlias] = meta; + } + foreach (var nested in link.LinkEntities) + { + CollectLinkScopes(nested, scopes); + } + } + + private void ValidateLinkCriteria(LinkEntity link, Dictionary scopes) + { + ValidateFilterAttributes(link.LinkCriteria, link.EntityAlias, scopes); + foreach (var nested in link.LinkEntities) + { + ValidateLinkCriteria(nested, scopes); + } + } + + private void ValidateFilterAttributes(FilterExpression filter, string defaultScope, Dictionary scopes) + { + if (filter == null) return; + + foreach (var condition in filter.Conditions) + { + // A null/empty attribute name is a filter on the primary id, not a real column. + if (string.IsNullOrEmpty(condition.AttributeName)) continue; + + var scopeKey = string.IsNullOrEmpty(condition.EntityName) ? defaultScope : condition.EntityName; + // If the scope can't be resolved to a known entity we can't validate it; leave it be + // rather than risk rejecting a legitimate query. + if (scopeKey == null || !scopes.TryGetValue(scopeKey, out var meta)) continue; + + if (!Utility.IsValidAttribute(condition.AttributeName, meta)) + { + var entityName = string.IsNullOrEmpty(meta.SchemaName) ? meta.LogicalName : meta.SchemaName; + throw new FaultException( + new OrganizationServiceFault(), + $"'{entityName}' entity doesn't contain attribute with Name = '{condition.AttributeName}' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)"); + } + } + + foreach (var sub in filter.Filters) + { + ValidateFilterAttributes(sub, defaultScope, scopes); + } + } + private static void CollectFilterAttributes(FilterExpression filter, HashSet set) { if (filter == null) return; diff --git a/tests/XrmMockup365Test/TestRetrieveMultiple.cs b/tests/XrmMockup365Test/TestRetrieveMultiple.cs index 8c6fb548..c826f929 100644 --- a/tests/XrmMockup365Test/TestRetrieveMultiple.cs +++ b/tests/XrmMockup365Test/TestRetrieveMultiple.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.ServiceModel; using Xunit; namespace DG.XrmMockupTest @@ -1867,5 +1868,90 @@ public void TestRetrieveMultipleEntityReferenceNamePopulatedWithFetchXml() Assert.Equal(parentAccount.Id, retrievedAccount.ParentAccountId.Id); Assert.Equal("FetchXml Parent Account", retrievedAccount.ParentAccountId.Name); } + + [Fact] + public void TestRetrieveMultipleFilterOnInvalidAttributeFails() + { + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name") + }; + query.Criteria.AddCondition("nonexistentattribute", ConditionOperator.Equal, "whatever"); + + var ex = Assert.Throws>( + () => orgAdminService.RetrieveMultiple(query)); + Assert.Equal( + "'Account' entity doesn't contain attribute with Name = 'nonexistentattribute' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", + ex.Message); + } + + [Fact] + public void TestRetrieveMultipleFilterAttributeLookupIsCaseSensitive() + { + // "Name" (capitalised) is not the logical name; the lookup must be case-sensitive and fault. + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name") + }; + query.Criteria.AddCondition("Name", ConditionOperator.Equal, "account1"); + + var ex = Assert.Throws>( + () => orgAdminService.RetrieveMultiple(query)); + Assert.Equal( + "'Account' entity doesn't contain attribute with Name = 'Name' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", + ex.Message); + } + + [Fact] + public void TestRetrieveMultipleFilterOnValidAttributeSucceeds() + { + var account = new Account { Name = "ValidAttributeFilterAccount" }; + account.Id = orgAdminService.Create(account); + + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name") + }; + query.Criteria.AddCondition("name", ConditionOperator.Equal, "ValidAttributeFilterAccount"); + + var result = orgAdminService.RetrieveMultiple(query); + Assert.Single(result.Entities); + } + + [Fact] + public void TestRetrieveMultipleFetchXmlFilterOnInvalidAttributeFails() + { + var fetchXml = @" + + + + + + + "; + + var ex = Assert.Throws>( + () => orgAdminService.RetrieveMultiple(new FetchExpression(fetchXml))); + Assert.Equal( + "'Account' entity doesn't contain attribute with Name = 'nonexistentattribute' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", + ex.Message); + } + + [Fact] + public void TestRetrieveMultipleLinkEntityFilterOnInvalidAttributeFails() + { + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name") + }; + var link = query.AddLink(Contact.EntityLogicalName, "primarycontactid", "contactid"); + link.LinkCriteria.AddCondition("nonexistentattribute", ConditionOperator.Equal, "x"); + + var ex = Assert.Throws>( + () => orgAdminService.RetrieveMultiple(query)); + Assert.Equal( + "'Contact' entity doesn't contain attribute with Name = 'nonexistentattribute' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", + ex.Message); + } } } \ No newline at end of file From fffbf24f81b0fbbc02a8efafe8c041899bd634f0 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 8 Jul 2026 16:27:25 +0200 Subject: [PATCH 2/2] Validate link-criteria attributes even when the link alias is empty Address Copilot review feedback: FillAliasIfEmpty only assigns an alias when it is null, so a link with an explicit empty-string EntityAlias kept it and its criteria were skipped by the new attribute validation. Fall back to the linked entity's logical name as the scope key so those filters are still validated. Co-Authored-By: Claude via Conducktor --- .../RetrieveMultipleRequestHandler.cs | 15 +++++++++++--- .../XrmMockup365Test/TestRetrieveMultiple.cs | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs index c606f0a7..6c72ef6d 100644 --- a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs +++ b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs @@ -397,10 +397,11 @@ private void ValidateFilterAttributes(QueryExpression query) private void CollectLinkScopes(LinkEntity link, Dictionary scopes) { - if (!string.IsNullOrEmpty(link.EntityAlias) && !scopes.ContainsKey(link.EntityAlias) && + var scopeKey = GetLinkScopeKey(link); + if (!scopes.ContainsKey(scopeKey) && metadata.EntityMetadata.TryGetValue(link.LinkToEntityName, out var meta)) { - scopes[link.EntityAlias] = meta; + scopes[scopeKey] = meta; } foreach (var nested in link.LinkEntities) { @@ -410,13 +411,21 @@ private void CollectLinkScopes(LinkEntity link, Dictionary scopes) { - ValidateFilterAttributes(link.LinkCriteria, link.EntityAlias, scopes); + ValidateFilterAttributes(link.LinkCriteria, GetLinkScopeKey(link), scopes); foreach (var nested in link.LinkEntities) { ValidateLinkCriteria(nested, scopes); } } + // FillAliasIfEmpty only assigns an alias when one is null, so a link whose EntityAlias is an + // explicit empty string keeps it. Fall back to the linked entity's logical name in that case + // so the link's criteria are still validated against the correct metadata. + private static string GetLinkScopeKey(LinkEntity link) + { + return string.IsNullOrEmpty(link.EntityAlias) ? link.LinkToEntityName : link.EntityAlias; + } + private void ValidateFilterAttributes(FilterExpression filter, string defaultScope, Dictionary scopes) { if (filter == null) return; diff --git a/tests/XrmMockup365Test/TestRetrieveMultiple.cs b/tests/XrmMockup365Test/TestRetrieveMultiple.cs index c826f929..35935300 100644 --- a/tests/XrmMockup365Test/TestRetrieveMultiple.cs +++ b/tests/XrmMockup365Test/TestRetrieveMultiple.cs @@ -1953,5 +1953,25 @@ public void TestRetrieveMultipleLinkEntityFilterOnInvalidAttributeFails() "'Contact' entity doesn't contain attribute with Name = 'nonexistentattribute' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", ex.Message); } + + [Fact] + public void TestRetrieveMultipleLinkEntityWithEmptyAliasFilterOnInvalidAttributeFails() + { + // An explicit empty alias is not replaced by alias-filling; the link criteria must still + // be validated against the linked entity's metadata. + var query = new QueryExpression(Account.EntityLogicalName) + { + ColumnSet = new ColumnSet("name") + }; + var link = query.AddLink(Contact.EntityLogicalName, "primarycontactid", "contactid"); + link.EntityAlias = ""; + link.LinkCriteria.AddCondition("nonexistentattribute", ConditionOperator.Equal, "x"); + + var ex = Assert.Throws>( + () => orgAdminService.RetrieveMultiple(query)); + Assert.Equal( + "'Contact' entity doesn't contain attribute with Name = 'nonexistentattribute' and NameMapping = 'Logical' (look up attribute by name is case-sensitive)", + ex.Message); + } } } \ No newline at end of file