Skip to content

[Bug]: KeyNotFoundException when querying multiple nested relationships #3026

@magnh

Description

@magnh

Problem

When querying multiple nested relationships in GraphQL, a KeyNotFoundException is thrown. This seems to occur because metadata keys for sibling relationships collide.

The following queries will pass

query {
  books {
    items {
      id
      category {
        name
      }
      author {
        name
      }
    }
  }
}
query {
  books {
    items {
      id
      category {
        name
        parent {
          name
        }
      }
    }
  }
}
query {
  books {
    items {
      id
      author {
        name
        publisher {
          name
        }
      }
    }
  }
}

When combining multiple nested relationships, the following query fails

query {
  books {
    items {
      id
      category {
        name
        parent {
          name
        }
      }
      author {
        name
        publisher {
          name
        }
      }
    }
  }
}

Root Cause

In ExecutionHelper.cs, the SetNewMetadataChildren method creates metadata dictionary keys using only the GraphQL type name (e.g., "Category"). When two sibling relationships resolve to the same type, the second one overwrites the first in the dictionary.

Later, GetMetadataObjectField tries to look up metadata by type name but finds the wrong relationship's metadata, causing the mismatch.

Proposed Solution

  1. Create unique metadata keys by incorporating the relationship path from the root, not just the type name
  2. Add a helper method GetRelationshipPathSuffix that builds a unique suffix from the selection path (e.g., "category" or "author")
  3. Modify SetNewMetadataChildren to append this suffix to metadata keys
  4. Modify GetMetadataObjectField to use the same suffix logic when looking up metadata

In ExecutionHelper.cs:

        /// <summary>
        /// Get the pagination metadata object for the field represented by the
        /// pure resolver context.
        /// e.g. when Context.Path is "/books/items[0]/authors", this function gets
        /// the pagination metadata for authors, which is stored in the global middleware
        /// context under key: "books_PURE_RESOLVER_CTX::1", where "books" is the parent object
        /// and depth of "1" implicitly represents the path "/books/items". When "/books/items"
        /// is processed by the pure resolver, the available pagination metadata maps to the object
        /// type that enumerated in "items"
        /// </summary>
        /// <param name="context">Pure resolver context</param>
        /// <returns>Pagination metadata</returns>
        private static IMetadata GetMetadataObjectField(IResolverContext context)
        {
            // Depth Levels:  / 0   /  1  /   2    /   3
            // Example Path: /books/items/items[0]/publishers
            // Depth of 1 should have key in context.ContextData
            // Depth of 2 will not have context.ContextData entry because non-Indexed path element is the path that is cached.
            // PaginationMetadata for items will be consistent across each subitem. So we can use the same metadata for each subitem.
            // An indexer path segment is a segment that looks like -> items[n]
            if (context.Path.Parent is IndexerPathSegment)
            {
                // When context.Path is "/books/items[0]/authors"
                // Parent -> "/books/items[0]"
                // Parent -> "/books/items" -> Depth of this path is used to create the key to get
                // pagination metadata from context.ContextData
                // The PaginationMetadata fetched has subquery metadata for "authors" from path "/books/items/authors"
                string objectParentName = GetMetadataKey(context.Path) + "::" + context.Path.Parent.Parent.Depth();
                return (IMetadata)context.ContextData[objectParentName]!;
            }

            if (!context.Path.IsRootField() && ((NamePathSegment)context.Path.Parent).Name != PURE_RESOLVER_CONTEXT_SUFFIX)
            {
                // This check handles when the current selection is a relationship field because in that case,
                // there will be no context data entry.
                // e.g. metadata for index 4 will not exist. only 3.
                // Depth: /  0   / 1  /   2    /   3      /   4
                // Path:  /books/items/items[0]/publishers/books
                // 
                // To handle arbitrary nesting depths with sibling relationships, we need to include
                // the relationship field path in the key. For example:
                // - /entity/items[0]/rel1/nested uses key ::3::rel1
                // - /entity/items[0]/rel2/nested uses key ::3::rel2
                // - /entity/items[0]/rel1/nested/deeper uses key ::4::rel1
                // - /entity/items[0]/rel1/nested2/deeper uses key ::4::rel1::nested2
                string objectParentName = GetMetadataKey(context.Path.Parent) + "::" + context.Path.Parent.Depth();
                string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent);
                if (!string.IsNullOrEmpty(relationshipPath))
                {
                    objectParentName = objectParentName + "::" + relationshipPath;
                }

                return (IMetadata)context.ContextData[objectParentName]!;
            }

            string metadataKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth();
            return (IMetadata)context.ContextData[metadataKey]!;
        }

        /// <summary>
        /// Builds a suffix representing the relationship path from the IndexerPathSegment (items[n])
        /// up to (but not including) the current path segment. This is used to create unique metadata
        /// keys for sibling relationships at any nesting depth.
        /// </summary>
        /// <param name="path">The path to build the suffix for</param>
        /// <returns>
        /// A string like "rel1" for /entity/items[0]/rel1, 
        /// or "rel1::nested" for /entity/items[0]/rel1/nested,
        /// or empty string if no IndexerPathSegment is found in the path ancestry.
        /// </returns>
        private static string GetRelationshipPathSuffix(HotChocolate.Path path)
        {
            List<string> pathParts = new();
            HotChocolate.Path? current = path;

            // Walk up the path collecting relationship field names until we hit an IndexerPathSegment
            while (current is not null && !current.IsRoot)
            {
                if (current is IndexerPathSegment)
                {
                    // We've reached items[n], stop here
                    break;
                }

                if (current is NamePathSegment nameSegment)
                {
                    // Don't include "items" in the path suffix
                    if (nameSegment.Name != QueryBuilder.PAGINATION_FIELD_NAME)
                    {
                        pathParts.Add(nameSegment.Name);
                    }
                }

                current = current.Parent;
            }

            // If we didn't find an IndexerPathSegment, return empty (this handles root-level queries)
            if (current is not IndexerPathSegment)
            {
                return string.Empty;
            }

            // Reverse because we walked up the tree, but we want the path from root to leaf
            pathParts.Reverse();
            return string.Join("::", pathParts);
        }

        private static string GetMetadataKey(HotChocolate.Path path)
        {
            if (path.Parent.IsRoot)
            {
                // current: "/entity/items -> "items"
                return ((NamePathSegment)path).Name + PURE_RESOLVER_CONTEXT_SUFFIX;
            }

            // If execution reaches this point, the state of currentPath looks something
            // like the following where there exists a Parent path element:
            // "/entity/items -> current.Parent: "entity"
            return GetMetadataKey(path: path.Parent);
        }

        /// <summary>
        /// Resolves the name of the root object of a selection set to
        /// use as the beginning of a key used to index pagination metadata in the
        /// global HC middleware context.
        /// </summary>
        /// <param name="rootSelection">Root object field of query.</param>
        /// <returns>"rootObjectName_PURE_RESOLVER_CTX"</returns>
        private static string GetMetadataKey(ISelection rootSelection)
        {
            return rootSelection.ResponseName + PURE_RESOLVER_CONTEXT_SUFFIX;
        }

        /// <summary>
        /// Persist new metadata with a key denoting the depth of the current path.
        /// The pagination metadata persisted here correlates to the top-level object type
        /// denoted in the request.
        /// e.g. books_PURE_RESOLVER_CTX::0 for:
        /// context.Path -> /books depth(0)
        /// context.Selection -> books { items {id, title}}
        /// </summary>
        private static void SetNewMetadata(IResolverContext context, IMetadata? metadata)
        {
            string metadataKey = GetMetadataKey(context.Selection) + "::" + context.Path.Depth();
            context.ContextData.Add(metadataKey, metadata);
        }

        /// <summary>
        /// Stores the pagination metadata in the global context.ContextData accessible to
        /// all pure resolvers for query fields referencing nested entities.
        /// </summary>
        /// <param name="context">Pure resolver context</param>
        /// <param name="metadata">Pagination metadata</param>
        private static void SetNewMetadataChildren(IResolverContext context, IMetadata? metadata)
        {
            // When context.Path is /entity/items the metadata key is "entity"
            // The context key will use the depth of "items" so that the provided
            // pagination metadata (which holds the subquery metadata for "/entity/items/nestedEntity")
            // can be stored for future access when the "/entity/items/nestedEntity" pure resolver executes.
            // When context.Path takes the form: "/entity/items[index]/nestedEntity" HC counts the depth as
            // if the path took the form: "/entity/items/items[index]/nestedEntity" -> Depth of "nestedEntity"
            // is 3 because depth is 0-indexed.
            string contextKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth();

            // For relationship fields at any depth, include the relationship path suffix to distinguish
            // between sibling relationships. This handles arbitrary nesting depths.
            // e.g., "/entity/items[0]/rel1" gets key ::3::rel1
            // e.g., "/entity/items[0]/rel1/nested" gets key ::4::rel1::nested
            string relationshipPath = GetRelationshipPathSuffix(context.Path);
            if (!string.IsNullOrEmpty(relationshipPath))
            {
                contextKey = contextKey + "::" + relationshipPath;
            }

            // It's okay to overwrite the context when we are visiting a different item in items e.g. books/items/items[1]/publishers since
            // context for books/items/items[0]/publishers processing is done and that context isn't needed anymore.
            if (!context.ContextData.TryAdd(contextKey, metadata))
            {
                context.ContextData[contextKey] = metadata;
            }
        }
    }
}

This should ensure each relationship gets its own unique metadata entry regardless of the target type.

Version

Latest (main-branch)

What database are you using?

Azure SQL

What hosting model are you using?

Local (including CLI)

Which API approach are you accessing DAB through?

GraphQL

Relevant log output

System.Collections.Generic.KeyNotFoundException: The given key 'parent' was not present in the dictionary.

System.Collections.Generic.KeyNotFoundException: The given key 'publisher' was not present in the dictionary.

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingtriageissues to be triaged

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions