-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Query generator #3979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Query generator #3979
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
11052a0
WIP: Field selection generator
felipe-gdr 60d5886
Deal with recursiveness
felipe-gdr 138b5bd
clean up code
felipe-gdr 2ed10a1
Generate valid query
felipe-gdr 7e0a0e1
WIP: Unions and Interfaces support
felipe-gdr db20a2b
Use breadth-first
felipe-gdr 7ae15f4
Add ability to limit field count on generated query
felipe-gdr 7a3e900
Add ability to filter fields and types
felipe-gdr d049855
Add support for union and interface types
felipe-gdr 3cafd37
Easy refactoring
felipe-gdr 3292b67
A few adjustments to support very large schemas
felipe-gdr 5a77090
Javadoc, no guava Predicates, @ExperimentalApi & change visibility
felipe-gdr 106a1f0
Remove duplicate method + Javadoc
felipe-gdr 4443ea6
Fix bug with cyclic dependency on unions
felipe-gdr 40af6a0
Fix test
felipe-gdr a1ea217
Use correct @Nullable
felipe-gdr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
147 changes: 147 additions & 0 deletions
147
src/main/java/graphql/util/querygenerator/QueryGenerator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| package graphql.util.querygenerator; | ||
|
|
||
| import graphql.ExperimentalApi; | ||
| import graphql.schema.GraphQLFieldDefinition; | ||
| import graphql.schema.GraphQLFieldsContainer; | ||
| import graphql.schema.GraphQLInterfaceType; | ||
| import graphql.schema.GraphQLObjectType; | ||
| import graphql.schema.GraphQLOutputType; | ||
| import graphql.schema.GraphQLSchema; | ||
| import graphql.schema.GraphQLUnionType; | ||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| import java.util.stream.Stream; | ||
|
|
||
| /** | ||
| * Generates a GraphQL query string based on the provided operation field path, operation name, arguments, and type classifier. | ||
| * <p> | ||
| * While this class is useful for testing purposes, such as ensuring that all fields from a certain type are being | ||
| * fetched correctly, it is important to note that generating GraphQL queries with all possible fields defeats one of | ||
| * the main purposes of a GraphQL API: allowing clients to be selective about the fields they want to fetch. | ||
| * <p> | ||
| * Callers can pass options to customize the query generation process, such as filtering fields or | ||
| * limiting the maximum number of fields. | ||
| * <p> | ||
| * | ||
| */ | ||
| @ExperimentalApi | ||
| public class QueryGenerator { | ||
| private final GraphQLSchema schema; | ||
| private final QueryGeneratorFieldSelection fieldSelectionGenerator; | ||
| private final QueryGeneratorPrinter printer; | ||
|
|
||
| /** | ||
| * Constructor for QueryGenerator. | ||
| * | ||
| * @param schema the GraphQL schema | ||
| * @param options the options for query generation | ||
| */ | ||
| public QueryGenerator(GraphQLSchema schema, QueryGeneratorOptions options) { | ||
| this.schema = schema; | ||
| this.fieldSelectionGenerator = new QueryGeneratorFieldSelection(schema, options); | ||
| this.printer = new QueryGeneratorPrinter(); | ||
| } | ||
|
|
||
| /** | ||
| * Generates a GraphQL query string based on the provided operation field path, operation name, arguments, | ||
| * and type classifier. | ||
| * | ||
| * <p> | ||
| * operationFieldPath is a string that represents the path to the field in the GraphQL schema. This method | ||
| * will generate a query that includes all fields from the specified type, including nested fields. | ||
| * <p> | ||
| * operationName is optional. When passed, the generated query will contain that value in the operation name. | ||
| * <p> | ||
| * arguments are optional. When passed, the generated query will contain that value in the arguments. | ||
| * <p> | ||
| * typeClassifier is optional. It should not be passed in when the field in the path is an object type, and it | ||
| * **should** be passed when the field in the path is an interface or union type. In the latter case, its value | ||
| * should be an object type that is part of the union or implements the interface. | ||
| * | ||
| * @param operationFieldPath the operation field path (e.g., "Query.user", "Mutation.createUser", "Subscription.userCreated") | ||
| * @param operationName optional: the operation name (e.g., "getUser") | ||
| * @param arguments optional: the arguments for the operation in a plain text form (e.g., "(id: 1)") | ||
| * @param typeClassifier optional: the type classifier for union or interface types (e.g., "FirstPartyUser") | ||
| * | ||
| * @return the generated GraphQL query string | ||
| */ | ||
| public String generateQuery( | ||
| String operationFieldPath, | ||
| @Nullable String operationName, | ||
| @Nullable String arguments, | ||
| @Nullable String typeClassifier | ||
| ) { | ||
| String[] fieldParts = operationFieldPath.split("\\."); | ||
| String operation = fieldParts[0]; | ||
|
|
||
| if (fieldParts.length < 2) { | ||
| throw new IllegalArgumentException("Field path must contain at least an operation and a field"); | ||
| } | ||
|
|
||
| if (!operation.equals("Query") && !operation.equals("Mutation") && !operation.equals("Subscription")) { | ||
| throw new IllegalArgumentException("Operation must be 'Query', 'Mutation' or 'Subscription'"); | ||
| } | ||
|
|
||
| GraphQLFieldsContainer fieldContainer = schema.getObjectType(operation); | ||
|
|
||
| for (int i = 1; i < fieldParts.length - 1; i++) { | ||
| String fieldName = fieldParts[i]; | ||
| GraphQLFieldDefinition fieldDefinition = fieldContainer.getFieldDefinition(fieldName); | ||
| if (fieldDefinition == null) { | ||
| throw new IllegalArgumentException("Field " + fieldName + " not found in type " + fieldContainer.getName()); | ||
| } | ||
| // intermediate fields in the path need to be a field container | ||
| if (!(fieldDefinition.getType() instanceof GraphQLFieldsContainer)) { | ||
| throw new IllegalArgumentException("Type " + fieldDefinition.getType() + " is not a field container"); | ||
| } | ||
| fieldContainer = (GraphQLFieldsContainer) fieldDefinition.getType(); | ||
| } | ||
|
|
||
| String lastFieldName = fieldParts[fieldParts.length - 1]; | ||
| GraphQLFieldDefinition lastFieldDefinition = fieldContainer.getFieldDefinition(lastFieldName); | ||
| if (lastFieldDefinition == null) { | ||
| throw new IllegalArgumentException("Field " + lastFieldName + " not found in type " + fieldContainer.getName()); | ||
| } | ||
|
|
||
| // last field may be an object, interface or union type | ||
| GraphQLOutputType lastType = lastFieldDefinition.getType(); | ||
|
|
||
| final GraphQLFieldsContainer lastFieldContainer; | ||
|
|
||
| if (lastType instanceof GraphQLObjectType) { | ||
| if (typeClassifier != null) { | ||
| throw new IllegalArgumentException("typeClassifier should be used only with interface or union types"); | ||
| } | ||
| lastFieldContainer = (GraphQLObjectType) lastType; | ||
| } else if (lastType instanceof GraphQLUnionType) { | ||
| if (typeClassifier == null) { | ||
| throw new IllegalArgumentException("typeClassifier is required for union types"); | ||
| } | ||
| lastFieldContainer = ((GraphQLUnionType) lastType).getTypes().stream() | ||
| .filter(GraphQLFieldsContainer.class::isInstance) | ||
| .map(GraphQLFieldsContainer.class::cast) | ||
| .filter(type -> type.getName().equals(typeClassifier)) | ||
| .findFirst() | ||
| .orElseThrow(() -> new IllegalArgumentException("Type " + typeClassifier + " not found in union " + ((GraphQLUnionType) lastType).getName())); | ||
| } else if (lastType instanceof GraphQLInterfaceType) { | ||
| if (typeClassifier == null) { | ||
| throw new IllegalArgumentException("typeClassifier is required for interface types"); | ||
| } | ||
| Stream<GraphQLFieldsContainer> fieldsContainerStream = Stream.concat( | ||
| Stream.of((GraphQLInterfaceType) lastType), | ||
| schema.getImplementations((GraphQLInterfaceType) lastType).stream() | ||
| ); | ||
|
|
||
| lastFieldContainer = fieldsContainerStream | ||
| .filter(type -> type.getName().equals(typeClassifier)) | ||
| .findFirst() | ||
| .orElseThrow(() -> new IllegalArgumentException("Type " + typeClassifier + " not found in interface " + ((GraphQLInterfaceType) lastType).getName())); | ||
| } else { | ||
| throw new IllegalArgumentException("Type " + lastType + " is not a field container"); | ||
| } | ||
|
|
||
| QueryGeneratorFieldSelection.FieldSelection rootFieldSelection = fieldSelectionGenerator.buildFields(lastFieldContainer); | ||
|
|
||
| return printer.print(operationFieldPath, operationName, arguments, rootFieldSelection); | ||
| } | ||
| } | ||
181 changes: 181 additions & 0 deletions
181
src/main/java/graphql/util/querygenerator/QueryGeneratorFieldSelection.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| package graphql.util.querygenerator; | ||
|
|
||
| import graphql.schema.FieldCoordinates; | ||
| import graphql.schema.GraphQLFieldDefinition; | ||
| import graphql.schema.GraphQLFieldsContainer; | ||
| import graphql.schema.GraphQLInterfaceType; | ||
| import graphql.schema.GraphQLObjectType; | ||
| import graphql.schema.GraphQLSchema; | ||
| import graphql.schema.GraphQLType; | ||
| import graphql.schema.GraphQLTypeUtil; | ||
| import graphql.schema.GraphQLUnionType; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.LinkedList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import java.util.Queue; | ||
| import java.util.Set; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| class QueryGeneratorFieldSelection { | ||
| private final QueryGeneratorOptions options; | ||
| private final GraphQLSchema schema; | ||
|
|
||
| private static final GraphQLObjectType EMPTY_OBJECT_TYPE = GraphQLObjectType.newObject() | ||
| .name("Empty") | ||
| .build(); | ||
|
|
||
| QueryGeneratorFieldSelection(GraphQLSchema schema, QueryGeneratorOptions options) { | ||
| this.options = options; | ||
| this.schema = schema; | ||
| } | ||
|
|
||
| FieldSelection buildFields(GraphQLFieldsContainer fieldsContainer) { | ||
| Queue<List<GraphQLFieldsContainer>> containersQueue = new LinkedList<>(); | ||
| containersQueue.add(Collections.singletonList(fieldsContainer)); | ||
|
|
||
| Queue<FieldSelection> fieldSelectionQueue = new LinkedList<>(); | ||
| FieldSelection root = new FieldSelection(fieldsContainer.getName(), new HashMap<>(), false); | ||
| fieldSelectionQueue.add(root); | ||
|
|
||
| Set<FieldCoordinates> visited = new HashSet<>(); | ||
| AtomicInteger totalFieldCount = new AtomicInteger(0); | ||
|
|
||
| while (!containersQueue.isEmpty()) { | ||
| processContainers(containersQueue, fieldSelectionQueue, visited, totalFieldCount); | ||
|
|
||
| if (totalFieldCount.get() >= options.getMaxFieldCount()) { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| return root; | ||
| } | ||
|
|
||
| private void processContainers(Queue<List<GraphQLFieldsContainer>> containersQueue, | ||
| Queue<FieldSelection> fieldSelectionQueue, | ||
| Set<FieldCoordinates> visited, | ||
| AtomicInteger totalFieldCount) { | ||
| List<GraphQLFieldsContainer> containers = containersQueue.poll(); | ||
| FieldSelection fieldSelection = fieldSelectionQueue.poll(); | ||
|
|
||
| for (GraphQLFieldsContainer container : Objects.requireNonNull(containers)) { | ||
| if (!options.getFilterFieldContainerPredicate().test(container)) { | ||
| continue; | ||
| } | ||
|
|
||
| for (GraphQLFieldDefinition fieldDef : container.getFieldDefinitions()) { | ||
| if (!options.getFilterFieldDefinitionPredicate().test(fieldDef)) { | ||
| continue; | ||
| } | ||
|
|
||
| if (totalFieldCount.get() >= options.getMaxFieldCount()) { | ||
| break; | ||
| } | ||
|
|
||
| if (hasRequiredArgs(fieldDef)) { | ||
| continue; | ||
| } | ||
|
|
||
| FieldCoordinates fieldCoordinates = FieldCoordinates.coordinates(container, fieldDef.getName()); | ||
|
|
||
| if (visited.contains(fieldCoordinates)) { | ||
| continue; | ||
| } | ||
|
|
||
| processField( | ||
| container, | ||
| fieldDef, | ||
| Objects.requireNonNull(fieldSelection), | ||
| containersQueue, | ||
| fieldSelectionQueue, | ||
| fieldCoordinates, | ||
| visited, | ||
| totalFieldCount | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void processField(GraphQLFieldsContainer container, | ||
| GraphQLFieldDefinition fieldDef, | ||
| FieldSelection fieldSelection, | ||
| Queue<List<GraphQLFieldsContainer>> containersQueue, | ||
| Queue<FieldSelection> fieldSelectionQueue, | ||
| FieldCoordinates fieldCoordinates, | ||
| Set<FieldCoordinates> visited, | ||
| AtomicInteger totalFieldCount) { | ||
|
|
||
| GraphQLType unwrappedType = GraphQLTypeUtil.unwrapAll(fieldDef.getType()); | ||
| FieldSelection newFieldSelection = getFieldSelection(fieldDef, unwrappedType); | ||
|
|
||
| fieldSelection.fieldsByContainer.computeIfAbsent(container.getName(), key -> new ArrayList<>()).add(newFieldSelection); | ||
|
|
||
| fieldSelectionQueue.add(newFieldSelection); | ||
|
|
||
| if (unwrappedType instanceof GraphQLInterfaceType) { | ||
| visited.add(fieldCoordinates); | ||
| GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) unwrappedType; | ||
| List<GraphQLFieldsContainer> possibleTypes = new ArrayList<>(schema.getImplementations(interfaceType)); | ||
|
|
||
| containersQueue.add(possibleTypes); | ||
| } else if (unwrappedType instanceof GraphQLUnionType) { | ||
| visited.add(fieldCoordinates); | ||
| GraphQLUnionType unionType = (GraphQLUnionType) unwrappedType; | ||
| List<GraphQLFieldsContainer> possibleTypes = unionType.getTypes().stream() | ||
| .filter(possibleType -> possibleType instanceof GraphQLFieldsContainer) | ||
| .map(possibleType -> (GraphQLFieldsContainer) possibleType) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| containersQueue.add(possibleTypes); | ||
| } else if (unwrappedType instanceof GraphQLFieldsContainer) { | ||
| visited.add(fieldCoordinates); | ||
| containersQueue.add(Collections.singletonList((GraphQLFieldsContainer) unwrappedType)); | ||
| } else { | ||
| containersQueue.add(Collections.singletonList(EMPTY_OBJECT_TYPE)); | ||
| } | ||
|
|
||
| totalFieldCount.incrementAndGet(); | ||
| } | ||
|
|
||
| private static FieldSelection getFieldSelection(GraphQLFieldDefinition fieldDef, GraphQLType unwrappedType) { | ||
| boolean typeNeedsClassifier = unwrappedType instanceof GraphQLUnionType || unwrappedType instanceof GraphQLInterfaceType; | ||
|
|
||
| // TODO: This statement is kinda awful | ||
| final FieldSelection newFieldSelection; | ||
|
|
||
| if (typeNeedsClassifier) { | ||
| newFieldSelection = new FieldSelection(fieldDef.getName(), new HashMap<>(), true); | ||
| } else if (unwrappedType instanceof GraphQLFieldsContainer) { | ||
| newFieldSelection = new FieldSelection(fieldDef.getName(), new HashMap<>(), false); | ||
| } else { | ||
| newFieldSelection = new FieldSelection(fieldDef.getName(), null, false); | ||
| } | ||
| return newFieldSelection; | ||
| } | ||
|
|
||
| private boolean hasRequiredArgs(GraphQLFieldDefinition fieldDefinition) { | ||
| // TODO: Maybe provide a hook to allow callers to resolve required arguments | ||
| return fieldDefinition.getArguments().stream() | ||
| .anyMatch(arg -> GraphQLTypeUtil.isNonNull(arg.getType()) && !arg.hasSetDefaultValue()); | ||
| } | ||
|
|
||
| static class FieldSelection { | ||
| public final String name; | ||
| public final boolean needsTypeClassifier; | ||
| public final Map<String, List<FieldSelection>> fieldsByContainer; | ||
|
|
||
| public FieldSelection(String name, Map<String, List<FieldSelection>> fieldsByContainer, boolean needsTypeClassifier) { | ||
| this.name = name; | ||
| this.needsTypeClassifier = needsTypeClassifier; | ||
| this.fieldsByContainer = fieldsByContainer; | ||
| } | ||
|
|
||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
javadoc
I am unsure what
argumentsandtypeClassifiermean