Skip to content

Fix nested filter on self-referencing relationships#3038

Merged
Aniruddh25 merged 11 commits intomainfrom
copilot/fix-nested-filter-bug
Mar 13, 2026
Merged

Fix nested filter on self-referencing relationships#3038
Aniruddh25 merged 11 commits intomainfrom
copilot/fix-nested-filter-bug

Conversation

Copy link
Contributor

Copilot AI commented Jan 6, 2026

Why make this change?

When using GraphQL nested filters on self-referencing relationships (e.g., parent/child hierarchies like DimAccount.parent_account), the filter returns incorrect results. The root cause is that the existing HandleNestedFilterForSql method uses AddJoinPredicatesForRelatedEntity which looks up foreign key metadata by entity names only, failing to distinguish between different relationships on the same entity (e.g., parent_account vs child_accounts).

What is this change?

Modified HandleNestedFilterForSql in GraphQLFilterParsers.cs to use AddJoinPredicatesForRelationship instead of AddJoinPredicatesForRelatedEntity. The key improvements:

  1. Relationship-aware FK lookup: Creates an EntityRelationshipKey containing both the entity name and relationship name (filterField.Name)
  2. Proper self-join handling: The AddJoinPredicatesForRelationship method detects self-referencing relationships (source entity == target entity) and calls AddJoinPredicatesForSelfJoinedEntity which uses the relationship metadata to get the correct foreign key definition
  3. Safe type checking: Added validation before casting to BaseSqlQueryStructure to prevent runtime exceptions

Before:

existsQuery.AddJoinPredicatesForRelatedEntity(
    targetEntityName: queryStructure.EntityName,
    relatedSourceAlias: queryStructure.SourceAlias,
    subQuery: existsQuery);

After:

string relationshipName = filterField.Name;
EntityRelationshipKey fkLookupKey = new(queryStructure.EntityName, relationshipName);

