diff --git a/.changesets/fix_sachin_fix_shareable_mutation_fields_in_qp.md b/.changesets/fix_sachin_fix_shareable_mutation_fields_in_qp.md new file mode 100644 index 0000000000..06433f5943 --- /dev/null +++ b/.changesets/fix_sachin_fix_shareable_mutation_fields_in_qp.md @@ -0,0 +1,5 @@ +### Error when query planning a satisfiable @shareable mutation field ([PR #8352](https://github.com/apollographql/router/pull/8352)) + +When query planning a mutation operation that executes a `@shareable` mutation field at top-level, query planning may unexpectedly error due to attempting to generate a plan where that mutation field is called more than once across multiple subgraphs. Query planning has now been updated to avoid generating such plans. + +By [@sachindshinde](https://github.com/sachindshinde) in https://github.com/apollographql/router/pull/8352 \ No newline at end of file diff --git a/apollo-federation/src/query_graph/graph_path.rs b/apollo-federation/src/query_graph/graph_path.rs index 6dd3e4ee48..9c80d408f5 100644 --- a/apollo-federation/src/query_graph/graph_path.rs +++ b/apollo-federation/src/query_graph/graph_path.rs @@ -1607,11 +1607,14 @@ where && matches!( edge_weight.transition, QueryGraphEdgeTransition::RootTypeResolution { .. } + | QueryGraphEdgeTransition::KeyResolution ) && !(to_advance.defer_on_tail.is_some() && self.graph.is_self_key_or_root_edge(edge)?) { - debug!(r#"Ignored: edge is a top-level "RootTypeResolution""#); + debug!( + r#"Ignored: edge is a top-level "RootTypeResolution" or "KeyResolution""# + ); continue; } @@ -1801,10 +1804,17 @@ where "Encountered non-root path with a subgraph-entering transition", )); }; - self.graph - .root_kinds_to_nodes_by_source(&edge_tail_weight.source)? - .get(root_kind) - .copied() + // Since mutation options need to originate from the same subgraph, we + // pretend we cannot find a root node in another subgraph (effectively + // skipping the optimization). + if *root_kind == SchemaRootDefinitionKind::Mutation { + None + } else { + self.graph + .root_kinds_to_nodes_by_source(&edge_tail_weight.source)? + .get(root_kind) + .copied() + } } else { Some(last_subgraph_entering_edge_head) }; diff --git a/apollo-federation/src/query_graph/graph_path/operation.rs b/apollo-federation/src/query_graph/graph_path/operation.rs index 32084c2a6c..e3707a06e1 100644 --- a/apollo-federation/src/query_graph/graph_path/operation.rs +++ b/apollo-federation/src/query_graph/graph_path/operation.rs @@ -2289,6 +2289,7 @@ pub(crate) fn create_initial_options( excluded_edges: ExcludedDestinations, excluded_conditions: ExcludedConditions, override_conditions: &OverrideConditions, + initial_subgraph_constraint: Option>, disabled_subgraphs: &IndexSet>, ) -> Result, FederationError> { let initial_paths = SimultaneousPaths::from(initial_path); @@ -2310,8 +2311,18 @@ pub(crate) fn create_initial_options( .paths .iter() .cloned() - .map(SimultaneousPaths::from) - .collect(); + .map(|path| { + let Some(initial_subgraph_constraint) = &initial_subgraph_constraint else { + return Ok::<_, FederationError>(Some(path)); + }; + let tail_weight = path.graph.node_weight(path.tail)?; + Ok(if &tail_weight.source == initial_subgraph_constraint { + Some(path) + } else { + None + }) + }) + .process_results(|iter| iter.flatten().map(SimultaneousPaths::from).collect())?; Ok(lazy_initial_path.create_lazy_options(options, initial_context)) } else { Ok(vec![lazy_initial_path]) diff --git a/apollo-federation/src/query_plan/query_planner.rs b/apollo-federation/src/query_plan/query_planner.rs index c27421784a..b6e3f1dea1 100644 --- a/apollo-federation/src/query_plan/query_planner.rs +++ b/apollo-federation/src/query_plan/query_planner.rs @@ -9,6 +9,7 @@ use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::validation::Valid; use itertools::Itertools; +use petgraph::visit::EdgeRef; use serde::Deserialize; use serde::Serialize; use tracing::trace; @@ -576,7 +577,7 @@ impl QueryPlanner { } } -fn compute_root_serial_dependency_graph( +fn compute_root_serial_dependency_graph_for_mutation( parameters: &QueryPlanningParameters, has_defers: bool, non_local_selection_state: &mut Option, @@ -606,7 +607,7 @@ fn compute_root_serial_dependency_graph( mut fetch_dependency_graph, path_tree: mut prev_path, .. - } = compute_root_parallel_best_plan( + } = compute_root_parallel_best_plan_for_mutation( parameters, selection_set, has_defers, @@ -618,7 +619,7 @@ fn compute_root_serial_dependency_graph( fetch_dependency_graph: new_dep_graph, path_tree: new_path, .. - } = compute_root_parallel_best_plan( + } = compute_root_parallel_best_plan_for_mutation( parameters, selection_set, has_defers, @@ -777,6 +778,7 @@ fn compute_root_parallel_best_plan( parameters.operation.root_kind, FetchDependencyGraphToCostProcessor, non_local_selection_state.as_mut(), + None, )?; // Getting no plan means the query is essentially unsatisfiable (it's a valid query, but we can prove it will never return a result), @@ -786,6 +788,43 @@ fn compute_root_parallel_best_plan( .unwrap_or_else(|| BestQueryPlanInfo::empty(parameters))) } +fn compute_root_parallel_best_plan_for_mutation( + parameters: &QueryPlanningParameters, + selection: SelectionSet, + has_defers: bool, + non_local_selection_state: &mut Option, +) -> Result { + parameters.federated_query_graph.out_edges(parameters.head).into_iter().map(|edge_ref| { + let mutation_subgraph = parameters.federated_query_graph.node_weight(edge_ref.target())?.source.clone(); + let planning_traversal = QueryPlanningTraversal::new( + parameters, + selection.clone(), + has_defers, + parameters.operation.root_kind, + FetchDependencyGraphToCostProcessor, + non_local_selection_state.as_mut(), + Some(mutation_subgraph), + )?; + planning_traversal.find_best_plan() + }).process_results(|iter| iter + .flatten() + .min_by(|a, b| a.cost.total_cmp(&b.cost)) + .map(Ok) + .unwrap_or_else(|| { + if parameters.disabled_subgraphs.is_empty() { + Err(FederationError::internal(format!( + "Was not able to plan {} starting from a single subgraph: This shouldn't have happened.", + parameters.operation, + ))) + } else { + // If subgraphs were disabled, this could be expected, and we indicate this in + // the error accordingly. + Err(SingleFederationError::NoPlanFoundWithDisabledSubgraphs.into()) + } + }) + )? +} + fn compute_plan_internal( parameters: &mut QueryPlanningParameters, processor: &mut FetchDependencyGraphToQueryPlanProcessor, @@ -797,7 +836,7 @@ fn compute_plan_internal( let (main, deferred, primary_selection, cost) = if root_kind == SchemaRootDefinitionKind::Mutation { - let dependency_graphs = compute_root_serial_dependency_graph( + let dependency_graphs = compute_root_serial_dependency_graph_for_mutation( parameters, has_defers, non_local_selection_state, diff --git a/apollo-federation/src/query_plan/query_planning_traversal.rs b/apollo-federation/src/query_plan/query_planning_traversal.rs index 18623ed00b..13c12add9a 100644 --- a/apollo-federation/src/query_plan/query_planning_traversal.rs +++ b/apollo-federation/src/query_plan/query_planning_traversal.rs @@ -222,6 +222,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { root_kind: SchemaRootDefinitionKind, cost_processor: FetchDependencyGraphToCostProcessor, non_local_selection_state: Option<&mut non_local_selections_estimation::State>, + initial_subgraph_constraint: Option>, ) -> Result { Self::new_inner( parameters, @@ -231,6 +232,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { root_kind, cost_processor, non_local_selection_state, + initial_subgraph_constraint, Default::default(), Default::default(), Default::default(), @@ -251,6 +253,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { root_kind: SchemaRootDefinitionKind, cost_processor: FetchDependencyGraphToCostProcessor, non_local_selection_state: Option<&mut non_local_selections_estimation::State>, + initial_subgraph_constraint: Option>, initial_context: OpGraphPathContext, excluded_destinations: ExcludedDestinations, excluded_conditions: ExcludedConditions, @@ -304,14 +307,17 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { excluded_destinations, excluded_conditions, ¶meters.override_conditions, + initial_subgraph_constraint.clone(), ¶meters.disabled_subgraphs, )?; traversal.open_branches = map_options_to_selections(selection_set, initial_options); if let Some(non_local_selection_state) = non_local_selection_state - && traversal - .check_non_local_selections_limit_exceeded_at_root(non_local_selection_state)? + && traversal.check_non_local_selections_limit_exceeded_at_root( + non_local_selection_state, + initial_subgraph_constraint.is_some(), + )? { return Err(SingleFederationError::QueryPlanComplexityExceeded { message: format!( @@ -519,8 +525,9 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { // If we have no options, it means there is no way to build a plan for that branch, and // that means the whole query planning process will generate no plan. This should never // happen for a top-level query planning (unless the supergraph has *not* been - // validated), but can happen when computing sub-plans for a key condition. - return if self.is_top_level { + // validated), but can happen when computing sub-plans for a key condition and when + // computing a top-level plan for a mutation field on a specific subgraph. + return if self.is_top_level && self.root_kind != SchemaRootDefinitionKind::Mutation { if self.parameters.disabled_subgraphs.is_empty() { Err(FederationError::internal(format!( "Was not able to find any options for {selection}: This shouldn't have happened.", @@ -1161,6 +1168,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { self.root_kind, self.cost_processor, None, + None, context.clone(), excluded_destinations.clone(), excluded_conditions.add_item(edge_conditions), diff --git a/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs b/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs index 82593fa878..2f8fae4c0c 100644 --- a/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs +++ b/apollo-federation/src/query_plan/query_planning_traversal/non_local_selections_estimation.rs @@ -8,6 +8,7 @@ use petgraph::visit::EdgeRef; use petgraph::visit::IntoNodeReferences; use crate::bail; +use crate::ensure; use crate::error::FederationError; use crate::operation::Selection; use crate::operation::SelectionSet; @@ -25,9 +26,13 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { /// This calls `check_non_local_selections_limit_exceeded()` for each of the selections in the /// open branches stack; see that function's doc comment for more information. + /// + /// To support mutations, we allow indicating the initial subgraph is constrained, in which case + /// indirect options will be ignored until the first field (similar to query planning). pub(super) fn check_non_local_selections_limit_exceeded_at_root( &self, state: &mut State, + is_initial_subgraph_constrained: bool, ) -> Result { for branch in &self.open_branches { let tail_nodes = branch @@ -36,7 +41,10 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { .iter() .flat_map(|option| option.paths.0.iter().map(|path| path.tail())) .collect::>(); - let tail_nodes_info = self.estimate_nodes_with_indirect_options(tail_nodes)?; + let tail_nodes_info = self.estimate_nodes_with_indirect_options( + tail_nodes, + is_initial_subgraph_constrained, + )?; // Note that top-level selections aren't avoided via fully-local selection set // optimization, so we always add them here. @@ -51,16 +59,21 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { for selection in &branch.selections { if let Some(selection_set) = selection.selection_set() { let selection_has_defer = selection.element().has_defer(); + let is_initial_subgraph_constrained_after_element = + is_initial_subgraph_constrained + && matches!(selection, Selection::InlineFragment(_)); let next_nodes = self.estimate_next_nodes_for_selection( &selection.element(), &tail_nodes_info, state, + is_initial_subgraph_constrained_after_element, )?; if self.check_non_local_selections_limit_exceeded( selection_set, &next_nodes, selection_has_defer, state, + is_initial_subgraph_constrained_after_element, )? { return Ok(true); } @@ -89,12 +102,16 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { /// /// Note that this function takes in whether the parent selection of the selection set has /// @defer, as that affects whether the optimization is disabled for that selection set. + /// + /// To support mutations, we allow indicating the initial subgraph is constrained, in which case + /// indirect options will be ignored until the first field (similar to query planning). fn check_non_local_selections_limit_exceeded( &self, selection_set: &SelectionSet, parent_nodes: &NextNodesInfo, parent_selection_has_defer: bool, state: &mut State, + is_initial_subgraph_constrained: bool, ) -> Result { // Compute whether the selection set is non-local, and if so, add its selections to the // count. Any of the following causes the selection set to be non-local. @@ -128,16 +145,20 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { let old_count = state.count; if let Some(selection_set) = selection.selection_set() { + let is_initial_subgraph_constrained_after_element = is_initial_subgraph_constrained + && matches!(selection, Selection::InlineFragment(_)); let next_nodes = self.estimate_next_nodes_for_selection( &selection.element(), parent_nodes, state, + is_initial_subgraph_constrained_after_element, )?; if self.check_non_local_selections_limit_exceeded( selection_set, &next_nodes, selection_has_defer, state, + is_initial_subgraph_constrained_after_element, )? { return Ok(true); } @@ -240,12 +261,48 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { /// In `check_non_local_selections_limit_exceeded()`, when handling a given selection for a set /// of parent nodes (including indirect options), this function can be used to estimate an /// upper bound on the next nodes after taking the selection (also with indirect options). + /// + /// To support mutations, we allow indicating the initial subgraph will be constrained after + /// taking the element, in which case indirect options will be ignored (and caching will be + /// skipped). This is to ensure that top-level mutation fields are not executed on a different + /// subgraph than the initial one during query planning. fn estimate_next_nodes_for_selection( &self, element: &OpPathElement, parent_nodes: &NextNodesInfo, state: &mut State, + is_initial_subgraph_constrained_after_element: bool, ) -> Result { + if is_initial_subgraph_constrained_after_element { + if let OpPathElement::InlineFragment(inline_fragment) = element + && inline_fragment.type_condition_position.is_none() + { + return Ok(parent_nodes.clone()); + } + + // When the initial subgraph is constrained, skip caching entirely. Note that caching + // is not skipped when the initial subgraph is constrained before this element but not + // after. Because of that, there may be cache entries for remaining nodes that were + // actually part of a complete digraph, but this is only a slight caching inefficiency + // and doesn't affect the computation's result. + ensure!( + parent_nodes + .next_nodes_with_indirect_options + .types + .is_empty(), + "Initial subgraph was constrained which indicates no indirect options should be \ + taken, but the parent nodes unexpectedly had a complete digraph which indicates \ + indirect options were taken upstream in the path." + ); + return self.estimate_next_nodes_for_selection_without_caching( + element, + parent_nodes + .next_nodes_with_indirect_options + .remaining_nodes + .iter(), + true, + ); + } let cache = state .next_nodes_cache .entry(match element { @@ -277,6 +334,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { let new_next_nodes = self.estimate_next_nodes_for_selection_without_caching( element, indirect_options.same_type_options.iter(), + false, )?; next_nodes.extend(entry.insert(new_next_nodes)); } @@ -292,6 +350,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { let new_next_nodes = self.estimate_next_nodes_for_selection_without_caching( element, std::iter::once(node), + false, )?; next_nodes.extend(entry.insert(new_next_nodes)); } @@ -302,18 +361,23 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { /// Estimate an upper bound on the next nodes after taking the selection on the given parent /// nodes. Because we're just trying for an upper bound, we assume we can always take - /// type-preserving non-collecting transitions, we ignore any conditions on the selection - /// edge, and we always type-explode. (We do account for override conditions, which are - /// relatively straightforward.) + /// type-preserving non-collecting transitions, we ignore any conditions on the selection edge, + /// and we always type-explode. (We do account for override conditions, which are relatively + /// straightforward.) + /// + /// Since we're iterating through next nodes in the process, for efficiency's sake we also + /// compute whether there are any reachable cross-subgraph edges from the next nodes (without + /// indirect options). This method assumes that inline fragments have type conditions. /// - /// Since we're iterating through next nodes in the process, for efficiency sake we also - /// compute whether there are any reachable cross-subgraph edges from the next nodes - /// (without indirect options). This method assumes that inline fragments have type - /// conditions. + /// To support mutations, we allow indicating the initial subgraph will be constrained after + /// taking the element, in which case indirect options will be ignored. This is to ensure that + /// top-level mutation fields are not executed on a different subgraph than the initial one + /// during query planning. fn estimate_next_nodes_for_selection_without_caching<'c>( &self, element: &OpPathElement, parent_nodes: impl Iterator, + is_initial_subgraph_constrained_after_element: bool, ) -> Result { let mut next_nodes = IndexSet::default(); let nodes_to_object_type_downcasts = &self @@ -348,7 +412,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { } }; for node in parent_nodes { - // As an upper bound for efficiency sake, we consider both non-type-exploded + // As an upper bound for efficiency's sake, we consider both non-type-exploded // and type-exploded options. process_head_node(*node); let Some(object_type_downcasts) = nodes_to_object_type_downcasts.get(node) @@ -436,15 +500,30 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { } } - self.estimate_nodes_with_indirect_options(next_nodes) + self.estimate_nodes_with_indirect_options( + next_nodes, + is_initial_subgraph_constrained_after_element, + ) } - /// Estimate the indirect options for the given next nodes, and add them to the given nodes. - /// As an upper bound for efficiency's sake, we assume we can take any indirect option (i.e. - /// ignore any edge conditions). + /// Estimate the indirect options for the given next nodes, and return the given next nodes + /// along with `next_nodes_with_indirect_options` which contains these direct and indirect + /// options. As an upper bound for efficiency's sake, we assume we can take any indirect option + /// (i.e. ignore any edge conditions). + /// + /// Since we're iterating through next nodes in the process, for efficiency's sake we also + /// compute whether there are any reachable cross-subgraph edges from the next nodes (without + /// indirect options). + /// + /// To support mutations, we allow ignoring indirect options, as we don't want top-level + /// mutation fields to be executed on a different subgraph than the initial one. In that case, + /// `next_nodes_with_indirect_options` will not have any `types`, and the given nodes will be + /// added to `remaining_nodes` (despite them potentially being part of the complete digraph for + /// their type). This is fine, as caching logic accounts for this accordingly. fn estimate_nodes_with_indirect_options( &self, next_nodes: IndexSet, + ignore_indirect_options: bool, ) -> Result { let mut next_nodes_info = NextNodesInfo { next_nodes, @@ -459,6 +538,16 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { .next_nodes_have_reachable_cross_subgraph_edges || next_node_weight.has_reachable_cross_subgraph_edges; + // As noted above, we don't want top-level mutation fields to be executed on a different + // subgraph than the initial one, so we support ignoring indirect options here. + if ignore_indirect_options { + next_nodes_info + .next_nodes_with_indirect_options + .remaining_nodes + .insert(*next_node); + continue; + } + let next_node_type_pos: CompositeTypeDefinitionPosition = next_node_weight.type_.clone().try_into()?; if let Some(options_metadata) = self @@ -488,7 +577,7 @@ impl<'a: 'b, 'b> QueryPlanningTraversal<'a, 'b> { continue; } } - // We need to add the remaining node, and if its our first time seeing it, we also + // We need to add the remaining node, and if it's our first time seeing it, we also // add any of its interface object options. if next_nodes_info .next_nodes_with_indirect_options diff --git a/apollo-federation/tests/query_plan/build_query_plan_tests.rs b/apollo-federation/tests/query_plan/build_query_plan_tests.rs index 5188034d62..26b0da4686 100644 --- a/apollo-federation/tests/query_plan/build_query_plan_tests.rs +++ b/apollo-federation/tests/query_plan/build_query_plan_tests.rs @@ -478,6 +478,152 @@ fn it_executes_mutation_operations_in_sequence() { ); } +#[test] +fn it_executes_a_single_mutation_operation_on_a_shareable_field() { + let planner = planner!( + Subgraph1: r#" + type Query { + dummy: Int + } + + type Mutation { + f: F @shareable + } + + type F @key(fields: "id") { + id: ID! + x: Int + } + "#, + Subgraph2: r#" + type Mutation { + f: F @shareable + } + + type F @key(fields: "id", resolvable: false) { + id: ID! + y: Int + } + "#, + ); + assert_plan!( + &planner, + r#" + mutation { + f { + x + y + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph2") { + { + f { + __typename + id + y + } + } + }, + Flatten(path: "f") { + Fetch(service: "Subgraph1") { + { + ... on F { + __typename + id + } + } => + { + ... on F { + x + } + } + }, + }, + }, + } + "### + ); +} + +#[test] +fn it_ignores_mutation_key_at_top_level_for_mutations() { + let planner = planner!( + Subgraph1: r#" + type Query { + dummy: Int + } + + type Mutation @key(fields: "__typename") { + f: F @shareable + } + + type F @key(fields: "id") { + id: ID! + x: Int + } + "#, + Subgraph2: r#" + type Mutation @key(fields: "__typename") { + f: F @shareable + } + + type F @key(fields: "id", resolvable: false) { + id: ID! + y: Int + } + "#, + ); + // Note that a plan that uses a mutation @key will typically be more costly than a plan that + // doesn't, so the query planner's plan won't show that we properly ignored the @key. We instead + // check both the number of evaluated plans and the plan itself. + let plan = assert_plan!( + &planner, + r#" + mutation { + f { + x + y + } + } + "#, + @r###" + QueryPlan { + Sequence { + Fetch(service: "Subgraph2") { + { + f { + __typename + id + y + } + } + }, + Flatten(path: "f") { + Fetch(service: "Subgraph1") { + { + ... on F { + __typename + id + } + } => + { + ... on F { + x + } + } + }, + }, + }, + } + "### + ); + assert_eq!(plan.statistics.evaluated_plan_count.get(), 1); +} + /// @requires references external field indirectly #[test] fn key_where_at_external_is_not_at_top_level_of_selection_of_requires() { diff --git a/apollo-federation/tests/query_plan/supergraphs/it_executes_a_single_mutation_operation_on_a_shareable_field.graphql b/apollo-federation/tests/query_plan/supergraphs/it_executes_a_single_mutation_operation_on_a_shareable_field.graphql new file mode 100644 index 0000000000..652edeab90 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_executes_a_single_mutation_operation_on_a_shareable_field.graphql @@ -0,0 +1,79 @@ +# Composed from subgraphs with hash: 7206fa0505b6ab6548c5d6460861c3fcdc746b91 +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type F + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id", resolvable: false) +{ + id: ID! + x: Int @join__field(graph: SUBGRAPH1) + y: Int @join__field(graph: SUBGRAPH2) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + f: F +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + dummy: Int @join__field(graph: SUBGRAPH1) +} diff --git a/apollo-federation/tests/query_plan/supergraphs/it_ignores_mutation_key_at_top_level_for_mutations.graphql b/apollo-federation/tests/query_plan/supergraphs/it_ignores_mutation_key_at_top_level_for_mutations.graphql new file mode 100644 index 0000000000..d33c5e9be9 --- /dev/null +++ b/apollo-federation/tests/query_plan/supergraphs/it_ignores_mutation_key_at_top_level_for_mutations.graphql @@ -0,0 +1,79 @@ +# Composed from subgraphs with hash: 566261b3fdbcfb08a6cd235660b8e8c89316d2ab +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +type F + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id", resolvable: false) +{ + id: ID! + x: Int @join__field(graph: SUBGRAPH1) + y: Int @join__field(graph: SUBGRAPH2) +} + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "none") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Mutation + @join__type(graph: SUBGRAPH1, key: "__typename") + @join__type(graph: SUBGRAPH2, key: "__typename") +{ + f: F +} + +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + dummy: Int @join__field(graph: SUBGRAPH1) +}