From 2632b38e54c6c2b1640f485909da09c7beb94033 Mon Sep 17 00:00:00 2001 From: Duckki Oe Date: Fri, 3 Jan 2025 13:45:06 -0800 Subject: [PATCH] implemented basic correctness check of query plans - comparison of two response shapes - query plans analysis, deriving plan's overall response shape --- apollo-federation/cli/src/main.rs | 4 +- .../src/query_plan/correctness/mod.rs | 20 +- .../correctness/query_plan_analysis.rs | 530 ++++++++++++ .../correctness/query_plan_analysis_test.rs | 201 +++++ .../query_plan/correctness/response_shape.rs | 809 +++++++++++++----- .../correctness/response_shape_compare.rs | 290 +++++++ .../correctness/response_shape_test.rs | 2 +- .../src/query_plan/query_planner.rs | 4 + 8 files changed, 1646 insertions(+), 214 deletions(-) create mode 100644 apollo-federation/src/query_plan/correctness/query_plan_analysis.rs create mode 100644 apollo-federation/src/query_plan/correctness/query_plan_analysis_test.rs create mode 100644 apollo-federation/src/query_plan/correctness/response_shape_compare.rs diff --git a/apollo-federation/cli/src/main.rs b/apollo-federation/cli/src/main.rs index 58a0873c6f8..6b07656c1f4 100644 --- a/apollo-federation/cli/src/main.rs +++ b/apollo-federation/cli/src/main.rs @@ -250,8 +250,10 @@ fn cmd_plan( ExecutableDocument::parse_and_validate(planner.api_schema().schema(), query, query_path)?; let query_plan = planner.build_query_plan(&query_doc, None, Default::default())?; println!("{query_plan}"); + // Use the supergraph schema, not the API schema, for correctness check, + // since the plan may access hidden fields. apollo_federation::query_plan::correctness::check_plan( - planner.api_schema(), + &supergraph.schema, &query_doc, &query_plan, )?; diff --git a/apollo-federation/src/query_plan/correctness/mod.rs b/apollo-federation/src/query_plan/correctness/mod.rs index 11cdd771edd..3a4b4907b7a 100644 --- a/apollo-federation/src/query_plan/correctness/mod.rs +++ b/apollo-federation/src/query_plan/correctness/mod.rs @@ -1,10 +1,15 @@ +pub mod query_plan_analysis; +#[cfg(test)] +pub mod query_plan_analysis_test; pub mod response_shape; +pub mod response_shape_compare; #[cfg(test)] pub mod response_shape_test; use apollo_compiler::validation::Valid; use apollo_compiler::ExecutableDocument; +use crate::internal_error; use crate::query_plan::QueryPlan; use crate::schema::ValidFederationSchema; use crate::FederationError; @@ -15,9 +20,16 @@ use crate::FederationError; pub fn check_plan( schema: &ValidFederationSchema, operation_doc: &Valid, - _plan: &QueryPlan, + plan: &QueryPlan, ) -> Result<(), FederationError> { - let rs = response_shape::compute_response_shape(operation_doc, schema)?; - println!("\nResponse shape from operation:\n{rs}"); - Ok(()) + let op_rs = response_shape::compute_response_shape_for_operation(operation_doc, schema)?; + + let root_type = response_shape::compute_the_root_type_condition_for_operation(operation_doc)?; + let plan_rs = query_plan_analysis::interpret_query_plan(schema, &root_type, plan)?; + + response_shape_compare::compare_response_shapes(&op_rs, &plan_rs) + .map_err(|e| internal_error!( + "Response shape from query plan does not match response shape from input operation:\n{}", + e.description() + )) } diff --git a/apollo-federation/src/query_plan/correctness/query_plan_analysis.rs b/apollo-federation/src/query_plan/correctness/query_plan_analysis.rs new file mode 100644 index 00000000000..27c3b4af6f0 --- /dev/null +++ b/apollo-federation/src/query_plan/correctness/query_plan_analysis.rs @@ -0,0 +1,530 @@ +// Analyze a QueryPlan and compute its overall response shape + +use apollo_compiler::executable::Name; + +use super::response_shape::compute_response_shape_for_entity_fetch_operation; +use super::response_shape::compute_response_shape_for_operation; +use super::response_shape::Clause; +use super::response_shape::Literal; +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape::ResponseShape; +use crate::bail; +use crate::ensure; +use crate::internal_error; +use crate::query_plan::ConditionNode; +use crate::query_plan::FetchDataPathElement; +use crate::query_plan::FetchDataRewrite; +use crate::query_plan::FetchNode; +use crate::query_plan::FlattenNode; +use crate::query_plan::ParallelNode; +use crate::query_plan::PlanNode; +use crate::query_plan::QueryPlan; +use crate::query_plan::SequenceNode; +use crate::query_plan::TopLevelPlanNode; +use crate::schema::ValidFederationSchema; +use crate::FederationError; + +//================================================================================================== +// ResponseShape extra methods to support query plan analysis + +impl ResponseShape { + /// Simplify the boolean conditions in the response shape so that there are no redundant + /// conditions on fields by removing conditions that are also present on an ancestor field. + fn simplify_boolean_conditions(&self) -> Self { + self.inner_simplify_boolean_conditions(&Clause::default()) + } + + fn inner_simplify_boolean_conditions(&self, inherited_clause: &Clause) -> Self { + let mut result = ResponseShape::new(self.default_type_condition().clone()); + for (key, defs) in self.iter() { + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .filter_map(|variant| { + let new_clause = variant.boolean_clause().clone(); + inherited_clause.concatenate_and_simplify(&new_clause).map( + |(inherited_clause, field_clause)| { + let sub_rs = + variant.sub_selection_response_shape().as_ref().map(|rs| { + rs.inner_simplify_boolean_conditions(&inherited_clause) + }); + variant.with_updated_fields(field_clause, sub_rs) + }, + ) + }); + let updated_defs_per_type_cond = defs_per_type_cond + .with_updated_conditional_variants(updated_variants.collect()); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + result.insert(key.clone(), updated_defs); + } + result + } + + /// concatenate `added_clause` to each field's boolean condition (only at the top level) + /// then simplify the boolean conditions below the top-level. + fn concatenate_and_simplify_boolean_conditions( + &self, + inherited_clause: &Clause, + added_clause: &Clause, + ) -> Self { + let mut result = ResponseShape::new(self.default_type_condition().clone()); + for (key, defs) in self.iter() { + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .filter_map(|variant| { + let new_clause = if added_clause.is_always_true() { + variant.boolean_clause().clone() + } else { + variant.boolean_clause().concatenate(added_clause)? + }; + inherited_clause.concatenate_and_simplify(&new_clause).map( + |(inherited_clause, field_clause)| { + let sub_rs = + variant.sub_selection_response_shape().as_ref().map(|rs| { + rs.inner_simplify_boolean_conditions(&inherited_clause) + }); + variant.with_updated_fields(field_clause, sub_rs) + }, + ) + }); + let updated_defs_per_type_cond = defs_per_type_cond + .with_updated_conditional_variants(updated_variants.collect()); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + result.insert(key.clone(), updated_defs); + } + result + } + + /// Add a new condition to a ResponseShape. + /// - This method is intended for the top-level response shape. + fn add_boolean_conditions(&self, literals: &[Literal]) -> Self { + let added_clause = Clause::from_literals(literals); + self.concatenate_and_simplify_boolean_conditions(&Clause::default(), &added_clause) + } +} + +//================================================================================================== +// Interpretation of QueryPlan + +pub fn interpret_query_plan( + schema: &ValidFederationSchema, + root_type: &Name, + plan: &QueryPlan, +) -> Result { + let state = ResponseShape::new(root_type.clone()); + let Some(plan_node) = &plan.node else { + // empty plan + return Ok(state); + }; + interpret_top_level_plan_node(schema, &state, plan_node) +} + +fn interpret_top_level_plan_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + node: &TopLevelPlanNode, +) -> Result { + let conditions = vec![]; + match node { + TopLevelPlanNode::Fetch(fetch) => interpret_fetch_node(schema, state, &conditions, fetch), + TopLevelPlanNode::Sequence(sequence) => { + interpret_sequence_node(schema, state, &conditions, sequence) + } + TopLevelPlanNode::Parallel(parallel) => { + interpret_parallel_node(schema, state, &conditions, parallel) + } + TopLevelPlanNode::Flatten(flatten) => { + interpret_flatten_node(schema, state, &conditions, flatten) + } + TopLevelPlanNode::Condition(condition) => { + interpret_condition_node(schema, state, &conditions, condition) + } + TopLevelPlanNode::Defer(_defer) => bail!("todo: defer (top-level)"), + TopLevelPlanNode::Subscription(subscription) => { + let mut result = + interpret_fetch_node(schema, state, &conditions, &subscription.primary)?; + if let Some(rest) = &subscription.rest { + let rest = interpret_plan_node(schema, state, &conditions, rest)?; + result.merge_with(&rest)?; + } + Ok(result) + } + } +} + +/// `conditions` are accumulated conditions to be applied at each fetch node's response shape. +fn interpret_plan_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + node: &PlanNode, +) -> Result { + match node { + PlanNode::Fetch(fetch) => interpret_fetch_node(schema, state, conditions, fetch), + PlanNode::Sequence(sequence) => { + interpret_sequence_node(schema, state, conditions, sequence) + } + PlanNode::Parallel(parallel) => { + interpret_parallel_node(schema, state, conditions, parallel) + } + PlanNode::Flatten(flatten) => interpret_flatten_node(schema, state, conditions, flatten), + PlanNode::Condition(condition) => { + interpret_condition_node(schema, state, conditions, condition) + } + PlanNode::Defer(_defer) => bail!("todo: defer"), + } +} + +// `type_filter`: The type condition to apply to the response shape. +// - This is from the previous path elements. +fn rename_at_path( + schema: &ValidFederationSchema, + state: &ResponseShape, + type_filter: Option, + path: &[FetchDataPathElement], + new_name: Name, +) -> Result { + let Some((first, rest)) = path.split_first() else { + bail!("rename_at_path: unexpected empty path") + }; + match first { + FetchDataPathElement::Key(name, _conditions) => { + ensure!(_conditions.is_none(), "not implemented yet"); // TODO + let Some(defs) = state.get(name) else { + // If some sub-states don't have the name, skip it and return the same state. + return Ok(state.clone()); + }; + let rename_here = rest.is_empty(); + // Interpret the node in every matching sub-state. + let mut updated_defs = PossibleDefinitions::default(); // for the old name + let mut target_defs = PossibleDefinitions::default(); // for the new name + for (type_cond, defs_per_type_cond) in defs.iter() { + if let Some(type_filter) = &type_filter { + let type_filter = + NormalizedTypeCondition::from_type_name(type_filter.clone(), schema)?; + if !type_filter.implies(type_cond) { + // Not applicable => same as before + updated_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + continue; + } + } + if rename_here { + // move all definitions to the target_defs + target_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + continue; + } + + // otherwise, rename in the sub-states + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .map(|variant| { + let Some(sub_state) = variant.sub_selection_response_shape() else { + return Err(internal_error!( + "No sub-selection at path: {}", + path.iter() + .map(|p| p.to_string()) + .collect::>() + .join(".") + )); + }; + let updated_sub_state = + rename_at_path(schema, sub_state, None, rest, new_name.clone())?; + Ok( + variant + .with_updated_sub_selection_response_shape(updated_sub_state), + ) + }); + let updated_variants: Result, _> = updated_variants.collect(); + let updated_variants = updated_variants?; + let updated_defs_per_type_cond = + defs_per_type_cond.with_updated_conditional_variants(updated_variants); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + let mut result = state.clone(); + result.insert(name.clone(), updated_defs); + if rename_here { + // also, update the new response key + let prev_defs = result.get(&new_name); + match prev_defs { + None => { + result.insert(new_name, target_defs); + } + Some(prev_defs) => { + let mut merged_defs = prev_defs.clone(); + for (type_cond, defs_per_type_cond) in target_defs.iter() { + let existed = + merged_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + if existed { + bail!("rename_at_path: new name/type already exists: {new_name} on {type_cond}") + } + } + result.insert(new_name, merged_defs); + } + } + } + Ok(result) + } + FetchDataPathElement::AnyIndex(_conditions) => { + ensure!(_conditions.is_none(), "not implemented yet"); // TODO + rename_at_path(schema, state, type_filter, rest, new_name) + } + FetchDataPathElement::TypenameEquals(type_name) => { + let type_filter = Some(type_name.clone()); + rename_at_path(schema, state, type_filter, rest, new_name) + } + FetchDataPathElement::Parent => { + bail!("todo: Parent") + } + } +} + +fn apply_rewrites( + schema: &ValidFederationSchema, + state: &ResponseShape, + rewrite: &FetchDataRewrite, +) -> Result { + match rewrite { + FetchDataRewrite::ValueSetter(_) => todo!(), + FetchDataRewrite::KeyRenamer(renamer) => rename_at_path( + schema, + state, + None, + &renamer.path, + renamer.rename_key_to.clone(), + ), + } +} + +fn interpret_fetch_node( + schema: &ValidFederationSchema, + _state: &ResponseShape, + conditions: &[Literal], + fetch: &FetchNode, +) -> Result { + let mut result = if let Some(_requires) = &fetch.requires { + // for required_selection in requires { + // let Selection::InlineFragment(inline) = required_selection else { + // return Err(internal_error!( + // "Inline fragment is expected in requires, but got: {required_selection:#?}" + // )); + // }; + // let rs = compute_response_shape_for_inline_fragment(inline, schema)?; + // if let Err(e) = compare_response_shapes(&rs, state) { + // return Err(internal_error!( + // "Unsatisfied requires:\n{}\nstate:\n{}\nrequires (as response shape):\n{}\nfetch node: {fetch}", + // e.description(), + // state, + // rs + // )); + // } + // } + compute_response_shape_for_entity_fetch_operation(&fetch.operation_document, schema) + .map(|rs| rs.add_boolean_conditions(conditions)) + } else { + compute_response_shape_for_operation(&fetch.operation_document, schema) + .map(|rs| rs.add_boolean_conditions(conditions)) + }?; + for rewrite in &fetch.output_rewrites { + result = apply_rewrites(schema, &result, rewrite)?; + } + Ok(result) +} + +/// Add a literal to the conditions +fn append_literal(conditions: &[Literal], literal: Literal) -> Vec { + let mut result = conditions.to_vec(); + result.push(literal); + result +} + +fn interpret_condition_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + condition: &ConditionNode, +) -> Result { + let condition_variable = &condition.condition_variable; + match (&condition.if_clause, &condition.else_clause) { + (None, None) => Err(internal_error!( + "Condition node must have either if or else clause" + )), + (Some(if_clause), None) => { + let literal = Literal::Pos(condition_variable.clone()); + let sub_conditions = append_literal(conditions, literal); + Ok(interpret_plan_node( + schema, + state, + &sub_conditions, + if_clause, + )?) + } + (None, Some(else_clause)) => { + let literal = Literal::Neg(condition_variable.clone()); + let sub_conditions = append_literal(conditions, literal); + Ok(interpret_plan_node( + schema, + state, + &sub_conditions, + else_clause, + )?) + } + (Some(if_clause), Some(else_clause)) => { + let lit_pos = Literal::Pos(condition_variable.clone()); + let lit_neg = Literal::Neg(condition_variable.clone()); + let sub_conditions_pos = append_literal(conditions, lit_pos); + let sub_conditions_neg = append_literal(conditions, lit_neg); + let if_val = interpret_plan_node(schema, state, &sub_conditions_pos, if_clause)?; + let else_val = interpret_plan_node(schema, state, &sub_conditions_neg, else_clause)?; + let mut result = if_val; + result.merge_with(&else_val)?; + Ok(result) + } + } +} + +fn interpret_plan_node_at_path( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result, FederationError> { + let Some((first, rest)) = path.split_first() else { + return Ok(Some(interpret_plan_node(schema, state, conditions, node)?)); + }; + match first { + FetchDataPathElement::Key(name, _conditions) => { + ensure!(_conditions.is_none(), "not implemented yet"); // TODO + let Some(defs) = state.get(name) else { + // If some sub-states don't have the name, skip it and return None. + // However, one of the `defs` must have one sub-state that has the name (see below). + return Ok(None); + }; + // Interpret the node in every matching sub-state. + let mut updated_defs = PossibleDefinitions::default(); + for (type_cond, defs_per_type_cond) in defs.iter() { + let updated_variants = + defs_per_type_cond + .conditional_variants() + .iter() + .filter_map(|variant| { + let Some(sub_state) = variant.sub_selection_response_shape() else { + return Some(Err(internal_error!( + "No sub-selection at path: {}", + path.iter() + .map(|p| p.to_string()) + .collect::>() + .join(".") + ))); + }; + // TODO: pass the `type_cond` + // println!("here: interpret_plan_node_at_path"); + // println!( + // "type_cond {type_cond} vs sub-state type {}", + // sub_state.default_type_condition() + // ); + // println!("state: {state}\npath: {path:?}\nnode: {node}"); + let updated_sub_state = interpret_plan_node_at_path( + schema, sub_state, conditions, rest, node, + ); + match updated_sub_state { + Err(e) => Some(Err(e)), + Ok(updated_sub_state) => { + updated_sub_state.map(|updated_sub_state| { + Ok(variant.with_updated_sub_selection_response_shape( + updated_sub_state, + )) + }) + } + } + }); + let updated_variants: Result, _> = updated_variants.collect(); + let updated_variants = updated_variants?; + if !updated_variants.is_empty() { + let updated_defs_per_type_cond = + defs_per_type_cond.with_updated_conditional_variants(updated_variants); + updated_defs.insert(type_cond.clone(), updated_defs_per_type_cond); + } + } + if updated_defs.is_empty() { + // Nothing to interpret here => return None + return Ok(None); + } + let mut result = state.clone(); + result.insert(name.clone(), updated_defs); + Ok(Some(result)) + } + FetchDataPathElement::AnyIndex(_conditions) => { + ensure!(_conditions.is_none(), "not implemented yet"); // TODO + interpret_plan_node_at_path(schema, state, conditions, rest, node) + } + FetchDataPathElement::TypenameEquals(_type_name) => { + bail!("todo: TypenameEquals") + } + FetchDataPathElement::Parent => { + bail!("todo: Parent") + } + } +} + +fn interpret_flatten_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + flatten: &FlattenNode, +) -> Result { + // println!("interpret_flatten_node: {flatten}\nstate: {state}\n"); + let result = + interpret_plan_node_at_path(schema, state, conditions, &flatten.path, &flatten.node)?; + let Some(result) = result else { + // `flatten.path` is addressing a non-existing response object. + // Ideally, this should not happen, but QP may try to fetch infeasible selections. + // println!( + // "warning: Response shape does not have a matching path: {:?}\nstate: {state}", + // flatten.path, + // ); + return Ok(state.clone()); + }; + Ok(result.simplify_boolean_conditions()) +} + +fn interpret_sequence_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + sequence: &SequenceNode, +) -> Result { + let mut response_shape = state.clone(); + for node in &sequence.nodes { + let node_rs = interpret_plan_node(schema, &response_shape, conditions, node)?; + response_shape.merge_with(&node_rs)?; + } + Ok(response_shape) +} + +fn interpret_parallel_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + parallel: &ParallelNode, +) -> Result { + let mut response_shape = state.clone(); + for node in ¶llel.nodes { + // Note: Use the same original state for each parallel node + let node_rs = interpret_plan_node(schema, state, conditions, node)?; + response_shape.merge_with(&node_rs)?; + } + Ok(response_shape) +} diff --git a/apollo-federation/src/query_plan/correctness/query_plan_analysis_test.rs b/apollo-federation/src/query_plan/correctness/query_plan_analysis_test.rs new file mode 100644 index 00000000000..4e09f4947e1 --- /dev/null +++ b/apollo-federation/src/query_plan/correctness/query_plan_analysis_test.rs @@ -0,0 +1,201 @@ +use apollo_compiler::ExecutableDocument; + +use super::query_plan_analysis::interpret_query_plan; +use super::response_shape::ResponseShape; +use super::*; +use crate::query_plan::query_planner; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query +} + +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) 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 + +interface I @join__type(graph: A, key: "id") @join__type(graph: B, key: "id") @join__type(graph: S) { + id: ID! + data_a(arg: Int!): String! @join__field(graph: A) + data_b(arg: Int!): String! @join__field(graph: B) + data(arg: Int!): Int! @join__field(graph: S) +} + +scalar join__FieldSet + +enum join__Graph { + A @join__graph(name: "A", url: "query-plan-response-shape/test.graphql?subgraph=A") + B @join__graph(name: "B", url: "query-plan-response-shape/test.graphql?subgraph=B") + S @join__graph(name: "S", url: "query-plan-response-shape/test.graphql?subgraph=S") +} + +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 Query @join__type(graph: A) @join__type(graph: B) @join__type(graph: S) { + test_i: I! @join__field(graph: S) +} + +type T implements I @join__implements(graph: A, interface: "I") @join__implements(graph: B, interface: "I") @join__implements(graph: S, interface: "I") @join__type(graph: A, key: "id") @join__type(graph: B, key: "id") @join__type(graph: S, key: "id") { + id: ID! + data_a(arg: Int!): String! @join__field(graph: A) + data_b(arg: Int!): String! @join__field(graph: B) + data(arg: Int!): Int! @join__field(graph: S) +} +"#; + +fn plan_response_shape(op_str: &str) -> ResponseShape { + // Parse the schema and operation + let supergraph = crate::Supergraph::new(SCHEMA_STR).unwrap(); + let api_schema = supergraph.to_api_schema(Default::default()).unwrap(); + let op = + ExecutableDocument::parse_and_validate(api_schema.schema(), op_str, "op.graphql").unwrap(); + + // Plan the query + let config = query_planner::QueryPlannerConfig { + generate_query_fragments: false, + type_conditioned_fetching: false, + ..Default::default() + }; + let planner = query_planner::QueryPlanner::new(&supergraph, config).unwrap(); + let query_plan = planner + .build_query_plan(&op, None, Default::default()) + .unwrap(); + + // Compare response shapes + let correctness_schema = planner.supergraph_schema(); + let op_rs = + response_shape::compute_response_shape_for_operation(&op, correctness_schema).unwrap(); + let root_type = response_shape::compute_the_root_type_condition_for_operation(&op).unwrap(); + let plan_rs = interpret_query_plan(correctness_schema, &root_type, &query_plan).unwrap(); + assert!(response_shape_compare::compare_response_shapes(&op_rs, &plan_rs).is_ok()); + + plan_rs +} + +//================================================================================================= +// Basic tests + +#[test] +fn test_single_fetch() { + let op_str = r#" + query { + test_i { + data(arg: 0) + alias1: data(arg: 1) + alias2: data(arg: 1) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -----> test_i { + __typename -----> __typename + data -----> data(arg: 0) + alias1 -----> data(arg: 1) + alias2 -----> data(arg: 1) + } + } + "###); +} + +#[test] +fn test_empty_plan() { + let op_str = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) @skip(if:true) { + data(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + } + "###); +} + +#[test] +fn test_condition_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + } + } + "###); +} + +#[test] +fn test_sequence_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + data_a(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + data_a -----> data_a(arg: 0) + } + } + "###); +} + +#[test] +fn test_parallel_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + data_a(arg: 0) + data_b(arg: 0) + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + data_b -----> data_b(arg: 0) + data_a -----> data_a(arg: 0) + } + } + "###); +} diff --git a/apollo-federation/src/query_plan/correctness/response_shape.rs b/apollo-federation/src/query_plan/correctness/response_shape.rs index 9fa83425dd3..5926a9269b5 100644 --- a/apollo-federation/src/query_plan/correctness/response_shape.rs +++ b/apollo-federation/src/query_plan/correctness/response_shape.rs @@ -6,6 +6,9 @@ use apollo_compiler::collections::IndexMap; use apollo_compiler::collections::IndexSet; use apollo_compiler::executable::Field; use apollo_compiler::executable::Fragment; +use apollo_compiler::executable::FragmentMap; +use apollo_compiler::executable::InlineFragment; +use apollo_compiler::executable::Operation; use apollo_compiler::executable::Selection; use apollo_compiler::executable::SelectionSet; use apollo_compiler::validation::Valid; @@ -14,11 +17,14 @@ use apollo_compiler::Name; use apollo_compiler::Node; use crate::bail; +use crate::compat::coerce_executable_values; use crate::display_helpers; +use crate::ensure; use crate::internal_error; use crate::schema::position::CompositeTypeDefinitionPosition; use crate::schema::position::InterfaceTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::schema::position::INTROSPECTION_TYPENAME_FIELD_NAME; use crate::schema::ValidFederationSchema; use crate::utils::FallibleIterator; use crate::FederationError; @@ -120,27 +126,22 @@ fn get_ground_types( } /// A sequence of type conditions applied (used for display) -// - The vector must be non-empty. +// - If the vector is empty, it means a "deduced type condition". +// Thus, we may not know how to display such a composition of types. +// That can happen when a more specific type condition is computed +// than the one that was explicitly provided. #[derive(Debug, Clone)] struct AppliedTypeCondition(Vec); -impl fmt::Display for AppliedTypeCondition { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - for (i, cond) in self.0.iter().enumerate() { - if i > 0 { - write!(f, " ∧ ")?; - } - write!(f, "{}", cond.type_name())?; - } - Ok(()) - } -} - impl AppliedTypeCondition { fn new(ty: CompositeTypeDefinitionPosition) -> Self { AppliedTypeCondition(vec![ty]) } + fn deduced() -> Self { + AppliedTypeCondition(Vec::new()) + } + /// Construct a new type condition with a named type condition added. fn add_type_name( &self, @@ -169,9 +170,10 @@ impl AppliedTypeCondition { } #[derive(Debug, Clone)] -struct NormalizedTypeCondition { +pub struct NormalizedTypeCondition { // The set of object types that are used for comparison. - // - The ground_set must be non-empty. + // - The empty ground_set means the `_Entity` type. + // - The ground_set must be non-empty, if it's not the `_Entity` type. // - The ground_set must be sorted by type name. ground_set: Vec, @@ -187,32 +189,45 @@ impl PartialEq for NormalizedTypeCondition { impl Eq for NormalizedTypeCondition {} -impl fmt::Display for NormalizedTypeCondition { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.for_display)?; - if self.for_display.0.len() > 1 { - write!(f, " = {{")?; - for (i, ty) in self.ground_set.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}", ty.type_name)?; - } - write!(f, "}}")?; - } - Ok(()) - } -} - impl std::hash::Hash for NormalizedTypeCondition { fn hash(&self, state: &mut H) { self.ground_set.hash(state); } } +// Public accessors +impl NormalizedTypeCondition { + pub fn implies(&self, other: &Self) -> bool { + self.ground_set.iter().all(|t| other.ground_set.contains(t)) + } + + pub(crate) fn ground_set(&self) -> &[ObjectTypeDefinitionPosition] { + &self.ground_set + } + + pub(crate) fn from_ground_type(ty: &ObjectTypeDefinitionPosition) -> Self { + NormalizedTypeCondition { + ground_set: vec![ty.clone()], + for_display: AppliedTypeCondition::new(ty.clone().into()), + } + } + + /// is this type condition represented by a single named type? + pub fn is_named_type(&self, type_name: &Name) -> bool { + let mut it = self.for_display.0.iter(); + let Some(first) = it.next() else { + return false; + }; + it.next().is_none() && first.type_name() == type_name + } +} + impl NormalizedTypeCondition { /// Construct a new type condition with a single named type condition. - fn from_type_name(name: Name, schema: &ValidFederationSchema) -> Result { + pub(crate) fn from_type_name( + name: Name, + schema: &ValidFederationSchema, + ) -> Result { let ty: CompositeTypeDefinitionPosition = schema.get_type(name)?.try_into()?; Ok(NormalizedTypeCondition { ground_set: get_ground_types(&ty, schema)?, @@ -220,6 +235,17 @@ impl NormalizedTypeCondition { }) } + pub(crate) fn from_object_types( + types: impl Iterator, + ) -> Result { + let mut ground_set: Vec<_> = types.collect(); + ground_set.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + Ok(NormalizedTypeCondition { + ground_set, + for_display: AppliedTypeCondition::deduced(), + }) + } + /// Construct a new type condition with a named type condition added. fn add_type_name( &self, @@ -229,6 +255,14 @@ impl NormalizedTypeCondition { let other_ty: CompositeTypeDefinitionPosition = schema.get_type(name.clone())?.try_into()?; let other_types = get_ground_types(&other_ty, schema)?; + if self.ground_set.is_empty() { + // Special case: The `self` is the `_Entity` type. + // - Just returns `other`, since _Entity ∩ other = other. + return Ok(Some(NormalizedTypeCondition { + ground_set: other_types, + for_display: AppliedTypeCondition::new(other_ty), + })); + } let ground_set: Vec = self .ground_set .iter() @@ -251,25 +285,81 @@ impl NormalizedTypeCondition { })) } } + + /// Special constructor for the `_Entity` type. + fn for_entity() -> Self { + NormalizedTypeCondition { + ground_set: Vec::new(), + for_display: AppliedTypeCondition(Vec::new()), + } + } + + fn field_type_condition( + &self, + field: &Field, + schema: &ValidFederationSchema, + ) -> Result { + let declared_type = field.ty().inner_named_type(); + + // Collect all possible object types for the field in the given parent type condition. + let mut types = IndexSet::default(); + for ty_pos in &self.ground_set { + let ty_def = ty_pos.get(schema.schema())?; + let Some(field_def) = ty_def.fields.get(&field.name) else { + continue; + }; + let field_ty = field_def.ty.inner_named_type().clone(); + types.insert(field_ty); + } + + // Simple case #1 - The collected types is just a single named type. + if types.len() == 1 { + if let Some(first) = types.first() { + return NormalizedTypeCondition::from_type_name(first.clone(), schema); + } + } + + // Grind the type names into object types. + let mut ground_types = IndexSet::default(); + for ty in &types { + let pos = schema.get_type(ty.clone())?.try_into()?; + let pos_types = schema.possible_runtime_types(pos)?; + ground_types.extend(pos_types.into_iter()); + } + + // Simple csae #2 - `declared_type` is same as the collected types. + let declared_type_cond = + NormalizedTypeCondition::from_type_name(declared_type.clone(), schema)?; + if declared_type_cond.ground_set.len() == ground_types.len() + && declared_type_cond + .ground_set + .iter() + .all(|t| ground_types.contains(t)) + { + return Ok(declared_type_cond); + } + + NormalizedTypeCondition::from_object_types(ground_types.into_iter()) + } } //================================================================================================== -// Logical conditions +// Boolean conditions #[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum Literal { +pub enum Literal { Pos(Name), // positive occurrence of the variable with the given name Neg(Name), // negated variable with the given name } impl Literal { - fn variable(&self) -> &Name { + pub fn variable(&self) -> &Name { match self { Literal::Pos(name) | Literal::Neg(name) => name, } } - fn polarity(&self) -> bool { + pub fn polarity(&self) -> bool { matches!(self, Literal::Pos(_)) } } @@ -279,32 +369,23 @@ impl Literal { // "false" can't be represented. Any cases with false condition must be dropped entirely. // This vector must be deduplicated. #[derive(Debug, Clone, Default, Eq)] -struct Clause(Vec); - -impl fmt::Display for Clause { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if self.0.is_empty() { - write!(f, "true") - } else { - for (i, l) in self.0.iter().enumerate() { - if i > 0 { - write!(f, " ∧ ")?; - } - match l { - Literal::Pos(v) => write!(f, "{}", v)?, - Literal::Neg(v) => write!(f, "¬{}", v)?, - } - } - Ok(()) - } - } -} +pub struct Clause(Vec); impl Clause { - fn is_always_true(&self) -> bool { + pub fn is_always_true(&self) -> bool { self.0.is_empty() } + /// Creates a clause from a vector of literals. + pub fn from_literals(literals: &[Literal]) -> Self { + let variables: IndexMap = literals + .iter() + .map(|lit| (lit.variable().clone(), lit.polarity())) + .collect(); + Self::from_variable_map(&variables) + } + + /// Creates a clause from a variable-to-Boolean mapping. /// variables: variable name (Name) -> polarity (bool) fn from_variable_map(variables: &IndexMap) -> Self { let mut buf: Vec = variables @@ -318,7 +399,7 @@ impl Clause { Clause(buf) } - fn concatenate(&self, other: &Clause) -> Option { + pub fn concatenate(&self, other: &Clause) -> Option { let mut variables: IndexMap = IndexMap::default(); // Assume that `self` has no conflicts. for lit in &self.0 { @@ -347,7 +428,7 @@ impl Clause { /// Returns a clause with everything included and a simplified version of the `clause`. /// - The simplified clause does not include variables that are already in `self`. - fn concatenate_and_simplify(&self, clause: &Clause) -> Option<(Clause, Clause)> { + pub fn concatenate_and_simplify(&self, clause: &Clause) -> Option<(Clause, Clause)> { let mut all_variables: IndexMap = IndexMap::default(); // Load `self` on `variables`. // - Assume that `self` has no conflicts. @@ -395,7 +476,7 @@ fn boolean_clause_from_directives( let mut variables = IndexMap::default(); // variable name (Name) -> polarity (bool) if let Some(skip) = directives.get("skip") { let Some(value) = skip.specified_argument_by_name("if") else { - bail!("missing @skip(if:) argument"); + bail!("missing @skip(if:) argument") }; match value.as_ref() { @@ -407,14 +488,14 @@ fn boolean_clause_from_directives( variables.insert(name.clone(), false); } _ => { - bail!("expected boolean or variable `if` argument, got {value}"); + bail!("expected boolean or variable `if` argument, got {value}") } } } if let Some(include) = directives.get("include") { let Some(value) = include.specified_argument_by_name("if") else { - bail!("missing @include(if:) argument"); + bail!("missing @include(if:) argument") }; match value.as_ref() { @@ -431,15 +512,43 @@ fn boolean_clause_from_directives( } } _ => { - bail!("expected boolean or variable `if` argument, got {value}"); + bail!("expected boolean or variable `if` argument, got {value}") } } } Ok(Some(Clause::from_variable_map(&variables))) } +fn normalize_ast_value(v: &mut ast::Value) { + // special cases + match v { + // Sort object fields by name + ast::Value::Object(fields) => { + fields.sort_by(|a, b| a.0.cmp(&b.0)); + for (_name, value) in fields { + normalize_ast_value(value.make_mut()); + } + } + + // Recurse into list items. + ast::Value::List(items) => { + for value in items { + normalize_ast_value(value.make_mut()); + } + } + + _ => (), // otherwise, do nothing + } +} + fn normalized_arguments(args: &[Node]) -> Vec> { - vec_sorted_by(args, |a, b| a.name.cmp(&b.name)) + // sort by name + let mut args = vec_sorted_by(args, |a, b| a.name.cmp(&b.name)); + // normalize argument values in place + for arg in &mut args { + normalize_ast_value(arg.make_mut().value.make_mut()); + } + args } fn remove_conditions_from_directives(directives: &ast::DirectiveList) -> ast::DirectiveList { @@ -450,7 +559,7 @@ fn remove_conditions_from_directives(directives: &ast::DirectiveList) -> ast::Di .collect() } -type FieldSelectionKey = Field; +pub type FieldSelectionKey = Field; // Extract the selection key fn field_selection_key(field: &Field) -> FieldSelectionKey { @@ -464,6 +573,11 @@ fn field_selection_key(field: &Field) -> FieldSelectionKey { } } +fn eq_field_selection_key(a: &FieldSelectionKey, b: &FieldSelectionKey) -> bool { + // Note: Arguments are expected to be normalized. + a.name == b.name && a.arguments == b.arguments +} + //================================================================================================== // ResponseShape @@ -480,33 +594,89 @@ fn field_display(field: &Field) -> Field { } #[derive(Debug, PartialEq, Eq, Clone)] -struct DefinitionVariant { +pub struct DefinitionVariant { /// Boolean clause is the secondary key after NormalizedTypeCondition as primary key. boolean_clause: Clause, - /// Field selection for definition/display (see `fn field_display`). + /// Representative field selection for definition/display (see `fn field_display`). /// - This is the first field of the same field selection key in depth-first order as /// defined by `CollectFields` and `ExecuteField` algorithms in the GraphQL spec. - field_display: Field, + representative_field: Field, /// Different variants can have different sets of sub-selections (if any). sub_selection_response_shape: Option, } +// Public accessors & constructors +impl DefinitionVariant { + pub fn boolean_clause(&self) -> &Clause { + &self.boolean_clause + } + + pub fn representative_field(&self) -> &Field { + &self.representative_field + } + + pub fn sub_selection_response_shape(&self) -> Option<&ResponseShape> { + self.sub_selection_response_shape.as_ref() + } + + pub fn with_updated_sub_selection_response_shape(&self, new_shape: ResponseShape) -> Self { + DefinitionVariant { + boolean_clause: self.boolean_clause.clone(), + representative_field: self.representative_field.clone(), + sub_selection_response_shape: Some(new_shape), + } + } + + pub fn with_updated_fields( + &self, + boolean_clause: Clause, + sub_selection_response_shape: Option, + ) -> Self { + DefinitionVariant { + boolean_clause, + sub_selection_response_shape, + representative_field: self.representative_field.clone(), + } + } +} + #[derive(Debug, PartialEq, Eq, Clone)] -struct PossibleDefinitionsPerTypeCondition { +pub struct PossibleDefinitionsPerTypeCondition { /// The key for comparison (only used for GraphQL invariant check). /// - Under each type condition, all variants must have the same selection key. field_selection_key: FieldSelectionKey, /// Under each type condition, there may be multiple variants with different Boolean conditions. conditional_variants: Vec, - // - Every variant's (Boolean condition, directive key) must be unique. - // - (TBD) Their Boolean conditions must be mutually exclusive. + // - Every variant's Boolean condition must be unique. + // - Note: The Boolean conditions between variants may not be mutually exclusive. } +// Public accessors & constructors impl PossibleDefinitionsPerTypeCondition { - fn insert_variant(&mut self, variant: DefinitionVariant) { + pub fn field_selection_key(&self) -> &FieldSelectionKey { + &self.field_selection_key + } + + pub fn conditional_variants(&self) -> &[DefinitionVariant] { + &self.conditional_variants + } + + pub fn with_updated_conditional_variants(&self, new_variants: Vec) -> Self { + PossibleDefinitionsPerTypeCondition { + field_selection_key: self.field_selection_key.clone(), + conditional_variants: new_variants, + } + } +} + +impl PossibleDefinitionsPerTypeCondition { + pub(crate) fn insert_variant( + &mut self, + variant: DefinitionVariant, + ) -> Result<(), FederationError> { for existing in &mut self.conditional_variants { if existing.boolean_clause == variant.boolean_clause { // Merge response shapes (MergeSelectionSets from GraphQL spec 6.4.3) @@ -516,16 +686,17 @@ impl PossibleDefinitionsPerTypeCondition { ) { (None, None) => {} // nothing to do (Some(existing_rs), Some(ref variant_rs)) => { - existing_rs.merge_with(variant_rs); + existing_rs.merge_with(variant_rs)?; } (None, Some(_)) | (Some(_), None) => { unreachable!("mismatched sub-selection options") } } - return; + return Ok(()); } } self.conditional_variants.push(variant); + Ok(()) } } @@ -533,25 +704,64 @@ impl PossibleDefinitionsPerTypeCondition { /// - At the top level, all possibilities are indexed by the type condition. /// - However, they are not necessarily mutually exclusive. #[derive(Debug, Default, PartialEq, Eq, Clone)] -struct PossibleDefinitions(IndexMap); +pub struct PossibleDefinitions( + IndexMap, +); + +// Public accessors +impl PossibleDefinitions { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter( + &self, + ) -> impl Iterator< + Item = ( + &NormalizedTypeCondition, + &PossibleDefinitionsPerTypeCondition, + ), + > { + self.0.iter() + } + + pub fn get( + &self, + type_cond: &NormalizedTypeCondition, + ) -> Option<&PossibleDefinitionsPerTypeCondition> { + self.0.get(type_cond) + } + + pub fn insert( + &mut self, + type_condition: NormalizedTypeCondition, + value: PossibleDefinitionsPerTypeCondition, + ) -> bool { + self.0.insert(type_condition, value).is_some() + } +} impl PossibleDefinitions { fn insert_possible_definition( &mut self, type_conditions: NormalizedTypeCondition, boolean_clause: Clause, // the aggregate boolean condition of the current selection set - field_display: Field, + representative_field: Field, sub_selection_response_shape: Option, - ) { - let field_selection_key = field_selection_key(&field_display); + ) -> Result<(), FederationError> { + let field_selection_key = field_selection_key(&representative_field); let entry = self.0.entry(type_conditions); let insert_variant = |per_type_cond: &mut PossibleDefinitionsPerTypeCondition| { let value = DefinitionVariant { boolean_clause, - field_display, + representative_field, sub_selection_response_shape, }; - per_type_cond.insert_variant(value); + per_type_cond.insert_variant(value) }; match entry { indexmap::map::Entry::Vacant(e) => { @@ -560,34 +770,67 @@ impl PossibleDefinitions { field_selection_key, conditional_variants: vec![], }; - insert_variant(e.insert(empty_per_type_cond)); + insert_variant(e.insert(empty_per_type_cond))?; } indexmap::map::Entry::Occupied(mut e) => { // GraphQL invariant: per_type_cond.field_selection_key must be the same // as the given field_selection_key. - assert_eq!(e.get().field_selection_key, field_selection_key); - insert_variant(e.get_mut()); + ensure!( + eq_field_selection_key(&e.get().field_selection_key, &field_selection_key), + "field_selection_key was expected to be the same" + ); + insert_variant(e.get_mut())?; } }; + Ok(()) } } #[derive(Debug, PartialEq, Eq, Clone)] pub struct ResponseShape { /// The default type condition is only used for display. - default_type_condition: NormalizedTypeCondition, + default_type_condition: Name, definitions_per_response_key: IndexMap, } +// Public accessors +impl ResponseShape { + pub fn default_type_condition(&self) -> &Name { + &self.default_type_condition + } + + pub fn is_empty(&self) -> bool { + self.definitions_per_response_key.is_empty() + } + + pub fn len(&self) -> usize { + self.definitions_per_response_key.len() + } + + pub fn iter(&self) -> impl Iterator { + self.definitions_per_response_key.iter() + } + + pub fn get(&self, response_key: &Name) -> Option<&PossibleDefinitions> { + self.definitions_per_response_key.get(response_key) + } + + pub fn insert(&mut self, response_key: Name, value: PossibleDefinitions) -> bool { + self.definitions_per_response_key + .insert(response_key, value) + .is_some() + } +} + impl ResponseShape { - fn new(default_type_condition: NormalizedTypeCondition) -> Self { + pub fn new(default_type_condition: Name) -> Self { ResponseShape { default_type_condition, definitions_per_response_key: IndexMap::default(), } } - fn merge_with(&mut self, other: &Self) { + pub fn merge_with(&mut self, other: &Self) -> Result<(), FederationError> { for (response_key, other_defs) in &other.definitions_per_response_key { let value = self .definitions_per_response_key @@ -598,99 +841,18 @@ impl ResponseShape { value.insert_possible_definition( type_condition.clone(), variant.boolean_clause.clone(), - variant.field_display.clone(), + variant.representative_field.clone(), variant.sub_selection_response_shape.clone(), - ); + )?; } } } + Ok(()) } } //================================================================================================== -// ResponseShape display - -impl PossibleDefinitionsPerTypeCondition { - fn has_boolean_conditions(&self) -> bool { - self.conditional_variants.len() > 1 - || self - .conditional_variants - .first() - .is_some_and(|variant| !variant.boolean_clause.is_always_true()) - } -} - -impl PossibleDefinitions { - /// Is conditional on runtime type? - fn has_type_conditions(&self, default_type_condition: &NormalizedTypeCondition) -> bool { - self.0.len() > 1 - || self - .0 - .first() - .is_some_and(|(type_condition, _)| type_condition != default_type_condition) - } - - /// Has multiple possible definitions or has any boolean conditions? - /// Note: This method may miss a type condition. So, check `has_type_conditions` as well. - fn has_multiple_definitions(&self) -> bool { - self.0.len() > 1 - || self - .0 - .first() - .is_some_and(|(_, per_type_cond)| per_type_cond.has_boolean_conditions()) - } -} - -impl ResponseShape { - fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { - state.write("{")?; - state.indent_no_new_line(); - for (response_key, defs) in &self.definitions_per_response_key { - let has_type_cond = defs.has_type_conditions(&self.default_type_condition); - let arrow_sym = if has_type_cond || defs.has_multiple_definitions() { - "-may->" - } else { - "----->" - }; - for (type_condition, per_type_cond) in &defs.0 { - for variant in &per_type_cond.conditional_variants { - let field_display = &variant.field_display; - let type_cond_str = if has_type_cond { - format!(" on {}", type_condition) - } else { - "".to_string() - }; - let boolean_str = if !variant.boolean_clause.is_always_true() { - format!(" if {}", variant.boolean_clause) - } else { - "".to_string() - }; - state.new_line()?; - state.write(format_args!( - "{response_key} {arrow_sym} {field_display}{type_cond_str}{boolean_str}" - ))?; - if let Some(sub_selection_response_shape) = - &variant.sub_selection_response_shape - { - state.write(" ")?; - sub_selection_response_shape.write_indented(state)?; - } - } - } - } - state.dedent()?; - state.write("}") - } -} - -impl fmt::Display for ResponseShape { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - self.write_indented(&mut display_helpers::State::new(f)) - } -} - -//================================================================================================== -// ResponseShape computation +// ResponseShape computation from operation struct ResponseShapeContext { schema: ValidFederationSchema, @@ -699,6 +861,7 @@ struct ResponseShapeContext { type_condition: NormalizedTypeCondition, // accumulated type condition down from the parent field. inherited_clause: Clause, // accumulated conditions from the root up to parent field current_clause: Clause, // accumulated conditions down from the parent field + skip_introspection: bool, // true for input operation's root contexts only } impl ResponseShapeContext { @@ -745,6 +908,15 @@ impl ResponseShapeContext { response_shape: &mut ResponseShape, field: &Node, ) -> Result<(), FederationError> { + // Skip __typename fields in the input root context. + if self.skip_introspection && field.name == *INTROSPECTION_TYPENAME_FIELD_NAME { + return Ok(()); + } + // Skip introspection fields since QP ignores them. + // (see comments on `FieldSelection::from_field`) + if is_introspection_field_name(&field.name) { + return Ok(()); + } let Some(field_clause) = self .current_clause .add_selection_directives(&field.directives)? @@ -764,14 +936,17 @@ impl ResponseShapeContext { { None } else { + // The field's declared type may not be the most specific type (in case of up-casting). + // internal invariant check - assert_eq!(*field.ty().inner_named_type(), field.selection_set.ty); + ensure!(*field.ty().inner_named_type() == field.selection_set.ty, "internal invariant failure: field's type does not match with its selection set's type"); // A brand new context with the new type condition. // - Still inherits the boolean conditions for simplification purposes. let parent_type = field.selection_set.ty.clone(); - let type_condition = - NormalizedTypeCondition::from_type_name(parent_type.clone(), &self.schema)?; + let type_condition = self + .type_condition + .field_type_condition(field, &self.schema)?; let context = ResponseShapeContext { schema: self.schema.clone(), fragment_defs: self.fragment_defs.clone(), @@ -779,6 +954,7 @@ impl ResponseShapeContext { type_condition, inherited_clause, current_clause: Clause::default(), // empty + skip_introspection: false, // false by default }; Some(context.process_selection_set(&field.selection_set)?) }; @@ -792,8 +968,7 @@ impl ResponseShapeContext { field_clause, field_display(field), sub_selection_response_shape, - ); - Ok(()) + ) } /// For both inline fragments and fragment spreads @@ -805,7 +980,7 @@ impl ResponseShapeContext { selection_set: &SelectionSet, ) -> Result<(), FederationError> { // internal invariant check - assert_eq!(*fragment_type_condition, selection_set.ty); + ensure!(*fragment_type_condition == selection_set.ty, "internal invariant failure: fragment's type condition does not match with its selection set's type"); let Some(type_condition) = NormalizedTypeCondition::add_type_name( &self.type_condition, @@ -835,6 +1010,7 @@ impl ResponseShapeContext { type_condition, inherited_clause: self.inherited_clause.clone(), // no change current_clause, + skip_introspection: self.skip_introspection, }; context.process_selection_set_within(response_shape, selection_set) } @@ -857,45 +1033,262 @@ impl ResponseShapeContext { &self, selection_set: &SelectionSet, ) -> Result { - let type_condition = - NormalizedTypeCondition::from_type_name(selection_set.ty.clone(), &self.schema)?; - let mut response_shape = ResponseShape::new(type_condition); + let mut response_shape = ResponseShape::new(selection_set.ty.clone()); self.process_selection_set_within(&mut response_shape, selection_set)?; Ok(response_shape) } +} - fn process_operation( - operation_doc: &Valid, - schema: &ValidFederationSchema, - ) -> Result { - let mut op_iter = operation_doc.operations.iter(); - let Some(first) = op_iter.next() else { - return Err(internal_error!("Operation not found")); - }; - if op_iter.next().is_some() { - return Err(internal_error!("Multiple operations are not supported")); - } +fn is_introspection_field_name(name: &Name) -> bool { + name == "__schema" || name == "__type" +} - let fragment_defs = Arc::new(operation_doc.fragments.clone()); - let parent_type = first.selection_set.ty.clone(); - let type_condition = NormalizedTypeCondition::from_type_name(parent_type.clone(), schema)?; - // Start a new root context. - // - Not using `process_selection_set` because there is no parent context. - let context = ResponseShapeContext { - schema: schema.clone(), - fragment_defs, - parent_type, - type_condition, - inherited_clause: Clause::default(), // empty - current_clause: Clause::default(), // empty - }; - context.process_selection_set(&first.selection_set) +fn get_operation_and_fragment_definitions( + operation_doc: &Valid, +) -> Result<(Node, Arc), FederationError> { + let mut op_iter = operation_doc.operations.iter(); + let Some(first) = op_iter.next() else { + bail!("Operation not found") + }; + if op_iter.next().is_some() { + bail!("Multiple operations are not supported") } + + let fragment_defs = Arc::new(operation_doc.fragments.clone()); + Ok((first.clone(), fragment_defs)) +} + +pub fn compute_response_shape_for_operation( + operation_doc: &Valid, + schema: &ValidFederationSchema, +) -> Result { + // Coerce constant expressions since query planner does it for subgraph fetch operations. + let mut operation_doc = operation_doc.clone().into_inner(); + coerce_executable_values(schema.schema(), &mut operation_doc); + let operation_doc = operation_doc.validate(schema.schema())?; + + let (operation, fragment_defs) = get_operation_and_fragment_definitions(&operation_doc)?; + + // Start a new root context and process the root selection set. + // - Not using `process_selection_set` because there is no parent context. + let parent_type = operation.selection_set.ty.clone(); + let type_condition = NormalizedTypeCondition::from_type_name(parent_type.clone(), schema)?; + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs, + parent_type, + type_condition, + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: true, // true for root context + }; + context.process_selection_set(&operation.selection_set) } -pub fn compute_response_shape( +pub fn compute_the_root_type_condition_for_operation( + operation_doc: &Valid, +) -> Result { + let (operation, _) = get_operation_and_fragment_definitions(operation_doc)?; + Ok(operation.selection_set.ty.clone()) +} + +pub fn compute_response_shape_for_entity_fetch_operation( operation_doc: &Valid, schema: &ValidFederationSchema, ) -> Result { - ResponseShapeContext::process_operation(operation_doc, schema) + let (operation, fragment_defs) = get_operation_and_fragment_definitions(operation_doc)?; + + // drill down the `_entities` selection set + let mut sel_iter = operation.selection_set.selections.iter(); + let Some(first_selection) = sel_iter.next() else { + bail!("Entity fetch is expected to have at least one selection") + }; + if sel_iter.next().is_some() { + bail!("Entity fetch is expected to have exactly one selection") + } + let Selection::Field(field) = first_selection else { + bail!("Entity fetch is expected to have a field selection only") + }; + if field.name != crate::subgraph::spec::ENTITIES_QUERY { + bail!("Entity fetch is expected to have a field selection named `_entities`") + } + + // Start a new root context and process the `_entities` selection set. + // - Not using `process_selection_set` because there is no parent context. + let parent_type = crate::subgraph::spec::ENTITY_UNION_NAME.clone(); + let type_condition = NormalizedTypeCondition::for_entity(); + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs, + parent_type: parent_type.clone(), + type_condition: type_condition.clone(), + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + // Note: Can't call `context.process_selection_set` here, since the type condition is + // special for the `_entities` field. + let mut response_shape = ResponseShape::new(parent_type); + context.process_selection_set_within(&mut response_shape, &field.selection_set)?; + Ok(response_shape) +} + +/// Used for fetch-requires handling +pub fn compute_response_shape_for_inline_fragment( + inline: &InlineFragment, + schema: &ValidFederationSchema, +) -> Result { + let Some(type_condition) = &inline.type_condition else { + bail!("Requires inline fragment must have a type condition") + }; + let normalized_type_condition = + NormalizedTypeCondition::from_type_name(type_condition.clone(), schema)?; + let context = ResponseShapeContext { + schema: schema.clone(), + fragment_defs: Default::default(), // empty + parent_type: type_condition.clone(), + type_condition: normalized_type_condition, + inherited_clause: Clause::default(), // empty + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + context.process_selection_set(&inline.selection_set) +} + +//================================================================================================== +// ResponseShape display +// - This section is only for display and thus untrusted. + +impl fmt::Display for AppliedTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.0.is_empty() { + return write!(f, ""); + } + for (i, cond) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ∧ ")?; + } + write!(f, "{}", cond.type_name())?; + } + Ok(()) + } +} + +impl fmt::Display for NormalizedTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.ground_set.is_empty() { + write!(f, "{}", crate::subgraph::spec::ENTITY_UNION_NAME)?; + return Ok(()); + } + + write!(f, "{}", self.for_display)?; + if self.for_display.0.len() != 1 { + write!(f, " = {{")?; + for (i, ty) in self.ground_set.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", ty.type_name)?; + } + write!(f, "}}")?; + } + Ok(()) + } +} + +impl fmt::Display for Clause { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.0.is_empty() { + write!(f, "true") + } else { + for (i, l) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ∧ ")?; + } + match l { + Literal::Pos(v) => write!(f, "{}", v)?, + Literal::Neg(v) => write!(f, "¬{}", v)?, + } + } + Ok(()) + } + } +} + +impl PossibleDefinitionsPerTypeCondition { + fn has_boolean_conditions(&self) -> bool { + self.conditional_variants.len() > 1 + || self + .conditional_variants + .first() + .is_some_and(|variant| !variant.boolean_clause.is_always_true()) + } +} + +impl PossibleDefinitions { + /// Is conditional on runtime type? + fn has_type_conditions(&self, default_type_condition: &Name) -> bool { + self.0.len() > 1 + || self.0.first().is_some_and(|(type_condition, _)| { + !type_condition.is_named_type(default_type_condition) + }) + } + + /// Has multiple possible definitions or has any boolean conditions? + /// Note: This method may miss a type condition. So, check `has_type_conditions` as well. + fn has_multiple_definitions(&self) -> bool { + self.0.len() > 1 + || self + .0 + .first() + .is_some_and(|(_, per_type_cond)| per_type_cond.has_boolean_conditions()) + } +} + +impl ResponseShape { + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + state.write("{")?; + state.indent_no_new_line(); + for (response_key, defs) in &self.definitions_per_response_key { + let has_type_cond = defs.has_type_conditions(&self.default_type_condition); + let arrow_sym = if has_type_cond || defs.has_multiple_definitions() { + "-may->" + } else { + "----->" + }; + for (type_condition, per_type_cond) in &defs.0 { + for variant in &per_type_cond.conditional_variants { + let field_display = &variant.representative_field; + let type_cond_str = if has_type_cond { + format!(" on {}", type_condition) + } else { + "".to_string() + }; + let boolean_str = if !variant.boolean_clause.is_always_true() { + format!(" if {}", variant.boolean_clause) + } else { + "".to_string() + }; + state.new_line()?; + state.write(format_args!( + "{response_key} {arrow_sym} {field_display}{type_cond_str}{boolean_str}" + ))?; + if let Some(sub_selection_response_shape) = + &variant.sub_selection_response_shape + { + state.write(" ")?; + sub_selection_response_shape.write_indented(state)?; + } + } + } + } + state.dedent()?; + state.write("}") + } +} + +impl fmt::Display for ResponseShape { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } } diff --git a/apollo-federation/src/query_plan/correctness/response_shape_compare.rs b/apollo-federation/src/query_plan/correctness/response_shape_compare.rs new file mode 100644 index 00000000000..e71a852b12d --- /dev/null +++ b/apollo-federation/src/query_plan/correctness/response_shape_compare.rs @@ -0,0 +1,290 @@ +// Compare response shapes from a query plan and an input operation. + +use apollo_compiler::ast; +use apollo_compiler::executable::Field; +use apollo_compiler::Node; + +use super::response_shape::DefinitionVariant; +use super::response_shape::FieldSelectionKey; +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape::PossibleDefinitionsPerTypeCondition; +use super::response_shape::ResponseShape; +use crate::error::FederationError; +use crate::internal_error; +use crate::utils::FallibleIterator; + +#[derive(Debug)] +pub struct MatchFailure { + description: String, +} + +impl MatchFailure { + pub fn description(&self) -> &str { + &self.description + } + + fn new(description: String) -> MatchFailure { + MatchFailure { description } + } + + fn add_description(self: MatchFailure, description: &str) -> MatchFailure { + MatchFailure { + description: format!("{}\n{}", self.description, description), + } + } +} + +macro_rules! check_match_eq { + ($a:expr, $b:expr) => { + if $a != $b { + let message = format!( + "mismatch between {} and {}:\nleft: {:?}\nright: {:?}", + stringify!($a), + stringify!($b), + $a, + $b, + ); + return Err(MatchFailure::new(message)); + } + }; +} + +// Check if `this` is a subset of `other`. +pub fn compare_response_shapes( + this: &ResponseShape, + other: &ResponseShape, +) -> Result<(), MatchFailure> { + // Note: `default_type_condition` is for display. + // Only response key and definitions are compared. + this.iter().try_for_each(|(key, this_def)| { + let other_def = other + .get(key) + .ok_or_else(|| MatchFailure::new(format!("missing response key: {key}")))?; + compare_possible_definitions(this_def, other_def) + .map_err(|e| e.add_description(&format!("mismatch for response key: {key}"))) + }) +} + +/// Merge all definitions applicable to the given type condition. +fn merge_definitions_for_type_condition( + defs: &PossibleDefinitions, + filter_cond: &NormalizedTypeCondition, +) -> Result { + let mut result: Option = None; + for (type_cond, def) in defs.iter() { + if filter_cond.implies(type_cond) { + let result = result.get_or_insert_with(|| def.clone()); + def.conditional_variants() + .iter() + .try_for_each(|variant| result.insert_variant(variant.clone()))?; + } + } + if let Some(result) = result { + Ok(result) + } else { + Err(internal_error!( + "no definitions found for type condition: {filter_cond}" + )) + } +} + +fn compare_possible_definitions( + this: &PossibleDefinitions, + other: &PossibleDefinitions, +) -> Result<(), MatchFailure> { + this.iter().try_for_each(|(this_cond, this_def)| { + if let Some(other_def) = other.get(this_cond) { + let result = compare_possible_definitions_per_type_condition(this_def, other_def); + match result { + Ok(result) => return Ok(result), + Err(err) => { + // See if `this_cond` is an object (or non-abstract) type. + if this_cond.ground_set().len() == 1 { + // Concrete types can't be type blasted. + return Err(MatchFailure::new(format!( + "mismatch for type condition: {this_cond}\n{}", + err.description() + ))); + } + } + } + // fall through + } + + // If there is no exact match for `this_cond`, try individual ground types. + this_cond.ground_set().iter().try_for_each(|ground_ty| { + let filter_cond = NormalizedTypeCondition::from_ground_type(ground_ty); + let other_def = + merge_definitions_for_type_condition(other, &filter_cond).map_err(|e| { + MatchFailure::new(format!( + "missing type condition: {this_cond} ({ground_ty})\n{e}" + )) + })?; + compare_possible_definitions_per_type_condition(this_def, &other_def).map_err(|e| { + e.add_description(&format!( + "mismatch for type condition: {this_cond} ({ground_ty})" + )) + }) + }) + }) +} + +fn compare_possible_definitions_per_type_condition( + this: &PossibleDefinitionsPerTypeCondition, + other: &PossibleDefinitionsPerTypeCondition, +) -> Result<(), MatchFailure> { + compare_field_selection_key(this.field_selection_key(), other.field_selection_key()).map_err( + |e| { + e.add_description( + "mismatch in field selection key of PossibleDefinitionsPerTypeCondition", + ) + }, + )?; + this.conditional_variants() + .iter() + .try_for_each(|this_def| { + // search the same boolean clause in other + let found = other + .conditional_variants() + .iter() + .fallible_any(|other_def| { + if this_def.boolean_clause() != other_def.boolean_clause() { + Ok(false) + } else { + compare_definition_variant(this_def, other_def)?; + Ok(true) + } + })?; + if !found { + Err(MatchFailure::new( + format!("mismatch in Boolean conditions of PossibleDefinitionsPerTypeCondition:\n expected clause: {}\ntarget definition: {:?}", + this_def.boolean_clause(), + other.conditional_variants(), + ), + )) + } else { + Ok(()) + } + }) +} + +fn compare_definition_variant( + this: &DefinitionVariant, + other: &DefinitionVariant, +) -> Result<(), MatchFailure> { + compare_representative_field(this.representative_field(), other.representative_field()) + .map_err(|e| e.add_description("mismatch in field display under definition variant"))?; + check_match_eq!(this.boolean_clause(), other.boolean_clause()); + match ( + this.sub_selection_response_shape(), + other.sub_selection_response_shape(), + ) { + (None, None) => Ok(()), + (Some(this_sub), Some(other_sub)) => { + compare_response_shapes(this_sub, other_sub).map_err(|e| { + e.add_description(&format!( + "mismatch in response shape under definition variant: ---> {} if {}", + this.representative_field(), + this.boolean_clause() + )) + }) + } + _ => Err(MatchFailure::new( + "mismatch in compare_definition_variant".to_string(), + )), + } +} + +fn compare_field_selection_key( + this: &FieldSelectionKey, + other: &FieldSelectionKey, +) -> Result<(), MatchFailure> { + check_match_eq!(this.name, other.name); + // Note: Arguments are expected to be normalized. + check_match_eq!(this.arguments, other.arguments); + Ok(()) +} + +fn compare_representative_field(this: &Field, other: &Field) -> Result<(), MatchFailure> { + check_match_eq!(this.name, other.name); + // Note: Arguments and directives are NOT normalized. + if !same_ast_arguments(&this.arguments, &other.arguments) { + return Err(MatchFailure::new(format!( + "mismatch in representative field arguments: {:?} vs {:?}", + this.arguments, other.arguments + ))); + } + if !same_directives(&this.directives, &other.directives) { + return Err(MatchFailure::new(format!( + "mismatch in representative field directives: {:?} vs {:?}", + this.directives, other.directives + ))); + } + Ok(()) +} + +//================================================================================================== +// AST comparison functions + +fn same_ast_argument_value(x: &ast::Value, y: &ast::Value) -> bool { + match (x, y) { + // Object fields may be in different order. + (ast::Value::Object(ref x), ast::Value::Object(ref y)) => vec_matches_sorted_by( + x, + y, + |(xx_name, _), (yy_name, _)| xx_name.cmp(yy_name), + |(_, xx_val), (_, yy_val)| same_ast_argument_value(xx_val, yy_val), + ), + + // Recurse into list items. + (ast::Value::List(ref x), ast::Value::List(ref y)) => { + vec_matches(x, y, |xx, yy| same_ast_argument_value(xx, yy)) + } + + _ => x == y, // otherwise, direct compare + } +} + +fn same_ast_argument(x: &ast::Argument, y: &ast::Argument) -> bool { + x.name == y.name && same_ast_argument_value(&x.value, &y.value) +} + +fn same_ast_arguments(x: &[Node], y: &[Node]) -> bool { + vec_matches_sorted_by( + x, + y, + |a, b| a.name.cmp(&b.name), + |a, b| same_ast_argument(a, b), + ) +} + +fn same_directives(x: &ast::DirectiveList, y: &ast::DirectiveList) -> bool { + vec_matches_sorted_by( + x, + y, + |a, b| a.name.cmp(&b.name), + |a, b| a.name == b.name && same_ast_arguments(&a.arguments, &b.arguments), + ) +} + +//================================================================================================== +// Vec comparison functions + +fn vec_matches(this: &[T], other: &[T], item_matches: impl Fn(&T, &T) -> bool) -> bool { + this.len() == other.len() + && std::iter::zip(this, other).all(|(this, other)| item_matches(this, other)) +} + +fn vec_matches_sorted_by( + this: &[T], + other: &[T], + compare: impl Fn(&T, &T) -> std::cmp::Ordering, + item_matches: impl Fn(&T, &T) -> bool, +) -> bool { + let mut this_sorted = this.to_owned(); + let mut other_sorted = other.to_owned(); + this_sorted.sort_by(&compare); + other_sorted.sort_by(&compare); + vec_matches(&this_sorted, &other_sorted, item_matches) +} diff --git a/apollo-federation/src/query_plan/correctness/response_shape_test.rs b/apollo-federation/src/query_plan/correctness/response_shape_test.rs index 0a218a916b3..f3f372f1a2f 100644 --- a/apollo-federation/src/query_plan/correctness/response_shape_test.rs +++ b/apollo-federation/src/query_plan/correctness/response_shape_test.rs @@ -72,7 +72,7 @@ fn response_shape(op_str: &str) -> response_shape::ResponseShape { let schema = Schema::parse_and_validate(SCHEMA_STR, "schema.graphql").unwrap(); let schema = ValidFederationSchema::new(schema).unwrap(); let op = ExecutableDocument::parse_and_validate(schema.schema(), op_str, "op.graphql").unwrap(); - response_shape::compute_response_shape(&op, &schema).unwrap() + response_shape::compute_response_shape_for_operation(&op, &schema).unwrap() } //================================================================================================= diff --git a/apollo-federation/src/query_plan/query_planner.rs b/apollo-federation/src/query_plan/query_planner.rs index 09de6487728..3ad8aa3d1d9 100644 --- a/apollo-federation/src/query_plan/query_planner.rs +++ b/apollo-federation/src/query_plan/query_planner.rs @@ -486,6 +486,10 @@ impl QueryPlanner { pub fn api_schema(&self) -> &ValidFederationSchema { &self.api_schema } + + pub fn supergraph_schema(&self) -> &ValidFederationSchema { + &self.supergraph_schema + } } fn compute_root_serial_dependency_graph(