diff --git a/apollo-federation/Cargo.toml b/apollo-federation/Cargo.toml index 03a4cca0ac..dc19571a1c 100644 --- a/apollo-federation/Cargo.toml +++ b/apollo-federation/Cargo.toml @@ -15,6 +15,8 @@ autotests = false # Integration tests are m # This logging is gated behind a feature to avoid any unnecessary (even if # small) runtime costs where this data will not be desired. snapshot_tracing = ["ron"] +# `correctness` feature enables the `correctness` module. +correctness = [] [dependencies] apollo-compiler.workspace = true diff --git a/apollo-federation/cli/Cargo.toml b/apollo-federation/cli/Cargo.toml index 8fbb8fb86a..7d4aa6d71d 100644 --- a/apollo-federation/cli/Cargo.toml +++ b/apollo-federation/cli/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] apollo-compiler.workspace = true -apollo-federation = { path = ".." } +apollo-federation = { path = "..", features = ["correctness"] } clap = { version = "4.5.1", features = ["derive"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/apollo-federation/cli/src/main.rs b/apollo-federation/cli/src/main.rs index 803ea59f2e..12ee5c867c 100644 --- a/apollo-federation/cli/src/main.rs +++ b/apollo-federation/cli/src/main.rs @@ -6,8 +6,10 @@ use std::path::PathBuf; use std::process::ExitCode; use apollo_compiler::ExecutableDocument; +use apollo_federation::correctness::CorrectnessError; use apollo_federation::error::FederationError; use apollo_federation::error::SingleFederationError; +use apollo_federation::internal_error; use apollo_federation::query_graph; use apollo_federation::query_plan::query_planner::QueryPlanner; use apollo_federation::query_plan::query_planner::QueryPlannerConfig; @@ -262,11 +264,31 @@ fn cmd_plan( let query_doc = ExecutableDocument::parse_and_validate(planner.api_schema().schema(), query, query_path)?; - print!( - "{}", - planner.build_query_plan(&query_doc, None, Default::default())? + let query_plan = planner.build_query_plan(&query_doc, None, Default::default())?; + println!("{query_plan}"); + + // Check the query plan + let subgraphs_by_name = supergraph + .extract_subgraphs() + .unwrap() + .into_iter() + .map(|(name, subgraph)| (name, subgraph.schema)) + .collect(); + let result = apollo_federation::correctness::check_plan( + planner.api_schema(), + &supergraph.schema, + &subgraphs_by_name, + &query_doc, + &query_plan, ); - Ok(()) + match result { + Ok(_) => Ok(()), + Err(CorrectnessError::FederationError(e)) => Err(e), + Err(CorrectnessError::ComparisonError(e)) => Err(internal_error!( + "Response shape from query plan does not match response shape from input operation:\n{}", + e.description() + )), + } } fn cmd_validate(file_paths: &[PathBuf]) -> Result<(), FederationError> { diff --git a/apollo-federation/src/correctness/mod.rs b/apollo-federation/src/correctness/mod.rs new file mode 100644 index 0000000000..f9ad484988 --- /dev/null +++ b/apollo-federation/src/correctness/mod.rs @@ -0,0 +1,83 @@ +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; +mod subgraph_constraint; + +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::validation::Valid; +use apollo_compiler::ExecutableDocument; + +use crate::compat::coerce_executable_values; +use crate::correctness::response_shape_compare::compare_response_shapes_with_constraint; +use crate::correctness::response_shape_compare::ComparisonError; +use crate::query_plan::QueryPlan; +use crate::schema::ValidFederationSchema; +use crate::FederationError; + +//================================================================================================== +// Public API + +#[derive(derive_more::From)] +pub enum CorrectnessError { + /// Correctness checker's own error + FederationError(FederationError), + /// Error in the input that is subject to comparison + ComparisonError(ComparisonError), +} + +/// Check if `this`'s response shape is a subset of `other`'s response shape. +pub fn compare_operations( + schema: &ValidFederationSchema, + this: &Valid, + other: &Valid, +) -> Result<(), CorrectnessError> { + let this_rs = response_shape::compute_response_shape_for_operation(this, schema)?; + let other_rs = response_shape::compute_response_shape_for_operation(other, schema)?; + tracing::debug!( + "compare_operations:\nResponse shape (left): {this_rs}\nResponse shape (right): {other_rs}" + ); + Ok(response_shape_compare::compare_response_shapes( + &this_rs, &other_rs, + )?) +} + +/// Check the correctness of the query plan against the schema and input operation by comparing +/// the response shape of the input operation and the response shape of the query plan. +/// - The input operation's response shape is supposed to be a subset of the input operation's. +pub fn check_plan( + api_schema: &ValidFederationSchema, + supergraph_schema: &ValidFederationSchema, + subgraphs_by_name: &IndexMap, ValidFederationSchema>, + operation_doc: &Valid, + plan: &QueryPlan, +) -> Result<(), CorrectnessError> { + // Coerce constant expressions in the input operation document since query planner does it for + // subgraph fetch operations. But, this may be unnecessary in the future (see ROUTER-816). + let mut operation_doc = operation_doc.clone().into_inner(); + coerce_executable_values(api_schema.schema(), &mut operation_doc); + let operation_doc = operation_doc + .validate(api_schema.schema()) + .map_err(FederationError::from)?; + + let op_rs = response_shape::compute_response_shape_for_operation(&operation_doc, api_schema)?; + let root_type = response_shape::compute_the_root_type_condition_for_operation(&operation_doc)?; + let plan_rs = query_plan_analysis::interpret_query_plan(supergraph_schema, &root_type, plan) + .map_err(|e| { + ComparisonError::new(format!( + "Failed to compute the response shape from query plan:\n{e}" + )) + })?; + tracing::debug!( + "check_plan:\nOperation response shape: {op_rs}\nQuery plan response shape: {plan_rs}" + ); + + let path_constraint = subgraph_constraint::SubgraphConstraint::at_root(subgraphs_by_name); + compare_response_shapes_with_constraint(&path_constraint, &op_rs, &plan_rs)?; + Ok(()) +} diff --git a/apollo-federation/src/correctness/query_plan_analysis.rs b/apollo-federation/src/correctness/query_plan_analysis.rs new file mode 100644 index 0000000000..3bab58c42c --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_analysis.rs @@ -0,0 +1,632 @@ +// Analyze a QueryPlan and compute its overall response shape + +use apollo_compiler::executable::Name; +use itertools::Itertools; + +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::query_plan::ConditionNode; +use crate::query_plan::DeferNode; +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::position::ObjectTypeDefinitionPosition; +use crate::schema::ValidFederationSchema; +use crate::FederationError; +use crate::SingleFederationError; + +//================================================================================================== +// 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 + +fn format_federation_error(e: FederationError) -> String { + match e { + FederationError::SingleFederationError(e) => match e { + SingleFederationError::Internal { message } => message, + _ => e.to_string(), + }, + _ => e.to_string(), + } +} + +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) => interpret_defer_node(schema, state, &conditions, defer), + 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, &result, &conditions, rest)?; + result.merge_with(&rest).map_err(|e| { + format!( + "Failed to merge response shapes in subscription node: {}\nrest: {rest}", + format_federation_error(e), + ) + })?; + } + 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) => interpret_defer_node(schema, state, conditions, defer), + } +} + +// `type_filter`: The type condition to apply to the response shape. +// - This is from the previous path elements. +// - It can be empty if there is no type conditions. +// - Also, multiple type conditions can be accumulated (meaning the conjunction of them). +fn rename_at_path( + schema: &ValidFederationSchema, + state: &ResponseShape, + mut type_filter: Vec, + path: &[FetchDataPathElement], + new_name: Name, +) -> Result { + let Some((first, rest)) = path.split_first() else { + return Err("rename_at_path: unexpected empty path".to_string()); + }; + match first { + FetchDataPathElement::Key(name, _conditions) => { + if _conditions.is_some() { + return Err("rename_at_path: unexpected key conditions".to_string()); + } + 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(); + // Compute the normalized type condition for the type filter. + let type_filter = if let Some((first_type, rest_of_types)) = type_filter.split_first() { + let mut type_condition = + NormalizedTypeCondition::from_type_name(first_type.clone(), schema) + .map_err(format_federation_error)?; + for type_name in rest_of_types { + let Some(updated) = type_condition + .add_type_name(type_name.clone(), schema) + .map_err(format_federation_error)? + else { + return Err(format!( + "rename_at_path: inconsistent type conditions: {type_filter:?}" + )); + }; + type_condition = updated; + } + Some(type_condition) + } else { + None + }; + // 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 { + 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(format!( + "No sub-selection at path: {}", + path.iter().join(".") + )); + }; + let updated_sub_state = rename_at_path( + schema, + sub_state, + Default::default(), // new type filter + 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 { + return Err(format!("rename_at_path: new name/type already exists: {new_name} on {type_cond}")); + } + } + result.insert(new_name, merged_defs); + } + } + } + Ok(result) + } + FetchDataPathElement::AnyIndex(_conditions) => { + Err("rename_at_path: unexpected AnyIndex path element".to_string()) + } + FetchDataPathElement::TypenameEquals(type_name) => { + type_filter.push(type_name.clone()); + rename_at_path(schema, state, type_filter, rest, new_name) + } + FetchDataPathElement::Parent => { + Err("rename_at_path: unexpected parent path element".to_string()) + } + } +} + +fn apply_rewrites( + schema: &ValidFederationSchema, + state: &ResponseShape, + rewrite: &FetchDataRewrite, +) -> Result { + match rewrite { + FetchDataRewrite::ValueSetter(_) => { + Err("apply_rewrites: unexpected value setter".to_string()) + } + FetchDataRewrite::KeyRenamer(renamer) => rename_at_path( + schema, + state, + Default::default(), // new type filter + &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 { + // TODO: check requires + 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)) + } + .map_err(|e| { + format!( + "Failed to compute the response shape from fetch node: {}\nnode: {fetch}", + format_federation_error(e), + ) + })?; + 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("Condition node must have either if or else clause".to_string()), + (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).map_err(|e| { + format!( + "Failed to merge response shapes from then and else clauses:\n{}", + format_federation_error(e), + ) + })?; + Ok(result) + } + } +} + +fn interpret_plan_node_at_path( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + type_condition: Option<&Vec>, + path: &[FetchDataPathElement], + node: &PlanNode, +) -> Result, String> { + let Some((first, rest)) = path.split_first() else { + return Ok(Some(interpret_plan_node(schema, state, conditions, node)?)); + }; + match first { + FetchDataPathElement::Key(name, next_type_condition) => { + // Note: `next_type_condition` is applied to the next key down the path. + let filter_type_cond = type_condition + .map(|cond| { + let obj_types: Result, FederationError> = + cond.iter() + .map(|name| { + let ty: ObjectTypeDefinitionPosition = + schema.get_type(name.clone())?.try_into()?; + Ok(ty) + }) + .collect(); + let obj_types = obj_types.map_err(format_federation_error)?; + Ok::<_, String>(NormalizedTypeCondition::from_object_types( + obj_types.into_iter(), + )) + }) + .transpose()?; + 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() { + if let Some(filter_type_cond) = &filter_type_cond { + if !filter_type_cond.implies(type_cond) { + // Not applicable => same as before + updated_defs.insert(type_cond.clone(), defs_per_type_cond.clone()); + continue; + } + } + 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(format!( + "No sub-selection at path: {}", + path.iter() + .map(|p| p.to_string()) + .collect::>() + .join(".") + ))); + }; + let updated_sub_state = interpret_plan_node_at_path( + schema, + sub_state, + conditions, + next_type_condition.as_ref(), + 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(next_type_condition) => { + if type_condition.is_some() { + return Err("flatten: unexpected multiple type conditions".to_string()); + } + let type_condition = next_type_condition.as_ref(); + interpret_plan_node_at_path(schema, state, conditions, type_condition, rest, node) + } + FetchDataPathElement::TypenameEquals(_type_name) => { + Err("flatten: unexpected TypenameEquals variant".to_string()) + } + FetchDataPathElement::Parent => Err("flatten: unexpected Parent variant".to_string()), + } +} + +fn interpret_flatten_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + flatten: &FlattenNode, +) -> Result { + let result = interpret_plan_node_at_path( + schema, + state, + conditions, + None, // no type condition at the top level + &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. + // TODO: Report this as a over-fetching later. + 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).map_err(|e| { + format!( + "Failed to merge response shapes in sequence node: {}\nnode: {node}", + format_federation_error(e), + ) + })?; + } + 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).map_err(|e| { + format!( + "Failed to merge response shapes in parallel node: {}\nnode: {node}", + format_federation_error(e), + ) + })?; + } + Ok(response_shape) +} + +fn interpret_defer_node( + schema: &ValidFederationSchema, + state: &ResponseShape, + conditions: &[Literal], + defer: &DeferNode, +) -> Result { + // new `state` after the primary node + let state = if let Some(primary_node) = &defer.primary.node { + &interpret_plan_node(schema, state, conditions, primary_node)? + } else { + state + }; + + // interpret the deferred nodes and merge their response shapes. + let mut result = state.clone(); + for defer_block in &defer.deferred { + let Some(node) = &defer_block.node else { + // Nothing to do => skip + continue; + }; + // Note: Use the same primary node state for each parallel node + let node_rs = interpret_plan_node(schema, state, conditions, node)?; + result.merge_with(&node_rs).map_err(|e| { + format!( + "Failed to merge response shapes in deferred node: {}\nnode: {node}", + format_federation_error(e), + ) + })?; + } + Ok(result) +} diff --git a/apollo-federation/src/correctness/query_plan_analysis_test.rs b/apollo-federation/src/correctness/query_plan_analysis_test.rs new file mode 100644 index 0000000000..565c421ecd --- /dev/null +++ b/apollo-federation/src/correctness/query_plan_analysis_test.rs @@ -0,0 +1,291 @@ +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: "local-tests/query-plan-response-shape/test-template.graphql?subgraph=A") + B @join__graph(name: "B", url: "local-tests/query-plan-response-shape/test-template.graphql?subgraph=B") + S @join__graph(name: "S", url: "local-tests/query-plan-response-shape/test-template.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) + nested: I! @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 { + // Initialization + let config = query_planner::QueryPlannerConfig { + generate_query_fragments: false, + type_conditioned_fetching: false, + incremental_delivery: query_planner::QueryPlanIncrementalDeliveryConfig { + enable_defer: true, + }, + ..Default::default() + }; + let supergraph = crate::Supergraph::new(SCHEMA_STR).unwrap(); + let planner = query_planner::QueryPlanner::new(&supergraph, config).unwrap(); + + // Parse the schema and operation + let api_schema = planner.api_schema(); + let op = + ExecutableDocument::parse_and_validate(api_schema.schema(), op_str, "op.graphql").unwrap(); + + // Plan the query + let query_plan = planner + .build_query_plan(&op, None, Default::default()) + .unwrap(); + + // Compare response shapes + let op_rs = response_shape::compute_response_shape_for_operation(&op, api_schema).unwrap(); + let root_type = response_shape::compute_the_root_type_condition_for_operation(&op).unwrap(); + let plan_rs = interpret_query_plan(&supergraph.schema, &root_type, &query_plan).unwrap(); + let subgraphs_by_name = supergraph + .extract_subgraphs() + .unwrap() + .into_iter() + .map(|(name, subgraph)| (name, subgraph.schema)) + .collect(); + let path_constraint = subgraph_constraint::SubgraphConstraint::at_root(&subgraphs_by_name); + assert!(compare_response_shapes_with_constraint(&path_constraint, &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) + } + } + "###); +} + +#[test] +fn test_defer_node() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + ... @defer { + data_a(arg: 0) + } + ... @defer { + 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) + } + } + "###); +} + +#[test] +fn test_defer_node_nested() { + let op_str = r#" + query($v1: Boolean!) { + test_i @include(if: $v1) { + data(arg: 0) + ... on T @defer { + nested { + ... @defer { + data_b(arg: 1) + } + } + } + } + } + "#; + insta::assert_snapshot!(plan_response_shape(op_str), @r###" + { + test_i -may-> test_i if v1 { + __typename -----> __typename + data -----> data(arg: 0) + id -----> id + nested -may-> nested on T { + __typename -----> __typename + id -----> id + data_b -----> data_b(arg: 1) + } + } + } + "###); +} diff --git a/apollo-federation/src/correctness/response_shape.rs b/apollo-federation/src/correctness/response_shape.rs new file mode 100644 index 0000000000..ea1a2ee232 --- /dev/null +++ b/apollo-federation/src/correctness/response_shape.rs @@ -0,0 +1,1362 @@ +use std::fmt; +use std::sync::Arc; + +use apollo_compiler::ast; +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::Operation; +use apollo_compiler::executable::Selection; +use apollo_compiler::executable::SelectionSet; +use apollo_compiler::validation::Valid; +use apollo_compiler::ExecutableDocument; +use apollo_compiler::Name; +use apollo_compiler::Node; + +use crate::bail; +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; + +//================================================================================================== +// Vec utilities + +fn vec_sorted_by(src: &[T], compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Vec { + let mut sorted = src.to_owned(); + sorted.sort_by(&compare); + sorted +} + +//================================================================================================== +// Type conditions + +fn get_interface_implementers<'a>( + interface: &InterfaceTypeDefinitionPosition, + schema: &'a ValidFederationSchema, +) -> Result<&'a IndexSet, FederationError> { + Ok(&schema + .referencers() + .get_interface_type(&interface.type_name)? + .object_types) +} + +/// Does `x` implies `y`? (`x`'s possible types is a subset of `y`'s possible types) +/// - All type-definition positions are in the given schema. +// Note: Similar to `runtime_types_intersect` (avoids using `possible_runtime_types`) +fn runtime_types_implies( + x: &CompositeTypeDefinitionPosition, + y: &CompositeTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result { + use CompositeTypeDefinitionPosition::*; + match (x, y) { + (Object(x), Object(y)) => Ok(x == y), + (Object(object), Union(union)) => { + // Union members must be object types in GraphQL. + let union_type = union.get(schema.schema())?; + Ok(union_type.members.contains(&object.type_name)) + } + (Union(union), Object(object)) => { + // Is `object` the only member of `union`? + let union_type = union.get(schema.schema())?; + Ok(union_type.members.len() == 1 && union_type.members.contains(&object.type_name)) + } + (Object(object), Interface(interface)) => { + // Interface implementers must be object types in GraphQL. + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(interface_implementers.contains(object)) + } + (Interface(interface), Object(object)) => { + // Is `object` the only implementer of `interface`? + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(interface_implementers.len() == 1 && interface_implementers.contains(object)) + } + + (Union(x), Union(y)) if x == y => Ok(true), + (Union(x), Union(y)) => { + let (x, y) = (x.get(schema.schema())?, y.get(schema.schema())?); + Ok(x.members.is_subset(&y.members)) + } + + (Interface(x), Interface(y)) if x == y => Ok(true), + (Interface(x), Interface(y)) => { + let x = get_interface_implementers(x, schema)?; + let y = get_interface_implementers(y, schema)?; + Ok(x.is_subset(y)) + } + + (Union(union), Interface(interface)) => { + let union = union.get(schema.schema())?; + let interface_implementers = get_interface_implementers(interface, schema)?; + Ok(union.members.iter().all(|m| { + let m_ty = ObjectTypeDefinitionPosition::new(m.name.clone()); + interface_implementers.contains(&m_ty) + })) + } + (Interface(interface), Union(union)) => { + let interface_implementers = get_interface_implementers(interface, schema)?; + let union = union.get(schema.schema())?; + Ok(interface_implementers + .iter() + .all(|t| union.members.contains(&t.type_name))) + } + } +} + +/// Constructs a set of object types +/// - Slow: calls `possible_runtime_types` and sorts the result. +fn get_ground_types( + ty: &CompositeTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result, FederationError> { + let mut result = schema.possible_runtime_types(ty.clone())?; + result.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + Ok(result.into_iter().collect()) +} + +/// A sequence of type conditions applied (used for display) +// - This displays a type condition as an intersection of named types. +// - 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 DisplayTypeCondition(Vec); + +impl DisplayTypeCondition { + fn new(ty: CompositeTypeDefinitionPosition) -> Self { + DisplayTypeCondition(vec![ty]) + } + + fn deduced() -> Self { + DisplayTypeCondition(Vec::new()) + } + + /// Construct a new type condition with a named type condition added. + fn add_type_name( + &self, + name: Name, + schema: &ValidFederationSchema, + ) -> Result { + let ty: CompositeTypeDefinitionPosition = schema.get_type(name)?.try_into()?; + if self + .0 + .iter() + .fallible_any(|t| runtime_types_implies(t, &ty, schema))? + { + return Ok(self.clone()); + } + // filter out existing conditions that are implied by `ty`. + let mut buf = Vec::new(); + for t in &self.0 { + if !runtime_types_implies(&ty, t, schema)? { + buf.push(t.clone()); + } + } + buf.push(ty); + buf.sort_by(|a, b| a.type_name().cmp(b.type_name())); + Ok(DisplayTypeCondition(buf)) + } +} + +/// Aggregated type conditions that are normalized for comparison +#[derive(Debug, Clone)] +pub struct NormalizedTypeCondition { + // The set of object types that are used for comparison. + // - 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, + + // Simplified type condition for display. + for_display: DisplayTypeCondition, +} + +impl PartialEq for NormalizedTypeCondition { + fn eq(&self, other: &Self) -> bool { + self.ground_set == other.ground_set + } +} + +impl Eq for NormalizedTypeCondition {} + +impl std::hash::Hash for NormalizedTypeCondition { + fn hash(&self, state: &mut H) { + self.ground_set.hash(state); + } +} + +// Public constructors & accessors +impl NormalizedTypeCondition { + /// Construct a new type condition with a single named type condition. + 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)?, + for_display: DisplayTypeCondition::new(ty), + }) + } + + pub(crate) fn from_object_type(ty: &ObjectTypeDefinitionPosition) -> Self { + NormalizedTypeCondition { + ground_set: vec![ty.clone()], + for_display: DisplayTypeCondition::new(ty.clone().into()), + } + } + + pub(crate) fn from_object_types( + types: impl Iterator, + ) -> Self { + let mut ground_set: Vec<_> = types.collect(); + ground_set.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + NormalizedTypeCondition { + ground_set, + for_display: DisplayTypeCondition::deduced(), + } + } + + /// Special constructor with empty conditions (logically contains *all* types). + /// - Used for the `_Entity` type. + pub(crate) fn unconstrained() -> Self { + NormalizedTypeCondition { + ground_set: Vec::new(), + for_display: DisplayTypeCondition::deduced(), + } + } + + pub(crate) fn ground_set(&self) -> &[ObjectTypeDefinitionPosition] { + &self.ground_set + } + + /// Is this type condition represented by a single named type? + pub fn is_named_type(&self, type_name: &Name) -> bool { + // Check the display type first. + let Some((first, rest)) = self.for_display.0.split_first() else { + return false; + }; + if rest.is_empty() && first.type_name() == type_name { + return true; + } + + // Check the ground set. + let Some((first, rest)) = self.ground_set.split_first() else { + return false; + }; + rest.is_empty() && first.type_name == *type_name + } + + /// Is this type condition a named object type? + pub fn is_named_object_type(&self) -> bool { + let Some((display_first, display_rest)) = self.for_display.0.split_first() else { + // Deduced condition is not an object type. + return false; + }; + display_rest.is_empty() && display_first.is_object_type() + } + + /// precondition: `self` and `other` are not empty. + pub fn implies(&self, other: &Self) -> bool { + self.ground_set.iter().all(|t| other.ground_set.contains(t)) + } +} + +impl NormalizedTypeCondition { + /// Construct a new type condition with a named type condition added. + pub(crate) fn add_type_name( + &self, + name: Name, + schema: &ValidFederationSchema, + ) -> Result, FederationError> { + 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: DisplayTypeCondition::new(other_ty), + })); + } + let ground_set: Vec = self + .ground_set + .iter() + .filter(|t| other_types.contains(t)) + .cloned() + .collect(); + if ground_set.is_empty() { + // Unsatisfiable condition + Ok(None) + } else { + let for_display = if ground_set.len() == self.ground_set.len() { + // unchanged + self.for_display.clone() + } else { + self.for_display.add_type_name(name, schema)? + }; + Ok(Some(NormalizedTypeCondition { + ground_set, + for_display, + })) + } + } + + 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 case #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); + } + + Ok(NormalizedTypeCondition::from_object_types( + ground_types.into_iter(), + )) + } +} + +//================================================================================================== +// Boolean conditions + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Literal { + Pos(Name), // positive occurrence of the variable with the given name + Neg(Name), // negated variable with the given name +} + +impl Literal { + pub fn variable(&self) -> &Name { + match self { + Literal::Pos(name) | Literal::Neg(name) => name, + } + } + + pub fn polarity(&self) -> bool { + matches!(self, Literal::Pos(_)) + } +} + +// A clause is a conjunction of literals. +// Empty Clause means "true". +// "false" can't be represented. Any cases with false condition must be dropped entirely. +// This vector must be deduplicated. +#[derive(Debug, Clone, Default, Eq)] +pub struct Clause(Vec); + +impl Clause { + 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 + .iter() + .map(|(name, polarity)| match polarity { + false => Literal::Neg(name.clone()), + true => Literal::Pos(name.clone()), + }) + .collect(); + buf.sort_by(|a, b| a.variable().cmp(b.variable())); + Clause(buf) + } + + pub fn concatenate(&self, other: &Clause) -> Option { + let mut variables: IndexMap = IndexMap::default(); + // Assume that `self` has no conflicts. + for lit in &self.0 { + variables.insert(lit.variable().clone(), lit.polarity()); + } + for lit in &other.0 { + let var = lit.variable(); + let entry = variables.entry(var.clone()).or_insert(lit.polarity()); + if *entry != lit.polarity() { + return None; // conflict + } + } + Some(Self::from_variable_map(&variables)) + } + + fn add_selection_directives( + &self, + directives: &ast::DirectiveList, + ) -> Result, FederationError> { + let Some(selection_clause) = boolean_clause_from_directives(directives)? else { + // The condition is unsatisfiable within the field itself. + return Ok(None); + }; + Ok(self.concatenate(&selection_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`. + 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. + for lit in &self.0 { + all_variables.insert(lit.variable().clone(), lit.polarity()); + } + + let mut added_variables: IndexMap = IndexMap::default(); + for lit in &clause.0 { + let var = lit.variable(); + match all_variables.entry(var.clone()) { + indexmap::map::Entry::Occupied(entry) => { + if entry.get() != &lit.polarity() { + return None; // conflict + } + } + indexmap::map::Entry::Vacant(entry) => { + entry.insert(lit.polarity()); + added_variables.insert(var.clone(), lit.polarity()); + } + } + } + Some(( + Self::from_variable_map(&all_variables), + Self::from_variable_map(&added_variables), + )) + } +} + +impl PartialEq for Clause { + fn eq(&self, other: &Self) -> bool { + // assume: The underlying vectors are deduplicated. + self.0.len() == other.0.len() && self.0.iter().all(|l| other.0.contains(l)) + } +} + +//================================================================================================== +// Normalization of Field Selection + +/// Extracts the Boolean clause from the directive list. +// Similar to `Conditions::from_directives` in `conditions.rs`. +fn boolean_clause_from_directives( + directives: &ast::DirectiveList, +) -> Result, FederationError> { + 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") + }; + + match value.as_ref() { + // Constant @skip(if: true) can never match + ast::Value::Boolean(true) => return Ok(None), + // Constant @skip(if: false) always matches + ast::Value::Boolean(_) => {} + ast::Value::Variable(name) => { + variables.insert(name.clone(), false); + } + _ => { + 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") + }; + + match value.as_ref() { + // Constant @include(if: false) can never match + ast::Value::Boolean(false) => return Ok(None), + // Constant @include(if: true) always matches + ast::Value::Boolean(true) => {} + // If both @skip(if: $var) and @include(if: $var) exist, the condition can also + // never match + ast::Value::Variable(name) => { + if variables.insert(name.clone(), true) == Some(false) { + // Conflict found + return Ok(None); + } + } + _ => { + 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> { + // 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 { + directives + .iter() + .filter(|d| d.name != "skip" && d.name != "include") + .cloned() + .collect() +} + +pub type FieldSelectionKey = Field; + +// Extract the selection key +fn field_selection_key(field: &Field) -> FieldSelectionKey { + Field { + definition: field.definition.clone(), + alias: None, // not used for comparison + name: field.name.clone(), + arguments: normalized_arguments(&field.arguments), + directives: ast::DirectiveList::default(), // not used for comparison + selection_set: SelectionSet::new(field.selection_set.ty.clone()), // not used for comparison + } +} + +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 + +/// Simplified field value used for display purposes +fn field_display(field: &Field) -> Field { + Field { + definition: field.definition.clone(), + alias: None, // not used for display + name: field.name.clone(), + arguments: field.arguments.clone(), + directives: remove_conditions_from_directives(&field.directives), + selection_set: SelectionSet::new(field.selection_set.ty.clone()), // not used for display + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DefinitionVariant { + /// Boolean clause is the secondary key after NormalizedTypeCondition as primary key. + boolean_clause: Clause, + + /// 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. + representative_field: Field, + + /// Different variants can have different sets of sub-selections (if any). + sub_selection_response_shape: Option, +} + +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)] +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 must be unique. + // - Note: The Boolean conditions between variants may not be mutually exclusive. +} + +impl PossibleDefinitionsPerTypeCondition { + 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, + } + } + + 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) + match ( + &mut existing.sub_selection_response_shape, + variant.sub_selection_response_shape, + ) { + (None, None) => {} // nothing to do + (Some(existing_rs), Some(ref variant_rs)) => { + existing_rs.merge_with(variant_rs)?; + } + (None, Some(_)) | (Some(_), None) => { + unreachable!("mismatched sub-selection options") + } + } + return Ok(()); + } + } + self.conditional_variants.push(variant); + Ok(()) + } +} + +/// All possible definitions that a response key can have. +/// - 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)] +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 + representative_field: Field, + sub_selection_response_shape: Option, + ) -> 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, + representative_field, + sub_selection_response_shape, + }; + per_type_cond.insert_variant(value) + }; + match entry { + indexmap::map::Entry::Vacant(e) => { + // New type condition + let empty_per_type_cond = PossibleDefinitionsPerTypeCondition { + field_selection_key, + conditional_variants: vec![], + }; + 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. + if !eq_field_selection_key(&e.get().field_selection_key, &field_selection_key) { + return Err(internal_error!( + "field_selection_key was expected to be the same\nexisting: {}\nadding: {}", + e.get().field_selection_key, + field_selection_key, + )); + } + 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: Name, + definitions_per_response_key: IndexMap, +} + +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() + } + + pub fn new(default_type_condition: Name) -> Self { + ResponseShape { + default_type_condition, + definitions_per_response_key: IndexMap::default(), + } + } + + 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 + .entry(response_key.clone()) + .or_default(); + for (type_condition, per_type_cond) in &other_defs.0 { + for variant in &per_type_cond.conditional_variants { + value.insert_possible_definition( + type_condition.clone(), + variant.boolean_clause.clone(), + variant.representative_field.clone(), + variant.sub_selection_response_shape.clone(), + )?; + } + } + } + Ok(()) + } +} + +//================================================================================================== +// ResponseShape computation from operation + +struct ResponseShapeContext { + schema: ValidFederationSchema, + fragment_defs: Arc>>, // fragment definitions in the operation + parent_type: Name, // the type of the current selection set + 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 { + fn process_selection( + &self, + response_shape: &mut ResponseShape, + selection: &Selection, + ) -> Result<(), FederationError> { + match selection { + Selection::Field(field) => self.process_field_selection(response_shape, field), + Selection::FragmentSpread(fragment_spread) => { + let fragment_def = self + .fragment_defs + .get(&fragment_spread.fragment_name) + .ok_or_else(|| { + internal_error!("Fragment not found: {}", fragment_spread.fragment_name) + })?; + // Note: `@skip/@include` directives are not allowed on fragment definitions. + // Thus, no need to check their directives for Boolean conditions. + self.process_fragment_selection( + response_shape, + fragment_def.type_condition(), + &fragment_spread.directives, + &fragment_def.selection_set, + ) + } + Selection::InlineFragment(inline_fragment) => { + let fragment_type_condition = inline_fragment + .type_condition + .as_ref() + .unwrap_or(&self.parent_type); + self.process_fragment_selection( + response_shape, + fragment_type_condition, + &inline_fragment.directives, + &inline_fragment.selection_set, + ) + } + } + } + + fn process_field_selection( + &self, + 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)? + else { + // Unsatisfiable local condition under the parent field => skip + return Ok(()); + }; + let Some((inherited_clause, field_clause)) = self + .inherited_clause + .concatenate_and_simplify(&field_clause) + else { + // Unsatisfiable full condition from the root => skip + return Ok(()); + }; + // Process the field's sub-selection + let sub_selection_response_shape: Option = if field.selection_set.is_empty() + { + None + } else { + // The field's declared type may not be the most specific type (in case of up-casting). + + // internal invariant check + 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 = self + .type_condition + .field_type_condition(field, &self.schema)?; + let context = ResponseShapeContext { + schema: self.schema.clone(), + fragment_defs: self.fragment_defs.clone(), + parent_type, + type_condition, + inherited_clause, + current_clause: Clause::default(), // empty + skip_introspection: false, // false by default + }; + Some(context.process_selection_set(&field.selection_set)?) + }; + // Record this selection's definition. + let value = response_shape + .definitions_per_response_key + .entry(field.response_key().clone()) + .or_default(); + value.insert_possible_definition( + self.type_condition.clone(), + field_clause, + field_display(field), + sub_selection_response_shape, + ) + } + + /// For both inline fragments and fragment spreads + fn process_fragment_selection( + &self, + response_shape: &mut ResponseShape, + fragment_type_condition: &Name, + directives: &ast::DirectiveList, + selection_set: &SelectionSet, + ) -> Result<(), FederationError> { + // internal invariant check + 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, + fragment_type_condition.clone(), + &self.schema, + )? + else { + // Unsatisfiable type condition => skip + return Ok(()); + }; + let Some(current_clause) = self.current_clause.add_selection_directives(directives)? else { + // Unsatisfiable local condition under the parent field => skip + return Ok(()); + }; + // check if `self.inherited_clause` and `current_clause` are unsatisfiable together. + if self.inherited_clause.concatenate(¤t_clause).is_none() { + // Unsatisfiable full condition from the root => skip + return Ok(()); + } + + // The inner context with a new type condition. + // Note: Non-conditional directives on inline spreads are ignored. + let context = ResponseShapeContext { + schema: self.schema.clone(), + fragment_defs: self.fragment_defs.clone(), + parent_type: fragment_type_condition.clone(), + 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) + } + + /// Using an existing response shape + fn process_selection_set_within( + &self, + response_shape: &mut ResponseShape, + selection_set: &SelectionSet, + ) -> Result<(), FederationError> { + for selection in &selection_set.selections { + self.process_selection(response_shape, selection)?; + } + Ok(()) + } + + /// For a new sub-ResponseShape + /// - This corresponds to the `CollectFields` algorithm in the GraphQL specification. + fn process_selection_set( + &self, + selection_set: &SelectionSet, + ) -> Result { + let mut response_shape = ResponseShape::new(selection_set.ty.clone()); + self.process_selection_set_within(&mut response_shape, selection_set)?; + Ok(response_shape) + } +} + +fn is_introspection_field_name(name: &Name) -> bool { + name == "__schema" || name == "__type" +} + +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 { + 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_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 { + 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::unconstrained(); + 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) +} + +//================================================================================================== +// ResponseShape display +// - This section is only for display and thus untrusted. + +impl fmt::Display for DisplayTypeCondition { + 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, "")?; + 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 DefinitionVariant { + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + let field_display = &self.representative_field; + let boolean_str = if !self.boolean_clause.is_always_true() { + format!(" if {}", self.boolean_clause) + } else { + "".to_string() + }; + state.write(format_args!("{field_display} (on ){boolean_str}"))?; + if let Some(sub_selection_response_shape) = &self.sub_selection_response_shape { + state.write(" ")?; + sub_selection_response_shape.write_indented(state)?; + } + Ok(()) + } +} + +impl fmt::Display for DefinitionVariant { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +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()) + } + + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + for (i, variant) in self.conditional_variants.iter().enumerate() { + if i > 0 { + state.new_line()?; + } + variant.write_indented(state)?; + } + Ok(()) + } +} + +impl fmt::Display for PossibleDefinitionsPerTypeCondition { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +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()) + } + + fn write_indented(&self, state: &mut display_helpers::State<'_, '_>) -> fmt::Result { + let arrow_sym = if self.has_multiple_definitions() { + "-may->" + } else { + "----->" + }; + let mut is_first = true; + for (type_condition, per_type_cond) in &self.0 { + for variant in &per_type_cond.conditional_variants { + let field_display = &variant.representative_field; + let type_cond_str = format!(" on {}", type_condition); + let boolean_str = if !variant.boolean_clause.is_always_true() { + format!(" if {}", variant.boolean_clause) + } else { + "".to_string() + }; + if is_first { + is_first = false; + } else { + state.new_line()?; + } + state.write(format_args!( + "{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)?; + } + } + } + Ok(()) + } +} + +impl fmt::Display for PossibleDefinitions { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.write_indented(&mut display_helpers::State::new(f)) + } +} + +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/correctness/response_shape_compare.rs b/apollo-federation/src/correctness/response_shape_compare.rs new file mode 100644 index 0000000000..5f7fd17c3f --- /dev/null +++ b/apollo-federation/src/correctness/response_shape_compare.rs @@ -0,0 +1,406 @@ +// 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::schema::position::ObjectTypeDefinitionPosition; +use crate::utils::FallibleIterator; + +pub struct ComparisonError { + description: String, +} + +impl ComparisonError { + pub fn description(&self) -> &str { + &self.description + } + + pub fn new(description: String) -> ComparisonError { + ComparisonError { description } + } + + fn add_description(self: ComparisonError, description: &str) -> ComparisonError { + ComparisonError { + 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(ComparisonError::new(message)); + } + }; +} + +/// Path-specific type constraints on top of GraphQL type conditions. +pub(crate) trait PathConstraint +where + Self: Sized, +{ + /// Returns a new path constraint under the given type condition. + fn under_type_condition(&self, type_cond: &NormalizedTypeCondition) -> Self; + + /// Returns a new path constraint for field's response shape. + fn for_field(&self, representative_field: &Field) -> Result; + + /// Is `ty` allowed under the path constraint? + fn allows(&self, _ty: &ObjectTypeDefinitionPosition) -> bool; + + /// Is `defs` feasible under the path constraint? + fn allows_any(&self, _defs: &PossibleDefinitions) -> bool; +} + +struct DummyPathConstraint; + +impl PathConstraint for DummyPathConstraint { + fn under_type_condition(&self, _type_cond: &NormalizedTypeCondition) -> Self { + DummyPathConstraint + } + + fn for_field(&self, _representative_field: &Field) -> Result { + Ok(DummyPathConstraint) + } + + fn allows(&self, _ty: &ObjectTypeDefinitionPosition) -> bool { + true + } + + fn allows_any(&self, _defs: &PossibleDefinitions) -> bool { + true + } +} + +// Check if `this` is a subset of `other`. +pub fn compare_response_shapes( + this: &ResponseShape, + other: &ResponseShape, +) -> Result<(), ComparisonError> { + compare_response_shapes_with_constraint(&DummyPathConstraint, this, other) +} + +// Check if `this` is a subset of `other`, but also use the `PathConstraint` to ignore infeasible +// type conditions in `other`. +pub(crate) fn compare_response_shapes_with_constraint( + path_constraint: &T, + this: &ResponseShape, + other: &ResponseShape, +) -> Result<(), ComparisonError> { + // Note: `default_type_condition` is for display. + // Only response key and definitions are compared. + this.iter().try_for_each(|(key, this_def)| { + let Some(other_def) = other.get(key) else { + // check this_def's type conditions are feasible under the path constraint. + if !path_constraint.allows_any(this_def) { + return Ok(()); + } + return Err(ComparisonError::new(format!("missing response key: {key}"))); + }; + compare_possible_definitions(path_constraint, this_def, other_def) + .map_err(|e| e.add_description(&format!("mismatch for response key: {key}"))) + }) +} + +/// Collect and merge all definitions applicable to the given type condition. +fn collect_definitions_for_type_condition( + defs: &PossibleDefinitions, + filter_cond: &NormalizedTypeCondition, +) -> Result { + let mut filter_iter = defs + .iter() + .filter(|(type_cond, _)| filter_cond.implies(type_cond)); + let Some((_type_cond, first)) = filter_iter.next() else { + return Err(ComparisonError::new(format!( + "no definitions found for type condition: {filter_cond}" + ))); + }; + let mut digest = first.clone(); + // Merge the rest of filter_iter into digest. + filter_iter.try_for_each(|(type_cond, def)| + def.conditional_variants() + .iter() + .try_for_each(|variant| digest.insert_variant(variant.clone())) + .map_err(|e| { + ComparisonError::new(format!( + "collect_definitions_for_type_condition failed for {filter_cond}\ntype_cond: {type_cond}\nerror: {e}", + )) + }) + )?; + Ok(digest) +} + +fn path_constraint_allows_type_condition( + path_constraint: &T, + type_cond: &NormalizedTypeCondition, +) -> bool { + type_cond + .ground_set() + .iter() + .any(|ty| path_constraint.allows(ty)) +} + +fn detail_single_object_type_condition(type_cond: &NormalizedTypeCondition) -> String { + let Some(ground_ty) = type_cond.ground_set().iter().next() else { + return "".to_string(); + }; + if !type_cond.is_named_object_type() { + format!(" (has single object type: {ground_ty})") + } else { + "".to_string() + } +} + +fn compare_possible_definitions( + path_constraint: &T, + this: &PossibleDefinitions, + other: &PossibleDefinitions, +) -> Result<(), ComparisonError> { + this.iter().try_for_each(|(this_cond, this_def)| { + if !path_constraint_allows_type_condition(path_constraint, this_cond) { + // Skip `this_cond` since it's not satisfiable under the path constraint. + return Ok(()); + } + + let updated_constraint = path_constraint.under_type_condition(this_cond); + + // First try: Use the single exact match (common case). + if let Some(other_def) = other.get(this_cond) { + let result = compare_possible_definitions_per_type_condition( + &updated_constraint, + this_def, + other_def, + ); + if let Ok(result) = result { + return Ok(result); + } + // fall through + } + + // Second try: Collect all definitions implied by the `this_cond`. + if let Ok(other_def) = collect_definitions_for_type_condition(other, this_cond) { + let result = compare_possible_definitions_per_type_condition( + &updated_constraint, + this_def, + &other_def, + ); + match result { + Ok(result) => return Ok(result), + Err(err) => { + // See if we can case-split over ground set items. + if this_cond.ground_set().len() == 1 { + // Single object type has no other option. Stop and report the error. + let detail = detail_single_object_type_condition(this_cond); + return Err(ComparisonError::new(format!( + "mismatch for type condition: {this_cond}{detail}\n{}", + err.description() + ))); + } + } + } + // fall through + }; + + // Finally: Case-split over individual ground types. + let ground_set_iter = this_cond.ground_set().iter(); + let mut ground_set_iter = ground_set_iter.filter(|ty| path_constraint.allows(ty)); + ground_set_iter.try_for_each(|ground_ty| { + let filter_cond = NormalizedTypeCondition::from_object_type(ground_ty); + let other_def = + collect_definitions_for_type_condition(other, &filter_cond).map_err(|e| { + e.add_description(&format!( + "missing type condition: {this_cond} (case: {ground_ty})" + )) + })?; + let updated_constraint = path_constraint.under_type_condition(&filter_cond); + compare_possible_definitions_per_type_condition( + &updated_constraint, + this_def, + &other_def, + ) + .map_err(|e| { + e.add_description(&format!( + "mismatch for type condition: {this_cond} (case: {ground_ty})" + )) + }) + }) + }) +} + +fn compare_possible_definitions_per_type_condition( + path_constraint: &T, + this: &PossibleDefinitionsPerTypeCondition, + other: &PossibleDefinitionsPerTypeCondition, +) -> Result<(), ComparisonError> { + 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(path_constraint, this_def, other_def)?; + Ok(true) + } + })?; + if !found { + Err(ComparisonError::new( + format!("mismatch in Boolean conditions of PossibleDefinitionsPerTypeCondition:\nexpected clause: {}\ntarget definitions:\n{}", + this_def.boolean_clause(), + other, + ), + )) + } else { + Ok(()) + } + }) +} + +fn compare_definition_variant( + path_constraint: &T, + this: &DefinitionVariant, + other: &DefinitionVariant, +) -> Result<(), ComparisonError> { + 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)) => { + let field_constraint = path_constraint.for_field(this.representative_field())?; + compare_response_shapes_with_constraint(&field_constraint, 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(ComparisonError::new( + "mismatch in compare_definition_variant".to_string(), + )), + } +} + +fn compare_field_selection_key( + this: &FieldSelectionKey, + other: &FieldSelectionKey, +) -> Result<(), ComparisonError> { + 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<(), ComparisonError> { + check_match_eq!(this.name, other.name); + // Note: Arguments and directives are NOT normalized. + if !same_ast_arguments(&this.arguments, &other.arguments) { + return Err(ComparisonError::new(format!( + "mismatch in representative field arguments: {:?} vs {:?}", + this.arguments, other.arguments + ))); + } + if !same_directives(&this.directives, &other.directives) { + return Err(ComparisonError::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/correctness/response_shape_test.rs b/apollo-federation/src/correctness/response_shape_test.rs new file mode 100644 index 0000000000..762595700e --- /dev/null +++ b/apollo-federation/src/correctness/response_shape_test.rs @@ -0,0 +1,519 @@ +use apollo_compiler::schema::Schema; +use apollo_compiler::ExecutableDocument; + +use super::*; +use crate::ValidFederationSchema; + +// The schema used in these tests. +const SCHEMA_STR: &str = r#" + type Query { + test_i: I! + test_j: J! + test_u: U! + test_v: V! + } + + interface I { + id: ID! + data(arg: Int!): String! + } + + interface J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + } + + type R implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + r: Int! + } + + type S implements I & J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + s: Int! + } + + type T implements I { + id: ID! + data(arg: Int!): String! + t: Int! + } + + type X implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + x: String! + } + + type Y { + id: ID! + y: String! + } + + type Z implements J { + id: ID! + data(arg: Int!): String! + object(id: ID!): J! + z: String! + } + + union U = R | S | X + union V = R | S | Y + + directive @mod(arg: Int!) on FIELD +"#; + +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_for_operation(&op, &schema).unwrap() +} + +//================================================================================================= +// Basic response key and alias tests + +#[test] +fn test_aliases() { + let op_str = r#" + query { + test_i { + data(arg: 0) + alias1: data(arg: 1) + alias2: data(arg: 1) + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -----> data(arg: 0) + alias1 -----> data(arg: 1) + alias2 -----> data(arg: 1) + } + } + "###); +} + +//================================================================================================= +// Type condition tests + +#[test] +fn test_type_conditions_over_multiple_different_types() { + let op_str = r#" + query { + test_i { + ... on R { + data(arg: 0) + } + ... on S { + data(arg: 1) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -may-> data(arg: 0) on R + data -may-> data(arg: 1) on S + } + } + "###); +} + +#[test] +fn test_type_conditions_over_multiple_different_interface_types() { + // These two intersections are distinct type conditions. + // - `U ∧ I` = {R, S} + // - `U ∧ J` = `U` = {R, S, X} + let op_str = r#" + query { + test_u { + ... on I { + data(arg: 0) + } + ... on J { + data(arg: 0) + } + } + } + "#; + // Note: The set {R, S} has no corresponding named type definition in the schema, while + // `U ∧ J` is just the same as `U`. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on I ∩ U = {R, S} + data -may-> data(arg: 0) on U + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_same_object_type() { + // Testing equivalent conditions: `U ∧ R` = `U ∧ I ∧ R` = `U ∧ R ∧ I` = `R` + // Also, that's different from `U ∧ I` = {R, S}. + let op_str = r#" + query { + test_u { + ... on R { + data(arg: 0) + } + ... on I { + ... on R { + data(arg: 0) + } + } + ... on R { + ... on I { + data(arg: 0) + } + } + ... on I { # different condition + data(arg: 0) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on R + data -may-> data(arg: 0) on I ∩ U = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_equivalent_intersections() { + // Testing equivalent conditions: `U ∧ I ∧ J` = `U ∧ J ∧ I` = `U ∧ I`= {R, S} + // Note: The order of applied type conditions is irrelevant. + let op_str = r#" + query { + test_u { + ... on I { + ... on J { + data(arg: 0) + } + } + ... on J { + ... on I { + data(arg: 0) + } + } + ... on I { + data(arg: 0) + } + } + } + "#; + // Note: They are merged into the same condition `I ∧ U`, since that is minimal. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + data -may-> data(arg: 0) on I ∩ U = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_merge_different_but_equivalent_intersection_expressions() { + // Testing equivalent conditions: `V ∧ I` = `V ∧ J` = `V ∧ J ∧ I` = {R, S} + // Note: Those conditions have different sets of types. But, they are still equivalent. + let op_str = r#" + query { + test_v { + ... on I { + data(arg: 0) + } + ... on J { + data(arg: 0) + } + ... on J { + ... on I { + data(arg: 0) + } + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_v -----> test_v { + data -may-> data(arg: 0) on I ∩ V = {R, S} + } + } + "###); +} + +#[test] +fn test_type_conditions_empty_intersection() { + // Testing unsatisfiable conditions: `U ∧ I ∧ T`= ∅ + let op_str = r#" + query { + test_u { + ... on I { + ... on T { + infeasible: data(arg: 0) + } + } + } + } + "#; + // Note: The response shape under `test_u` is empty. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_u -----> test_u { + } + } + "###); +} + +//================================================================================================= +// Boolean condition tests + +#[test] +fn test_boolean_conditions_constants() { + let op_str = r#" + query { + test_i { + # constant true conditions + merged: data(arg: 0) + merged: data(arg: 0) @include(if: true) + merged: data(arg: 0) @skip(if: false) + + # constant false conditions + infeasible_1: data(arg: 0) @include(if: false) + infeasible_2: data(arg: 0) @skip(if: true) + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + merged -----> data(arg: 0) + } + } + "###); +} + +#[test] +fn test_boolean_conditions_different_multiple_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!, $v2: Boolean!) { + test_i @include(if: $v0) { + data(arg: 0) + data(arg: 0) @include(if: $v1) + ... @include(if: $v1) { + data(arg: 0) @include(if: $v2) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + data -may-> data(arg: 0) + data -may-> data(arg: 0) if v1 + data -may-> data(arg: 0) if v1 ∧ v2 + } + } + "###); +} + +#[test] +fn test_boolean_conditions_unsatisfiable_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_i @include(if: $v0) { + # conflict directly within the field directives + infeasible_1: data(arg: 0) @include(if: $v1) @skip(if: $v1) + # conflicting with the parent inline fragment + ... @skip(if: $v1) { + infeasible_2: data(arg: 0) @include(if: $v1) + } + infeasible_3: data(arg: 0) @skip(if: $v0) # conflicting with the parent-selection condition + } + } + "#; + // Note: The response shape under `test_i` is empty. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + } + } + "###); +} + +//================================================================================================= +// Non-conditional directive tests + +#[test] +fn test_non_conditional_directives() { + let op_str = r#" + query { + test_i { + data(arg: 0) @mod(arg: 0) # different only in directives + data(arg: 0) @mod(arg: 1) # different only in directives + data(arg: 0) # no directives + } + } + "#; + // Note: All `data` definitions are merged, but the first selection (in depth-first order) is + // chosen as the representative. + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -----> test_i { + data -----> data(arg: 0) @mod(arg: 0) + } + } + "###); +} + +//================================================================================================= +// Fragment spread tests + +#[test] +fn test_fragment_spread() { + let op_str = r#" + query($v0: Boolean!) { + test_i @include(if: $v0) { + merge_1: data(arg: 0) + ...F + } + } + + fragment F on I { + merge_1: data(arg: 0) + from_fragment: data(arg: 0) + infeasible_1: data(arg: 0) @skip(if: $v0) + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_i -may-> test_i if v0 { + merge_1 -----> data(arg: 0) + from_fragment -----> data(arg: 0) + } + } + "###); +} + +//================================================================================================= +// Sub-selection merging tests + +#[test] +fn test_merge_sub_selection_sets() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_j { + object(id: 0) { + merged_1: data(arg: 0) + ... on R { + merged_2: data(arg: 0) + } + merged_3: data(arg: 0) @include(if: $v0) + } + object(id: 0) { + merged_1: data(arg: 0) + ... on S { + merged_2: data(arg: 1) + } + merged_3: data(arg: 0) @include(if: $v1) + } + object(id: 0) { + merged_3: data(arg: 0) # no condition + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -----> object(id: 0) { + merged_1 -----> data(arg: 0) + merged_2 -may-> data(arg: 0) on R + merged_2 -may-> data(arg: 1) on S + merged_3 -may-> data(arg: 0) if v0 + merged_3 -may-> data(arg: 0) if v1 + merged_3 -may-> data(arg: 0) + } + } + } + "###); +} + +#[test] +fn test_not_merge_sub_selection_sets_under_different_type_conditions() { + let op_str = r#" + query { + test_j { + object(id: 0) { + unmerged: data(arg: 0) + } + # unmerged due to parents with different type conditions + ... on R { + object(id: 0) { + unmerged: data(arg: 0) + } + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -may-> object(id: 0) on J { + unmerged -----> data(arg: 0) + } + object -may-> object(id: 0) on R { + unmerged -----> data(arg: 0) + } + } + } + "###); +} + +#[test] +fn test_merge_sub_selection_sets_with_boolean_conditions() { + let op_str = r#" + query($v0: Boolean!, $v1: Boolean!) { + test_j { + object(id: 0) @include(if: $v0) { + merged: data(arg: 0) + unmerged: data(arg: 0) + } + object(id: 0) @include(if: $v0) { + merged: data(arg: 0) @include(if: $v0) + } + # unmerged due to parents with different Boolean conditions + object(id: 0) @include(if: $v1) { + unmerged: data(arg: 0) + } + } + } + "#; + insta::assert_snapshot!(response_shape(op_str), @r###" + { + test_j -----> test_j { + object -may-> object(id: 0) if v0 { + merged -----> data(arg: 0) + unmerged -----> data(arg: 0) + } + object -may-> object(id: 0) if v1 { + unmerged -----> data(arg: 0) + } + } + } + "###); +} diff --git a/apollo-federation/src/correctness/subgraph_constraint.rs b/apollo-federation/src/correctness/subgraph_constraint.rs new file mode 100644 index 0000000000..6086a9d605 --- /dev/null +++ b/apollo-federation/src/correctness/subgraph_constraint.rs @@ -0,0 +1,155 @@ +// Path-specific constraints imposed by subgraph schemas. + +use std::sync::Arc; + +use apollo_compiler::collections::IndexMap; +use apollo_compiler::collections::IndexSet; +use apollo_compiler::executable::Field; + +use super::response_shape::NormalizedTypeCondition; +use super::response_shape::PossibleDefinitions; +use super::response_shape_compare::ComparisonError; +use super::response_shape_compare::PathConstraint; +use crate::error::FederationError; +use crate::internal_error; +use crate::link::federation_spec_definition::get_federation_spec_definition_from_subgraph; +use crate::schema::position::CompositeTypeDefinitionPosition; +use crate::schema::position::ObjectTypeDefinitionPosition; +use crate::utils::FallibleIterator; +use crate::ValidFederationSchema; + +pub(crate) struct SubgraphConstraint<'a> { + /// Reference to the all subgraph schemas in the supergraph. + subgraphs_by_name: &'a IndexMap, ValidFederationSchema>, + + /// possible_subgraphs: The set of subgraphs that are possible under the current context. + possible_subgraphs: IndexSet>, + + /// subgraph_types: The set of object types that are possible under the current context. + /// - Note: The empty subgraph_types means all types are possible. + subgraph_types: IndexSet, +} + +/// Is the object type resolvable in the subgraph schema? +fn is_resolvable( + ty_pos: &ObjectTypeDefinitionPosition, + schema: &ValidFederationSchema, +) -> Result { + let federation_spec_definition = get_federation_spec_definition_from_subgraph(schema)?; + let key_directive_definition = federation_spec_definition.key_directive_definition(schema)?; + let ty_def = ty_pos.get(schema.schema())?; + ty_def + .directives + .get_all(&key_directive_definition.name) + .map(|directive| federation_spec_definition.key_directive_arguments(directive)) + .find_ok(|key_directive_application| key_directive_application.resolvable) + .map(|result| result.is_some()) +} + +impl<'a> SubgraphConstraint<'a> { + pub(crate) fn at_root( + subgraphs_by_name: &'a IndexMap, ValidFederationSchema>, + ) -> Self { + let all_subgraphs = subgraphs_by_name.keys().cloned().collect(); + SubgraphConstraint { + subgraphs_by_name, + possible_subgraphs: all_subgraphs, + subgraph_types: Default::default(), + } + } + + // Current subgraphs + entity subgraphs + fn possible_subgraphs_for_type( + &self, + ty_pos: &ObjectTypeDefinitionPosition, + ) -> Result>, FederationError> { + let mut result = self.possible_subgraphs.clone(); + for (subgraph_name, subgraph_schema) in self.subgraphs_by_name.iter() { + if let Some(entity_ty_pos) = subgraph_schema.entity_type()? { + let entity_ty_def = entity_ty_pos.get(subgraph_schema.schema())?; + if entity_ty_def.members.contains(&ty_pos.type_name) + && is_resolvable(ty_pos, subgraph_schema)? + { + result.insert(subgraph_name.clone()); + } + } + } + Ok(result) + } + + // (Parent type & field type consistency in subgraphs) Considering the field's possible parent + // types ( `self.subgraph_types`) and their possible entity subgraphs, find all object types + // that the field can resolve to. + fn subgraph_types_for_field(&self, field_name: &str) -> Result { + let mut possible_subgraphs = IndexSet::default(); + let mut subgraph_types = IndexSet::default(); + for parent_type in &self.subgraph_types { + let candidate_subgraphs = self.possible_subgraphs_for_type(parent_type)?; + for subgraph_name in candidate_subgraphs.iter() { + let Some(subgraph_schema) = self.subgraphs_by_name.get(subgraph_name) else { + return Err(internal_error!("subgraph not found: {subgraph_name}")); + }; + // check if this subgraph has the definition for ` { }` + let Some(parent_type) = parent_type.try_get(subgraph_schema.schema()) else { + continue; + }; + let Some(field) = parent_type.fields.get(field_name) else { + continue; + }; + let field_type_name = field.ty.inner_named_type(); + let field_type_pos = subgraph_schema.get_type(field_type_name.clone())?; + if let Ok(composite_type) = + CompositeTypeDefinitionPosition::try_from(field_type_pos) + { + let ground_set = subgraph_schema.possible_runtime_types(composite_type)?; + possible_subgraphs.insert(subgraph_name.clone()); + subgraph_types.extend(ground_set.into_iter()); + } + } + } + Ok(SubgraphConstraint { + subgraphs_by_name: self.subgraphs_by_name, + possible_subgraphs, + subgraph_types, + }) + } +} + +impl PathConstraint for SubgraphConstraint<'_> { + fn under_type_condition(&self, type_cond: &NormalizedTypeCondition) -> Self { + SubgraphConstraint { + subgraphs_by_name: self.subgraphs_by_name, + possible_subgraphs: self.possible_subgraphs.clone(), + subgraph_types: type_cond.ground_set().iter().cloned().collect(), + } + } + + fn for_field(&self, representative_field: &Field) -> Result { + self.subgraph_types_for_field(&representative_field.name) + .map_err(|e| { + // Note: This is an internal federation error, not a comparison error. + // But, we are only allowed to return `ComparisonError` to keep the + // response_shape_compare module free from internal errors. + ComparisonError::new(format!( + "failed to compute subgraph types for {} on {:?} due to an error:\n{e}", + representative_field.name, self.subgraph_types, + )) + }) + } + + fn allows(&self, ty: &ObjectTypeDefinitionPosition) -> bool { + self.subgraph_types.is_empty() || self.subgraph_types.contains(ty) + } + + fn allows_any(&self, defs: &PossibleDefinitions) -> bool { + if self.subgraph_types.is_empty() { + return true; + } + let intersects = |ground_set: &[ObjectTypeDefinitionPosition]| { + // See if `self.subgraph_types` and `ground_set` have any intersection. + ground_set.iter().any(|ty| self.subgraph_types.contains(ty)) + }; + defs.iter() + .any(|(type_cond, _)| intersects(type_cond.ground_set())) + } +} diff --git a/apollo-federation/src/lib.rs b/apollo-federation/src/lib.rs index 4e8ba89640..e87e9a4d0f 100644 --- a/apollo-federation/src/lib.rs +++ b/apollo-federation/src/lib.rs @@ -27,6 +27,8 @@ mod api_schema; mod compat; +#[cfg(feature = "correctness")] +pub mod correctness; mod display_helpers; pub mod error; pub mod link; diff --git a/apollo-federation/src/query_plan/query_planner.rs b/apollo-federation/src/query_plan/query_planner.rs index 97143b50fd..8f3d18c6b0 100644 --- a/apollo-federation/src/query_plan/query_planner.rs +++ b/apollo-federation/src/query_plan/query_planner.rs @@ -485,6 +485,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(