Skip to content

Commit

Permalink
Add schema introspection support and execution-related APIs (#758)
Browse files Browse the repository at this point in the history
- Add data structure in `apollo_compiler::execution` for a GraphQL response, its data, and errors.
  All (de)serializable with `serde`.
- Add [`coerce_variable_values()`] in that same module.
- Add `apollo_compiler::execution::SchemaIntrospection`
  providing full execution for the [schema introspection] parts of an operation
  and separating the rest to be executed separately.
  In order to support all kinds of introspection queries this actually includes
  a full execution engine where users provide objects with resolvable fields.
  At this time this engine is not exposed in the public API.
  If you’re interested in it [let us know] about your use case!
- Add `ExecutableDocument::insert_operation` convenience method.

[`coerce_variable_values()`]: https://spec.graphql.org/October2021/#sec-Coercing-Variable-Values
[schema introspection]: https://spec.graphql.org/October2021/#sec-Schema-Introspection
[let us know]: https://github.com/apollographql/apollo-rs/issues/new?assignees=&labels=triage&projects=&template=feature_request.md
  • Loading branch information
SimonSapin authored Dec 11, 2023
1 parent fcc2ca8 commit d97abbc
Show file tree
Hide file tree
Showing 21 changed files with 5,046 additions and 48 deletions.
16 changes: 16 additions & 0 deletions crates/apollo-compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
# [x.x.x] (unreleased) - 2023-xx-xx

## Features
- **Add execution-related and introspection functionality - [SimonSapin], [pull/758]:**
- Add data structure in `apollo_compiler::execution` for a GraphQL response, its data, and errors.
All (de)serializable with `serde`.
- Add [`coerce_variable_values()`] in that same module.
- Add `apollo_compiler::execution::SchemaIntrospection`
providing full execution for the [schema introspection] parts of an operation
and separating the rest to be executed separately.
In order to support all kinds of introspection queries this actually includes
a full execution engine where users provide objects with resolvable fields.
At this time this engine is not exposed in the public API.
If you’re interested in it [let us know] about your use case!
- Add `ExecutableDocument::insert_operation` convenience method.
- **Add `NodeStr::from(Name)` - [goto-bus-stop], [pull/773]**
- **Convenience accessors for `ast::Selection` enum - [SimonSapin], [pull/777]**
`as_field`, `as_inline_fragment`, and `as_fragment_spread`; all returning `Option<&_>`.
Expand All @@ -30,9 +42,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

[goto-bus-stop]: https://github.com/goto-bus-stop]
[SimonSapin]: https://github.com/SimonSapin
[pull/758]: https://github.com/apollographql/apollo-rs/pull/758
[pull/773]: https://github.com/apollographql/apollo-rs/pull/773
[pull/774]: https://github.com/apollographql/apollo-rs/pull/774
[pull/777]: https://github.com/apollographql/apollo-rs/pull/777
[`coerce_variable_values()`]: https://spec.graphql.org/October2021/#sec-Coercing-Variable-Values
[schema introspection]: https://spec.graphql.org/October2021/#sec-Schema-Introspection
[let us know]: https://github.com/apollographql/apollo-rs/issues/new?assignees=&labels=triage&projects=&template=feature_request.md

# [1.0.0-beta.10](https://crates.io/crates/apollo-compiler/1.0.0-beta.10) - 2023-12-04

Expand Down
1 change: 1 addition & 0 deletions crates/apollo-compiler/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ indexmap = "2.0.0"
rowan = "0.15.5"
salsa = "0.16.1"
serde = { version = "1.0", features = ["derive"] }
serde_json_bytes = { version = "0.2.2", features = ["preserve_order"] }
thiserror = "1.0.31"
triomphe = "0.1.9"
# TODO: replace `sptr` with standard library methods when available:
Expand Down
16 changes: 15 additions & 1 deletion crates/apollo-compiler/src/executable/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ impl ExecutableDocument {

/// Return the relevant operation for a request, or a request error
///
/// This the [GetOperation](https://spec.graphql.org/October2021/#GetOperation())
/// This the [GetOperation()](https://spec.graphql.org/October2021/#GetOperation())
/// algorithm in the _Executing Requests_ section of the specification.
///
/// A GraphQL request comes with a document (which may contain multiple operations)
Expand Down Expand Up @@ -292,6 +292,20 @@ impl ExecutableDocument {
.ok_or(GetOperationError())
}

/// Insert the given operation in either `named_operations` or `anonymous_operation`
/// as appropriate, and return the old operation (if any) with that name (or lack thereof).
pub fn insert_operation(
&mut self,
operation: impl Into<Node<Operation>>,
) -> Option<Node<Operation>> {
let operation = operation.into();
if let Some(name) = &operation.name {
self.named_operations.insert(name.clone(), operation)
} else {
self.anonymous_operation.replace(operation)
}
}

serialize_method!();
}

Expand Down
327 changes: 327 additions & 0 deletions crates/apollo-compiler/src/execution/engine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
use crate::ast::Name;
use crate::ast::Value;
use crate::executable::Field;
use crate::executable::Selection;
use crate::execution::input_coercion::coerce_argument_values;
use crate::execution::resolver::ObjectValue;
use crate::execution::resolver::ResolverError;
use crate::execution::result_coercion::complete_value;
use crate::execution::GraphQLError;
use crate::execution::JsonMap;
use crate::execution::JsonValue;
use crate::execution::ResponseDataPathElement;
use crate::node::NodeLocation;
use crate::schema::ExtendedType;
use crate::schema::FieldDefinition;
use crate::schema::ObjectType;
use crate::schema::Type;
use crate::validation::SuspectedValidationBug;
use crate::validation::Valid;
use crate::ExecutableDocument;
use crate::Schema;
use crate::SourceMap;
use indexmap::IndexMap;
use std::collections::HashSet;

/// <https://spec.graphql.org/October2021/#sec-Normal-and-Serial-Execution>
#[derive(Debug, Copy, Clone)]
pub(crate) enum ExecutionMode {
/// Allowed to resolve fields in any order, including in parellel
Normal,
/// Top-level fields of a mutation operation must be executed in order
#[allow(unused)]
Sequential,
}

/// Return in `Err` when a field error occurred at some non-nullable place
///
/// <https://spec.graphql.org/October2021/#sec-Handling-Field-Errors>
pub(crate) struct PropagateNull;

/// Linked-list version of `Vec<PathElement>`, taking advantage of the call stack
pub(crate) type LinkedPath<'a> = Option<&'a LinkedPathElement<'a>>;

pub(crate) struct LinkedPathElement<'a> {
pub(crate) element: ResponseDataPathElement,
pub(crate) next: LinkedPath<'a>,
}

