Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/Core/Resolvers/SqlQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,18 @@ public JsonElement ResolveObject(JsonElement element, ObjectField fieldSchema, r
parentMetadata = paginationObjectMetadata;
}

PaginationMetadata currentMetadata = parentMetadata.Subqueries[fieldSchema.Name];
metadata = currentMetadata;

if (currentMetadata.IsPaginated)
// In some scenarios (for example when RBAC removes a relationship
// or when multiple sibling nested entities are present), we may not
// have pagination metadata for the current field. In those cases we
// should simply return the element as-is instead of throwing.
if (parentMetadata.Subqueries.TryGetValue(fieldSchema.Name, out PaginationMetadata? currentMetadata))
{
return SqlPaginationUtil.CreatePaginationConnectionFromJsonElement(element, currentMetadata);
metadata = currentMetadata;
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we do something with this metadata variable?


if (currentMetadata.IsPaginated)
{
return SqlPaginationUtil.CreatePaginationConnectionFromJsonElement(element, currentMetadata);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -229,6 +231,240 @@ FOR JSON PATH
await InFilterOneToOneJoinQuery(msSqlQuery);
}

/// <summary>
/// Integration-style regression test for nested sibling relationships
/// under RBAC. This exercises the HotChocolate pipeline, SQL query
/// engine and pagination metadata together.
///
/// It uses the existing Book graph:
/// Book -> websiteplacement (one)
/// -> reviews (many)
/// -> authors (many)
/// and ensures that querying multiple nested navigation branches in a
/// single GraphQL request while authenticated does not result in a
/// KeyNotFoundException and that nested data is present.
/// </summary>
[TestMethod]
public async Task NestedSiblingRelationshipsWithRbac_DoNotThrowAndMaterialize()
{
string graphQLQueryName = "books";
string graphQLQuery = @"query {
books(first: 2) {
items {
id
title
websiteplacement {
price
}
reviews {
items {
id
content
}
}
authors {
items {
name
}
}
}
}
}";

JsonElement actual = await ExecuteGraphQLRequestAsync(
graphQLQuery,
graphQLQueryName,
isAuthenticated: true,
clientRoleHeader: "authenticated");

// 1) No KeyNotFoundException (historic bug signature)
if (actual.TryGetProperty("errors", out JsonElement errors))
{
string errorsText = errors.ToString();
Assert.IsFalse(
errorsText.Contains("KeyNotFoundException", StringComparison.OrdinalIgnoreCase) ||
errorsText.Contains("The given key", StringComparison.OrdinalIgnoreCase),
"GraphQL response should not contain KeyNotFoundException when resolving nested sibling relationships.");
}

// 2) Compare the materialized nested branches against an equivalent SQL JSON query
string dbQuery = @"
SELECT TOP 2 [table0].[id] AS [id]
,[table0].[title] AS [title]
,JSON_QUERY([website_subq].[data]) AS [websiteplacement]
,JSON_QUERY(COALESCE([reviews_subq].[data], '[]')) AS [reviews]
,JSON_QUERY(COALESCE([authors_subq].[data], '[]')) AS [authors]
FROM [dbo].[books] AS [table0]
OUTER APPLY (
SELECT TOP 1 [w].[price] AS [price]
FROM [dbo].[book_website_placements] AS [w]
WHERE [w].[book_id] = [table0].[id]
ORDER BY [w].[id] ASC
FOR JSON PATH
,INCLUDE_NULL_VALUES
,WITHOUT_ARRAY_WRAPPER
) AS [website_subq]([data])
OUTER APPLY (
SELECT [r].[id] AS [id], [r].[content] AS [content]
FROM [dbo].[reviews] AS [r]
WHERE [r].[book_id] = [table0].[id]
ORDER BY [r].[id] ASC
FOR JSON PATH
,INCLUDE_NULL_VALUES
) AS [reviews_subq]([data])
OUTER APPLY (
SELECT [a].[name] AS [name]
FROM [dbo].[book_author_link] AS [l]
JOIN [dbo].[authors] AS [a] ON [a].[id] = [l].[author_id]
WHERE [l].[book_id] = [table0].[id]
ORDER BY [a].[id] ASC
FOR JSON PATH
,INCLUDE_NULL_VALUES
) AS [authors_subq]([data])
WHERE [table0].[id] IN (1, 2)
ORDER BY [table0].[id] ASC
FOR JSON PATH
,INCLUDE_NULL_VALUES";

string expected = await GetDatabaseResultAsync(dbQuery);

// Normalize the GraphQL response to match the SQL JSON shape by
// unwrapping connection objects (reviews/authors.items).
JsonArray normalizedBooks = new();
foreach (JsonElement book in actual.GetProperty("items").EnumerateArray())
{
JsonObject normalizedBook = new()
{
["id"] = JsonNode.Parse(book.GetProperty("id").GetRawText()),
["title"] = JsonNode.Parse(book.GetProperty("title").GetRawText()),
["websiteplacement"] = JsonNode.Parse(book.GetProperty("websiteplacement").GetRawText()),
["reviews"] = JsonNode.Parse(book.GetProperty("reviews").GetProperty("items").GetRawText()),
["authors"] = JsonNode.Parse(book.GetProperty("authors").GetProperty("items").GetRawText())
};

normalizedBooks.Add(normalizedBook);
}

string normalizedActual = normalizedBooks.ToJsonString();

SqlTestHelper.PerformTestEqualJsonStrings(
expected,
normalizedActual);
}