if (queryStructure is not BaseSqlQueryStructure sqlQueryStructure)
{
    throw new DataApiBuilderException(
        message: "Expected SQL query structure for nested filter processing.",
        statusCode: HttpStatusCode.InternalServerError,
        subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}

sqlQueryStructure.AddJoinPredicatesForRelationship(
    fkLookupKey: fkLookupKey,
    targetEntityName: nestedFilterEntityName,
    subqueryTargetTableAlias: existsQuery.SourceAlias,
    subQuery: existsQuery);

How was this tested?

  • Integration Tests
  • Unit Tests

Added TestNestedFilterSelfReferencing integration test in MsSqlGQLFilterTests.cs that validates nested filters on the DimAccount table's parent_account self-referencing relationship.

Test data:

  • Account 1: ParentAccountKey = null
  • Account 2: ParentAccountKey = 1
  • Account 3: ParentAccountKey = 2
  • Account 4: ParentAccountKey = 2

Test query: Filters for accounts whose parent has AccountKey = 1
Expected result: Returns only account 2 (the direct child of account 1)

Sample Request(s)

GraphQL Query:

{
  dbo_DimAccounts(filter: { parent_account: { AccountKey: { eq: 1 }}}) {
    items {
      AccountKey
      ParentAccountKey
    }
  }
}

Expected Response:

{
  "data": {
    "dbo_DimAccounts": {
      "items": [
        {
          "AccountKey": 2,
          "ParentAccountKey": 1
        }
      ]
    }
  }
}

Generated SQL (WITHOUT fix - incorrect):

SELECT TOP 100 AccountKey, ParentAccountKey
FROM [dbo].[DimAccount] AS [table0]
WHERE EXISTS(
  SELECT 1
  FROM [dbo].[DimAccount] AS [table1]
  WHERE [table1].[AccountKey] = 1
  AND [table1].[ParentAccountKey] = [table0].[AccountKey]
)
ORDER BY [table0].[AccountKey] asc

Note: The incorrect join condition [table1].[ParentAccountKey] = [table0].[AccountKey] looks for accounts that have table0 as their parent, instead of checking if table0's parent matches table1.

Generated SQL (WITH fix - correct):

SELECT TOP 100 AccountKey, ParentAccountKey
FROM [dbo].[DimAccount] AS [table0]
WHERE EXISTS(
  SELECT 1
  FROM [dbo].[DimAccount] AS [table1]
  WHERE [table1].[AccountKey] = 1
  AND [table0].[ParentAccountKey] = [table1].[AccountKey]
)
ORDER BY [table0].[AccountKey] asc

Note: The correct join condition [table0].[ParentAccountKey] = [table1].[AccountKey] properly checks if table0's parent is table1.

Original prompt

This section details on the original issue you should resolve

<issue_title>[Bug]: Nested filter on Self-Referencing Relationships returns incorrect results</issue_title>
<issue_description>### What happened?

Problem

When using GraphQL nested filters on self-referencing relationships (e.g., parent/child hierarchy), the filter returns incorrect results.

This query gives incorrect results

query {
  books(filter: { category: { parent: { name: { contains: "Classic" } } } }) {
    items {
      id
      category {
        name
        parent {
          name
        }
      }
    }
  }
}

Expected Behavior

Returns book items with categories whose parent's name contains "Classic".

Proposed Solution

  1. In HandleNestedFilterForSql, use AddJoinPredicatesForRelationship instead of AddJoinPredicatesForRelatedEntity
  2. Create an EntityRelationshipKey using the relationship name (filter field name) to look up the correct FK definition
  3. Call the method on the parent query structure (not the EXISTS subquery) with the correct parameters:
    • fkLookupKey: {queryStructure.EntityName, filterField.Name}
    • targetEntityName: the nested filter entity name
    • subqueryTargetTableAlias: the EXISTS subquery's source alias

In BaseGraphQLFilterParsers.cs:

/// <summary>
/// For SQL, a nested filter represents an EXISTS clause with a join between
/// the parent entity being filtered and the related entity representing the
/// non-scalar filter input. This function:
/// 1. Defines the Exists Query structure
/// 2. Recursively parses any more(possibly nested) filters on the Exists sub query.
/// 3. Adds join predicates between the related entities to the Exists sub query.
/// 4. Adds the Exists subquery to the existing list of predicates.
/// </summary>
/// <param name="ctx">The middleware context</param>
/// <param name="filterField">The nested filter field.</param>
/// <param name="subfields">The subfields of the nested filter.</param>
/// <param name="predicates">The predicates parsed so far.</param>
/// <param name="queryStructure">The query structure of the entity being filtered.</param>
/// <exception cref="DataApiBuilderException">
/// throws if a relationship directive is not found on the nested filter input</exception>
private void HandleNestedFilterForSql(
    IMiddlewareContext ctx,
    InputField filterField,
    List<ObjectFieldNode> subfields,
    List<PredicateOperand> predicates,
    BaseQueryStructure queryStructure,
    ISqlMetadataProvider metadataProvider)
{
    string? targetGraphQLTypeNameForFilter = RelationshipDirectiveType.GetTarget(filterField);

    if (targetGraphQLTypeNameForFilter is null)
    {
        throw new DataApiBuilderException(
            message: "The GraphQL schema is missing the relationship directive on input field.",
            statusCode: HttpStatusCode.InternalServerError,
            subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
    }

    string nestedFilterEntityName = metadataProvider.GetEntityName(targetGraphQLTypeNameForFilter);

    // Validate that the field referenced in the nested input filter can be accessed.
    bool entityAccessPermitted = queryStructure.AuthorizationResolver.AreRoleAndOperationDefinedForEntity(
        entityIdentifier: nestedFilterEntityName,
        roleName: GetHttpContextFromMiddlewareContext(ctx).Request.Headers[CLIENT_ROLE_HEADER].ToString(),
        operation: EntityActionOperation.Read);

    if (!entityAccessPermitted)
    {
        throw new DataApiBuilderException(
            message: DataApiBuilderException.GRAPHQL_FILTER_ENTITY_AUTHZ_FAILURE,
            statusCode: HttpStatusCode.Forbidden,
            subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
    }

    List<Predicate> predicatesForExistsQuery = new();

    // Create an SqlExistsQueryStructure as the predicate operand of Exists predicate
    // This query structure has no order by, no limit and selects 1
    // its predicates are obtained from recursively parsing the nested filter
    // and an additional predicate to reflect the join between main query and this exists subquery.
    SqlExistsQueryStructure existsQuery = new(
        GetHttpContextFromMiddlewareContext(ctx),
        metadataProvider,
        queryStructure.AuthorizationResolver,
        this,
        predicatesForExistsQuery,
        nestedFilterEntityName,
        queryStructure.Counter);

    // Recursively parse and obtain the predicates for the Exists clause subquery
    Predicate existsQueryFilterPredicate = Parse(ctx,
            filterField,
            subfields,
            existsQuery);
    predicatesForExistsQuery.Push(existsQueryFilterPredicate);

    // Add JoinPredicates to the subquery query structure so a predicate connecting
    // the outer table is added to the where clause of subquery.
    // For self-referencing relationships (e.g., parent/child hierarchy),...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes Azure/data-api-builder#3028

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

Copilot AI and others added 2 commits January 6, 2026 00:15
…ion test

Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix nested filter on self-referencing relationships Fix nested filter on self-referencing relationships Jan 6, 2026
Copilot AI requested a review from Aniruddh25 January 6, 2026 00:25
@JerryNixon JerryNixon added the 2.0 label Feb 4, 2026
@Aniruddh25 Aniruddh25 marked this pull request as ready for review March 12, 2026 02:23
Copilot AI review requested due to automatic review settings March 12, 2026 02:23
@anushakolan
Copy link
Contributor

@copilot the PR description should follow the standard template on why his change is needed, what is this change doing, testing etc. Please rewrite it in the right format. It is easier to get context and review like that.

@anushakolan
Copy link
Contributor

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot the PR description should follow the standard template on why his change is needed, what is this change doing, testing etc. Please rewrite it in the right format. It is easier to get context ...

Updated the PR description to follow the standard template with sections for "Why make this change?", "What is this change?", "How was this tested?", and "Sample Request(s)".

@anushakolan
Copy link
Contributor

@copilot add the generated sql without the fix as well to the PR description

Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot add the generated sql without the fix as well to the PR description

Added the generated SQL without the fix to the PR description. The comparison now shows both the incorrect SQL (without fix) and correct SQL (with fix), highlighting the difference in join conditions: the incorrect version uses [table1].[ParentAccountKey] = [table0].[AccountKey] while the correct version uses [table0].[ParentAccountKey] = [table1].[AccountKey].

Copy link
Contributor

@anushakolan anushakolan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@anushakolan
Copy link
Contributor

@copilot link the associated task/issue to this PR.

Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot link the associated task/issue to this PR.

I don't have access to the issue number to link it. Could you provide the issue number (e.g., #XXXX) so I can add the proper reference using "Closes #XXXX" syntax in the PR description?

@anushakolan
Copy link
Contributor

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

Copy link
Contributor

@souvikghosh04 souvikghosh04 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving

@Aniruddh25 Aniruddh25 merged commit fb4b2af into main Mar 13, 2026
11 checks passed
@Aniruddh25 Aniruddh25 deleted the copilot/fix-nested-filter-bug branch March 13, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants