diff --git a/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs b/src/XrmMockup365/Requests/RetrieveMultipleRequestHandler.cs index 1cd703d7..6c72ef6d 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,90 @@ 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) + { + var scopeKey = GetLinkScopeKey(link); + if (!scopes.ContainsKey(scopeKey) && + metadata.EntityMetadata.TryGetValue(link.LinkToEntityName, out var meta)) + { + scopes[scopeKey] = meta; + } + foreach (var nested in link.LinkEntities) + { + CollectLinkScopes(nested, scopes); + } + } + + private void ValidateLinkCriteria(LinkEntity link, Dictionary 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; + + 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..35935300 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,110 @@ 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); + } + + [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