diff --git a/src/main/java/graphql/execution/conditional/ConditionalNodes.java b/src/main/java/graphql/execution/conditional/ConditionalNodes.java index 7013c53bf..48efec5f1 100644 --- a/src/main/java/graphql/execution/conditional/ConditionalNodes.java +++ b/src/main/java/graphql/execution/conditional/ConditionalNodes.java @@ -4,14 +4,16 @@ import graphql.GraphQLContext; import graphql.Internal; import graphql.execution.CoercedVariables; -import graphql.execution.ValuesResolver; +import graphql.language.Argument; +import graphql.language.BooleanValue; import graphql.language.Directive; import graphql.language.DirectivesContainer; import graphql.language.NodeUtil; +import graphql.language.VariableReference; import graphql.schema.GraphQLSchema; +import org.jetbrains.annotations.Nullable; import java.util.List; -import java.util.Locale; import java.util.Map; import static graphql.Directives.IncludeDirective; @@ -20,11 +22,17 @@ @Internal public class ConditionalNodes { + /** + * return null if skip/include argument contains a variable and therefore could not be resolved + */ + public Boolean shouldIncludeWithoutVariables(DirectivesContainer element) { + return shouldInclude(null, element.getDirectives()); + } public boolean shouldInclude(DirectivesContainer element, Map variables, GraphQLSchema graphQLSchema, - GraphQLContext graphQLContext + @Nullable GraphQLContext graphQLContext ) { // // call the base @include / @skip first @@ -75,12 +83,15 @@ public GraphQLContext getGraphQLContext() { } - private boolean shouldInclude(Map variables, List directives) { + private @Nullable Boolean shouldInclude(Map variables, List directives) { // shortcut on no directives if (directives.isEmpty()) { return true; } - boolean skip = getDirectiveResult(variables, directives, SkipDirective.getName(), false); + Boolean skip = getDirectiveResult(variables, directives, SkipDirective.getName(), false); + if (skip == null) { + return null; + } if (skip) { return false; } @@ -88,15 +99,58 @@ private boolean shouldInclude(Map variables, List dir return getDirectiveResult(variables, directives, IncludeDirective.getName(), true); } - private boolean getDirectiveResult(Map variables, List directives, String directiveName, boolean defaultValue) { + public boolean containsSkipOrIncludeDirective(DirectivesContainer directivesContainer) { + return NodeUtil.findNodeByName(directivesContainer.getDirectives(), SkipDirective.getName()) != null || + NodeUtil.findNodeByName(directivesContainer.getDirectives(), IncludeDirective.getName()) != null; + } + + + public String getSkipVariableName(DirectivesContainer directivesContainer) { + Directive skipDirective = NodeUtil.findNodeByName(directivesContainer.getDirectives(), SkipDirective.getName()); + if (skipDirective == null) { + return null; + } + Argument argument = skipDirective.getArgument("if"); + if (argument.getValue() instanceof VariableReference) { + return ((VariableReference) argument.getValue()).getName(); + } + return null; + } + + public String getIncludeVariableName(DirectivesContainer directivesContainer) { + Directive skipDirective = NodeUtil.findNodeByName(directivesContainer.getDirectives(), IncludeDirective.getName()); + if (skipDirective == null) { + return null; + } + Argument argument = skipDirective.getArgument("if"); + if (argument.getValue() instanceof VariableReference) { + return ((VariableReference) argument.getValue()).getName(); + } + return null; + } + + + private @Nullable Boolean getDirectiveResult(Map variables, List directives, String directiveName, boolean defaultValue) { Directive foundDirective = NodeUtil.findNodeByName(directives, directiveName); if (foundDirective != null) { - Map argumentValues = ValuesResolver.getArgumentValues(SkipDirective.getArguments(), foundDirective.getArguments(), CoercedVariables.of(variables), GraphQLContext.getDefault(), Locale.getDefault()); - Object flag = argumentValues.get("if"); - Assert.assertTrue(flag instanceof Boolean, "The '%s' directive MUST have a value for the 'if' argument", directiveName); - return (Boolean) flag; + return getIfValue(foundDirective.getArguments(), variables); } return defaultValue; } + private @Nullable Boolean getIfValue(List arguments, @Nullable Map variables) { + for (Argument argument : arguments) { + if (argument.getName().equals("if")) { + Object value = argument.getValue(); + if (value instanceof BooleanValue) { + return ((BooleanValue) value).isValue(); + } + if (value instanceof VariableReference && variables != null) { + return (boolean) variables.get(((VariableReference) value).getName()); + } + return null; + } + } + return Assert.assertShouldNeverHappen("The 'if' argument must be present"); + } } diff --git a/src/main/java/graphql/normalized/nf/NormalizedDocument.java b/src/main/java/graphql/normalized/nf/NormalizedDocument.java new file mode 100644 index 000000000..7252f1c50 --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedDocument.java @@ -0,0 +1,47 @@ +package graphql.normalized.nf; + +import graphql.Assert; +import graphql.ExperimentalApi; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +@ExperimentalApi +public class NormalizedDocument { + + private final List normalizedOperations; + + public NormalizedDocument(List normalizedOperations) { + this.normalizedOperations = normalizedOperations; + } + + public List getNormalizedOperations() { + return normalizedOperations; + } + + public NormalizedOperation getSingleNormalizedOperation() { + Assert.assertTrue(normalizedOperations.size() == 1, "Expecting a single normalized operation"); + return normalizedOperations.get(0).getNormalizedOperation(); + } + + public static class NormalizedOperationWithAssumedSkipIncludeVariables { + + Map assumedSkipIncludeVariables; + NormalizedOperation normalizedOperation; + + public NormalizedOperationWithAssumedSkipIncludeVariables(@Nullable Map assumedSkipIncludeVariables, NormalizedOperation normalizedOperation) { + this.assumedSkipIncludeVariables = assumedSkipIncludeVariables; + this.normalizedOperation = normalizedOperation; + } + + public Map getAssumedSkipIncludeVariables() { + return assumedSkipIncludeVariables; + } + + public NormalizedOperation getNormalizedOperation() { + return normalizedOperation; + } + } +} + diff --git a/src/main/java/graphql/normalized/nf/NormalizedDocumentFactory.java b/src/main/java/graphql/normalized/nf/NormalizedDocumentFactory.java new file mode 100644 index 000000000..69c72c2b7 --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedDocumentFactory.java @@ -0,0 +1,683 @@ +package graphql.normalized.nf; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import graphql.Assert; +import graphql.ExperimentalApi; +import graphql.GraphQLContext; +import graphql.collect.ImmutableKit; +import graphql.execution.AbortExecutionException; +import graphql.execution.MergedField; +import graphql.execution.conditional.ConditionalNodes; +import graphql.execution.directives.QueryDirectives; +import graphql.introspection.Introspection; +import graphql.language.Directive; +import graphql.language.Document; +import graphql.language.Field; +import graphql.language.FragmentDefinition; +import graphql.language.FragmentSpread; +import graphql.language.InlineFragment; +import graphql.language.NodeUtil; +import graphql.language.OperationDefinition; +import graphql.language.Selection; +import graphql.language.SelectionSet; +import graphql.schema.FieldCoordinates; +import graphql.schema.GraphQLCompositeType; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLNamedOutputType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLUnionType; +import graphql.schema.GraphQLUnmodifiedType; +import graphql.schema.impl.SchemaUtil; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static graphql.Assert.assertNotNull; +import static graphql.Assert.assertShouldNeverHappen; +import static graphql.collect.ImmutableKit.map; +import static graphql.schema.GraphQLTypeUtil.unwrapAll; +import static graphql.util.FpKit.filterSet; +import static graphql.util.FpKit.groupingBy; +import static graphql.util.FpKit.intersection; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; + +@ExperimentalApi +public class NormalizedDocumentFactory { + + public static class Options { + + + private final GraphQLContext graphQLContext; + private final Locale locale; + private final int maxChildrenDepth; + private final int maxFieldsCount; + + private final boolean deferSupport; + + /** + * The default max fields count is 100,000. + * This is big enough for even very large queries, but + * can be changed via {#setDefaultOptions + */ + public static final int DEFAULT_MAX_FIELDS_COUNT = 100_000; + private static Options defaultOptions = new Options(GraphQLContext.getDefault(), + Locale.getDefault(), + Integer.MAX_VALUE, + DEFAULT_MAX_FIELDS_COUNT, + false); + + private Options(GraphQLContext graphQLContext, + Locale locale, + int maxChildrenDepth, + int maxFieldsCount, + boolean deferSupport) { + this.graphQLContext = graphQLContext; + this.locale = locale; + this.maxChildrenDepth = maxChildrenDepth; + this.deferSupport = deferSupport; + this.maxFieldsCount = maxFieldsCount; + } + + /** + * Sets new default Options used when creating instances of {@link NormalizedDocument}. + * + * @param options new default options + */ + public static void setDefaultOptions(Options options) { + defaultOptions = Assert.assertNotNull(options); + } + + + /** + * Returns the default options used when creating instances of {@link NormalizedDocument}. + * + * @return the default options + */ + public static Options defaultOptions() { + return defaultOptions; + } + + /** + * Locale to use when parsing the query. + *

+ * e.g. can be passed to {@link graphql.schema.Coercing} for parsing. + * + * @param locale the locale to use + * + * @return new options object to use + */ + public Options locale(Locale locale) { + return new Options(this.graphQLContext, locale, this.maxChildrenDepth, this.maxFieldsCount, this.deferSupport); + } + + /** + * Context object to use when parsing the operation. + *

+ * Can be used to intercept input values e.g. using {@link graphql.execution.values.InputInterceptor}. + * + * @param graphQLContext the context to use + * + * @return new options object to use + */ + public Options graphQLContext(GraphQLContext graphQLContext) { + return new Options(graphQLContext, this.locale, this.maxChildrenDepth, this.maxFieldsCount, this.deferSupport); + } + + /** + * Controls the maximum depth of the operation. Can be used to prevent + * against malicious operations. + * + * @param maxChildrenDepth the max depth + * + * @return new options object to use + */ + public Options maxChildrenDepth(int maxChildrenDepth) { + return new Options(this.graphQLContext, this.locale, maxChildrenDepth, this.maxFieldsCount, this.deferSupport); + } + + /** + * Controls the maximum number of ENFs created. Can be used to prevent + * against malicious operations. + * + * @param maxFieldsCount the max number of ENFs created + * + * @return new options object to use + */ + public Options maxFieldsCount(int maxFieldsCount) { + return new Options(this.graphQLContext, this.locale, this.maxChildrenDepth, maxFieldsCount, this.deferSupport); + } + + /** + * Controls whether defer execution is supported when creating instances of {@link NormalizedDocument}. + * + * @param deferSupport true to enable support for defer + * + * @return new options object to use + */ + @ExperimentalApi + public Options deferSupport(boolean deferSupport) { + return new Options(this.graphQLContext, this.locale, this.maxChildrenDepth, this.maxFieldsCount, deferSupport); + } + + /** + * @return context to use during operation parsing + * + * @see #graphQLContext(GraphQLContext) + */ + public GraphQLContext getGraphQLContext() { + return graphQLContext; + } + + /** + * @return locale to use during operation parsing + * + * @see #locale(Locale) + */ + public Locale getLocale() { + return locale; + } + + /** + * @return maximum children depth before aborting parsing + * + * @see #maxChildrenDepth(int) + */ + public int getMaxChildrenDepth() { + return maxChildrenDepth; + } + + public int getMaxFieldsCount() { + return maxFieldsCount; + } + + } + + private static final ConditionalNodes conditionalNodes = new ConditionalNodes(); + + private NormalizedDocumentFactory() { + + } + + public static NormalizedDocument createNormalizedDocument( + GraphQLSchema graphQLSchema, + Document document) { + return createNormalizedDocument( + graphQLSchema, + document, + Options.defaultOptions()); + } + + + public static NormalizedDocument createNormalizedDocument(GraphQLSchema graphQLSchema, + Document document, + Options options) { + return new NormalizedDocumentFactoryImpl( + graphQLSchema, + document, + options + ).createNormalizedQueryImpl(); + } + + + private static class NormalizedDocumentFactoryImpl { + private final GraphQLSchema graphQLSchema; + private final Document document; + private final Options options; + private final Map fragments; + + private final List possibleMergerList = new ArrayList<>(); + + private ImmutableListMultimap.Builder fieldToNormalizedField = ImmutableListMultimap.builder(); + private ImmutableMap.Builder normalizedFieldToMergedField = ImmutableMap.builder(); + private ImmutableMap.Builder normalizedFieldToQueryDirectives = ImmutableMap.builder(); + private ImmutableListMultimap.Builder coordinatesToNormalizedFields = ImmutableListMultimap.builder(); + + private int fieldCount = 0; + private int maxDepthSeen = 0; + + private final List rootEnfs = new ArrayList<>(); + + private final Set skipIncludeVariableNames = new LinkedHashSet<>(); + + private Map assumedSkipIncludeVariableValues; + + private NormalizedDocumentFactoryImpl( + GraphQLSchema graphQLSchema, + Document document, + Options options + ) { + this.graphQLSchema = graphQLSchema; + this.document = document; + this.options = options; + this.fragments = NodeUtil.getFragmentsByName(document); + } + + /** + * Creates a new NormalizedDocument for the provided query + */ + private NormalizedDocument createNormalizedQueryImpl() { + List normalizedOperations = new ArrayList<>(); + for (OperationDefinition operationDefinition : document.getDefinitionsOfType(OperationDefinition.class)) { + + assumedSkipIncludeVariableValues = null; + skipIncludeVariableNames.clear(); + NormalizedOperation normalizedOperation = createNormalizedOperation(operationDefinition); + + if (skipIncludeVariableNames.size() == 0) { + normalizedOperations.add(new NormalizedDocument.NormalizedOperationWithAssumedSkipIncludeVariables(null, normalizedOperation)); + } else { + int combinations = (int) Math.pow(2, skipIncludeVariableNames.size()); + for (int i = 0; i < combinations; i++) { + assumedSkipIncludeVariableValues = new LinkedHashMap<>(); + int variableIndex = 0; + for (String variableName : skipIncludeVariableNames) { + assumedSkipIncludeVariableValues.put(variableName, (i & (1 << variableIndex++)) != 0); + } + NormalizedOperation operationWithAssumedVariables = createNormalizedOperation(operationDefinition); + normalizedOperations.add(new NormalizedDocument.NormalizedOperationWithAssumedSkipIncludeVariables(assumedSkipIncludeVariableValues, operationWithAssumedVariables)); + } + } + } + + return new NormalizedDocument( + normalizedOperations + ); + } + + private NormalizedOperation createNormalizedOperation(OperationDefinition operationDefinition) { + this.rootEnfs.clear(); + this.fieldCount = 0; + this.maxDepthSeen = 0; + this.possibleMergerList.clear(); + fieldToNormalizedField = ImmutableListMultimap.builder(); + normalizedFieldToMergedField = ImmutableMap.builder(); + normalizedFieldToQueryDirectives = ImmutableMap.builder(); + coordinatesToNormalizedFields = ImmutableListMultimap.builder(); + + buildNormalizedFieldsRecursively(null, operationDefinition, null, 0); + + for (PossibleMerger possibleMerger : possibleMergerList) { + List childrenWithSameResultKey = possibleMerger.parent.getChildrenWithSameResultKey(possibleMerger.resultKey); + NormalizedFieldsMerger.merge(possibleMerger.parent, childrenWithSameResultKey, graphQLSchema); + } + + NormalizedOperation normalizedOperation = new NormalizedOperation( + operationDefinition.getOperation(), + operationDefinition.getName(), + new ArrayList<>(rootEnfs), + fieldToNormalizedField.build(), + normalizedFieldToMergedField.build(), + normalizedFieldToQueryDirectives.build(), + coordinatesToNormalizedFields.build(), + fieldCount, + maxDepthSeen + ); + return normalizedOperation; + } + + + private void captureMergedField(NormalizedField enf, MergedField mergedFld) { +// // QueryDirectivesImpl is a lazy object and only computes itself when asked for +// QueryDirectives queryDirectives = new QueryDirectivesImpl(mergedFld, graphQLSchema, coercedVariableValues.toMap(), options.getGraphQLContext(), options.getLocale()); +// normalizedFieldToQueryDirectives.put(enf, queryDirectives); + normalizedFieldToMergedField.put(enf, mergedFld); + } + + private void buildNormalizedFieldsRecursively(@Nullable NormalizedField normalizedField, + @Nullable OperationDefinition operationDefinition, + @Nullable ImmutableList fieldAndAstParents, + int curLevel) { + if (this.maxDepthSeen < curLevel) { + this.maxDepthSeen = curLevel; + checkMaxDepthExceeded(curLevel); + } + Set possibleObjects; + List collectedFields; + + // special handling for the root selection Set + if (normalizedField == null) { + GraphQLObjectType rootType = SchemaUtil.getOperationRootType(graphQLSchema, operationDefinition); + possibleObjects = ImmutableSet.of(rootType); + collectedFields = new ArrayList<>(); + collectFromSelectionSet(operationDefinition.getSelectionSet(), collectedFields, rootType, possibleObjects); + } else { + List fieldDefs = normalizedField.getFieldDefinitions(graphQLSchema); + possibleObjects = resolvePossibleObjects(fieldDefs); + if (possibleObjects.isEmpty()) { + return; + } + collectedFields = new ArrayList<>(); + for (CollectedField fieldAndAstParent : fieldAndAstParents) { + if (fieldAndAstParent.field.getSelectionSet() == null) { + continue; + } + // the AST parent comes from the previous collect from selection set call + // and is the type to which the field belongs (the container type of the field) and output type + // of the field needs to be determined based on the field name + GraphQLFieldDefinition fieldDefinition = Introspection.getFieldDef(graphQLSchema, fieldAndAstParent.astTypeCondition, fieldAndAstParent.field.getName()); + // it must a composite type, because the field has a selection set + GraphQLCompositeType selectionSetType = (GraphQLCompositeType) unwrapAll(fieldDefinition.getType()); + this.collectFromSelectionSet(fieldAndAstParent.field.getSelectionSet(), + collectedFields, + selectionSetType, + possibleObjects + ); + } + } + + Map> fieldsByName = fieldsByResultKey(collectedFields); + ImmutableList.Builder resultNFs = ImmutableList.builder(); + ImmutableListMultimap.Builder normalizedFieldToAstFields = ImmutableListMultimap.builder(); + createNFs(resultNFs, fieldsByName, normalizedFieldToAstFields, curLevel + 1, normalizedField); + + ImmutableList nextLevelChildren = resultNFs.build(); + ImmutableListMultimap nextLevelNormalizedFieldToAstFields = normalizedFieldToAstFields.build(); + + for (NormalizedField childENF : nextLevelChildren) { + if (normalizedField == null) { + // all root ENFs don't have a parent, but are collected in the rootEnfs list + rootEnfs.add(childENF); + } else { + normalizedField.addChild(childENF); + } + ImmutableList childFieldAndAstParents = nextLevelNormalizedFieldToAstFields.get(childENF); + + MergedField mergedField = newMergedField(childFieldAndAstParents); + captureMergedField(childENF, mergedField); + + updateFieldToNFMap(childENF, childFieldAndAstParents); + updateCoordinatedToNFMap(childENF); + + // recursive call + buildNormalizedFieldsRecursively(childENF, + null, + childFieldAndAstParents, + curLevel + 1); + } + } + + private void checkMaxDepthExceeded(int depthSeen) { + if (depthSeen > this.options.getMaxChildrenDepth()) { + throw new AbortExecutionException("Maximum query depth exceeded. " + depthSeen + " > " + this.options.getMaxChildrenDepth()); + } + } + + private static MergedField newMergedField(ImmutableList fieldAndAstParents) { + return MergedField.newMergedField(map(fieldAndAstParents, fieldAndAstParent -> fieldAndAstParent.field)).build(); + } + + private void updateFieldToNFMap(NormalizedField NormalizedField, + ImmutableList mergedField) { + for (CollectedField astField : mergedField) { + fieldToNormalizedField.put(astField.field, NormalizedField); + } + } + + private void updateCoordinatedToNFMap(NormalizedField topLevel) { + for (String objectType : topLevel.getObjectTypeNames()) { + FieldCoordinates coordinates = FieldCoordinates.coordinates(objectType, topLevel.getFieldName()); + coordinatesToNormalizedFields.put(coordinates, topLevel); + } + } + + + private Map> fieldsByResultKey(List collectedFields) { + Map> fieldsByName = new LinkedHashMap<>(); + for (CollectedField collectedField : collectedFields) { + fieldsByName.computeIfAbsent(collectedField.field.getResultKey(), ignored -> new ArrayList<>()).add(collectedField); + } + return fieldsByName; + } + + + private void createNFs(ImmutableList.Builder nfListBuilder, + Map> fieldsByName, + ImmutableListMultimap.Builder normalizedFieldToAstFields, + int level, + NormalizedField parent) { + for (String resultKey : fieldsByName.keySet()) { + List fieldsWithSameResultKey = fieldsByName.get(resultKey); + List commonParentsGroups = groupByCommonParents(fieldsWithSameResultKey); + for (CollectedFieldGroup fieldGroup : commonParentsGroups) { + NormalizedField nf = createNF(fieldGroup, level, parent); + if (nf == null) { + continue; + } + for (CollectedField collectedField : fieldGroup.fields) { + normalizedFieldToAstFields.put(nf, collectedField); + } + nfListBuilder.add(nf); + + } + if (commonParentsGroups.size() > 1) { + possibleMergerList.add(new PossibleMerger(parent, resultKey)); + } + } + } + + // new single ENF + private NormalizedField createNF(CollectedFieldGroup collectedFieldGroup, + int level, + NormalizedField parent) { + + this.fieldCount++; + if (this.fieldCount > this.options.getMaxFieldsCount()) { + throw new AbortExecutionException("Maximum field count exceeded. " + this.fieldCount + " > " + this.options.getMaxFieldsCount()); + } + Field field; + Set objectTypes = collectedFieldGroup.objectTypes; + field = collectedFieldGroup.fields.iterator().next().field; + List directives = collectedFieldGroup.fields.stream().flatMap(f -> f.field.getDirectives().stream()).collect(Collectors.toList()); + String fieldName = field.getName(); + ImmutableList objectTypeNames = map(objectTypes, GraphQLObjectType::getName); + return NormalizedField.newNormalizedField() + .alias(field.getAlias()) + .astArguments(field.getArguments()) + .astDirectives(directives) + .objectTypeNames(objectTypeNames) + .fieldName(fieldName) + .level(level) + .parent(parent) + .build(); + } + + + private List groupByCommonParents(Collection fields) { + ImmutableSet.Builder objectTypes = ImmutableSet.builder(); + for (CollectedField collectedField : fields) { + objectTypes.addAll(collectedField.objectTypes); + } + Set allRelevantObjects = objectTypes.build(); + Map> groupByAstParent = groupingBy(fields, fieldAndType -> fieldAndType.astTypeCondition); + if (groupByAstParent.size() == 1) { + return singletonList(new CollectedFieldGroup(ImmutableSet.copyOf(fields), allRelevantObjects)); + } + ImmutableList.Builder result = ImmutableList.builder(); + for (GraphQLObjectType objectType : allRelevantObjects) { + Set relevantFields = filterSet(fields, field -> field.objectTypes.contains(objectType)); + result.add(new CollectedFieldGroup(relevantFields, singleton(objectType))); + } + return result.build(); + } + + + private void collectFromSelectionSet(SelectionSet selectionSet, + List result, + GraphQLCompositeType astTypeCondition, + Set possibleObjects + ) { + for (Selection selection : selectionSet.getSelections()) { + if (selection instanceof Field) { + collectField(result, (Field) selection, possibleObjects, astTypeCondition); + } else if (selection instanceof InlineFragment) { + collectInlineFragment(result, (InlineFragment) selection, possibleObjects, astTypeCondition); + } else if (selection instanceof FragmentSpread) { + collectFragmentSpread(result, (FragmentSpread) selection, possibleObjects); + } + } + } + + private void collectFragmentSpread(List result, + FragmentSpread fragmentSpread, + Set possibleObjects + ) { +// if (!conditionalNodes.shouldInclude(fragmentSpread, +// this.coercedVariableValues.toMap(), +// this.graphQLSchema, +// this.options.graphQLContext)) { +// return; +// } + FragmentDefinition fragmentDefinition = assertNotNull(this.fragments.get(fragmentSpread.getName())); + +// if (!conditionalNodes.shouldInclude(fragmentDefinition, +// this.coercedVariableValues.toMap(), +// this.graphQLSchema, +// this.options.graphQLContext)) { +// return; +// } + GraphQLCompositeType newAstTypeCondition = (GraphQLCompositeType) assertNotNull(this.graphQLSchema.getType(fragmentDefinition.getTypeCondition().getName())); + Set newPossibleObjects = narrowDownPossibleObjects(possibleObjects, newAstTypeCondition); + collectFromSelectionSet(fragmentDefinition.getSelectionSet(), result, newAstTypeCondition, newPossibleObjects); + } + + private void collectInlineFragment(List result, + InlineFragment inlineFragment, + Set possibleObjects, + GraphQLCompositeType astTypeCondition + ) { +// if (!conditionalNodes.shouldInclude(inlineFragment, this.coercedVariableValues.toMap(), this.graphQLSchema, this.options.graphQLContext)) { +// return; +// } + Set newPossibleObjects = possibleObjects; + GraphQLCompositeType newAstTypeCondition = astTypeCondition; + + if (inlineFragment.getTypeCondition() != null) { + newAstTypeCondition = (GraphQLCompositeType) this.graphQLSchema.getType(inlineFragment.getTypeCondition().getName()); + newPossibleObjects = narrowDownPossibleObjects(possibleObjects, newAstTypeCondition); + + } + + + collectFromSelectionSet(inlineFragment.getSelectionSet(), result, newAstTypeCondition, newPossibleObjects); + } + + private void collectField(List result, + Field field, + Set possibleObjectTypes, + GraphQLCompositeType astTypeCondition + ) { + Boolean shouldInclude; + if (assumedSkipIncludeVariableValues == null) { + if ((shouldInclude = conditionalNodes.shouldIncludeWithoutVariables(field)) == null) { + + String skipVariableName = conditionalNodes.getSkipVariableName(field); + String includeVariableName = conditionalNodes.getIncludeVariableName(field); + if (skipVariableName != null) { + skipIncludeVariableNames.add(skipVariableName); + } + if (includeVariableName != null) { + skipIncludeVariableNames.add(includeVariableName); + } + } + if (shouldInclude != null && !shouldInclude) { + return; + } + } else { + if (!conditionalNodes.shouldInclude(field, (Map) assumedSkipIncludeVariableValues, graphQLSchema, null)) { + return; + } + } + // this means there is actually no possible type for this field, and we are done + if (possibleObjectTypes.isEmpty()) { + return; + } + result.add(new CollectedField(field, possibleObjectTypes, astTypeCondition)); + } + + private Set narrowDownPossibleObjects(Set currentOnes, + GraphQLCompositeType typeCondition) { + + ImmutableSet resolvedTypeCondition = resolvePossibleObjects(typeCondition); + if (currentOnes.isEmpty()) { + return resolvedTypeCondition; + } + + // Faster intersection, as either set often has a size of 1. + return intersection(currentOnes, resolvedTypeCondition); + } + + private ImmutableSet resolvePossibleObjects(List defs) { + ImmutableSet.Builder builder = ImmutableSet.builder(); + + for (GraphQLFieldDefinition def : defs) { + GraphQLUnmodifiedType outputType = unwrapAll(def.getType()); + if (outputType instanceof GraphQLCompositeType) { + builder.addAll(resolvePossibleObjects((GraphQLCompositeType) outputType)); + } + } + + return builder.build(); + } + + private ImmutableSet resolvePossibleObjects(GraphQLCompositeType type) { + if (type instanceof GraphQLObjectType) { + return ImmutableSet.of((GraphQLObjectType) type); + } else if (type instanceof GraphQLInterfaceType) { + return ImmutableSet.copyOf(graphQLSchema.getImplementations((GraphQLInterfaceType) type)); + } else if (type instanceof GraphQLUnionType) { + List unionTypes = ((GraphQLUnionType) type).getTypes(); + return ImmutableSet.copyOf(ImmutableKit.map(unionTypes, GraphQLObjectType.class::cast)); + } else { + return assertShouldNeverHappen(); + } + } + + private static class PossibleMerger { + NormalizedField parent; + String resultKey; + + public PossibleMerger(NormalizedField parent, String resultKey) { + this.parent = parent; + this.resultKey = resultKey; + } + } + + private static class CollectedField { + Field field; + Set objectTypes; + GraphQLCompositeType astTypeCondition; + + public CollectedField(Field field, Set objectTypes, GraphQLCompositeType astTypeCondition) { + this.field = field; + this.objectTypes = objectTypes; + this.astTypeCondition = astTypeCondition; + } + } + + private static class CollectedFieldGroup { + Set objectTypes; + Set fields; + + public CollectedFieldGroup(Set fields, Set objectTypes) { + this.fields = fields; + this.objectTypes = objectTypes; + } + } + } + +} diff --git a/src/main/java/graphql/normalized/nf/NormalizedField.java b/src/main/java/graphql/normalized/nf/NormalizedField.java new file mode 100644 index 000000000..7c5da741b --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedField.java @@ -0,0 +1,678 @@ +package graphql.normalized.nf; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import graphql.ExperimentalApi; +import graphql.Internal; +import graphql.Mutable; +import graphql.collect.ImmutableKit; +import graphql.introspection.Introspection; +import graphql.language.Argument; +import graphql.language.Directive; +import graphql.normalized.ExecutableNormalizedOperation; +import graphql.normalized.NormalizedInputValue; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLNamedOutputType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLUnionType; +import graphql.util.FpKit; +import graphql.util.MutableRef; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import static graphql.Assert.assertNotNull; +import static graphql.Assert.assertTrue; +import static graphql.schema.GraphQLTypeUtil.simplePrint; +import static graphql.schema.GraphQLTypeUtil.unwrapAll; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; + +/** + * An {@link NormalizedField} represents a field in an executable graphql operation. Its models what + * could be executed during a given operation. + *

+ * This class is intentionally mutable for performance reasons since building immutable parent child + * objects is too expensive. + */ +@ExperimentalApi +@Mutable +public class NormalizedField { + private final String alias; + private final ImmutableMap normalizedArguments; + private final LinkedHashMap resolvedArguments; + private final ImmutableList astArguments; + private List astDirectives; + + // Mutable List on purpose: it is modified after creation + private final LinkedHashSet objectTypeNames; + private final ArrayList children; + private NormalizedField parent; + + private final String fieldName; + private final int level; + + + private NormalizedField(Builder builder) { + this.alias = builder.alias; + this.resolvedArguments = builder.resolvedArguments; + this.normalizedArguments = builder.normalizedArguments; + this.astArguments = builder.astArguments; + this.objectTypeNames = builder.objectTypeNames; + this.fieldName = assertNotNull(builder.fieldName); + this.children = builder.children; + this.level = builder.level; + this.parent = builder.parent; + this.astDirectives = builder.astDirectives; + } + + /** + * Determines whether this {@link NormalizedField} needs a fragment to select the field. However, it considers the parent + * output type when determining whether it needs a fragment. + *

+ * Consider the following schema + * + *

+     * interface Animal {
+     *     name: String
+     *     parent: Animal
+     * }
+     * type Cat implements Animal {
+     *     name: String
+     *     parent: Cat
+     * }
+     * type Dog implements Animal {
+     *     name: String
+     *     parent: Dog
+     *     isGoodBoy: Boolean
+     * }
+     * type Query {
+     *     animal: Animal
+     * }
+     * 
+ *

+ * and the following query + * + *

+     * {
+     *     animal {
+     *         parent {
+     *             name
+     *         }
+     *     }
+     * }
+     * 
+ *

+ * Then we would get the following {@link ExecutableNormalizedOperation} + * + *

+     * -Query.animal: Animal
+     * --[Cat, Dog].parent: Cat, Dog
+     * ---[Cat, Dog].name: String
+     * 
+ *

+ * If we simply checked the {@link #parent}'s {@link #getFieldDefinitions(GraphQLSchema)} that would + * point us to {@code Cat.parent} and {@code Dog.parent} whose output types would incorrectly answer + * our question whether this is conditional? + *

+ * We MUST consider that the output type of the {@code parent} field is {@code Animal} and + * NOT {@code Cat} or {@code Dog} as their respective implementations would say. + * + * @param schema - the graphql schema in play + * @return true if the field is conditional + */ + public boolean isConditional(@NotNull GraphQLSchema schema) { + if (parent == null) { + return false; + } + + for (GraphQLInterfaceType commonParentOutputInterface : parent.getInterfacesCommonToAllOutputTypes(schema)) { + List implementations = schema.getImplementations(commonParentOutputInterface); + // __typename + if (fieldName.equals(Introspection.TypeNameMetaFieldDef.getName()) && implementations.size() == objectTypeNames.size()) { + return false; + } + if (commonParentOutputInterface.getField(fieldName) == null) { + continue; + } + if (implementations.size() == objectTypeNames.size()) { + return false; + } + } + + // __typename is the only field in a union type that CAN be NOT conditional + GraphQLFieldDefinition parentFieldDef = parent.getOneFieldDefinition(schema); + if (unwrapAll(parentFieldDef.getType()) instanceof GraphQLUnionType) { + GraphQLUnionType parentOutputTypeAsUnion = (GraphQLUnionType) unwrapAll(parentFieldDef.getType()); + if (fieldName.equals(Introspection.TypeNameMetaFieldDef.getName()) && objectTypeNames.size() == parentOutputTypeAsUnion.getTypes().size()) { + return false; // Not conditional + } + } + + // This means there is no Union or Interface which could serve as unconditional parent + if (objectTypeNames.size() > 1) { + return true; // Conditional + } + if (parent.objectTypeNames.size() > 1) { + return true; + } + + GraphQLObjectType oneObjectType = (GraphQLObjectType) schema.getType(objectTypeNames.iterator().next()); + return unwrapAll(parentFieldDef.getType()) != oneObjectType; + } + + public boolean hasChildren() { + return children.size() > 0; + } + + public GraphQLOutputType getType(GraphQLSchema schema) { + List fieldDefinitions = getFieldDefinitions(schema); + Set fieldTypes = fieldDefinitions.stream().map(fd -> simplePrint(fd.getType())).collect(toSet()); + assertTrue(fieldTypes.size() == 1, () -> "More than one type ... use getTypes"); + return fieldDefinitions.get(0).getType(); + } + + public List getTypes(GraphQLSchema schema) { + return ImmutableKit.map(getFieldDefinitions(schema), fd -> fd.getType()); + } + + public void forEachFieldDefinition(GraphQLSchema schema, Consumer consumer) { + var fieldDefinition = resolveIntrospectionField(schema, objectTypeNames, fieldName); + if (fieldDefinition != null) { + consumer.accept(fieldDefinition); + return; + } + + for (String objectTypeName : objectTypeNames) { + GraphQLObjectType type = (GraphQLObjectType) assertNotNull(schema.getType(objectTypeName)); + consumer.accept(assertNotNull(type.getField(fieldName), "No field %s found for type %s", fieldName, objectTypeName)); + } + } + + public List getFieldDefinitions(GraphQLSchema schema) { + ImmutableList.Builder builder = ImmutableList.builder(); + forEachFieldDefinition(schema, builder::add); + return builder.build(); + } + + /** + * This is NOT public as it is not recommended usage. + *

+ * Internally there are cases where we know it is safe to use this, so this exists. + */ + private GraphQLFieldDefinition getOneFieldDefinition(GraphQLSchema schema) { + var fieldDefinition = resolveIntrospectionField(schema, objectTypeNames, fieldName); + if (fieldDefinition != null) { + return fieldDefinition; + } + + String objectTypeName = objectTypeNames.iterator().next(); + GraphQLObjectType type = (GraphQLObjectType) assertNotNull(schema.getType(objectTypeName)); + return assertNotNull(type.getField(fieldName), "No field %s found for type %s", fieldName, objectTypeName); + } + + private static GraphQLFieldDefinition resolveIntrospectionField(GraphQLSchema schema, Set objectTypeNames, String fieldName) { + if (fieldName.equals(schema.getIntrospectionTypenameFieldDefinition().getName())) { + return schema.getIntrospectionTypenameFieldDefinition(); + } else if (objectTypeNames.size() == 1 && objectTypeNames.iterator().next().equals(schema.getQueryType().getName())) { + if (fieldName.equals(schema.getIntrospectionSchemaFieldDefinition().getName())) { + return schema.getIntrospectionSchemaFieldDefinition(); + } else if (fieldName.equals(schema.getIntrospectionTypeFieldDefinition().getName())) { + return schema.getIntrospectionTypeFieldDefinition(); + } + } + return null; + } + + @Internal + public void addObjectTypeNames(Collection objectTypeNames) { + this.objectTypeNames.addAll(objectTypeNames); + } + + @Internal + public void setObjectTypeNames(Collection objectTypeNames) { + this.objectTypeNames.clear(); + this.objectTypeNames.addAll(objectTypeNames); + } + + @Internal + public void addChild(NormalizedField normalizedField) { + this.children.add(normalizedField); + } + + @Internal + public void clearChildren() { + this.children.clear(); + } + + + /** + * All merged fields have the same name so this is the name of the {@link NormalizedField}. + *

+ * WARNING: This is not always the key in the execution result, because of possible field aliases. + * + * @return the name of this {@link NormalizedField} + * @see #getResultKey() + * @see #getAlias() + */ + public String getName() { + return getFieldName(); + } + + /** + * @return the same value as {@link #getName()} + * @see #getResultKey() + * @see #getAlias() + */ + public String getFieldName() { + return fieldName; + } + + /** + * Returns the result key of this {@link NormalizedField} within the overall result. + * This is either a field alias or the value of {@link #getName()} + * + * @return the result key for this {@link NormalizedField}. + * @see #getName() + */ + public String getResultKey() { + if (alias != null) { + return alias; + } + return getName(); + } + + /** + * @return the field alias used or null if there is none + * @see #getResultKey() + * @see #getName() + */ + public String getAlias() { + return alias; + } + + /** + * @return a list of the {@link Argument}s on the field + */ + public ImmutableList getAstArguments() { + return astArguments; + } + + public List getAstDirectives() { + return astDirectives; + } + + public void setAstDirectives(List astDirectives) { + this.astDirectives = astDirectives; + } + + + /** + * Returns an argument value as a {@link NormalizedInputValue} which contains its type name and its current value + * + * @param name the name of the argument + * @return an argument value + */ + public NormalizedInputValue getNormalizedArgument(String name) { + return normalizedArguments.get(name); + } + + /** + * @return a map of all the arguments in {@link NormalizedInputValue} form + */ + public ImmutableMap getNormalizedArguments() { + return normalizedArguments; + } + + /** + * @return a map of the resolved argument values + */ + public LinkedHashMap getResolvedArguments() { + return resolvedArguments; + } + + + /** + * A {@link NormalizedField} can sometimes (for non-concrete types like interfaces and unions) + * have more than one object type it could be when executed. There is no way to know what it will be until + * the field is executed over data and the type is resolved via a {@link graphql.schema.TypeResolver}. + *

+ * This method returns all the possible types a field can be which is one or more {@link GraphQLObjectType} + * names. + *

+ * Warning: This returns a Mutable Set. No defensive copy is made for performance reasons. + * + * @return a set of the possible type names this field could be. + */ + public Set getObjectTypeNames() { + return objectTypeNames; + } + + + /** + * This returns the first entry in {@link #getObjectTypeNames()}. Sometimes you know a field cant be more than one + * type and this method is a shortcut one to help you. + * + * @return the first entry from + */ + public String getSingleObjectTypeName() { + return objectTypeNames.iterator().next(); + } + + /** + * @return a helper method show field details + */ + public String printDetails() { + StringBuilder result = new StringBuilder(); + if (getAlias() != null) { + result.append(getAlias()).append(": "); + } + return result + objectTypeNamesToString() + "." + fieldName; + } + + /** + * @return a helper method to show the object types names as a string + */ + public String objectTypeNamesToString() { + if (objectTypeNames.size() == 1) { + return objectTypeNames.iterator().next(); + } else { + return objectTypeNames.toString(); + } + } + + /** + * This returns the list of the result keys (see {@link #getResultKey()} that lead from this field upwards to + * its parent field + * + * @return a list of the result keys from this {@link NormalizedField} to the top of the operation via parent fields + */ + public List getListOfResultKeys() { + LinkedList list = new LinkedList<>(); + NormalizedField current = this; + while (current != null) { + list.addFirst(current.getResultKey()); + current = current.parent; + } + return list; + } + + /** + * @return the children of the {@link NormalizedField} + */ + public List getChildren() { + return children; + } + + /** + * Returns the list of child fields that would have the same result key + * + * @param resultKey the result key to check + * @return a list of all direct {@link NormalizedField} children with the specified result key + */ + public List getChildrenWithSameResultKey(String resultKey) { + return FpKit.filterList(children, child -> child.getResultKey().equals(resultKey)); + } + + public List getChildren(int includingRelativeLevel) { + List result = new ArrayList<>(); + assertTrue(includingRelativeLevel >= 1, () -> "relative level must be >= 1"); + + this.getChildren().forEach(child -> { + traverseImpl(child, result::add, 1, includingRelativeLevel); + }); + return result; + } + + /** + * This returns the child fields that can be used if the object is of the specified object type + * + * @param objectTypeName the object type + * @return a list of child fields that would apply to that object type + */ + public List getChildren(String objectTypeName) { + return children.stream() + .filter(cld -> cld.objectTypeNames.contains(objectTypeName)) + .collect(toList()); + } + + /** + * the level of the {@link NormalizedField} in the operation hierarchy with top level fields + * starting at 1 + * + * @return the level of the {@link NormalizedField} in the operation hierarchy + */ + public int getLevel() { + return level; + } + + /** + * @return the parent of this {@link NormalizedField} or null if it's a top level field + */ + public NormalizedField getParent() { + return parent; + } + + + @Internal + public void replaceParent(NormalizedField newParent) { + this.parent = newParent; + } + + + @Override + public String toString() { + return "NormalizedField{" + + objectTypeNamesToString() + "." + fieldName + + ", alias=" + alias + + ", level=" + level + + ", children=" + children.stream().map(NormalizedField::toString).collect(joining("\n")) + + '}'; + } + + + /** + * Traverse from this {@link NormalizedField} down into itself and all of its children + * + * @param consumer the callback for each {@link NormalizedField} in the hierarchy. + */ + public void traverseSubTree(Consumer consumer) { + this.getChildren().forEach(child -> { + traverseImpl(child, consumer, 1, Integer.MAX_VALUE); + }); + } + + private void traverseImpl(NormalizedField root, + Consumer consumer, + int curRelativeLevel, + int abortAfter) { + if (curRelativeLevel > abortAfter) { + return; + } + consumer.accept(root); + root.getChildren().forEach(child -> { + traverseImpl(child, consumer, curRelativeLevel + 1, abortAfter); + }); + } + + /** + * This tries to find interfaces common to all the field output types. + *

+ * i.e. goes through {@link #getFieldDefinitions(GraphQLSchema)} and finds interfaces that + * all the field's unwrapped output types are assignable to. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private Set getInterfacesCommonToAllOutputTypes(GraphQLSchema schema) { + // Shortcut for performance + if (objectTypeNames.size() == 1) { + var fieldDef = getOneFieldDefinition(schema); + var outputType = unwrapAll(fieldDef.getType()); + + if (outputType instanceof GraphQLObjectType) { + return new LinkedHashSet<>((List) ((GraphQLObjectType) outputType).getInterfaces()); + } else if (outputType instanceof GraphQLInterfaceType) { + var result = new LinkedHashSet<>((List) ((GraphQLInterfaceType) outputType).getInterfaces()); + result.add(outputType); + return result; + } else { + return Collections.emptySet(); + } + } + + MutableRef> commonInterfaces = new MutableRef<>(); + forEachFieldDefinition(schema, (fieldDef) -> { + var outputType = unwrapAll(fieldDef.getType()); + + List outputTypeInterfaces; + if (outputType instanceof GraphQLObjectType) { + outputTypeInterfaces = (List) ((GraphQLObjectType) outputType).getInterfaces(); + } else if (outputType instanceof GraphQLInterfaceType) { + // This interface and superinterfaces + List superInterfaces = ((GraphQLInterfaceType) outputType).getInterfaces(); + + outputTypeInterfaces = new ArrayList<>(superInterfaces.size() + 1); + outputTypeInterfaces.add((GraphQLInterfaceType) outputType); + + if (!superInterfaces.isEmpty()) { + outputTypeInterfaces.addAll((List) superInterfaces); + } + } else { + outputTypeInterfaces = Collections.emptyList(); + } + + if (commonInterfaces.value == null) { + commonInterfaces.value = new LinkedHashSet<>(outputTypeInterfaces); + } else { + commonInterfaces.value.retainAll(outputTypeInterfaces); + } + }); + + return commonInterfaces.value; + } + + /** + * @return a {@link Builder} of {@link NormalizedField}s + */ + public static Builder newNormalizedField() { + return new Builder(); + } + + /** + * Allows this {@link NormalizedField} to be transformed via a {@link Builder} consumer callback + * + * @param builderConsumer the consumer given a builder + * @return a new transformed {@link NormalizedField} + */ + public NormalizedField transform(Consumer builderConsumer) { + Builder builder = new Builder(this); + builderConsumer.accept(builder); + return builder.build(); + } + + + public static class Builder { + private LinkedHashSet objectTypeNames = new LinkedHashSet<>(); + private String fieldName; + private ArrayList children = new ArrayList<>(); + private int level; + private NormalizedField parent; + private String alias; + private ImmutableMap normalizedArguments = ImmutableKit.emptyMap(); + private LinkedHashMap resolvedArguments = new LinkedHashMap<>(); + private ImmutableList astArguments = ImmutableKit.emptyList(); + private List astDirectives = Collections.emptyList(); + + + private Builder() { + } + + private Builder(NormalizedField existing) { + this.alias = existing.alias; + this.normalizedArguments = existing.normalizedArguments; + this.astArguments = existing.astArguments; + this.resolvedArguments = existing.resolvedArguments; + this.objectTypeNames = new LinkedHashSet<>(existing.getObjectTypeNames()); + this.fieldName = existing.getFieldName(); + this.children = new ArrayList<>(existing.children); + this.level = existing.getLevel(); + this.parent = existing.getParent(); + } + + public Builder clearObjectTypesNames() { + this.objectTypeNames.clear(); + return this; + } + + public Builder objectTypeNames(List objectTypeNames) { + this.objectTypeNames.addAll(objectTypeNames); + return this; + } + + public Builder alias(String alias) { + this.alias = alias; + return this; + } + + public Builder normalizedArguments(@Nullable Map arguments) { + this.normalizedArguments = arguments == null ? ImmutableKit.emptyMap() : ImmutableMap.copyOf(arguments); + return this; + } + + public Builder resolvedArguments(@Nullable Map arguments) { + this.resolvedArguments = arguments == null ? new LinkedHashMap<>() : new LinkedHashMap<>(arguments); + return this; + } + + public Builder astArguments(@NotNull List astArguments) { + this.astArguments = ImmutableList.copyOf(astArguments); + return this; + } + + public Builder astDirectives(@NotNull List astDirectives) { + this.astDirectives = astDirectives; + return this; + } + + + public Builder fieldName(String fieldName) { + this.fieldName = fieldName; + return this; + } + + + public Builder children(List children) { + this.children.clear(); + this.children.addAll(children); + return this; + } + + public Builder level(int level) { + this.level = level; + return this; + } + + public Builder parent(NormalizedField parent) { + this.parent = parent; + return this; + } + + + public NormalizedField build() { + return new NormalizedField(this); + } + } +} diff --git a/src/main/java/graphql/normalized/nf/NormalizedFieldsMerger.java b/src/main/java/graphql/normalized/nf/NormalizedFieldsMerger.java new file mode 100644 index 000000000..4261aa530 --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedFieldsMerger.java @@ -0,0 +1,195 @@ +package graphql.normalized.nf; + +import graphql.Internal; +import graphql.introspection.Introspection; +import graphql.language.Argument; +import graphql.language.AstComparator; +import graphql.language.Directive; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +@Internal +public class NormalizedFieldsMerger { + + public static void merge( + NormalizedField parent, + List childrenWithSameResultKey, + GraphQLSchema schema + ) { + // they have all the same result key + // we can only merge the fields if they have the same field name + arguments + all children are the same + List> possibleGroupsToMerge = new ArrayList<>(); + for (NormalizedField field : childrenWithSameResultKey) { + boolean addToGroup = false; + overPossibleGroups: + for (Set group : possibleGroupsToMerge) { + for (NormalizedField fieldInGroup : group) { + if (field.getFieldName().equals(Introspection.TypeNameMetaFieldDef.getName())) { + addToGroup = true; + group.add(field); + continue overPossibleGroups; + } + if (field.getFieldName().equals(fieldInGroup.getFieldName()) && + sameArguments(field.getAstArguments(), fieldInGroup.getAstArguments()) + && isFieldInSharedInterface(field, fieldInGroup, schema) + ) { + addToGroup = true; + group.add(field); + continue overPossibleGroups; + } + } + } + if (!addToGroup) { + LinkedHashSet group = new LinkedHashSet<>(); + group.add(field); + possibleGroupsToMerge.add(group); + } + } + for (Set groupOfFields : possibleGroupsToMerge) { + // for each group we check if it could be merged + List> listOfChildrenForGroup = new ArrayList<>(); + for (NormalizedField fieldInGroup : groupOfFields) { + Set childrenSets = new LinkedHashSet<>(fieldInGroup.getChildren()); + listOfChildrenForGroup.add(childrenSets); + } + boolean mergeable = areFieldSetsTheSame(listOfChildrenForGroup); + if (mergeable) { + Set mergedObjects = new LinkedHashSet<>(); + List mergedDirectives = new ArrayList<>(); + groupOfFields.forEach(f -> mergedObjects.addAll(f.getObjectTypeNames())); + groupOfFields.forEach(f -> mergedDirectives.addAll(f.getAstDirectives())); + // patching the first one to contain more objects, remove all others + Iterator iterator = groupOfFields.iterator(); + NormalizedField first = iterator.next(); + + while (iterator.hasNext()) { + NormalizedField next = iterator.next(); + parent.getChildren().remove(next); + } + first.setObjectTypeNames(mergedObjects); + first.setAstDirectives(mergedDirectives); + } + } + } + + private static boolean isFieldInSharedInterface(NormalizedField fieldOne, NormalizedField fieldTwo, GraphQLSchema schema) { + + /* + * we can get away with only checking one of the object names, because all object names in one ENF are guaranteed to be the same field. + * This comes from how the ENFs are created in the factory before. + */ + String firstObject = fieldOne.getSingleObjectTypeName(); + String secondObject = fieldTwo.getSingleObjectTypeName(); + // we know that the field names are the same, therefore we can just take the first one + String fieldName = fieldOne.getFieldName(); + + GraphQLObjectType objectTypeOne = schema.getObjectType(firstObject); + GraphQLObjectType objectTypeTwo = schema.getObjectType(secondObject); + List interfacesOne = (List) objectTypeOne.getInterfaces(); + List interfacesTwo = (List) objectTypeTwo.getInterfaces(); + + Optional firstInterfaceFound = interfacesOne.stream().filter(singleInterface -> singleInterface.getFieldDefinition(fieldName) != null).findFirst(); + Optional secondInterfaceFound = interfacesTwo.stream().filter(singleInterface -> singleInterface.getFieldDefinition(fieldName) != null).findFirst(); + if (!firstInterfaceFound.isPresent() || !secondInterfaceFound.isPresent()) { + return false; + } + return firstInterfaceFound.get().getName().equals(secondInterfaceFound.get().getName()); + } + + + private static boolean areFieldSetsTheSame(List> listOfSets) { + if (listOfSets.size() == 0 || listOfSets.size() == 1) { + return true; + } + Set first = listOfSets.get(0); + Iterator> iterator = listOfSets.iterator(); + iterator.next(); + while (iterator.hasNext()) { + Set set = iterator.next(); + if (!compareTwoFieldSets(first, set)) { + return false; + } + } + List> nextLevel = new ArrayList<>(); + for (Set set : listOfSets) { + for (NormalizedField fieldInSet : set) { + nextLevel.add(new LinkedHashSet<>(fieldInSet.getChildren())); + } + } + return areFieldSetsTheSame(nextLevel); + } + + private static boolean compareTwoFieldSets(Set setOne, Set setTwo) { + if (setOne.size() != setTwo.size()) { + return false; + } + for (NormalizedField field : setOne) { + if (!isContained(field, setTwo)) { + return false; + } + } + return true; + } + + private static boolean isContained(NormalizedField searchFor, Set set) { + for (NormalizedField field : set) { + if (compareWithoutChildren(searchFor, field)) { + return true; + } + } + return false; + } + + private static boolean compareWithoutChildren(NormalizedField one, NormalizedField two) { + + if (!one.getObjectTypeNames().equals(two.getObjectTypeNames())) { + return false; + } + if (!Objects.equals(one.getAlias(), two.getAlias())) { + return false; + } + if (!Objects.equals(one.getFieldName(), two.getFieldName())) { + return false; + } + if (!sameArguments(one.getAstArguments(), two.getAstArguments())) { + return false; + } + return true; + } + + // copied from graphql.validation.rules.OverlappingFieldsCanBeMerged + private static boolean sameArguments(List arguments1, List arguments2) { + if (arguments1.size() != arguments2.size()) { + return false; + } + for (Argument argument : arguments1) { + Argument matchedArgument = findArgumentByName(argument.getName(), arguments2); + if (matchedArgument == null) { + return false; + } + if (!AstComparator.sameValue(argument.getValue(), matchedArgument.getValue())) { + return false; + } + } + return true; + } + + private static Argument findArgumentByName(String name, List arguments) { + for (Argument argument : arguments) { + if (argument.getName().equals(name)) { + return argument; + } + } + return null; + } + +} diff --git a/src/main/java/graphql/normalized/nf/NormalizedOperation.java b/src/main/java/graphql/normalized/nf/NormalizedOperation.java new file mode 100644 index 000000000..6d3c333d0 --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedOperation.java @@ -0,0 +1,180 @@ +package graphql.normalized.nf; + +import com.google.common.collect.ImmutableListMultimap; +import graphql.Assert; +import graphql.ExperimentalApi; +import graphql.execution.MergedField; +import graphql.execution.ResultPath; +import graphql.execution.directives.QueryDirectives; +import graphql.language.Field; +import graphql.language.OperationDefinition; +import graphql.schema.FieldCoordinates; +import graphql.schema.GraphQLFieldsContainer; + +import java.util.List; +import java.util.Map; + +/** + * A {@link NormalizedOperation} represent how the text of a graphql operation (sometimes known colloquially as a query) + * will be executed at runtime according to the graphql specification. It handles complex mechanisms like merging + * duplicate fields into one and also detecting when the types of a given field may actually be for more than one possible object + * type. + *

+ * An operation consists of a list of {@link NormalizedField}s in a parent child hierarchy + */ +@ExperimentalApi +public class NormalizedOperation { + private final OperationDefinition.Operation operation; + private final String operationName; + private final List rootFields; + private final ImmutableListMultimap fieldToNormalizedField; + private final Map normalizedFieldToMergedField; + private final Map normalizedFieldToQueryDirectives; + private final ImmutableListMultimap coordinatesToNormalizedFields; + private final int operationFieldCount; + private final int operationDepth; + + public NormalizedOperation( + OperationDefinition.Operation operation, + String operationName, + List rootFields, + ImmutableListMultimap fieldToNormalizedField, + Map normalizedFieldToMergedField, + Map normalizedFieldToQueryDirectives, + ImmutableListMultimap coordinatesToNormalizedFields, + int operationFieldCount, + int operationDepth) { + this.operation = operation; + this.operationName = operationName; + this.rootFields = rootFields; + this.fieldToNormalizedField = fieldToNormalizedField; + this.normalizedFieldToMergedField = normalizedFieldToMergedField; + this.normalizedFieldToQueryDirectives = normalizedFieldToQueryDirectives; + this.coordinatesToNormalizedFields = coordinatesToNormalizedFields; + this.operationFieldCount = operationFieldCount; + this.operationDepth = operationDepth; + } + + /** + * @return operation AST being executed + */ + public OperationDefinition.Operation getOperation() { + return operation; + } + + /** + * @return the operation name, which can be null + */ + public String getOperationName() { + return operationName; + } + + /** + * @return This returns how many {@link NormalizedField}s are in the operation. + */ + public int getOperationFieldCount() { + return operationFieldCount; + } + + /** + * @return This returns the depth of the operation + */ + public int getOperationDepth() { + return operationDepth; + } + + /** + * This multimap shows how a given {@link NormalizedField} maps to a one or more field coordinate in the schema + * + * @return a multimap of fields to schema field coordinates + */ + public ImmutableListMultimap getCoordinatesToNormalizedFields() { + return coordinatesToNormalizedFields; + } + + /** + * @return a list of the top level {@link NormalizedField}s in this operation. + */ + public List getRootFields() { + return rootFields; + } + + /** + * This is a multimap and the size of it reflects all the normalized fields in the operation + * + * @return an immutable list multimap of {@link Field} to {@link NormalizedField} + */ + public ImmutableListMultimap getFieldToNormalizedField() { + return fieldToNormalizedField; + } + + /** + * Looks up one or more {@link NormalizedField}s given a {@link Field} AST element in the operation + * + * @param field the field to look up + * + * @return zero, one or more possible {@link NormalizedField}s that represent that field + */ + public List getNormalizedFields(Field field) { + return fieldToNormalizedField.get(field); + } + + /** + * @return a map of {@link NormalizedField} to {@link MergedField}s + */ + public Map getNormalizedFieldToMergedField() { + return normalizedFieldToMergedField; + } + + /** + * Looks up the {@link MergedField} given a {@link NormalizedField} + * + * @param NormalizedField the field to use the key + * + * @return a {@link MergedField} or null if its not present + */ + public MergedField getMergedField(NormalizedField NormalizedField) { + return normalizedFieldToMergedField.get(NormalizedField); + } + + /** + * @return a map of {@link NormalizedField} to its {@link QueryDirectives} + */ + public Map getNormalizedFieldToQueryDirectives() { + return normalizedFieldToQueryDirectives; + + } + + /** + * This looks up the {@link QueryDirectives} associated with the given {@link NormalizedField} + * + * @param NormalizedField the executable normalised field in question + * + * @return the fields query directives or null + */ + public QueryDirectives getQueryDirectives(NormalizedField NormalizedField) { + return normalizedFieldToQueryDirectives.get(NormalizedField); + } + + /** + * This will find a {@link NormalizedField} given a merged field and a result path. If this does not find a field it will assert with an exception + * + * @param mergedField the merged field + * @param fieldsContainer the containing type of that field + * @param resultPath the result path in play + * + * @return the NormalizedField + */ + public NormalizedField getNormalizedField(MergedField mergedField, GraphQLFieldsContainer fieldsContainer, ResultPath resultPath) { + List NormalizedFields = fieldToNormalizedField.get(mergedField.getSingleField()); + List keysOnlyPath = resultPath.getKeysOnly(); + for (NormalizedField NormalizedField : NormalizedFields) { + if (NormalizedField.getListOfResultKeys().equals(keysOnlyPath)) { + if (NormalizedField.getObjectTypeNames().contains(fieldsContainer.getName())) { + return NormalizedField; + } + } + } + return Assert.assertShouldNeverHappen("normalized field not found"); + } +} diff --git a/src/main/java/graphql/normalized/nf/NormalizedOperationToAstCompiler.java b/src/main/java/graphql/normalized/nf/NormalizedOperationToAstCompiler.java new file mode 100644 index 000000000..6a9ce9f7a --- /dev/null +++ b/src/main/java/graphql/normalized/nf/NormalizedOperationToAstCompiler.java @@ -0,0 +1,213 @@ +package graphql.normalized.nf; + +import com.google.common.collect.ImmutableList; +import graphql.Assert; +import graphql.ExperimentalApi; +import graphql.introspection.Introspection; +import graphql.language.Argument; +import graphql.language.Directive; +import graphql.language.Document; +import graphql.language.Field; +import graphql.language.InlineFragment; +import graphql.language.OperationDefinition; +import graphql.language.Selection; +import graphql.language.SelectionSet; +import graphql.language.TypeName; +import graphql.schema.GraphQLCompositeType; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLUnmodifiedType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static graphql.collect.ImmutableKit.emptyList; +import static graphql.language.Field.newField; +import static graphql.language.InlineFragment.newInlineFragment; +import static graphql.language.SelectionSet.newSelectionSet; +import static graphql.language.TypeName.newTypeName; +import static graphql.schema.GraphQLTypeUtil.unwrapAll; + +/** + * This class can take a list of {@link NormalizedField}s and compiling out a + * normalised operation {@link Document} that would represent how those fields + * may be executed. + *

+ * This is essentially the reverse of {@link NormalizedDocumentFactory} which takes + * operation text and makes {@link NormalizedField}s from it, this takes {@link NormalizedField}s + * and makes operation text from it. + *

+ * You could for example send that operation text onto to some other graphql server if it + * has the same schema as the one provided. + */ +@ExperimentalApi +public class NormalizedOperationToAstCompiler { + + /** + * The result is a {@link Document} and a map of variables + * that would go with that document. + */ + public static class CompilerResult { + private final Document document; + private final Map variables; + + public CompilerResult(Document document, Map variables) { + this.document = document; + this.variables = variables; + } + + public Document getDocument() { + return document; + } + + public Map getVariables() { + return variables; + } + } + + public static CompilerResult compileToDocument(@NotNull GraphQLSchema schema, + NormalizedOperation normalizedOperation) { + GraphQLObjectType operationType = getOperationType(schema, normalizedOperation.getOperation()); + + List> selections = subSelectionsForNormalizedField(schema, operationType.getName(), normalizedOperation.getRootFields()); + SelectionSet selectionSet = new SelectionSet(selections); + + OperationDefinition.Builder definitionBuilder = OperationDefinition.newOperationDefinition() + .name(normalizedOperation.getOperationName()) + .operation(normalizedOperation.getOperation()) + .selectionSet(selectionSet); + +// definitionBuilder.variableDefinitions(variableAccumulator.getVariableDefinitions()); + + return new CompilerResult( + Document.newDocument() + .definition(definitionBuilder.build()) + .build(), + null + ); + } + + private static List> subSelectionsForNormalizedField(GraphQLSchema schema, + @NotNull String parentOutputType, + List normalizedFields + ) { + ImmutableList.Builder> selections = ImmutableList.builder(); + + // All conditional fields go here instead of directly to selections, so they can be grouped together + // in the same inline fragment in the output + Map> fieldsByTypeCondition = new LinkedHashMap<>(); + + for (NormalizedField nf : normalizedFields) { + if (nf.isConditional(schema)) { + selectionForNormalizedField(schema, nf) + .forEach((objectTypeName, field) -> + fieldsByTypeCondition + .computeIfAbsent(objectTypeName, ignored -> new ArrayList<>()) + .add(field)); + } else { + selections.add(selectionForNormalizedField(schema, parentOutputType, nf)); + } + } + + fieldsByTypeCondition.forEach((objectTypeName, fields) -> { + TypeName typeName = newTypeName(objectTypeName).build(); + InlineFragment inlineFragment = newInlineFragment() + .typeCondition(typeName) + .selectionSet(selectionSet(fields)) + .build(); + selections.add(inlineFragment); + }); + + return selections.build(); + } + + /** + * @return Map of object type names to list of fields + */ + private static Map selectionForNormalizedField(GraphQLSchema schema, + NormalizedField normalizedField + ) { + Map groupedFields = new LinkedHashMap<>(); + + for (String objectTypeName : normalizedField.getObjectTypeNames()) { + groupedFields.put(objectTypeName, selectionForNormalizedField(schema, objectTypeName, normalizedField)); + } + + return groupedFields; + } + + /** + * @return Map of object type names to list of fields + */ + private static Field selectionForNormalizedField(GraphQLSchema schema, + String objectTypeName, + NormalizedField normalizedField) { + + final List> subSelections; + if (normalizedField.getChildren().isEmpty()) { + subSelections = emptyList(); + } else { + GraphQLFieldDefinition fieldDef = getFieldDefinition(schema, objectTypeName, normalizedField); + GraphQLUnmodifiedType fieldOutputType = unwrapAll(fieldDef.getType()); + + subSelections = subSelectionsForNormalizedField( + schema, + fieldOutputType.getName(), + normalizedField.getChildren() + ); + } + + SelectionSet selectionSet = selectionSetOrNullIfEmpty(subSelections); +// List arguments = createArguments(executableNormalizedField, variableAccumulator); + List arguments = normalizedField.getAstArguments(); + List directives = normalizedField.getAstDirectives(); + + + Field.Builder builder = newField() + .name(normalizedField.getFieldName()) + .alias(normalizedField.getAlias()) + .selectionSet(selectionSet) + .directives(directives) + .arguments(arguments); + return builder.build(); + } + + @Nullable + private static SelectionSet selectionSetOrNullIfEmpty(List> selections) { + return selections.isEmpty() ? null : newSelectionSet().selections(selections).build(); + } + + private static SelectionSet selectionSet(List fields) { + return newSelectionSet().selections(fields).build(); + } + + + @NotNull + private static GraphQLFieldDefinition getFieldDefinition(GraphQLSchema schema, + String parentType, + NormalizedField nf) { + return Introspection.getFieldDef(schema, (GraphQLCompositeType) schema.getType(parentType), nf.getName()); + } + + + @Nullable + private static GraphQLObjectType getOperationType(@NotNull GraphQLSchema schema, + @NotNull OperationDefinition.Operation operationKind) { + switch (operationKind) { + case QUERY: + return schema.getQueryType(); + case MUTATION: + return schema.getMutationType(); + case SUBSCRIPTION: + return schema.getSubscriptionType(); + } + + return Assert.assertShouldNeverHappen("Unknown operation kind " + operationKind); + } + +} diff --git a/src/test/groovy/graphql/normalized/nf/NormalizedDocumentFactoryTest.groovy b/src/test/groovy/graphql/normalized/nf/NormalizedDocumentFactoryTest.groovy new file mode 100644 index 000000000..5ef79cd41 --- /dev/null +++ b/src/test/groovy/graphql/normalized/nf/NormalizedDocumentFactoryTest.groovy @@ -0,0 +1,251 @@ +package graphql.normalized.nf + +import graphql.ExecutionInput +import graphql.GraphQL +import graphql.TestUtil +import graphql.language.Document +import graphql.schema.GraphQLSchema +import graphql.schema.GraphQLTypeUtil +import graphql.util.TraversalControl +import graphql.util.Traverser +import graphql.util.TraverserContext +import graphql.util.TraverserVisitorStub +import spock.lang.Specification + +class NormalizedDocumentFactoryTest extends Specification { + + def "test"() { + String schema = """ +type Query{ + animal: Animal +} +interface Animal { + name: String + friends: [Friend] +} + +union Pet = Dog | Cat + +type Friend { + name: String + isBirdOwner: Boolean + isCatOwner: Boolean + pets: [Pet] +} + +type Bird implements Animal { + name: String + friends: [Friend] +} + +type Cat implements Animal{ + name: String + friends: [Friend] + breed: String +} + +type Dog implements Animal{ + name: String + breed: String + friends: [Friend] +} + + """ + GraphQLSchema graphQLSchema = TestUtil.schema(schema) + + String query = """ + { + animal{ + name + otherName: name + ... on Animal { + name + } + ... on Cat { + name + friends { + ... on Friend { + isCatOwner + pets { + ... on Dog { + name + } + } + } + } + } + ... on Bird { + friends { + isBirdOwner + } + friends { + name + pets { + ... on Cat { + breed + } + } + } + } + ... on Dog { + name + } + }} + + """ + + assertValidQuery(graphQLSchema, query) + + Document document = TestUtil.parseQuery(query) + def tree = NormalizedDocumentFactory.createNormalizedDocument(graphQLSchema, document) + def printedTree = printDocumentWithLevelInfo(tree, graphQLSchema) + + expect: + printedTree == ['-Query.animal: Animal', + '--[Bird, Cat, Dog].name: String', + '--otherName: [Bird, Cat, Dog].name: String', + '--Cat.friends: [Friend]', + '---Friend.isCatOwner: Boolean', + '---Friend.pets: [Pet]', + '----Dog.name: String', + '--Bird.friends: [Friend]', + '---Friend.isBirdOwner: Boolean', + '---Friend.name: String', + '---Friend.pets: [Pet]', + '----Cat.breed: String' + ] + } + + def "document with skip/include with variables"() { + String schema = """ + type Query{ + foo: Foo + } + type Foo { + bar: Bar + name: String + } + type Bar { + baz: String + } + """ + GraphQLSchema graphQLSchema = TestUtil.schema(schema) + + String query = ''' + query ($skip: Boolean!, $include: Boolean!) { + foo { + name + bar @skip(if: $skip) { + baz @include(if: $include) + } + } + } + ''' + + + assertValidQuery(graphQLSchema, query, [skip: false, include: true]) + + Document document = TestUtil.parseQuery(query) + def tree = NormalizedDocumentFactory.createNormalizedDocument(graphQLSchema, document) + def printedTree = printDocumentWithLevelInfo(tree, graphQLSchema) + + expect: + printedTree.join("\n") == '''variables: [skip:false, include:false] +-Query.foo: Foo +--Foo.name: String +--Foo.bar: Bar +variables: [skip:true, include:false] +-Query.foo: Foo +--Foo.name: String +variables: [skip:false, include:true] +-Query.foo: Foo +--Foo.name: String +--Foo.bar: Bar +---Bar.baz: String +variables: [skip:true, include:true] +-Query.foo: Foo +--Foo.name: String''' + } + + def "document with custom directives"() { + String schema = """ + directive @cache(time: Int!) on FIELD + type Query{ + foo: Foo + } + type Foo { + bar: Bar + name: String + } + type Bar { + baz: String + } + """ + GraphQLSchema graphQLSchema = TestUtil.schema(schema) + + String query = ''' + query { + foo { + name + bar @cache(time:100) { + baz + } + bar @cache(time:200) { + baz + } + + } + } + ''' + + + assertValidQuery(graphQLSchema, query, [skip: false, include: true]) + + Document document = TestUtil.parseQuery(query) + def normalizedDocument = NormalizedDocumentFactory.createNormalizedDocument(graphQLSchema, document) + def rootField = normalizedDocument.getSingleNormalizedOperation().getRootFields().get(0) + def bar = rootField.getChildren().get(1) + + expect: + bar.getAstDirectives().size() == 2 + } + + + private void assertValidQuery(GraphQLSchema graphQLSchema, String query, Map variables = [:]) { + GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build() + def ei = ExecutionInput.newExecutionInput(query).variables(variables).build() + assert graphQL.execute(ei).errors.size() == 0 + } + + static List printDocumentWithLevelInfo(NormalizedDocument normalizedDocument, GraphQLSchema schema) { + def result = [] + for (NormalizedDocument.NormalizedOperationWithAssumedSkipIncludeVariables normalizedOperationWithAssumedSkipIncludeVariables : normalizedDocument.normalizedOperations) { + NormalizedOperation normalizedOperation = normalizedOperationWithAssumedSkipIncludeVariables.normalizedOperation; + if (normalizedOperationWithAssumedSkipIncludeVariables.assumedSkipIncludeVariables != null) { + result << "variables: " + normalizedOperationWithAssumedSkipIncludeVariables.assumedSkipIncludeVariables + } + Traverser traverser = Traverser.depthFirst({ it.getChildren() }) + traverser.traverse(normalizedOperation.getRootFields(), new TraverserVisitorStub() { + @Override + TraversalControl enter(TraverserContext context) { + NormalizedField normalizedField = context.thisNode() + String prefix = "" + for (int i = 1; i <= normalizedField.getLevel(); i++) { + prefix += "-" + } + + def possibleOutputTypes = new LinkedHashSet() + for (fieldDef in normalizedField.getFieldDefinitions(schema)) { + possibleOutputTypes.add(GraphQLTypeUtil.simplePrint(fieldDef.type)) + } + + result << (prefix + normalizedField.printDetails() + ": " + possibleOutputTypes.join(", ")) + return TraversalControl.CONTINUE + } + }) + } + result + } + + +} diff --git a/src/test/groovy/graphql/normalized/nf/NormalizedOperationToAstCompilerTest.groovy b/src/test/groovy/graphql/normalized/nf/NormalizedOperationToAstCompilerTest.groovy new file mode 100644 index 000000000..49f9cc0a5 --- /dev/null +++ b/src/test/groovy/graphql/normalized/nf/NormalizedOperationToAstCompilerTest.groovy @@ -0,0 +1,194 @@ +package graphql.normalized.nf + +import graphql.GraphQL +import graphql.TestUtil +import graphql.language.AstPrinter +import graphql.language.AstSorter +import graphql.parser.Parser +import graphql.schema.GraphQLSchema +import spock.lang.Specification + +import static graphql.ExecutionInput.newExecutionInput + +class NormalizedOperationToAstCompilerTest extends Specification { + + + def "test pet interfaces"() { + String sdl = """ + type Query { + animal: Animal + } + interface Animal { + name: String + friends: [Friend] + } + + union Pet = Dog | Cat + + type Friend { + name: String + isBirdOwner: Boolean + isCatOwner: Boolean + pets: [Pet] + } + + type Bird implements Animal { + name: String + friends: [Friend] + } + + type Cat implements Animal { + name: String + friends: [Friend] + breed: String + mood: String + } + + type Dog implements Animal { + name: String + breed: String + friends: [Friend] + } + """ + + String query = """ + { + animal { + name + otherName: name + ... on Animal { + name + } + ... on Cat { + name + mood + friends { + ... on Friend { + isCatOwner + pets { + ... on Dog { + name + } + } + } + } + } + ... on Bird { + friends { + isBirdOwner + } + friends { + name + pets { + ... on Cat { + breed + } + } + } + } + ... on Dog { + name + breed + } + } + } + """ + GraphQLSchema schema = TestUtil.schema(sdl) + assertValidQuery(schema, query) + def normalizedDocument = NormalizedDocumentFactory.createNormalizedDocument(schema, Parser.parse(query)) + def normalizedOperation = normalizedDocument.getSingleNormalizedOperation() + when: + def result = NormalizedOperationToAstCompiler.compileToDocument(schema, normalizedOperation) + def printed = AstPrinter.printAst(new AstSorter().sort(result.document)) + then: + printed == '''{ + animal { + name + otherName: name + ... on Bird { + friends { + isBirdOwner + name + pets { + ... on Cat { + breed + } + } + } + } + ... on Cat { + friends { + isCatOwner + pets { + ... on Dog { + name + } + } + } + mood + } + ... on Dog { + breed + } + } +} +''' + } + + def "print custom directives"() { + String sdl = """ + directive @cache(time: Int!) on FIELD + type Query{ + foo: Foo + } + type Foo { + bar: Bar + name: String + } + type Bar { + baz: String + } + """ + + String query = ''' + query { + foo { + name + bar @cache(time:100) { + baz + } + bar @cache(time:200) { + baz + } + + } + } + ''' + + GraphQLSchema schema = TestUtil.schema(sdl) + assertValidQuery(schema, query) + def normalizedDocument = NormalizedDocumentFactory.createNormalizedDocument(schema, Parser.parse(query)) + def normalizedOperation = normalizedDocument.getSingleNormalizedOperation() + when: + def result = NormalizedOperationToAstCompiler.compileToDocument(schema, normalizedOperation) + def printed = AstPrinter.printAst(new AstSorter().sort(result.document)) + then: + printed == '''{ + foo { + bar @cache(time: 100) @cache(time: 200) { + baz + } + name + } +} +''' + } + + + private void assertValidQuery(GraphQLSchema graphQLSchema, String query, Map variables = [:]) { + GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build() + assert graphQL.execute(newExecutionInput().query(query).variables(variables)).errors.isEmpty() + } + + +}