/// <https://spec.graphql.org/October2021/#ExecuteSelectionSet()>
#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal
pub(crate) fn execute_selection_set<'a>(
schema: &Valid<Schema>,
document: &'a Valid<ExecutableDocument>,
variable_values: &Valid<JsonMap>,
errors: &mut Vec<GraphQLError>,
path: LinkedPath<'_>,
mode: ExecutionMode,
object_type_name: &str,
object_type: &ObjectType,
object_value: &ObjectValue<'_>,
selections: impl IntoIterator<Item = &'a Selection>,
) -> Result<JsonMap, PropagateNull> {
let mut grouped_field_set = IndexMap::new();
collect_fields(
schema,
document,
variable_values,
object_type_name,
object_type,
selections,
&mut HashSet::new(),
&mut grouped_field_set,
);

match mode {
ExecutionMode::Normal => {}
ExecutionMode::Sequential => {
// If we want parallelism, use `futures::future::join_all` (async)
// or Rayon’s `par_iter` (sync) here.
}
}

let mut response_map = JsonMap::with_capacity(grouped_field_set.len());
for (&response_key, fields) in &grouped_field_set {
// Indexing should not panic: `collect_fields` only creates a `Vec` to push to it
let field_name = &fields[0].name;
let Ok(field_def) = schema.type_field(object_type_name, field_name) else {
// TODO: Return a `validation_bug`` field error here?
// The spec specifically has a “If fieldType is defined” condition,
// but it being undefined would make the request invalid, right?
continue;
};
let value = if field_name == "__typename" {
JsonValue::from(object_type_name)
} else {
let field_path = LinkedPathElement {
element: ResponseDataPathElement::Field(response_key.clone()),
next: path,
};
execute_field(
schema,
document,
variable_values,
errors,
Some(&field_path),
mode,
object_value,
field_def,
fields,
)?
};
response_map.insert(response_key.as_str(), value);
}
Ok(response_map)
}

