Skip to content

fix: validate subscription with conditional selections #963

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/apollo-compiler/src/executable/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ pub(crate) enum BuildError {
/// Name of the introspection field
field: Name,
},
#[error(
"{} can not specify @skip or @include on root fields",
subscription_name_or_anonymous(name)
)]
SubscriptionUsesConditionalSelection {
/// Name of the operation
name: Option<Name>,
},

#[error("{0}")]
ConflictingFieldType(Box<ConflictingFieldType>),
Expand Down
25 changes: 20 additions & 5 deletions crates/apollo-compiler/src/validation/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
//! Supporting APIs for [GraphQL validation](https://spec.graphql.org/October2021/#sec-Validation)
//! and other kinds of errors.

use crate::coordinate::SchemaCoordinate;
#[cfg(doc)]
use crate::ExecutableDocument;
use crate::Schema;

pub(crate) mod argument;
pub(crate) mod diagnostics;
pub(crate) mod directive;
Expand All @@ -26,6 +21,7 @@ pub(crate) mod variable;
use crate::collections::HashMap;
use crate::collections::HashSet;
use crate::collections::IndexSet;
use crate::coordinate::SchemaCoordinate;
use crate::diagnostic::CliReport;
use crate::diagnostic::Diagnostic;
use crate::diagnostic::ToCliReport;
Expand All @@ -41,6 +37,7 @@ use crate::schema::BuildError as SchemaBuildError;
use crate::schema::Implementers;
use crate::Name;
use crate::Node;
use crate::Schema;
use std::fmt;
use std::sync::Arc;
use std::sync::OnceLock;
Expand Down Expand Up @@ -338,6 +335,9 @@ impl DiagnosticData {
ExecutableBuildError::SubscriptionUsesIntrospection { .. } => {
"SubscriptionUsesIntrospection"
}
ExecutableBuildError::SubscriptionUsesConditionalSelection { .. } => {
"SubscriptionUsesConditionalSelection"
}
ExecutableBuildError::ConflictingFieldType(_) => "ConflictingFieldType",
ExecutableBuildError::ConflictingFieldName(_) => "ConflictingFieldName",
ExecutableBuildError::ConflictingFieldArgument(_) => "ConflictingFieldArgument",
Expand Down Expand Up @@ -586,6 +586,18 @@ impl DiagnosticData {
.to_string())
}
}
ExecutableBuildError::SubscriptionUsesConditionalSelection { name, .. } => {
if let Some(name) = name {
Some(format!(
r#"Subscription "{name}" can not specify @skip or @include on root fields."#
))
} else {
Some(
"Anonymous Subscription can not specify @skip or @include on root fields."
.to_string(),
)
}
}
ExecutableBuildError::ConflictingFieldType(inner) => {
let ConflictingFieldType {
alias,
Expand Down Expand Up @@ -839,6 +851,9 @@ impl ToCliReport for DiagnosticData {
format_args!("{field} is an introspection field"),
);
}
ExecutableBuildError::SubscriptionUsesConditionalSelection { .. } => {
report.with_label_opt(self.location, "conditional directive used here");
}
ExecutableBuildError::ConflictingFieldType(inner) => {
let ConflictingFieldType {
alias,
Expand Down
122 changes: 94 additions & 28 deletions crates/apollo-compiler/src/validation/operation.rs
Original file line number Diff line number Diff line change
@@ -1,51 +1,117 @@
use crate::collections::HashSet;
use crate::executable;
use crate::validation::diagnostics::DiagnosticData;
use crate::validation::DiagnosticList;
use crate::validation::ExecutableValidationContext;
use crate::validation::RecursionLimitError;
use crate::ExecutableDocument;
use crate::Name;
use crate::Node;

/// Iterate all selections in the selection set.
///
/// This includes fields, fragment spreads, and inline fragments. For fragments, both the spread
/// and the fragment's nested selections are reported.
///
/// Does not recurse into nested fields.
fn walk_selections<'doc>(
document: &'doc ExecutableDocument,
selections: &'doc executable::SelectionSet,
mut f: impl FnMut(&'doc executable::Selection),
) -> Result<(), RecursionLimitError> {
fn walk_selections_inner<'doc>(
document: &'doc ExecutableDocument,
selection_set: &'doc executable::SelectionSet,
seen: &mut HashSet<&'doc Name>,
f: &mut dyn FnMut(&'doc executable::Selection),
) -> Result<(), RecursionLimitError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation here doesn't actually raise a RecursionLimitError right now, but that will be part of a follow-up PR

for selection in &selection_set.selections {
f(selection);
match selection {
executable::Selection::Field(_) => {
// Nothing to do
}
executable::Selection::FragmentSpread(fragment) => {
let new = seen.insert(&fragment.fragment_name);
if !new {
continue;
}

// If the fragment doesn't exist, that error is reported elsewhere.
if let Some(fragment_definition) =
document.fragments.get(&fragment.fragment_name)
{
walk_selections_inner(
document,
&fragment_definition.selection_set,
seen,
f,
)?;
}
}
executable::Selection::InlineFragment(fragment) => {
walk_selections_inner(document, &fragment.selection_set, seen, f)?;
}
}
}
Ok(())
}

walk_selections_inner(document, selections, &mut HashSet::default(), &mut f)
}

pub(crate) fn validate_subscription(
document: &executable::ExecutableDocument,
operation: &Node<executable::Operation>,
diagnostics: &mut DiagnosticList,
) {
if operation.is_subscription() {
let fields = super::selection::expand_selections(
&document.fragments,
std::iter::once(&operation.selection_set),
);
if !operation.is_subscription() {
return;
}

if fields.len() > 1 {
diagnostics.push(
operation.location(),
executable::BuildError::SubscriptionUsesMultipleFields {
name: operation.name.clone(),
fields: fields
.iter()
.map(|field| field.field.name.clone())
.collect(),
},
);
let mut field_names = vec![];

let walked = walk_selections(document, &operation.selection_set, |selection| {
if let executable::Selection::Field(field) = selection {
field_names.push(field.name.clone());
if matches!(field.name.as_str(), "__type" | "__schema" | "__typename") {
diagnostics.push(
field.location(),
executable::BuildError::SubscriptionUsesIntrospection {
name: operation.name.clone(),
field: field.name.clone(),
},
);
}
}

let has_introspection_fields = fields
if let Some(conditional_directive) = selection
.directives()
.iter()
.find(|field| {
matches!(
field.field.name.as_str(),
"__type" | "__schema" | "__typename"
)
})
.map(|field| &field.field);
if let Some(field) = has_introspection_fields {
.find(|d| matches!(d.name.as_str(), "skip" | "include"))
{
diagnostics.push(
field.location(),
executable::BuildError::SubscriptionUsesIntrospection {
conditional_directive.location(),
executable::BuildError::SubscriptionUsesConditionalSelection {
name: operation.name.clone(),
field: field.name.clone(),
},
);
}
});

if walked.is_err() {
diagnostics.push(None, DiagnosticData::RecursionError {});
return;
}

if field_names.len() > 1 {
diagnostics.push(
operation.location(),
executable::BuildError::SubscriptionUsesMultipleFields {
name: operation.name.clone(),
fields: field_names,
},
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/apollo-compiler/src/validation/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ impl<'a> FieldSelection<'a> {
}

/// Expand one or more selection sets to a list of all fields selected.
pub(crate) fn expand_selections<'doc>(
fn expand_selections<'doc>(
fragments: &'doc IndexMap<Name, Node<executable::Fragment>>,
selection_sets: impl Iterator<Item = &'doc executable::SelectionSet>,
) -> Vec<FieldSelection<'doc>> {
Expand Down
9 changes: 8 additions & 1 deletion crates/apollo-compiler/src/validation/variable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,14 @@ pub(crate) fn validate_variable_definitions(
}
}

/// Named fragments are "deduplicated": only visited once even if spread multiple times
/// Call a function for every selection that is reachable from the given selection set.
///
/// This includes fields, fragment spreads, and inline fragments. For fragments, both the spread
/// and the fragment's nested selections are reported. For fields, nested selections are also
/// reported.
///
/// Named fragments are "deduplicated": only visited once even if spread multiple times *in
/// different locations*. This is only appropriate for certain kinds of validations, so reuser beware.
fn walk_selections_with_deduped_fragments<'doc>(
document: &'doc ExecutableDocument,
selections: &'doc executable::SelectionSet,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
subscription ConditionalSub($condition: Boolean = true) {
ticker @include(if: $condition)
}

type Query {
hello: String
}

type Subscription {
ticker: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Error: subscription `ConditionalSub` can not specify @skip or @include on root fields
╭─[ 0120_conditional_subscriptions.graphql:2:12 ]
2 │ ticker @include(if: $condition)
│ ────────────┬───────────
│ ╰───────────── conditional directive used here
───╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
subscription ConditionalInlineSub($condition: Boolean = true) {
... @include(if: $condition) {
ticker
}
}

type Query {
hello: String
}

type Subscription {
ticker: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Error: subscription `ConditionalInlineSub` can not specify @skip or @include on root fields
╭─[ 0121_conditional_subscriptions_with_inline_fragment.graphql:2:9 ]
2 │ ... @include(if: $condition) {
│ ────────────┬───────────
│ ╰───────────── conditional directive used here
───╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
subscription ConditionalInlineSub($condition: Boolean = true) {
...mySubscription @include(if: $condition)
}

fragment mySubscription on Subscription {
ticker
}

type Query {
hello: String
}

type Subscription {
ticker: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Error: subscription `ConditionalInlineSub` can not specify @skip or @include on root fields
╭─[ 0122_conditional_subscriptions_with_named_fragment.graphql:2:23 ]
2 │ ...mySubscription @include(if: $condition)
│ ────────────┬───────────
│ ╰───────────── conditional directive used here
───╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
subscription ConditionalInlineSub($condition: Boolean = true) {
... {
ticker @include(if: $condition)
}
}

type Query {
hello: String
}

type Subscription {
ticker: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Error: subscription `ConditionalInlineSub` can not specify @skip or @include on root fields
╭─[ 0123_conditional_subscriptions_inside_inline_fragment.graphql:3:16 ]
3 │ ticker @include(if: $condition)
│ ────────────┬───────────
│ ╰───────────── conditional directive used here
───╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
subscription ConditionalInlineSub($condition: Boolean = true) {
...mySubscription
}

fragment mySubscription on Subscription {
ticker @include(if: $condition)
}

type Query {
hello: String
}

type Subscription {
ticker: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Error: subscription `ConditionalInlineSub` can not specify @skip or @include on root fields
╭─[ 0124_conditional_subscriptions_inside_named_fragment.graphql:6:12 ]
6 │ ticker @include(if: $condition)
│ ────────────┬───────────
│ ╰───────────── conditional directive used here
───╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
subscription ConditionalSub($includeContent: Boolean = true, $small: Boolean = true) {
messages {
username
text @include(if: $includeContent)
avatar @skip(if: $small)
}
}

type Query {
hello: String
}

type Message {
username: String
text: String
avatar: String
}

type Subscription {
messages: Message
}
Loading