/// <summary>
/// Verifies that the nested reviews connection under books correctly paginates
/// when there are more than 100 reviews for a single book. The first page
/// should contain 100 reviews and the endCursor should encode the id of the
/// last review on that page.
/// </summary>
[TestMethod]
public async Task NestedReviewsConnection_PaginatesMoreThanHundredItems()
{
// Seed > 100 reviews for book id 1. Use a distinct id range so we can
// clean up without impacting existing rows used by other tests.
StringBuilder sb = new();
sb.AppendLine("SET IDENTITY_INSERT reviews ON;");
sb.AppendLine("INSERT INTO reviews(id, book_id, content) VALUES");

for (int id = 2000; id <= 2100; id++)
{
string line = $" ({id}, 1, 'Bulk review {id}')";
if (id < 2100)
{
line += ",";
}
else
{
line += ";";
}

sb.AppendLine(line);
}

sb.AppendLine("SET IDENTITY_INSERT reviews OFF;");
string seedReviewsSql = sb.ToString();

string cleanupReviewsSql = "DELETE FROM reviews WHERE id BETWEEN 2000 AND 2100;";

try
{
// Seed additional data for this test only.
await _queryExecutor.ExecuteQueryAsync<object>(
seedReviewsSql,
dataSourceName: string.Empty,
parameters: null,
dataReaderHandler: null);

string graphQLQueryName = "books";
string graphQLQuery = @"query {
books(filter: { id: { eq: 1 } }) {
items {
id
reviews(first: 100) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you double check if we already have a test for pagination where the data is more than 100? My review comment was to club this scenario with the nested sibling usecase for which the bug has been filed. This test doesnt seem to combine the two.

items {
id
}
endCursor
hasNextPage
}
}
}
}";

JsonElement actual = await ExecuteGraphQLRequestAsync(
graphQLQuery,
graphQLQueryName,
isAuthenticated: true,
clientRoleHeader: "authenticated");

JsonElement book = actual.GetProperty("items")[0];
JsonElement reviewsConnection = book.GetProperty("reviews");
JsonElement reviewItems = reviewsConnection.GetProperty("items");

// First page should contain exactly 100 reviews when more than 100 exist.
Assert.AreEqual(100, reviewItems.GetArrayLength(), "Expected first page of reviews to contain 100 items.");

bool hasNextPage = reviewsConnection.GetProperty("hasNextPage").GetBoolean();
Assert.IsTrue(hasNextPage, "Expected hasNextPage to be true when more than 100 reviews exist.");

string endCursor = reviewsConnection.GetProperty("endCursor").GetString();
Assert.IsFalse(string.IsNullOrEmpty(endCursor), "Expected endCursor to be populated when hasNextPage is true.");

// Decode the opaque cursor and verify it encodes the id of the
// last review on the first page.
int lastReviewIdOnPage = reviewItems[reviewItems.GetArrayLength() - 1].GetProperty("id").GetInt32();

string decodedCursorJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(endCursor));
using JsonDocument cursorDoc = JsonDocument.Parse(decodedCursorJson);
JsonElement cursorArray = cursorDoc.RootElement;

bool foundIdFieldInCursor = false;
foreach (JsonElement field in cursorArray.EnumerateArray())
{
if (field.GetProperty("FieldName").GetString() == "id")
{
int cursorId = field.GetProperty("FieldValue").GetInt32();
Assert.AreEqual(lastReviewIdOnPage, cursorId, "endCursor should encode the id of the last review on the first page.");
foundIdFieldInCursor = true;
break;
}
}

Assert.IsTrue(foundIdFieldInCursor, "endCursor payload should include a pagination field for id.");
}
finally
{
// Clean up the seeded reviews so other tests relying on the base
// fixture data remain unaffected.
await _queryExecutor.ExecuteQueryAsync<object>(
cleanupReviewsSql,
dataSourceName: string.Empty,
parameters: null,
dataReaderHandler: null);
}
}

/// <summary>
/// Test query on One-To-One relationship when the fields defining
/// the relationship in the entity include fields that are mapped in
Expand Down