diff --git a/src/jmh/java/benchmark/SubscriptionValidationBenchmark.java b/src/jmh/java/benchmark/SubscriptionValidationBenchmark.java new file mode 100644 index 000000000..fc7986b62 --- /dev/null +++ b/src/jmh/java/benchmark/SubscriptionValidationBenchmark.java @@ -0,0 +1,160 @@ +package benchmark; + +import graphql.language.Document; +import graphql.parser.Parser; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaGenerator; +import graphql.validation.ValidationError; +import graphql.validation.Validator; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import static graphql.Assert.assertTrue; + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@Warmup(iterations = 2, time = 5) +@Measurement(iterations = 3) +@Fork(2) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +public class SubscriptionValidationBenchmark { + + private GraphQLSchema schema; + private Document complexSubscriptionDocument; + + @Setup + public void setup() { + schema = SchemaGenerator.createdMockedSchema(schemaSdl()); + complexSubscriptionDocument = Parser.parse(complexSubscriptionQuery()); + assertTrue(validate(complexSubscriptionDocument).isEmpty()); + } + + @Benchmark + public List complexSubscription() { + return validate(complexSubscriptionDocument); + } + + private List validate(Document document) { + Validator validator = new Validator(); + return validator.validateDocument(schema, document, Locale.ENGLISH); + } + + private static String schemaSdl() { + return String.join("\n", + "schema {", + " query: Query", + " subscription: Subscription", + "}", + "", + "type Query {", + " event: Event", + "}", + "", + "type Subscription {", + " event: Event", + "}", + "", + "interface Node {", + " id: ID", + "}", + "", + "interface Named {", + " name: String", + "}", + "", + "type Event implements Node & Named {", + " id: ID", + " name: String", + " timestamp: String", + " payload: Payload", + " related: Event", + "}", + "", + "type Payload {", + " id: ID", + " value: String", + " nested: Payload", + " metrics: Metrics", + "}", + "", + "type Metrics {", + " count: Int", + " label: String", + "}"); + } + + private static String complexSubscriptionQuery() { + StringBuilder query = new StringBuilder(); + query.append("subscription ComplexSubscription {\n"); + appendRootFragmentSpreads(query); + query.append("}\n"); + appendRootFragments(query); + appendEventFragments(query); + appendSharedEventFragment(query); + return query.toString(); + } + + private static void appendRootFragmentSpreads(StringBuilder query) { + for (int i = 0; i < 50; i++) { + query.append(" ...RootFragment").append(i).append("\n"); + } + } + + private static void appendRootFragments(StringBuilder query) { + for (int i = 0; i < 50; i++) { + query.append("fragment RootFragment").append(i).append(" on Subscription {\n"); + query.append(" event {\n"); + query.append(" ...EventFragment").append(i % 20).append("\n"); + query.append(" ...SharedEventFields\n"); + query.append(" related {\n"); + query.append(" ...SharedEventFields\n"); + query.append(" }\n"); + query.append(" }\n"); + query.append("}\n"); + } + } + + private static void appendEventFragments(StringBuilder query) { + for (int i = 0; i < 20; i++) { + query.append("fragment EventFragment").append(i).append(" on Event {\n"); + query.append(" id\n"); + query.append(" name\n"); + query.append(" payload {\n"); + query.append(" value\n"); + query.append(" metrics {\n"); + query.append(" count\n"); + query.append(" label\n"); + query.append(" }\n"); + query.append(" }\n"); + query.append("}\n"); + } + } + + private static void appendSharedEventFragment(StringBuilder query) { + query.append("fragment SharedEventFields on Event {\n"); + query.append(" id\n"); + query.append(" name\n"); + query.append(" timestamp\n"); + query.append(" payload {\n"); + query.append(" nested {\n"); + query.append(" value\n"); + query.append(" metrics {\n"); + query.append(" count\n"); + query.append(" }\n"); + query.append(" }\n"); + query.append(" }\n"); + query.append("}\n"); + } +} diff --git a/src/jmh/java/benchmark/ValidatorBenchmark.java b/src/jmh/java/benchmark/ValidatorBenchmark.java index f9dd34a0c..e6bf71894 100644 --- a/src/jmh/java/benchmark/ValidatorBenchmark.java +++ b/src/jmh/java/benchmark/ValidatorBenchmark.java @@ -1,11 +1,10 @@ package benchmark; -import graphql.ExecutionResult; -import graphql.GraphQL; import graphql.language.Document; import graphql.parser.Parser; import graphql.schema.GraphQLSchema; import graphql.schema.idl.SchemaGenerator; +import graphql.validation.ValidationError; import graphql.validation.Validator; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; @@ -18,12 +17,10 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Warmup; +import java.util.List; import java.util.Locale; import java.util.concurrent.TimeUnit; -import static graphql.Assert.assertTrue; - - @State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) @Warmup(iterations = 2, time = 5) @@ -61,11 +58,6 @@ private Scenario load(String schemaPath, String queryPath) { String query = BenchmarkUtils.loadResource(queryPath); GraphQLSchema schema = SchemaGenerator.createdMockedSchema(schemaString); Document document = Parser.parse(query); - - // make sure this is a valid query overall - GraphQL graphQL = GraphQL.newGraphQL(schema).build(); - ExecutionResult executionResult = graphQL.execute(query); - assertTrue(executionResult.getErrors().size() == 0); return new Scenario(schema, document); } catch (Exception e) { throw new RuntimeException(e); @@ -74,23 +66,23 @@ private Scenario load(String schemaPath, String queryPath) { } - private void run(Scenario scenario) { + private List run(Scenario scenario) { Validator validator = new Validator(); - validator.validateDocument(scenario.schema, scenario.document, Locale.ENGLISH); + return validator.validateDocument(scenario.schema, scenario.document, Locale.ENGLISH); } @Benchmark - public void largeSchema1(MyState state) { - run(state.largeSchema1); + public List largeSchema1(MyState state) { + return run(state.largeSchema1); } @Benchmark - public void largeSchema4(MyState state) { - run(state.largeSchema4); + public List largeSchema4(MyState state) { + return run(state.largeSchema4); } @Benchmark - public void manyFragments(MyState state) { - run(state.manyFragments); + public List manyFragments(MyState state) { + return run(state.manyFragments); } } diff --git a/src/main/java/graphql/validation/OperationValidator.java b/src/main/java/graphql/validation/OperationValidator.java index 43925d6ee..8bf769761 100644 --- a/src/main/java/graphql/validation/OperationValidator.java +++ b/src/main/java/graphql/validation/OperationValidator.java @@ -6,11 +6,6 @@ import graphql.Directives; import graphql.ExperimentalApi; import graphql.Internal; -import graphql.execution.CoercedVariables; -import graphql.execution.FieldCollector; -import graphql.execution.FieldCollectorParameters; -import graphql.execution.MergedField; -import graphql.execution.MergedSelectionSet; import graphql.execution.TypeFromAST; import graphql.execution.ValuesResolver; import graphql.i18n.I18nMsg; @@ -23,13 +18,13 @@ import graphql.language.Definition; import graphql.language.Directive; import graphql.language.DirectiveDefinition; +import graphql.language.DirectivesContainer; import graphql.language.Document; import graphql.language.Field; import graphql.language.FragmentDefinition; import graphql.language.FragmentSpread; import graphql.language.InlineFragment; import graphql.language.Node; -import graphql.language.NodeUtil; import graphql.language.NullValue; import graphql.language.ObjectField; import graphql.language.ObjectValue; @@ -102,6 +97,7 @@ import static graphql.validation.ValidationErrorType.DuplicateVariableName; import static graphql.validation.ValidationErrorType.FieldUndefined; import static graphql.validation.ValidationErrorType.FieldsConflict; +import static graphql.validation.ValidationErrorType.ForbidSkipAndIncludeOnSubscriptionRoot; import static graphql.validation.ValidationErrorType.FragmentCycle; import static graphql.validation.ValidationErrorType.FragmentTypeConditionInvalid; import static graphql.validation.ValidationErrorType.InlineFragmentTypeConditionInvalid; @@ -329,7 +325,16 @@ public class OperationValidator implements DocumentVisitor { private final Set checkedDeferLabels = new LinkedHashSet<>(); // --- State: SubscriptionUniqueRootField --- - private final FieldCollector fieldCollector = new FieldCollector(); + private @Nullable OperationDefinition subscriptionRoot_operationDefinition; + private @Nullable GraphQLObjectType subscriptionRoot_type; + private final Map> subscriptionRoot_fields = new LinkedHashMap<>(); + private final List subscriptionRoot_forbiddenDirectives = new ArrayList<>(); + private final Set subscriptionRoot_visitedFragments = new LinkedHashSet<>(); + private final List subscriptionRoot_activeFragmentNames = new ArrayList<>(); + private final List subscriptionRoot_activeFragmentBaseDepths = new ArrayList<>(); + private final List subscriptionRoot_activeFragmentApplies = new ArrayList<>(); + private final Map>> subscriptionRoot_fragmentFields = new HashMap<>(); + private final Map> subscriptionRoot_fragmentForbiddenDirectives = new HashMap<>(); // --- State: Query Complexity Limits --- private int fieldCount = 0; @@ -622,6 +627,9 @@ private void checkField(Field field) { if (operationScope && isRuleEnabled(OperationValidationRule.GOOD_FAITH_INTROSPECTION)) { checkGoodFaithIntrospection(field); } + if (operationScope && isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) { + validateSubscriptionUniqueRootField_field(field); + } } // --- GoodFaithIntrospection --- @@ -679,6 +687,11 @@ private void checkInlineFragment(InlineFragment inlineFragment) { validateUniqueDirectiveNamesPerLocation(inlineFragment, inlineFragment.getDirectives()); } } + if (shouldRunOperationScopedRules()) { + if (isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) { + validateSubscriptionUniqueRootField_inlineFragment(inlineFragment); + } + } } private void checkDirective(Directive directive, List ancestors) { @@ -722,6 +735,12 @@ private void checkFragmentSpread(FragmentSpread node, List ancestors) { } } + if (shouldRunOperationScopedRules()) { + if (isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) { + validateSubscriptionUniqueRootField_fragmentSpread(node); + } + } + // Handle complexity tracking and fragment traversal if (operationScope) { String fragmentName = node.getName(); @@ -751,9 +770,11 @@ private void checkFragmentSpread(FragmentSpread node, List ancestors) { // Initialize max depth tracking for this fragment fragmentTraversalMaxDepth = currentFieldDepth; + enterSubscriptionRootFragment(node, fragment, currentFieldDepth); fragmentRetraversalDepth++; new LanguageTraversal(ancestors).traverse(fragment, this); fragmentRetraversalDepth--; + leaveSubscriptionRootFragment(); // Calculate and store fragment complexity int fragmentFieldCount = fieldCount - fieldCountBefore; @@ -808,7 +829,7 @@ private void checkOperationDefinition(OperationDefinition operationDefinition) { validateUniqueVariableNames(operationDefinition); } if (isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) { - validateSubscriptionUniqueRootField(operationDefinition); + enterSubscriptionUniqueRootField(operationDefinition); } if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) { validateUniqueDirectiveNamesPerLocation(operationDefinition, operationDefinition.getDirectives()); @@ -864,6 +885,10 @@ private void checkObjectValue(ObjectValue objectValue) { } private void leaveOperationDefinition() { + if (isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) { + leaveSubscriptionUniqueRootField(); + } + // fragments should be revisited for each operation visitedFragmentSpreads.clear(); operationScope = false; @@ -1819,34 +1844,287 @@ private void validateUniqueVariableNames(OperationDefinition operationDefinition } // --- SubscriptionUniqueRootField --- - private void validateSubscriptionUniqueRootField(OperationDefinition operationDef) { - if (operationDef.getOperation() == SUBSCRIPTION) { - GraphQLObjectType subscriptionType = validationContext.getSchema().getSubscriptionType(); - FieldCollectorParameters collectorParameters = FieldCollectorParameters.newParameters() - .schema(validationContext.getSchema()) - .fragments(NodeUtil.getFragmentsByName(validationContext.getDocument())) - .variables(CoercedVariables.emptyVariables().toMap()) - .objectType(subscriptionType) - .graphQLContext(validationContext.getGraphQLContext()) - .build(); - MergedSelectionSet fields = fieldCollector.collectFields(collectorParameters, operationDef.getSelectionSet()); - if (fields.size() > 1) { - String message = i18n(SubscriptionMultipleRootFields, "SubscriptionUniqueRootField.multipleRootFields", Objects.toString(operationDef.getName(), "")); - addError(SubscriptionMultipleRootFields, operationDef.getSourceLocation(), message); - } else { - MergedField mergedField = fields.getSubFieldsList().get(0); - if (isIntrospectionField(mergedField)) { - String message = i18n(SubscriptionIntrospectionRootField, "SubscriptionIntrospectionRootField.introspectionRootField", Objects.toString(operationDef.getName(), ""), mergedField.getName()); - addError(SubscriptionIntrospectionRootField, mergedField.getSingleField().getSourceLocation(), message); - } + private void enterSubscriptionUniqueRootField(OperationDefinition operationDef) { + resetSubscriptionRootState(); + if (operationDef.getOperation() != SUBSCRIPTION) { + return; + } + + GraphQLObjectType subscriptionType = validationContext.getSchema().getSubscriptionType(); + if (subscriptionType == null) { + return; + } + + subscriptionRoot_operationDefinition = operationDef; + subscriptionRoot_type = subscriptionType; + } + + private void validateSubscriptionUniqueRootField_field(Field field) { + if (!isSubscriptionRootActive()) { + return; + } + + if (isSubscriptionRootField()) { + collectForbiddenSkipOrIncludeDirectives(field, subscriptionRoot_forbiddenDirectives); + addSubscriptionRootField(subscriptionRoot_fields, field); + } + + for (int i = 0; i < subscriptionRoot_activeFragmentNames.size(); i++) { + if (!isSubscriptionRootActiveFragmentField(i)) { + continue; + } + collectForbiddenSkipOrIncludeDirectives(field, getSubscriptionRootFragmentForbiddenDirectives(i)); + addSubscriptionRootField(getSubscriptionRootFragmentFields(i), field); + } + } + + private void validateSubscriptionUniqueRootField_inlineFragment(InlineFragment inlineFragment) { + if (!isSubscriptionRootActive()) { + return; + } + + if (isSubscriptionRootSelection()) { + collectForbiddenSkipOrIncludeDirectives(inlineFragment, subscriptionRoot_forbiddenDirectives); + } + + for (int i = 0; i < subscriptionRoot_activeFragmentNames.size(); i++) { + if (!isSubscriptionRootActiveFragmentSelection(i)) { + continue; + } + collectForbiddenSkipOrIncludeDirectives(inlineFragment, getSubscriptionRootFragmentForbiddenDirectives(i)); + } + } + + private void validateSubscriptionUniqueRootField_fragmentSpread(FragmentSpread fragmentSpread) { + if (!isSubscriptionRootActive()) { + return; + } + + boolean rootSelection = isSubscriptionRootSelection(); + boolean forbiddenDirective = containsSkipOrIncludeDirective(fragmentSpread); + if (rootSelection) { + collectForbiddenSkipOrIncludeDirectives(fragmentSpread, subscriptionRoot_forbiddenDirectives); + addSubscriptionRootFragmentSummary(fragmentSpread, subscriptionRoot_fields, subscriptionRoot_forbiddenDirectives, forbiddenDirective, true); + } + + for (int i = 0; i < subscriptionRoot_activeFragmentNames.size(); i++) { + if (!isSubscriptionRootActiveFragmentSelection(i)) { + continue; + } + List forbiddenDirectives = getSubscriptionRootFragmentForbiddenDirectives(i); + collectForbiddenSkipOrIncludeDirectives(fragmentSpread, forbiddenDirectives); + addSubscriptionRootFragmentSummary(fragmentSpread, getSubscriptionRootFragmentFields(i), forbiddenDirectives, forbiddenDirective, false); + } + } + + private void leaveSubscriptionUniqueRootField() { + if (subscriptionRoot_operationDefinition == null) { + resetSubscriptionRootState(); + return; + } + + OperationDefinition operationDefinition = subscriptionRoot_operationDefinition; + if (!subscriptionRoot_forbiddenDirectives.isEmpty()) { + String message = i18n(ForbidSkipAndIncludeOnSubscriptionRoot, "SubscriptionUniqueRootField.skipIncludeNotAllowed", Objects.toString(operationDefinition.getName(), "")); + addError(ForbidSkipAndIncludeOnSubscriptionRoot, subscriptionRoot_forbiddenDirectives, message); + resetSubscriptionRootState(); + return; + } + + if (subscriptionRoot_fields.size() != 1) { + String message = i18n(SubscriptionMultipleRootFields, "SubscriptionUniqueRootField.multipleRootFields", Objects.toString(operationDefinition.getName(), "")); + addError(SubscriptionMultipleRootFields, operationDefinition.getSourceLocation(), message); + resetSubscriptionRootState(); + return; + } + + Field field = subscriptionRoot_fields.values().iterator().next().get(0); + if (isIntrospectionField(field)) { + String message = i18n(SubscriptionIntrospectionRootField, "SubscriptionIntrospectionRootField.introspectionRootField", Objects.toString(operationDefinition.getName(), ""), field.getName()); + addError(SubscriptionIntrospectionRootField, field.getSourceLocation(), message); + } + resetSubscriptionRootState(); + } + + private void enterSubscriptionRootFragment(FragmentSpread fragmentSpread, FragmentDefinition fragmentDefinition, int baseDepth) { + if (!isSubscriptionRootActive()) { + return; + } + + boolean applies = doesSubscriptionFragmentConditionMatch(fragmentDefinition); + subscriptionRoot_activeFragmentNames.add(fragmentSpread.getName()); + subscriptionRoot_activeFragmentBaseDepths.add(baseDepth); + subscriptionRoot_activeFragmentApplies.add(applies); + } + + private void leaveSubscriptionRootFragment() { + if (subscriptionRoot_activeFragmentNames.isEmpty()) { + return; + } + + int lastIndex = subscriptionRoot_activeFragmentNames.size() - 1; + subscriptionRoot_activeFragmentNames.remove(lastIndex); + subscriptionRoot_activeFragmentBaseDepths.remove(lastIndex); + subscriptionRoot_activeFragmentApplies.remove(lastIndex); + } + + private boolean isSubscriptionRootActive() { + return subscriptionRoot_operationDefinition != null; + } + + private boolean isSubscriptionRootField() { + GraphQLCompositeType parentType = validationContext.getParentType(); + return currentFieldDepth == 1 && doesSubscriptionFragmentConditionMatch(parentType); + } + + private boolean isSubscriptionRootSelection() { + if (currentFieldDepth != 0) { + return false; + } + if (subscriptionRoot_activeFragmentNames.isEmpty()) { + return true; + } + for (int i = 0; i < subscriptionRoot_activeFragmentNames.size(); i++) { + if (isSubscriptionRootOperationFragmentSelection(i)) { + return true; } } + return false; + } + + private boolean isSubscriptionRootActiveFragmentField(int index) { + return subscriptionRoot_activeFragmentApplies.get(index) && + currentFieldDepth == subscriptionRoot_activeFragmentBaseDepths.get(index) + 1 && + doesSubscriptionFragmentConditionMatch(validationContext.getParentType()); + } + + private boolean isSubscriptionRootActiveFragmentSelection(int index) { + return subscriptionRoot_activeFragmentApplies.get(index) && + currentFieldDepth == subscriptionRoot_activeFragmentBaseDepths.get(index); + } + + private boolean isSubscriptionRootOperationFragmentSelection(int index) { + return subscriptionRoot_activeFragmentApplies.get(index) && + subscriptionRoot_activeFragmentBaseDepths.get(index) == 0; + } + + private void addSubscriptionRootFragmentSummary(FragmentSpread fragmentSpread, Map> fields, List forbiddenDirectives, boolean spreadHasForbiddenDirective, boolean trackVisited) { + if (spreadHasForbiddenDirective) { + return; + } + if (trackVisited && !subscriptionRoot_visitedFragments.add(fragmentSpread.getName())) { + return; + } + + Map> fragmentFields = subscriptionRoot_fragmentFields.get(fragmentSpread.getName()); + if (fragmentFields != null && fragmentFields != fields) { + addSubscriptionRootFields(fields, fragmentFields); + } + List fragmentForbiddenDirectives = subscriptionRoot_fragmentForbiddenDirectives.get(fragmentSpread.getName()); + if (fragmentForbiddenDirectives != null && fragmentForbiddenDirectives != forbiddenDirectives) { + forbiddenDirectives.addAll(fragmentForbiddenDirectives); + } } - private boolean isIntrospectionField(MergedField field) { + private void addSubscriptionRootFields(Map> targetFields, Map> sourceFields) { + for (List fields : sourceFields.values()) { + for (Field field : fields) { + addSubscriptionRootField(targetFields, field); + } + } + } + + private void addSubscriptionRootField(Map> fields, Field field) { + String responseName = field.getResultKey(); + List fieldsForResponseName = fields.get(responseName); + if (fieldsForResponseName == null) { + fieldsForResponseName = new ArrayList<>(); + fields.put(responseName, fieldsForResponseName); + } + fieldsForResponseName.add(field); + } + + private Map> getSubscriptionRootFragmentFields(int index) { + String fragmentName = subscriptionRoot_activeFragmentNames.get(index); + Map> fields = subscriptionRoot_fragmentFields.get(fragmentName); + if (fields == null) { + fields = new LinkedHashMap<>(); + subscriptionRoot_fragmentFields.put(fragmentName, fields); + } + return fields; + } + + private List getSubscriptionRootFragmentForbiddenDirectives(int index) { + String fragmentName = subscriptionRoot_activeFragmentNames.get(index); + List forbiddenDirectives = subscriptionRoot_fragmentForbiddenDirectives.get(fragmentName); + if (forbiddenDirectives == null) { + forbiddenDirectives = new ArrayList<>(); + subscriptionRoot_fragmentForbiddenDirectives.put(fragmentName, forbiddenDirectives); + } + return forbiddenDirectives; + } + + private boolean containsSkipOrIncludeDirective(DirectivesContainer directivesContainer) { + for (Directive directive : directivesContainer.getDirectives()) { + if (isSkipOrIncludeDirective(directive)) { + return true; + } + } + return false; + } + + private void collectForbiddenSkipOrIncludeDirectives(DirectivesContainer directivesContainer, List forbiddenDirectives) { + for (Directive directive : directivesContainer.getDirectives()) { + if (isSkipOrIncludeDirective(directive)) { + forbiddenDirectives.add(directive); + } + } + } + + private boolean isSkipOrIncludeDirective(Directive directive) { + return directive.getName().equals(Directives.SkipDirective.getName()) || + directive.getName().equals(Directives.IncludeDirective.getName()); + } + + private boolean doesSubscriptionFragmentConditionMatch(FragmentDefinition fragmentDefinition) { + GraphQLType conditionType = TypeFromAST.getTypeFromAST(validationContext.getSchema(), fragmentDefinition.getTypeCondition()); + return doesSubscriptionFragmentConditionMatch(conditionType); + } + + private boolean doesSubscriptionFragmentConditionMatch(@Nullable GraphQLType conditionType) { + if (conditionType == null) { + return false; + } + GraphQLObjectType subscriptionType = Assert.assertNotNull(subscriptionRoot_type, "subscriptionRoot_type must be set"); + if (conditionType.equals(subscriptionType)) { + return true; + } + if (conditionType instanceof GraphQLInterfaceType) { + return validationContext.getSchema().isPossibleType((GraphQLInterfaceType) conditionType, subscriptionType); + } + if (conditionType instanceof GraphQLUnionType) { + return validationContext.getSchema().isPossibleType((GraphQLUnionType) conditionType, subscriptionType); + } + return false; + } + + private boolean isIntrospectionField(Field field) { return field.getName().startsWith("__"); } + private void resetSubscriptionRootState() { + subscriptionRoot_operationDefinition = null; + subscriptionRoot_type = null; + subscriptionRoot_fields.clear(); + subscriptionRoot_forbiddenDirectives.clear(); + subscriptionRoot_visitedFragments.clear(); + subscriptionRoot_activeFragmentNames.clear(); + subscriptionRoot_activeFragmentBaseDepths.clear(); + subscriptionRoot_activeFragmentApplies.clear(); + subscriptionRoot_fragmentFields.clear(); + subscriptionRoot_fragmentForbiddenDirectives.clear(); + } + // --- UniqueObjectFieldName --- private void validateUniqueObjectFieldName(ObjectValue objectValue) { Set fieldNames = Sets.newHashSetWithExpectedSize(objectValue.getObjectFields().size()); diff --git a/src/main/java/graphql/validation/ValidationErrorType.java b/src/main/java/graphql/validation/ValidationErrorType.java index afd75cda5..df21c532a 100644 --- a/src/main/java/graphql/validation/ValidationErrorType.java +++ b/src/main/java/graphql/validation/ValidationErrorType.java @@ -44,6 +44,7 @@ public enum ValidationErrorType implements ValidationErrorClassification { NullValueForNonNullArgument, SubscriptionMultipleRootFields, SubscriptionIntrospectionRootField, + ForbidSkipAndIncludeOnSubscriptionRoot, UniqueObjectFieldName, UnknownOperation, MaxQueryDepthExceeded, diff --git a/src/main/resources/i18n/Validation.properties b/src/main/resources/i18n/Validation.properties index afdbc13dd..afbedf76a 100644 --- a/src/main/resources/i18n/Validation.properties +++ b/src/main/resources/i18n/Validation.properties @@ -69,6 +69,7 @@ ScalarLeaves.subselectionRequired=Validation error ({0}) : Subselection required # SubscriptionUniqueRootField.multipleRootFields=Validation error ({0}) : Subscription operation ''{1}'' must have exactly one root field SubscriptionUniqueRootField.multipleRootFieldsWithFragment=Validation error ({0}) : Subscription operation ''{1}'' must have exactly one root field with fragments +SubscriptionUniqueRootField.skipIncludeNotAllowed=Validation error ({0}) : Subscription operation ''{1}'' must not use @skip or @include directives in the top level selection SubscriptionIntrospectionRootField.introspectionRootField=Validation error ({0}) : Subscription operation ''{1}'' root field ''{2}'' cannot be an introspection field SubscriptionIntrospectionRootField.introspectionRootFieldWithFragment=Validation error ({0}) : Subscription operation ''{1}'' fragment root field ''{2}'' cannot be an introspection field # @@ -115,4 +116,4 @@ ArgumentValidationUtil.handleExtraFieldError=Validation error ({0}) : argument ' ArgumentValidationUtil.extraOneOfFieldsError=Validation error ({0}) : Exactly one key must be specified for OneOf type ''{3}''. # MaxQueryDepthExceeded.message=Query depth {1} exceeds maximum allowed depth {0} -MaxQueryFieldsExceeded.message=Query has {1} fields which exceeds maximum allowed {0} \ No newline at end of file +MaxQueryFieldsExceeded.message=Query has {1} fields which exceeds maximum allowed {0} diff --git a/src/main/resources/i18n/Validation_de.properties b/src/main/resources/i18n/Validation_de.properties index 7823c9d51..e06a21bb8 100644 --- a/src/main/resources/i18n/Validation_de.properties +++ b/src/main/resources/i18n/Validation_de.properties @@ -61,6 +61,7 @@ ScalarLeaves.subselectionRequired=Validierungsfehler ({0}) : Unterauswahl erford # SubscriptionUniqueRootField.multipleRootFields=Validierungsfehler ({0}) : Subscription operation ''{1}'' muss genau ein root field haben SubscriptionUniqueRootField.multipleRootFieldsWithFragment=Validierungsfehler ({0}) : Subscription operation ''{1}'' muss genau ein root field mit Fragmenten haben +SubscriptionUniqueRootField.skipIncludeNotAllowed=Validierungsfehler ({0}) : Subscription operation ''{1}'' darf @skip oder @include directives nicht in der top level selection verwenden SubscriptionIntrospectionRootField.introspectionRootField=Validierungsfehler ({0}) : Subscription operation ''{1}'' root field ''{2}'' kann kein introspection field sein SubscriptionIntrospectionRootField.introspectionRootFieldWithFragment=Validierungsfehler ({0}) : Subscription operation ''{1}'' fragment root field ''{2}'' kann kein introspection field sein # diff --git a/src/main/resources/i18n/Validation_nl.properties b/src/main/resources/i18n/Validation_nl.properties index e30b34264..3a3ed3872 100644 --- a/src/main/resources/i18n/Validation_nl.properties +++ b/src/main/resources/i18n/Validation_nl.properties @@ -59,6 +59,7 @@ ScalarLeaves.subselectionRequired=Validatiefout ({0}) : Sub-selectie verplicht v # SubscriptionUniqueRootField.multipleRootFields=Validatiefout ({0}) : Subscription operation ''{1}'' moet exact één root field hebben SubscriptionUniqueRootField.multipleRootFieldsWithFragment=Validatiefout ({0}) : Subscription operation ''{1}'' moet exact één root field met fragmenten hebben +SubscriptionUniqueRootField.skipIncludeNotAllowed=Validatiefout ({0}) : Subscription operation ''{1}'' mag geen @skip of @include directives in de top level selection gebruiken SubscriptionIntrospectionRootField.introspectionRootField=Validatiefout ({0}) : Subscription operation ''{1}'' root field ''{2}'' kan geen introspectieveld zijn SubscriptionIntrospectionRootField.introspectionRootFieldWithFragment=Validatiefout ({0}) : Subscription operation ''{1}'' fragment root field ''{2}'' kan geen introspectieveld zijn # diff --git a/src/test/groovy/graphql/execution/MergedSelectionSetTest.groovy b/src/test/groovy/graphql/execution/MergedSelectionSetTest.groovy new file mode 100644 index 000000000..a6139fccd --- /dev/null +++ b/src/test/groovy/graphql/execution/MergedSelectionSetTest.groovy @@ -0,0 +1,25 @@ +package graphql.execution + +import graphql.TestUtil +import spock.lang.Specification + +class MergedSelectionSetTest extends Specification { + + def "returns sub fields as a list in insertion order"() { + given: + def first = TestUtil.mergedField(TestUtil.parseField("first")) + def second = TestUtil.mergedField(TestUtil.parseField("second")) + def subFields = new LinkedHashMap() + subFields.put("first", first) + subFields.put("second", second) + + when: + def selectionSet = MergedSelectionSet.newMergedSelectionSet() + .subFields(subFields) + .build() + + then: + selectionSet.subFieldsList == [first, second] + !selectionSet.empty + } +} diff --git a/src/test/groovy/graphql/validation/SubscriptionUniqueRootFieldTest.groovy b/src/test/groovy/graphql/validation/SubscriptionUniqueRootFieldTest.groovy index dbf88344c..e01fecd8e 100644 --- a/src/test/groovy/graphql/validation/SubscriptionUniqueRootFieldTest.groovy +++ b/src/test/groovy/graphql/validation/SubscriptionUniqueRootFieldTest.groovy @@ -1,6 +1,8 @@ package graphql.validation import graphql.parser.Parser +import graphql.schema.GraphQLSchema +import graphql.schema.idl.SchemaGenerator import graphql.validation.SpecValidationSchema import graphql.validation.ValidationError import graphql.validation.ValidationErrorType @@ -46,6 +48,26 @@ class SubscriptionUniqueRootFieldTest extends Specification { validationErrors.empty } + def "5.2.3.1 subscription with repeated same root field passes validation"() { + given: + def subscriptionRepeatedRootField = ''' + subscription doggo { + dog { + name + } + dog { + nickname + } + } + ''' + + when: + def validationErrors = validate(subscriptionRepeatedRootField) + + then: + validationErrors.empty + } + def "5.2.3.1 subscription with more than one root field fails validation"() { given: def subscriptionTwoRoots = ''' @@ -286,8 +308,483 @@ class SubscriptionUniqueRootFieldTest extends Specification { then: validationErrors.empty } + + def "5.2.3.1 subscription root field with #directive fails validation"() { + given: + def variableDefinition = directive.contains('$bool') ? '($bool: Boolean = true)' : '' + def subscriptionRootDirective = """ + subscription doggo${variableDefinition} { + dog ${directive} { + name + } + } + """ + + when: + def validationErrors = validate(subscriptionRootDirective) + + then: + assertSingleSkipIncludeError(validationErrors, 1) + + where: + directive << [ + '@skip(if: $bool)', + '@include(if: $bool)', + '@skip(if: true)', + '@skip(if: false)', + '@include(if: true)', + '@include(if: false)' + ] + } + + def "5.2.3.1 subscription with skip and include root directives reports one error with both locations"() { + given: + def subscriptionRootDirectives = ''' + subscription doggo($bool: Boolean!) { + dog @include(if: $bool) { + name + } + cat @skip(if: $bool) { + name + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootDirectives) + + then: + assertSingleSkipIncludeError(validationErrors, 2) + } + + def "5.2.3.1 anonymous subscription with skip and include root directives fails validation"() { + given: + def anonymousSubscriptionRootDirectives = ''' + subscription ($bool: Boolean!) { + dog @include(if: $bool) { + name + } + cat @skip(if: $bool) { + name + } + } + ''' + + when: + def validationErrors = validate(anonymousSubscriptionRootDirectives) + + then: + assertSingleSkipIncludeError(validationErrors, 2) + } + + def "5.2.3.1 subscription root fragment spread must not use skip or include directives"() { + given: + def subscriptionRootFragmentSpreadDirective = ''' + subscription doggo($bool: Boolean!) { + ...doggoFields @include(if: $bool) + } + + fragment doggoFields on SubscriptionRoot { + dog { + name + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootFragmentSpreadDirective) + + then: + assertSingleSkipIncludeError(validationErrors, 1) + } + + def "5.2.3.1 subscription root fragment spread may use other directives"() { + given: + def subscriptionRootFragmentSpreadOtherDirective = ''' + subscription doggo { + ...doggoFields @dogDirective + } + + fragment doggoFields on SubscriptionRoot { + dog { + name + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootFragmentSpreadOtherDirective) + + then: + validationErrors.empty + } + + def "5.2.3.1 subscription root inline fragment must not use skip or include directives"() { + given: + def subscriptionRootInlineFragmentDirective = ''' + subscription doggo($bool: Boolean!) { + ... @skip(if: $bool) { + dog { + name + } + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootInlineFragmentDirective) + + then: + assertSingleSkipIncludeError(validationErrors, 1) + } + + def "5.2.3.1 subscription root field reached through fragment must not use skip or include directives"() { + given: + def subscriptionRootFieldInFragmentDirective = ''' + subscription doggo($bool: Boolean!) { + ...doggoFields + } + + fragment doggoFields on SubscriptionRoot { + dog @include(if: $bool) { + name + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootFieldInFragmentDirective) + + then: + assertSingleSkipIncludeError(validationErrors, 1) + } + + def "5.2.3.1 recursive subscription fragments do not loop and still report skip or include directives"() { + given: + def recursiveSubscriptionFragments = ''' + subscription doggo($bool: Boolean!) { + ...doggoFields + } + + fragment doggoFields on SubscriptionRoot { + dog @skip(if: $bool) { + name + } + ...doggoFields + } + ''' + + when: + def validationErrors = validate(recursiveSubscriptionFragments) + + then: + skipIncludeErrors(validationErrors).size() == 1 + skipIncludeErrors(validationErrors)[0].locations.size() == 1 + } + + def "5.2.3.1 subscription root fragment with non matching object condition does not contribute fields"() { + given: + def nonMatchingRootFragment = ''' + subscription doggo { + dog { + name + } + ...dogFields + } + + fragment dogFields on Dog { + ...dogName + } + + fragment dogName on Dog { + name + } + ''' + + when: + def validationErrors = validate(nonMatchingRootFragment) + + then: + !validationErrors.any { it.validationErrorType == ValidationErrorType.SubscriptionMultipleRootFields } + !skipIncludeErrors(validationErrors) + validationErrors.any { it.validationErrorType == ValidationErrorType.InvalidFragmentType } + } + + def "5.2.3.1 subscription root fragment with unknown condition does not contribute fields"() { + given: + def unknownRootFragment = ''' + subscription doggo { + dog { + name + } + ...unknownRootFields + } + + fragment unknownRootFields on MissingRoot { + cat { + name + } + } + ''' + + when: + def validationErrors = validate(unknownRootFragment) + + then: + !validationErrors.any { it.validationErrorType == ValidationErrorType.SubscriptionMultipleRootFields } + !skipIncludeErrors(validationErrors) + validationErrors.any { it.validationErrorType == ValidationErrorType.UnknownType } + } + + def "5.2.3.1 subscription root fragment uses cached traversal summary when first seen below root"() { + given: + def fragmentFirstSeenBelowRoot = ''' + subscription doggo($bool: Boolean!) { + dog { + ...doggoFields + } + ...doggoFields + } + + fragment doggoFields on SubscriptionRoot { + cat @include(if: $bool) { + name + } + } + ''' + + when: + def validationErrors = validate(fragmentFirstSeenBelowRoot) + + then: + skipIncludeErrors(validationErrors).size() == 1 + skipIncludeErrors(validationErrors)[0].locations.size() == 1 + } + + def "5.2.3.1 subscription subfields may use skip or include directives"() { + given: + def subscriptionSubfieldDirectives = ''' + subscription doggo($bool: Boolean!) { + dog { + name @skip(if: $bool) + nickname @include(if: $bool) + } + } + ''' + + when: + def validationErrors = validate(subscriptionSubfieldDirectives) + + then: + validationErrors.empty + } + + def "5.2.3.1 subscription fragment subfield inline fragments and fragment spreads may use skip or include directives"() { + given: + def subscriptionFragmentSubfieldDirectives = ''' + subscription doggo($bool: Boolean!) { + ...rootFields + } + + fragment rootFields on SubscriptionRoot { + dog { + ... @skip(if: $bool) { + name + } + ...dogFields @include(if: $bool) + } + } + + fragment dogFields on Dog { + nickname + } + ''' + + when: + def validationErrors = validate(subscriptionFragmentSubfieldDirectives) + + then: + validationErrors.empty + } + + def "5.2.3.1 subscription subfield inline fragments and fragment spreads may use skip or include directives"() { + given: + def subscriptionSubfieldFragmentDirectives = ''' + subscription doggo($bool: Boolean!) { + dog { + ... @skip(if: $bool) { + name + } + ...dogFields @include(if: $bool) + } + } + + fragment dogFields on Dog { + nickname + } + ''' + + when: + def validationErrors = validate(subscriptionSubfieldFragmentDirectives) + + then: + validationErrors.empty + } + + def "5.2.3.1 query and mutation root fields may use skip or include directives"() { + given: + def queryAndMutationRootDirectives = ''' + query doggoQuery($bool: Boolean!) { + dog @skip(if: $bool) { + name + } + pet @include(if: $bool) { + name + } + } + + mutation doggoMutation($bool: Boolean!) { + createDog(input: {id: "1"}) @skip(if: $bool) { + name + } + otherDog: createDog(input: {id: "2"}) @include(if: $bool) { + name + } + } + ''' + + when: + def validationErrors = validate(queryAndMutationRootDirectives) + + then: + validationErrors.empty + } + + def "5.2.3.1 query root inline fragments and fragment spreads may use skip or include directives"() { + given: + def queryRootFragmentDirectives = ''' + query doggoQuery($bool: Boolean!) { + ... @skip(if: $bool) { + dog { + name + } + } + ...dogFields @include(if: $bool) + } + + fragment dogFields on QueryRoot { + pet { + name + } + } + ''' + + when: + def validationErrors = validate(queryRootFragmentDirectives) + + then: + validationErrors.empty + } + + def "5.2.3.1 subscription root inline fragment can match implemented interface"() { + given: + def subscriptionRootInterfaceFragment = ''' + subscription events { + ... on SubscriptionEvent { + eventId + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootInterfaceFragment, abstractSubscriptionSchema()) + + then: + validationErrors.empty + } + + def "5.2.3.1 subscription root inline fragment can match union containing subscription type"() { + given: + def subscriptionRootUnionFragment = ''' + subscription events { + ... on SubscriptionUnion { + __typename + } + } + ''' + + when: + def validationErrors = validate(subscriptionRootUnionFragment, abstractSubscriptionSchema()) + + then: + validationErrors.size() == 1 + validationErrors[0].validationErrorType == ValidationErrorType.SubscriptionIntrospectionRootField + } + + def "5.2.3.1 aliased subscription root introspection field fails validation"() { + given: + def subscriptionAliasedIntrospectionField = ''' + subscription doggo { + typename: __typename + } + ''' + + when: + def validationErrors = validate(subscriptionAliasedIntrospectionField) + + then: + !validationErrors.empty + validationErrors.size() == 1 + validationErrors[0].validationErrorType == ValidationErrorType.SubscriptionIntrospectionRootField + validationErrors[0].message == "Validation error (SubscriptionIntrospectionRootField) : Subscription operation 'doggo' root field '__typename' cannot be an introspection field" + } + + static void assertSingleSkipIncludeError(List validationErrors, int locationCount) { + assert validationErrors.size() == 1 + assert validationErrors[0].validationErrorType == ValidationErrorType.ForbidSkipAndIncludeOnSubscriptionRoot + assert validationErrors[0].message.contains("must not use @skip or @include directives in the top level selection") + assert validationErrors[0].locations.size() == locationCount + } + + static List skipIncludeErrors(List validationErrors) { + return validationErrors.findAll { it.validationErrorType == ValidationErrorType.ForbidSkipAndIncludeOnSubscriptionRoot } + } + static List validate(String query) { def document = new Parser().parseDocument(query) return new Validator().validateDocument(SpecValidationSchema.specValidationSchema, document, Locale.ENGLISH) } + + static List validate(String query, GraphQLSchema schema) { + def document = new Parser().parseDocument(query) + return new Validator().validateDocument(schema, document, Locale.ENGLISH) + } + + static GraphQLSchema abstractSubscriptionSchema() { + return SchemaGenerator.createdMockedSchema(''' + schema { + query: Query + subscription: SubscriptionRoot + } + + interface SubscriptionEvent { + eventId: ID + } + + type SubscriptionRoot implements SubscriptionEvent { + eventId: ID + dog: Dog + } + + union SubscriptionUnion = SubscriptionRoot + + type Dog { + name: String + } + + type Query { + dog: Dog + } + ''') + } }