Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix a query planning bug where invalid subgraph queries are generated with reuseQueryFragments set true #2963

Merged
merged 5 commits into from
Apr 3, 2024
Merged
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
7 changes: 7 additions & 0 deletions .changeset/invalid-reused-fragment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@apollo/query-planner": patch
"@apollo/composition": patch
"@apollo/federation-internals": patch
---

Fix a query planning bug where invalid subgraph queries are generated with `reuseQueryFragments` set true. ([#2952](https://github.com/apollographql/federation/issues/2952))
67 changes: 66 additions & 1 deletion internals-js/src/__tests__/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3100,6 +3100,38 @@ describe('named fragment selection set restrictions at type', () => {
return frag.expandedSelectionSetAtType(type);
}

test('for fragment on object types', () => {
const schema = parseSchema(`
type Query {
t1: T1
}

type T1 {
x: Int
y: Int
}
`);

const operation = parseOperation(schema, `
{
t1 {
...FonT1
}
}

fragment FonT1 on T1 {
x
y
}
`);

const frag = operation.fragments?.get('FonT1')!;

const { selectionSet, validator } = expandAtType(frag, schema, 'T1');
expect(selectionSet.toString()).toBe('{ x y }');
expect(validator?.toString()).toBeUndefined();
});

test('for fragment on interfaces', () => {
const schema = parseSchema(`
type Query {
Expand Down Expand Up @@ -3159,17 +3191,32 @@ describe('named fragment selection set restrictions at type', () => {

let { selectionSet, validator } = expandAtType(frag, schema, 'I1');
expect(selectionSet.toString()).toBe('{ x ... on T1 { x } ... on T2 { x } ... on I2 { x } ... on I3 { x } }');
expect(validator?.toString()).toBeUndefined();
// Note: Due to `FieldsInSetCanMerge` rule, we can't use trimmed validators for
// fragments on non-object types.
expect(validator?.toString()).toMatchString(`
{
x: [
I1.x
T1.x
T2.x
I2.x
I3.x
]
}
`);

({ selectionSet, validator } = expandAtType(frag, schema, 'T1'));
expect(selectionSet.toString()).toBe('{ x }');
// Note: Fragments on non-object types always have the full validator and thus
// results in the same validator regardless of the type they are expanded at.
// Note: one could remark that having `T1.x` in the `validator` below is a tad weird: this is
// because in this case `normalized` removed that fragment and so when we do `normalized.minus(original)`,
// then it shows up. It a tad difficult to avoid this however and it's ok for what we do (`validator`
// is used to check for field conflict and save for efficiency, we could use the `original` instead).
expect(validator?.toString()).toMatchString(`
{
x: [
I1.x
T1.x
T2.x
I2.x
Expand Down Expand Up @@ -3239,8 +3286,16 @@ describe('named fragment selection set restrictions at type', () => {
// this happens due to the "lifting" of selection mentioned above, is a bit hard to avoid,
// and is essentially harmess (it may result in a bit more cpu cycles in some cases but
// that is likely negligible).
// Note: Due to `FieldsInSetCanMerge` rule, we can't use trimmed validators for
// fragments on non-object types.
expect(validator?.toString()).toMatchString(`
{
x: [
T1.x
]
z: [
T2.z
]
y: [
T1.y
]
Expand All @@ -3252,8 +3307,13 @@ describe('named fragment selection set restrictions at type', () => {

({ selectionSet, validator } = expandAtType(frag, schema, 'U2'));
expect(selectionSet.toString()).toBe('{ ... on T1 { x y } }');
// Note: Fragments on non-object types always have the full validator and thus
// results in the same validator regardless of the type they are expanded at.
expect(validator?.toString()).toMatchString(`
{
x: [
T1.x
]
z: [
T2.z
]
Expand All @@ -3268,11 +3328,16 @@ describe('named fragment selection set restrictions at type', () => {

({ selectionSet, validator } = expandAtType(frag, schema, 'U3'));
expect(selectionSet.toString()).toBe('{ ... on T2 { z w } }');
// Note: Fragments on non-object types always have the full validator and thus
// results in the same validator regardless of the type they are expanded at.
expect(validator?.toString()).toMatchString(`
{
x: [
T1.x
]
z: [
T2.z
]
y: [
T1.y
]
Expand Down
10 changes: 9 additions & 1 deletion internals-js/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,14 @@ export class NamedFragmentDefinition extends DirectiveTargetElement<NamedFragmen
const expandedSelectionSet = this.expandedSelectionSet();
const selectionSet = expandedSelectionSet.normalize({ parentType: type });

if (!isObjectType(this.typeCondition)) {
// When the type condition of the fragment is not an object type, the `FieldsInSetCanMerge` rule is more
// restrictive and any fields can create conflicts. Thus, we have to use the full validator in this case.
// (see https://github.com/graphql/graphql-spec/issues/1085 for details.)
const validator = FieldsConflictValidator.build(expandedSelectionSet);
return { selectionSet, validator };
}

// Note that `trimmed` is the difference of 2 selections that may not have been normalized on the same parent type,
// so in practice, it is possible that `trimmed` contains some of the selections that `selectionSet` contains, but
// that they have been simplified in `selectionSet` in such a way that the `minus` call does not see it. However,
Expand Down Expand Up @@ -2880,7 +2888,7 @@ class FieldsConflictValidator {
continue;
}

// We're basically checking [FieldInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()),
// We're basically checking [FieldsInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()),
// but from 2 set of fields (`thisFields` and `thatFields`) of the same response that we know individually
// merge already.
for (const [thisField, thisValidator] of thisFields.entries()) {
Expand Down
160 changes: 159 additions & 1 deletion query-planner-js/src/__tests__/buildPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@ import {
operationFromDocument,
ServiceDefinition,
Supergraph,
buildSubgraph,
} from '@apollo/federation-internals';
import gql from 'graphql-tag';
import {
FetchNode,
FlattenNode,
PlanNode,
SequenceNode,
SubscriptionNode,
serializeQueryPlan,
} from '../QueryPlan';
import { FieldNode, OperationDefinitionNode, parse } from 'graphql';
import {
FieldNode,
OperationDefinitionNode,
parse,
validate,
GraphQLError,
} from 'graphql';
import {
composeAndCreatePlanner,
composeAndCreatePlannerWithOptions,
Expand Down Expand Up @@ -5041,8 +5050,157 @@ describe('Named fragments preservation', () => {
}
`);
});

it('validates fragments on non-object types across spreads', () => {
const subgraph1 = {
name: 'subgraph1',
typeDefs: gql`
type Query {
theEntity: AnyEntity
}

union AnyEntity = EntityTypeA | EntityTypeB

type EntityTypeA @key(fields: "unifiedEntityId") {
unifiedEntityId: ID!
unifiedEntity: UnifiedEntity
}

type EntityTypeB @key(fields: "unifiedEntityId") {
unifiedEntityId: ID!
unifiedEntity: UnifiedEntity
}

interface UnifiedEntity {
id: ID!
}

type Generic implements UnifiedEntity @key(fields: "id") {
id: ID!
}

type Movie implements UnifiedEntity @key(fields: "id") {
id: ID!
}

type Show implements UnifiedEntity @key(fields: "id") {
id: ID!
}
`,
};

const subgraph2 = {
name: 'subgraph2',
typeDefs: gql`
interface Video {
videoId: Int!
taglineMessage(uiContext: String): String
}

interface UnifiedEntity {
id: ID!
}

type Generic implements UnifiedEntity @key(fields: "id") {
id: ID!
taglineMessage(uiContext: String): String
}

type Movie implements UnifiedEntity & Video @key(fields: "id") {
videoId: Int!
id: ID!
taglineMessage(uiContext: String): String
}

type Show implements UnifiedEntity & Video @key(fields: "id") {
videoId: Int!
id: ID!
taglineMessage(uiContext: String): String
}
`,
};

const [api, queryPlanner] = composeAndCreatePlannerWithOptions(
[subgraph1, subgraph2],
{ reuseQueryFragments: true },
);

const query = gql`
query Search {
theEntity {
... on EntityTypeA {
unifiedEntity {
... on Generic {
taglineMessage(uiContext: "Generic")
}
}
}
... on EntityTypeB {
unifiedEntity {
...VideoSummary
}
}
}
}

fragment VideoSummary on Video {
videoId # Note: This extra field selection is necessary, so this fragment is not ignored.
taglineMessage(uiContext: "Video")
}
`;
const operation = operationFromDocument(api, query, { validate: true });

const plan = queryPlanner.buildQueryPlan(operation);
const validationErrors = validateSubFetches(plan.node, subgraph2);
expect(validationErrors).toHaveLength(0);
});
});

/**
* For each fetch in a PlanNode validate the generated operation is actually spec valid against its subgraph schema
* @param plan
* @param subgraphs
*/
function validateSubFetches(
plan: PlanNode | SubscriptionNode | undefined,
subgraphDef: ServiceDefinition,
): {
errors: readonly GraphQLError[];
serviceName: string;
fetchNode: FetchNode;
}[] {
if (!plan) {
return [];
}
const fetches = findFetchNodes(subgraphDef.name, plan);
const results: {
errors: readonly GraphQLError[];
serviceName: string;
fetchNode: FetchNode;
}[] = [];
for (const fetch of fetches) {
const subgraphName: string = fetch.serviceName;
const operation: string = fetch.operation;
const subgraph = buildSubgraph(
subgraphName,
'http://subgraph',
subgraphDef.typeDefs,
);
const gql_errors = validate(
subgraph.schema.toGraphQLJSSchema(),
parse(operation),
);
if (gql_errors.length > 0) {
results.push({
errors: gql_errors,
serviceName: subgraphName,
fetchNode: fetch,
});
}
}
return results;
}

describe('Fragment autogeneration', () => {
const subgraph = {
name: 'Subgraph1',
Expand Down