Skip to content

Commit

Permalink
Add fields referenced only in entity arguments to split subgraphs (#6203
Browse files Browse the repository at this point in the history
)
  • Loading branch information
dylan-apollo authored Oct 29, 2024
1 parent d4657f0 commit 1eafe2f
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 119 deletions.
248 changes: 145 additions & 103 deletions apollo-federation/src/sources/connect/expand/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ mod helpers {
use apollo_compiler::schema::ScalarType;
use apollo_compiler::Name;
use apollo_compiler::Node;
use apollo_compiler::Schema;
use indexmap::IndexMap;
use indexmap::IndexSet;
use itertools::Itertools;
Expand All @@ -199,9 +200,11 @@ mod helpers {
use crate::error::FederationError;
use crate::link::spec::Identity;
use crate::link::Link;
use crate::schema::position::InterfaceFieldDefinitionPosition;
use crate::schema::position::ObjectFieldDefinitionPosition;
use crate::schema::position::ObjectOrInterfaceTypeDefinitionPosition;
use crate::schema::position::ObjectTypeDefinitionPosition;
use crate::schema::position::PositionLookupError;
use crate::schema::position::SchemaRootDefinitionKind;
use crate::schema::position::SchemaRootDefinitionPosition;
use crate::schema::position::TypeDefinitionPosition;
Expand Down Expand Up @@ -453,119 +456,116 @@ mod helpers {
// The actual selection might make use of the $this variable, so we grab them too
let selection_parameters = extract_params_from_selection(&connector.selection);

let (key_for_type, var_filter) =
if matches!(connector.entity_resolver, Some(EntityResolver::Explicit)) {
// `entity: true` connectors only exist on Query.
// We don't generate keys for `Query`, these are keys for the output type of the field.
// Therefore, we're also only considering the `$args` fields as keys, which should
// map 1-1 with output type fields.
(output_type, VariableType::Args)
} else {
// We're extending an entity by adding a new field dependent on some other fields
// (identified by `$this`).
(parent_type, VariableType::This)
};

// We'll need to collect all synthesized keys for the output type, adding a federation
// `@key` directive once completed.
let mut keys = Vec::new();
for Variable { var_type, path, .. } in body_parameters
for Variable { path, .. } in body_parameters
.into_iter()
.chain(url_parameters)
.chain(selection_parameters)
.unique()
.filter(|var| var.var_type == var_filter)
{
match var_type {
// Arguments should be added to the synthesized key, since they are mandatory
// to resolving the output type. The synthesized key should only include the portions
// of the inputs actually used throughout the selections of the transport.
//
// Note that this only applies to connectors marked as an entity resolver, since only
// those should be allowed to fully resolve a type given the required arguments /
// synthesized keys.
VariableType::Args => {
// Synthesize the key based on the argument. Note that this is only relevant in the
// argument case when the connector is marked as being an entity resolved.
if matches!(connector.entity_resolver, Some(EntityResolver::Explicit)) {
let (field, selection) = path_to_selection(&path);
keys.push(format!("{field}{selection}"));
}
// Arguments should be added to the synthesized key, since they are mandatory
// to resolving the output type. The synthesized key should only include the portions
// of the inputs actually used throughout the selections of the transport.
//
// Note that this only applies to connectors marked as an entity resolver, since only
// those should be allowed to fully resolve a type given the required arguments /
// synthesized keys.
//
// All sibling fields marked by $this in a transport must be carried over to the output type
// regardless of its use in the output selection.
let (field_name_str, selection) = path_to_selection(&path);
let field_name = Name::new(&field_name_str)?;
let field: Box<dyn Field> = match &key_for_type {
TypeDefinitionPosition::Object(o) => Box::new(o.field(field_name)),
TypeDefinitionPosition::Interface(i) => Box::new(i.field(field_name)),
TypeDefinitionPosition::Union(_) | TypeDefinitionPosition::InputObject(_)=> {
return Err(FederationError::internal(
"siblings of type interface, input object, or union are not yet handled",
))
}
other => {
return Err(FederationError::internal(format!(
"cannot select a sibling on a leaf type: {}",
other.type_name()
)))
}
};

VariableType::Config => {} // Expansion doesn't care about config

// All sibling fields marked by $this in a transport must be carried over to the output type
// regardless of its use in the output selection.
VariableType::This => {
match parent_type {
TypeDefinitionPosition::Object(ref o) => {
let (field, selection) = path_to_selection(&path);
let field_name = Name::new(&field)?;
let field = o.field(field_name.clone());
let field_def = field.get(self.original_schema.schema())?;

// Mark it as a required key for the output type
if !selection.is_empty() {
// We'll also need to carry over the output type of this sibling if there is a sub
// selection.
let field_output = field_def.ty.inner_named_type();
if to_schema.try_get_type(field_output.clone()).is_none() {
// We use a fake JSONSelection here so that we can reuse the visitor
// when generating the output types and their required members.
let visitor = SchemaVisitor::new(
self.original_schema,
to_schema,
&self.directive_deny_list,
);
let (_, parsed) =
JSONSelection::parse(&selection).map_err(|e| {
FederationError::internal(format!("could not parse fake selection for sibling field: {e}"))
})?;

let output_type = match self
.original_schema
.get_type(field_output.clone())?
{
TypeDefinitionPosition::Object(object) => object,

other => {
return Err(FederationError::internal(format!("connector output types currently only support object types: found {}", other.type_name())))
}
};

visitor.walk((
output_type,
parsed.next_subselection().cloned().ok_or(
FederationError::internal(
"empty selections are not allowed",
),
)?,
))?;
}
}

keys.push(format!("{field_name}{selection}"));

// Add the field if not already present in the output schema
if field.try_get(to_schema.schema()).is_none() {
field.insert(
to_schema,
Component::new(FieldDefinition {
description: field_def.description.clone(),
name: field_def.name.clone(),
arguments: field_def.arguments.clone(),
ty: field_def.ty.clone(),
directives: filter_directives(
&self.directive_deny_list,
&field_def.directives,
),
}),
)?;
}
}
TypeDefinitionPosition::Interface(_)
| TypeDefinitionPosition::Union(_) | TypeDefinitionPosition::InputObject(_)=> {
return Err(FederationError::internal(
"siblings of type interface, input object, or union are not yet handled",
))
}
let field_def = field.get(self.original_schema.schema())?;

// Mark it as a required key for the output type
if !selection.is_empty() {
// We'll also need to carry over the output type of this sibling if there is a sub
// selection.
let field_output = field_def.ty.inner_named_type();
if to_schema.try_get_type(field_output.clone()).is_none() {
// We use a fake JSONSelection here so that we can reuse the visitor
// when generating the output types and their required members.
let visitor = SchemaVisitor::new(
self.original_schema,
to_schema,
&self.directive_deny_list,
);
let (_, parsed) = JSONSelection::parse(&selection).map_err(|e| {
FederationError::internal(format!(
"could not parse fake selection for sibling field: {e}"
))
})?;

let output_type = match self
.original_schema
.get_type(field_output.clone())?
{
TypeDefinitionPosition::Object(object) => object,

other => {
return Err(FederationError::internal(format!(
"cannot select a sibling on a leaf type: {}",
other.type_name()
)))
return Err(FederationError::internal(format!("connector output types currently only support object types: found {}", other.type_name())))
}
};

visitor.walk((
output_type,
parsed.next_subselection().cloned().ok_or(
FederationError::internal("empty selections are not allowed"),
)?,
))?;
}
}

keys.push(format!("{field_name_str}{selection}"));

// Add the field if not already present in the output schema
if field.get(to_schema.schema()).is_err() {
field.insert(
to_schema,
Component::new(FieldDefinition {
description: field_def.description.clone(),
name: field_def.name.clone(),
arguments: field_def.arguments.clone(),
ty: field_def.ty.clone(),
directives: filter_directives(
&self.directive_deny_list,
&field_def.directives,
),
}),
)?;
}
}

// If we have marked keys as being necessary for this output type, add them as an `@key`
Expand All @@ -579,12 +579,6 @@ mod helpers {
})],
};

let key_for_type =
if matches!(connector.entity_resolver, Some(EntityResolver::Explicit)) {
output_type
} else {
parent_type
};
match key_for_type {
TypeDefinitionPosition::Object(o) => {
o.insert_directive(to_schema, Component::new(key_directive))
Expand Down Expand Up @@ -789,6 +783,54 @@ mod helpers {
})
})
}

// TODO: contribute some code to `position.rs` to make those types more flexible rather than adding it here
trait Field {
fn get<'schema>(
&self,
schema: &'schema Schema,
) -> Result<&'schema Component<FieldDefinition>, PositionLookupError>;

fn insert(
&self,
schema: &mut FederationSchema,
field: Component<FieldDefinition>,
) -> Result<(), FederationError>;
}

impl Field for ObjectFieldDefinitionPosition {
fn get<'schema>(
&self,
schema: &'schema Schema,
) -> Result<&'schema Component<FieldDefinition>, PositionLookupError> {
self.get(schema)
}