/// <https://spec.graphql.org/October2021/#CollectFields()>
#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal
fn collect_fields<'a>(
schema: &Schema,
document: &'a ExecutableDocument,
variable_values: &Valid<JsonMap>,
object_type_name: &str,
object_type: &ObjectType,
selections: impl IntoIterator<Item = &'a Selection>,
visited_fragments: &mut HashSet<&'a Name>,
grouped_fields: &mut IndexMap<&'a Name, Vec<&'a Field>>,
) {
for selection in selections {
if eval_if_arg(selection, "skip", variable_values).unwrap_or(false)
|| !eval_if_arg(selection, "include", variable_values).unwrap_or(true)
{
continue;
}
match selection {
Selection::Field(field) => grouped_fields
.entry(field.response_key())
.or_default()
.push(field.as_ref()),
Selection::FragmentSpread(spread) => {
let new = visited_fragments.insert(&spread.fragment_name);
if !new {
continue;
}
let Some(fragment) = document.fragments.get(&spread.fragment_name) else {
continue;
};
if !does_fragment_type_apply(
schema,
object_type_name,
object_type,
fragment.type_condition(),
) {
continue;
}
collect_fields(
schema,
document,
variable_values,
object_type_name,
object_type,
&fragment.selection_set.selections,
visited_fragments,
grouped_fields,
)
}
Selection::InlineFragment(inline) => {
if let Some(condition) = &inline.type_condition {
if !does_fragment_type_apply(schema, object_type_name, object_type, condition) {
continue;
}
}
collect_fields(
schema,
document,
variable_values,
object_type_name,
object_type,
&inline.selection_set.selections,
visited_fragments,
grouped_fields,
)
}
}
}
}

/// <https://spec.graphql.org/October2021/#DoesFragmentTypeApply()>
fn does_fragment_type_apply(
schema: &Schema,
object_type_name: &str,
object_type: &ObjectType,
fragment_type: &Name,
) -> bool {
match schema.types.get(fragment_type) {
Some(ExtendedType::Object(_)) => fragment_type == object_type_name,
Some(ExtendedType::Interface(_)) => {
object_type.implements_interfaces.contains(fragment_type)
}
Some(ExtendedType::Union(def)) => def.members.contains(object_type_name),
// Undefined or not an output type: validation should have caught this
_ => false,
}
}

fn eval_if_arg(
selection: &Selection,
directive_name: &str,
variable_values: &Valid<JsonMap>,
) -> Option<bool> {
match selection
.directives()
.get(directive_name)?
.argument_by_name("if")?
.as_ref()
{
Value::Boolean(value) => Some(*value),
Value::Variable(var) => variable_values.get(var.as_str())?.as_bool(),
_ => None,
}
}

/// <https://spec.graphql.org/October2021/#ExecuteField()>
#[allow(clippy::too_many_arguments)] // yes it’s not a nice API but it’s internal
fn execute_field(
schema: &Valid<Schema>,
document: &Valid<ExecutableDocument>,
variable_values: &Valid<JsonMap>,
errors: &mut Vec<GraphQLError>,
path: LinkedPath<'_>,
mode: ExecutionMode,
object_value: &ObjectValue<'_>,
field_def: &FieldDefinition,
fields: &[&Field],
) -> Result<JsonValue, PropagateNull> {
let field = fields[0];
let argument_values = match coerce_argument_values(
schema,
document,
variable_values,
errors,
path,
field_def,
field,
) {
Ok(argument_values) => argument_values,
Err(PropagateNull) => return try_nullify(&field_def.ty, Err(PropagateNull)),
};
let resolved_result = object_value.resolve_field(&field.name, &argument_values);
let completed_result = match resolved_result {
Ok(resolved) => complete_value(
schema,
document,
variable_values,
errors,
path,
mode,
field.ty(),
resolved,
fields,
),
Err(ResolverError { message }) => {
errors.push(GraphQLError::field_error(
format!("resolver error: {message}"),
path,
field.name.location(),
&document.sources,
));
Err(PropagateNull)
}
};
try_nullify(&field_def.ty, completed_result)
}

/// Try to insert a propagated null if possible, or keep propagating it.
///
/// <https://spec.graphql.org/October2021/#sec-Handling-Field-Errors>
pub(crate) fn try_nullify(
ty: &Type,
result: Result<JsonValue, PropagateNull>,
) -> Result<JsonValue, PropagateNull> {
match result {
Ok(json) => Ok(json),
Err(PropagateNull) => {
if ty.is_non_null() {
Err(PropagateNull)
} else {
Ok(JsonValue::Null)
}
}
}
}

pub(crate) fn path_to_vec(mut link: LinkedPath<'_>) -> Vec<ResponseDataPathElement> {
let mut path = Vec::new();
while let Some(node) = link {
path.push(node.element.clone());
link = node.next;
}
path.reverse();
path
}

impl GraphQLError {
pub(crate) fn field_error(
message: impl Into<String>,
path: LinkedPath<'_>,
location: Option<NodeLocation>,
sources: &SourceMap,
) -> Self {
let mut err = Self::new(message, location, sources);
err.path = path_to_vec(path);
err
}
}

impl SuspectedValidationBug {
pub(crate) fn into_field_error(
self,
sources: &SourceMap,
path: LinkedPath<'_>,
) -> GraphQLError {
let mut err = self.into_graphql_error(sources);
err.path = path_to_vec(path);
err
}
}
Loading

0 comments on commit d97abbc

Please sign in to comment.