Skip to content

Commit e17173b

Browse files
fix(query-planner): Fix bug where subgraph jump key fields are fetched from the wrong group (#3293)
When a `@requires` edge needs a subgraph jump, this would sometimes cause the locally satisfiable key fields needed by that jump to be fetched from the parent group instead of the current group (which apparently was done as an optimization). This can be a problem, since `canSatisfyConditions()` only really guarantees the current group has those key fields. This PR updates the code using the parent group to now explicitly check whether the locally satisfiable key can be fetched from that group, and if not, falls back to the current group (adding it as a parent, if needed). This lets us keep the optimization and avoid unnecessarily changing query plans. Fixes #3113 and #2987 , and derives a simpler example test from the one given in #3246 .
1 parent 4653320 commit e17173b

File tree

3 files changed

+148
-2
lines changed

3 files changed

+148
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/query-planner": patch
3+
---
4+
5+
Fix bug in query planning where a subgraph jump for `@requires` can sometimes try to fetch `@key` fields from a subgraph that doesn't have them. This bug would previously cause query planning to error with a message that looks like "Cannot add selection of field `T.id` to selection set of parent type `T`".

query-planner-js/src/__tests__/buildPlan.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3162,6 +3162,126 @@ describe('@requires', () => {
31623162
}
31633163
`);
31643164
});
3165+
3166+
it('avoids selecting inapplicable @key from parent fetch group', () => {
3167+
// Previously, there was an issue where a subgraph jump inserted due to a
3168+
// @requires field would try (as an optimization) to collect its locally
3169+
// satisfiable key from the parent, but the parent may not have that locally
3170+
// satisfiable key. We now explicitly check for this, falling back to the
3171+
// current node if needed (which should be guaranteed to have it).
3172+
const subgraph1 = {
3173+
name: 'A',
3174+
typeDefs: gql`
3175+
type Query {
3176+
t: T
3177+
}
3178+
type T @key(fields: "id1") {
3179+
id1: ID!
3180+
}
3181+
`,
3182+
};
3183+
3184+
const subgraph2 = {
3185+
name: 'B',
3186+
typeDefs: gql`
3187+
type T @key(fields: "id2") @key(fields: "id1") {
3188+
id1: ID!
3189+
id2: ID!
3190+
x: Int @external
3191+
req: Int @requires(fields: "x")
3192+
}
3193+
`,
3194+
};
3195+
3196+
const subgraph3 = {
3197+
name: 'C',
3198+
typeDefs: gql`
3199+
type T @key(fields: "id2") {
3200+
id2: ID!
3201+
x: Int
3202+
}
3203+
`,
3204+
};
3205+
3206+
const [api, queryPlanner] = composeAndCreatePlanner(
3207+
subgraph1,
3208+
subgraph2,
3209+
subgraph3,
3210+
);
3211+
const operation = operationFromDocument(
3212+
api,
3213+
gql`
3214+
{
3215+
t {
3216+
req
3217+
}
3218+
}
3219+
`,
3220+
);
3221+
3222+
const plan = queryPlanner.buildQueryPlan(operation);
3223+
expect(plan).toMatchInlineSnapshot(`
3224+
QueryPlan {
3225+
Sequence {
3226+
Fetch(service: "A") {
3227+
{
3228+
t {
3229+
__typename
3230+
id1
3231+
}
3232+
}
3233+
},
3234+
Flatten(path: "t") {
3235+
Fetch(service: "B") {
3236+
{
3237+
... on T {
3238+
__typename
3239+
id1
3240+
}
3241+
} =>
3242+
{
3243+
... on T {
3244+
__typename
3245+
id2
3246+
}
3247+
}
3248+
},
3249+
},
3250+
Flatten(path: "t") {
3251+
Fetch(service: "C") {
3252+
{
3253+
... on T {
3254+
__typename
3255+
id2
3256+
}
3257+
} =>
3258+
{
3259+
... on T {
3260+
x
3261+
}
3262+
}
3263+
},
3264+
},
3265+
Flatten(path: "t") {
3266+
Fetch(service: "B") {
3267+
{
3268+
... on T {
3269+
__typename
3270+
x
3271+
id2
3272+
}
3273+
} =>
3274+
{
3275+
... on T {
3276+
req
3277+
}
3278+
}
3279+
},
3280+
},
3281+
},
3282+
}
3283+
`);
3284+
});
31653285
});
31663286

31673287
describe('fetch operation names', () => {

query-planner-js/src/buildPlan.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4814,13 +4814,34 @@ function createPostRequiresGroup(
48144814
// we need the path here, so this will have to do for now, and if this ever breaks in practice, we'll at least have an example to
48154815
// guide us toward improving/fixing.
48164816
assert(parent.path, `Missing path-in-parent for @requires on ${edge} with group ${group} and parent ${parent}`);
4817+
let requirePath = path.forParentOfGroup(parent.path, parent.group.parentType.schema());
4818+
let preRequireGroup = parent.group;
4819+
4820+
// The `postRequireGroup` needs a key. This can come from `group` (and code
4821+
// in `canSatisfyConditions()` guarantees such a locally satisfiable key
4822+
// exists in `group`), but it can also potentially come from `parent.group`,
4823+
// and previous code had (wrongfully) always assumed it could.
4824+
//
4825+
// To keep this previous optimization, we now explicitly check whether the
4826+
// known locally satisfiable key can be rebased in `parent.group`, and we
4827+
// fall back to `group` if it doesn't.
4828+
const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head);
4829+
assert(keyCondition, () => `Due to @requires, validation should have required a key to be present for ${edge}`);
4830+
if (!keyCondition.canRebaseOn(typeAtPath(preRequireGroup.selection.parentType, requirePath.inGroup()))) {
4831+
requirePath = path;
4832+
preRequireGroup = group;
4833+
// It's possible we didn't add `group` as a parent previously, so we do so
4834+
// here similarly to how `handleRequiresTree()` specifies it.
4835+
postRequireGroup.addParent({ group, path: [] });
4836+
}
4837+
48174838
addPostRequireInputs(
48184839
dependencyGraph,
4819-
path.forParentOfGroup(parent.path, parent.group.parentType.schema()),
4840+
requirePath,
48204841
entityType,
48214842
edge,
48224843
context,
4823-
parent.group,
4844+
preRequireGroup,
48244845
postRequireGroup,
48254846
);
48264847
return {

0 commit comments

Comments
 (0)