fn insert(
&self,
schema: &mut FederationSchema,
field: Component<FieldDefinition>,
) -> Result<(), FederationError> {
self.insert(schema, field)
}
}

impl Field for InterfaceFieldDefinitionPosition {
fn get<'schema>(
&self,
schema: &'schema Schema,
) -> Result<&'schema Component<FieldDefinition>, PositionLookupError> {
self.get(schema)
}

fn insert(
&self,
schema: &mut FederationSchema,
field: Component<FieldDefinition>,
) -> Result<(), FederationError> {
self.insert(schema, field)
}
}
}

/// Turn a path like a.b.c into a selection like (a, { b { c } }). Join together to get a key.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ enum link__Purpose {
type Query
@join__type(graph: ONE)
{
t(id: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}"}, selection: "id id2", entity: true})
t2(id: ID!, id2: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}?id2={$args.id2}"}, selection: "id id2", entity: true})
t(id: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}"}, selection: "id id2 unselected", entity: true})
t2(id: ID!, id2: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.id}?id2={$args.id2}"}, selection: "id id2 unselected", entity: true})

""" Uses the `unselected` field as a key, but doesn't select it """
unselected(unselected: ID!): T @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/ts/{$args.unselected}"}, selection: "id id2 accessibleByUnselected", entity: true})
}

