Skip to content
6 changes: 6 additions & 0 deletions .changeset/wet-bugs-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@graphql-tools/federation': patch
'@graphql-tools/delegate': patch
---

Correctly resolve circular @requires in different subgraphs
55 changes: 47 additions & 8 deletions packages/delegate/src/defaultMergedResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,14 @@ export function defaultMergedResolver(
if (
fieldNodesByType?.every((fieldNode) => {
const responseKey = fieldNode.alias?.value ?? fieldNode.name.value;
if (Object.prototype.hasOwnProperty.call(parent, responseKey)) {
return true;
}
return false;
return Object.prototype.hasOwnProperty.call(parent, responseKey);
})
) {
handleResult(parent, responseKey, context, info);
} else {
// not all fields are present, we need to resolve more leftovers
// this can occur when there are circular @requires, see requires-circular audit test
handleLeftOver(parent, context, info, leftOver);
}
return deferred.promise;
}
Expand Down Expand Up @@ -304,13 +305,18 @@ function handleFlattenedParent<TContext extends Record<string, any>>(
nestedTypeName
]?.resolvers.get(subschema);
if (resolver) {
const subschemaTypes =
stitchingInfo.mergedTypes[
nestedTypeName
]!.typeMaps.get(subschema)!;
const returnType = subschemaTypes[nestedTypeName];
const res = await resolver(
nestedParentItem,
context,
info,
subschema,
selectionSet,
info.parentType,
returnType, // returnType
info.parentType,
);
if (res) {
Expand Down Expand Up @@ -383,15 +389,48 @@ function handleDeferredResolverResult<TContext extends Record<string, any>>(
const deferredFields =
leftOver.missingFieldsParentDeferredMap.get(leftOverParent);
if (deferredFields) {
const stitchingInfo = info.schema.extensions?.[
'stitchingInfo'
] as StitchingInfo;
const parentTypeName = leftOverParent?.__typename || info.parentType.name;
const resolvedKeys = new Set<string>();
for (const [responseKey, deferred] of deferredFields) {
// If the deferred field is resolved, resolve the deferred field
if (Object.prototype.hasOwnProperty.call(resolverResult, responseKey)) {
// after handleResolverResult, check if the field is now in the parent
if (Object.prototype.hasOwnProperty.call(leftOverParent, responseKey)) {
// field was added to parent by handleResolverResult
deferred.resolve(
handleResult(leftOverParent, responseKey, context, info),
);
resolvedKeys.add(responseKey);
} else {
// check if the required fields for this deferred field are now satisfied
const fieldNodesByType =
stitchingInfo?.fieldNodesByField?.[parentTypeName]?.[responseKey];
if (fieldNodesByType) {
if (
fieldNodesByType.every((fieldNode) => {
const requiredKey =
fieldNode.alias?.value ?? fieldNode.name.value;
return Object.prototype.hasOwnProperty.call(
leftOverParent,
requiredKey,
);
})
) {
// requirements are satisfied, trigger handleLeftOver to fetch this field
handleLeftOver(leftOverParent, context, info, leftOver);
}
}
}
}
leftOver.missingFieldsParentDeferredMap.delete(leftOverParent);
// delete resolved deferred fields
for (const key of resolvedKeys) {
deferredFields.delete(key);
}
// and delete map if empty
if (deferredFields.size === 0) {
leftOver.missingFieldsParentDeferredMap.delete(leftOverParent);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/federation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"@apollo/subgraph": "^2.11.2",
"@types/lodash": "4.17.20",
"graphql": "^16.9.0",
"graphql-federation-gateway-audit": "the-guild-org/graphql-federation-gateway-audit#main",
"graphql-federation-gateway-audit": "graphql-hive/federation-gateway-audit#main",
"pkgroll": "2.17.0"
},
"sideEffects": false
Expand Down
13 changes: 12 additions & 1 deletion packages/federation/src/supergraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,10 +806,21 @@ export function getStitchingOptionsFromSupergraphSdl(
selection.kind === Kind.FIELD &&
selection.arguments?.length
) {
const argsHash = selection.arguments
.map(
(arg) =>
arg.name.value +
// TODO: slow? faster hash?
memoizedASTPrint(arg.value).replace(
/[^a-zA-Z0-9]/g,
'',
),
)
.join('');
Comment on lines +809 to +819
Copy link
Preview

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

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

The current hashing approach using memoizedASTPrint followed by regex replacement could be inefficient for large argument sets. Consider using a proper hash function like crypto.createHash or a lightweight hash library for better performance.

Copilot uses AI. Check for mistakes.

// @ts-expect-error it's ok we're mutating consciously
selection.alias = {
kind: Kind.NAME,
value: '_' + selection.name.value,
value: '_' + selection.name.value + '_' + argsHash,
};
}
if ('selectionSet' in selection && selection.selectionSet) {
Expand Down
111 changes: 33 additions & 78 deletions packages/federation/tests/federation-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,20 @@
GatewayRuntime,
useCustomFetch,
} from '@graphql-hive/gateway-runtime';
import { normalizedExecutor } from '@graphql-tools/executor';
import {
ExecutionResult,
filterSchema,
getDirective,
MapperKind,
mapSchema,
} from '@graphql-tools/utils';
import { assertSingleExecutionValue } from '@internal/testing';
import {
buildSchema,
getNamedType,
GraphQLSchema,
isEnumType,
lexicographicSortSchema,
parse,
printSchema,
validate,
} from 'graphql';
import { createRouter } from 'graphql-federation-gateway-audit';
import { beforeAll, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -154,83 +150,42 @@
);
});
tests.forEach((_, i) => {
describe(`test-query-${i}`, () => {
it('gives the correct result w/ core', async () => {
const test = tests[i];
if (!test) {
throw new Error(`Test ${i} not found`);
}
const document = parse(test.query, { noLocation: true });
const validationErrors = validate(stitchedSchema, document);
let result: ExecutionResult;
if (validationErrors.length > 0) {
result = {
errors: validationErrors,
};
} else {
const execRes = await normalizedExecutor({
schema: stitchedSchema,
document,
});
assertSingleExecutionValue(execRes);
result = execRes;
}
const received = {
data: result.data ?? null,
errors: !!result.errors?.length,
};

const expected = {
data: test.expected.data ?? null,
errors: test.expected.errors ?? false,
};

try {
expect(received).toEqual(expected);
} catch (e) {
result.errors?.forEach((err) => {
console.error(err);
});
throw e;
}
});
it('gives the correct result w/ gateway', async () => {
const test = tests[i];
if (!test) {
throw new Error(`Test ${i} not found`);
}
const response = await gatewayRuntime.fetch(
'http://localhost/graphql',
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
query: test.query,
}),
it(`test-query-${i}`, async () => {
const test = tests[i];
if (!test) {
throw new Error(`Test ${i} not found`);
}
const response = await gatewayRuntime.fetch(
'http://localhost/graphql',
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
);
const result: ExecutionResult = await response.json();
const received = {
data: result.data ?? null,
errors: !!result.errors?.length,
};
body: JSON.stringify({
query: test.query,
}),
},
);
const result: ExecutionResult = await response.json();
const received = {
data: result.data ?? null,
errors: !!result.errors?.length,
};

const expected = {
data: test.expected.data ?? null,
errors: test.expected.errors ?? false,
};
const expected = {
data: test.expected.data ?? null,
errors: test.expected.errors ?? false,
};

try {
expect(received).toEqual(expected);
} catch (e) {
result.errors?.forEach((err) => {
console.error(err);
});
throw e;
}
});
try {
expect(received).toEqual(expected);

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Bun

error: expect(received).toEqual(expected)

{ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, "errors": false, } - Expected - 2 + Received + 2 at <anonymous> (/home/runner/work/gateway/gateway/packages/federation/tests/federation-compatibility.test.ts:182:30)

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Bun

error: expect(received).toEqual(expected)

{ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, "errors": false, } - Expected - 2 + Received + 2 at <anonymous> (/home/runner/work/gateway/gateway/packages/federation/tests/federation-compatibility.test.ts:182:30)

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Bun

error: expect(received).toEqual(expected)

{ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, "errors": false, } - Expected - 2 + Received + 2 at <anonymous> (/home/runner/work/gateway/gateway/packages/federation/tests/federation-compatibility.test.ts:182:30)

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Node v22

packages/federation/tests/federation-compatibility.test.ts > Federation Compatibility > requires-with-argument-conflict > test-query-0

AssertionError: expected { data: { products: [ …(2) ] }, …(1) } to deeply equal { data: { products: [ …(2) ] }, …(1) } - Expected + Received @@ -2,18 +2,18 @@ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, ❯ packages/federation/tests/federation-compatibility.test.ts:182:30

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Node v24

packages/federation/tests/federation-compatibility.test.ts > Federation Compatibility > requires-with-argument-conflict > test-query-0

AssertionError: expected { data: { products: [ …(2) ] }, …(1) } to deeply equal { data: { products: [ …(2) ] }, …(1) } - Expected + Received @@ -2,18 +2,18 @@ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, ❯ packages/federation/tests/federation-compatibility.test.ts:182:30

Check failure on line 182 in packages/federation/tests/federation-compatibility.test.ts

View workflow job for this annotation

GitHub Actions / Unit / Node v20

packages/federation/tests/federation-compatibility.test.ts > Federation Compatibility > requires-with-argument-conflict > test-query-0

AssertionError: expected { data: { products: [ …(2) ] }, …(1) } to deeply equal { data: { products: [ …(2) ] }, …(1) } - Expected + Received @@ -2,18 +2,18 @@ "data": { "products": [ { "isExpensiveCategory": false, "name": "p-name-1", - "shippingEstimate": 110, + "shippingEstimate": 220, "shippingEstimateEUR": 220, "upc": "p1", }, { "isExpensiveCategory": true, "name": "p-name-2", - "shippingEstimate": 440, + "shippingEstimate": 880, "shippingEstimateEUR": 880, "upc": "p2", }, ], }, ❯ packages/federation/tests/federation-compatibility.test.ts:182:30
} catch (e) {
result.errors?.forEach((err) => {
console.error(err);
});
throw e;
}
});
});
});
Expand Down
Loading
Loading