Skip to content

Commit df16557

Browse files
dariuszkucSimonSapingoto-bus-stop
authored
fix: validate subscription with conditional selections (#963)
* fix: validate subscription with conditional selections Adds validation for subscription operations that specify `@skip`/`@include` conditionals on root selections. See: graphql/graphql-spec#860 * check for all selections * Subscription root validation: * Recur into inline fragments and fragment spread * Point to the faulty directive * Skip printing an entire selection set * Use a single special-purpose walk in subscription validation --------- Co-authored-by: Simon Sapin <[email protected]> Co-authored-by: Renée Kooi <[email protected]>
1 parent d265dc2 commit df16557

24 files changed

+662
-35
lines changed

crates/apollo-compiler/src/executable/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,14 @@ pub(crate) enum BuildError {
266266
/// Name of the introspection field
267267
field: Name,
268268
},
269+
#[error(
270+
"{} can not specify @skip or @include on root fields",
271+
subscription_name_or_anonymous(name)
272+
)]
273+
SubscriptionUsesConditionalSelection {
274+
/// Name of the operation
275+
name: Option<Name>,
276+
},
269277

270278
#[error("{0}")]
271279
ConflictingFieldType(Box<ConflictingFieldType>),

crates/apollo-compiler/src/validation/mod.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
//! Supporting APIs for [GraphQL validation](https://spec.graphql.org/October2021/#sec-Validation)
22
//! and other kinds of errors.
33
4-
use crate::coordinate::SchemaCoordinate;
5-
#[cfg(doc)]
6-
use crate::ExecutableDocument;
7-
use crate::Schema;
8-
94
pub(crate) mod argument;
105
pub(crate) mod diagnostics;
116
pub(crate) mod directive;
@@ -26,6 +21,7 @@ pub(crate) mod variable;
2621
use crate::collections::HashMap;
2722
use crate::collections::HashSet;
2823
use crate::collections::IndexSet;
24+
use crate::coordinate::SchemaCoordinate;
2925
use crate::diagnostic::CliReport;
3026
use crate::diagnostic::Diagnostic;
3127
use crate::diagnostic::ToCliReport;
@@ -41,6 +37,7 @@ use crate::schema::BuildError as SchemaBuildError;
4137
use crate::schema::Implementers;
4238
use crate::Name;
4339
use crate::Node;
40+
use crate::Schema;
4441
use std::fmt;
4542
use std::sync::Arc;
4643
use std::sync::OnceLock;
@@ -338,6 +335,9 @@ impl DiagnosticData {
338335
ExecutableBuildError::SubscriptionUsesIntrospection { .. } => {
339336
"SubscriptionUsesIntrospection"
340337
}
338+
ExecutableBuildError::SubscriptionUsesConditionalSelection { .. } => {
339+
"SubscriptionUsesConditionalSelection"
340+
}
341341
ExecutableBuildError::ConflictingFieldType(_) => "ConflictingFieldType",
342342
ExecutableBuildError::ConflictingFieldName(_) => "ConflictingFieldName",
343343
ExecutableBuildError::ConflictingFieldArgument(_) => "ConflictingFieldArgument",
@@ -586,6 +586,18 @@ impl DiagnosticData {
586586
.to_string())
587587
}
588588
}
589+
ExecutableBuildError::SubscriptionUsesConditionalSelection { name, .. } => {
590+
if let Some(name) = name {
591+
Some(format!(
592+
r#"Subscription "{name}" can not specify @skip or @include on root fields."#
593+
))
594+
} else {
595+
Some(
596+
"Anonymous Subscription can not specify @skip or @include on root fields."
597+
.to_string(),
598+
)
599+
}
600+
}
589601
ExecutableBuildError::ConflictingFieldType(inner) => {
590602
let ConflictingFieldType {
591603
alias,
@@ -839,6 +851,9 @@ impl ToCliReport for DiagnosticData {
839851
format_args!("{field} is an introspection field"),
840852
);
841853
}
854+
ExecutableBuildError::SubscriptionUsesConditionalSelection { .. } => {
855+
report.with_label_opt(self.location, "conditional directive used here");
856+
}
842857
ExecutableBuildError::ConflictingFieldType(inner) => {
843858
let ConflictingFieldType {
844859
alias,

crates/apollo-compiler/src/validation/operation.rs

Lines changed: 94 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,117 @@
1+
use crate::collections::HashSet;
12
use crate::executable;
3+
use crate::validation::diagnostics::DiagnosticData;
24
use crate::validation::DiagnosticList;
35
use crate::validation::ExecutableValidationContext;
6+
use crate::validation::RecursionLimitError;
47
use crate::ExecutableDocument;
8+
use crate::Name;
59
use crate::Node;
610

11+
/// Iterate all selections in the selection set.
12+
///
13+
/// This includes fields, fragment spreads, and inline fragments. For fragments, both the spread
14+
/// and the fragment's nested selections are reported.
15+
///
16+
/// Does not recurse into nested fields.
17+
fn walk_selections<'doc>(
18+
document: &'doc ExecutableDocument,
19+
selections: &'doc executable::SelectionSet,
20+
mut f: impl FnMut(&'doc executable::Selection),
21+
) -> Result<(), RecursionLimitError> {
22+
fn walk_selections_inner<'doc>(
23+
document: &'doc ExecutableDocument,
24+
selection_set: &'doc executable::SelectionSet,
25+
seen: &mut HashSet<&'doc Name>,
26+
f: &mut dyn FnMut(&'doc executable::Selection),
27+
) -> Result<(), RecursionLimitError> {
28+
for selection in &selection_set.selections {
29+
f(selection);
30+
match selection {
31+
executable::Selection::Field(_) => {
32+
// Nothing to do
33+
}
34+
executable::Selection::FragmentSpread(fragment) => {
35+
let new = seen.insert(&fragment.fragment_name);
36+
if !new {
37+
continue;
38+
}
39+
40+
// If the fragment doesn't exist, that error is reported elsewhere.
41+
if let Some(fragment_definition) =
42+
document.fragments.get(&fragment.fragment_name)
43+
{
44+
walk_selections_inner(
45+
document,
46+
&fragment_definition.selection_set,
47+
seen,
48+
f,
49+
)?;
50+
}
51+
}
52+
executable::Selection::InlineFragment(fragment) => {
53+
walk_selections_inner(document, &fragment.selection_set, seen, f)?;
54+
}
55+
}
56+
}
57+
Ok(())
58+
}
59+
60+
walk_selections_inner(document, selections, &mut HashSet::default(), &mut f)
61+
}
62+
763
pub(crate) fn validate_subscription(
864
document: &executable::ExecutableDocument,
965
operation: &Node<executable::Operation>,
1066
diagnostics: &mut DiagnosticList,
1167
) {
12-
if operation.is_subscription() {
13-
let fields = super::selection::expand_selections(
14-
&document.fragments,
15-
std::iter::once(&operation.selection_set),
16-
);
68+
if !operation.is_subscription() {
69+
return;
70+
}
1771

18-
if fields.len() > 1 {
19-
diagnostics.push(
20-
operation.location(),
21-
executable::BuildError::SubscriptionUsesMultipleFields {
22-
name: operation.name.clone(),
23-
fields: fields
24-
.iter()
25-
.map(|field| field.field.name.clone())
26-
.collect(),
27-
},
28-
);
72+
let mut field_names = vec![];
73+
74+
let walked = walk_selections(document, &operation.selection_set, |selection| {
75+
if let executable::Selection::Field(field) = selection {
76+
field_names.push(field.name.clone());
77+
if matches!(field.name.as_str(), "__type" | "__schema" | "__typename") {
78+
diagnostics.push(
79+
field.location(),
80+
executable::BuildError::SubscriptionUsesIntrospection {
81+
name: operation.name.clone(),
82+
field: field.name.clone(),
83+
},
84+
);
85+
}
2986
}
3087

31-
let has_introspection_fields = fields
88+
if let Some(conditional_directive) = selection
89+
.directives()
3290
.iter()
33-
.find(|field| {
34-
matches!(
35-
field.field.name.as_str(),
36-
"__type" | "__schema" | "__typename"
37-
)
38-
})
39-
.map(|field| &field.field);
40-
if let Some(field) = has_introspection_fields {
91+
.find(|d| matches!(d.name.as_str(), "skip" | "include"))
92+
{
4193
diagnostics.push(
42-
field.location(),
43-
executable::BuildError::SubscriptionUsesIntrospection {
94+
conditional_directive.location(),
95+
executable::BuildError::SubscriptionUsesConditionalSelection {
4496
name: operation.name.clone(),
45-
field: field.name.clone(),
4697
},
4798
);
4899
}
100+
});
101+
102+
if walked.is_err() {
103+
diagnostics.push(None, DiagnosticData::RecursionError {});
104+
return;
105+
}
106+
107+
if field_names.len() > 1 {
108+
diagnostics.push(
109+
operation.location(),
110+
executable::BuildError::SubscriptionUsesMultipleFields {
111+
name: operation.name.clone(),
112+
fields: field_names,
113+
},
114+
);
49115
}
50116
}
51117

crates/apollo-compiler/src/validation/selection.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ impl<'a> FieldSelection<'a> {
6464
}
6565

6666
/// Expand one or more selection sets to a list of all fields selected.
67-
pub(crate) fn expand_selections<'doc>(
67+
fn expand_selections<'doc>(
6868
fragments: &'doc IndexMap<Name, Node<executable::Fragment>>,
6969
selection_sets: impl Iterator<Item = &'doc executable::SelectionSet>,
7070
) -> Vec<FieldSelection<'doc>> {

crates/apollo-compiler/src/validation/variable.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ pub(crate) fn validate_variable_definitions(
8080
}
8181
}
8282

83-
/// Named fragments are "deduplicated": only visited once even if spread multiple times
83+
/// Call a function for every selection that is reachable from the given selection set.
84+
///
85+
/// This includes fields, fragment spreads, and inline fragments. For fragments, both the spread
86+
/// and the fragment's nested selections are reported. For fields, nested selections are also
87+
/// reported.
88+
///
89+
/// Named fragments are "deduplicated": only visited once even if spread multiple times *in
90+
/// different locations*. This is only appropriate for certain kinds of validations, so reuser beware.
8491
fn walk_selections_with_deduped_fragments<'doc>(
8592
document: &'doc ExecutableDocument,
8693
selections: &'doc executable::SelectionSet,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
subscription ConditionalSub($condition: Boolean = true) {
2+
ticker @include(if: $condition)
3+
}
4+
5+
type Query {
6+
hello: String
7+
}
8+
9+
type Subscription {
10+
ticker: String
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Error: subscription `ConditionalSub` can not specify @skip or @include on root fields
2+
╭─[ 0120_conditional_subscriptions.graphql:2:12 ]
3+
4+
2 │ ticker @include(if: $condition)
5+
│ ────────────┬───────────
6+
│ ╰───────────── conditional directive used here
7+
───╯
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
subscription ConditionalInlineSub($condition: Boolean = true) {
2+
... @include(if: $condition) {
3+
ticker
4+
}
5+
}
6+
7+
type Query {
8+
hello: String
9+
}
10+
11+
type Subscription {
12+
ticker: String
13+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Error: subscription `ConditionalInlineSub` can not specify @skip or @include on root fields
2+
╭─[ 0121_conditional_subscriptions_with_inline_fragment.graphql:2:9 ]
3+
4+
2 │ ... @include(if: $condition) {
5+
│ ────────────┬───────────
6+
│ ╰───────────── conditional directive used here
7+
───╯
8+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
subscription ConditionalInlineSub($condition: Boolean = true) {
2+
...mySubscription @include(if: $condition)
3+
}
4+
5+
fragment mySubscription on Subscription {
6+
ticker
7+
}
8+
9+
type Query {
10+
hello: String
11+
}
12+
13+
type Subscription {
14+
ticker: String
15+
}

0 commit comments

Comments
 (0)