type R
Expand All @@ -71,9 +74,12 @@ type R
type T
@join__type(graph: ONE, key: "id")
@join__type(graph: ONE, key: "id id2")
@join__type(graph: ONE, key: "unselected")
{
id: ID!
id2: ID!
unselected: ID!
accessibleByUnselected: ID!
r1: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}"}, selection: "id id2"})
r2: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}?id2={$this.id2}"}, selection: "id id2"})
r3: R @join__directive(graphs: [ONE], name: "connect", args: {http: {GET: "http://localhost/rs/{$this.id}"}, selection: "id id2: $this.id2"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,34 @@ subgraphs:
schema:
sdl: |
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key"])
@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"])
@link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect"])
type Query {
t(id: ID!): T
@connect( # expect `key: "id"`
http: { GET: "http://localhost/ts/{$$args.id}" }
selection: "id id2"
selection: "id id2 unselected"
entity: true
)
t2(id: ID! id2: ID!): T
t2(id: ID!, id2: ID!): T
@connect( # expect `key: "id id2"`
http: { GET: "http://localhost/ts/{$$args.id}?id2={$$args.id2}" }
selection: "id id2"
selection: "id id2 unselected"
entity: true
)
""" Uses the `unselected` field as a key, but doesn't select it """
unselected(unselected: ID!): T
@connect(
http: { GET: "http://localhost/ts/{$$args.unselected}" }
selection: "id id2 accessibleByUnselected"
entity: true
)
}
type T @key(fields: "id") @key(fields: "id id2") {
type T @key(fields: "id") @key(fields: "id id2") @key(fields: "unselected") {
id: ID!
id2: ID!
unselected: ID!
accessibleByUnselected: ID!
r1: R @connect(http: { GET: "http://localhost/rs/{$$this.id}" }, selection: "id id2") # expect `key: "id"``
r2: R @connect(http: { GET: "http://localhost/rs/{$$this.id}?id2={$$this.id2}" }, selection: "id id2") # expect `key: "id id2"`
r3: R @connect(http: { GET: "http://localhost/rs/{$$this.id}" }, selection: "id id2: $$this.id2") # expect `key: "id id2"`
Expand Down
Loading

0 comments on commit 1eafe2f

Please sign in to comment.