com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker Maven / Gradle / Ivy
/*
* Copyright 2019, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.graphql.parser;
import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION;
import static com.yahoo.elide.graphql.KeyWord.EDGES;
import static com.yahoo.elide.graphql.KeyWord.NODE;
import static com.yahoo.elide.graphql.KeyWord.PAGE_INFO;
import static com.yahoo.elide.graphql.KeyWord.PAGE_INFO_TOTAL_RECORDS;
import static com.yahoo.elide.graphql.KeyWord.SCHEMA;
import static com.yahoo.elide.graphql.KeyWord.TYPE;
import static com.yahoo.elide.graphql.KeyWord.TYPENAME;
import com.yahoo.elide.ElideSettings;
import com.yahoo.elide.core.dictionary.ArgumentType;
import com.yahoo.elide.core.dictionary.EntityDictionary;
import com.yahoo.elide.core.dictionary.RelationshipType;
import com.yahoo.elide.core.exceptions.BadRequestException;
import com.yahoo.elide.core.exceptions.InvalidEntityBodyException;
import com.yahoo.elide.core.exceptions.InvalidValueException;
import com.yahoo.elide.core.filter.dialect.ParseException;
import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect;
import com.yahoo.elide.core.filter.expression.AndFilterExpression;
import com.yahoo.elide.core.filter.expression.FilterExpression;
import com.yahoo.elide.core.pagination.PaginationImpl;
import com.yahoo.elide.core.request.Attribute;
import com.yahoo.elide.core.request.EntityProjection;
import com.yahoo.elide.core.request.EntityProjection.EntityProjectionBuilder;
import com.yahoo.elide.core.request.Pagination;
import com.yahoo.elide.core.request.Relationship;
import com.yahoo.elide.core.request.Sorting;
import com.yahoo.elide.core.sort.SortingImpl;
import com.yahoo.elide.core.type.Type;
import com.yahoo.elide.graphql.GraphQLNameUtils;
import com.yahoo.elide.graphql.ModelBuilder;
import graphql.language.Argument;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.SourceLocation;
import graphql.parser.Parser;
import lombok.extern.slf4j.Slf4j;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* This class converts a GraphQL query string into an Elide {@link EntityProjection} using.
* {@link #make(String)} method.
*/
@Slf4j
public class GraphQLEntityProjectionMaker {
private final ElideSettings elideSettings;
private final EntityDictionary entityDictionary;
private final FilterDialect filterDialect;
private final VariableResolver variableResolver;
private final FragmentResolver fragmentResolver;
private final Map relationshipMap = new HashMap<>();
private final Map rootProjections = new HashMap<>();
private final Map attributeMap = new HashMap<>();
private final GraphQLNameUtils nameUtils;
private final String apiVersion;
/**
* Constructor.
*
* @param elideSettings settings of current Elide instance
* @param variables variables provided in the request
* @param apiVersion The client requested API version.
*/
public GraphQLEntityProjectionMaker(ElideSettings elideSettings, Map variables, String apiVersion) {
this.elideSettings = elideSettings;
this.entityDictionary = elideSettings.getDictionary();
this.filterDialect = elideSettings.getGraphqlDialect();
this.variableResolver = new VariableResolver(variables);
this.fragmentResolver = new FragmentResolver();
this.nameUtils = new GraphQLNameUtils(entityDictionary);
this.apiVersion = apiVersion;
}
/**
* Constructor.
*
* @param elideSettings settings of current Elide instance
*/
public GraphQLEntityProjectionMaker(ElideSettings elideSettings) {
this(elideSettings, new HashMap<>(), NO_VERSION);
}
/**
* Convert a GraphQL query string into a collection of Elide {@link EntityProjection}s.
*
* @param query GraphQL query
* @return all projections in the query
*/
public GraphQLProjectionInfo make(String query) {
Parser parser = new Parser();
Document parsedDocument;
try {
parsedDocument = parser.parseDocument(query);
} catch (Exception e) {
throw new InvalidEntityBodyException("Can't parse query: " + query);
}
// resolve fragment definitions
fragmentResolver.addFragments(parsedDocument);
// resolve operation definitions
parsedDocument.getDefinitions().forEach(definition -> {
if (definition instanceof OperationDefinition) {
// Operations would be converted into EntityProjection tree
OperationDefinition operationDefinition = (OperationDefinition) definition;
if (operationDefinition.getOperation() == OperationDefinition.Operation.SUBSCRIPTION) {
// TODO: support SUBSCRIPTION
return;
}
// resolve variable definitions in this operation
variableResolver.newScope(operationDefinition);
addRootProjection(operationDefinition.getSelectionSet());
} else if (!(definition instanceof FragmentDefinition)) {
throw new InvalidEntityBodyException(
String.format("Unsupported definition type {%s}.", definition.getClass()));
}
});
return new GraphQLProjectionInfo(rootProjections, relationshipMap, attributeMap);
}
/**
* Root projection would be an operation applied on an single entity class.
* The EntityProjection tree would be constructed recursively to add all child projections.
*
* @param selectionSet a root-level selection set
*/
private void addRootProjection(SelectionSet selectionSet) {
List selections = selectionSet.getSelections();
selections.stream().forEach(rootSelection -> {
if (!(rootSelection instanceof Field)) {
throw new InvalidEntityBodyException("Entity selection must be a graphQL field.");
}
Field rootSelectionField = (Field) rootSelection;
String entityName = rootSelectionField.getName();
String aliasName = rootSelectionField.getAlias();
if (SCHEMA.hasName(entityName) || TYPE.hasName(entityName)) {
// '__schema' and '__type' would not be handled by entity projection
return;
}
Type> entityType = entityDictionary.getEntityClass(rootSelectionField.getName(), apiVersion);
if (entityType == null) {
throw new InvalidEntityBodyException(String.format("Unknown entity {%s}.",
rootSelectionField.getName()));
}
String keyName = GraphQLProjectionInfo.computeProjectionKey(aliasName, entityName);
if (rootProjections.containsKey(keyName)) {
throw new InvalidEntityBodyException(
String.format("Found two root level query for Entity {%s} with same alias name",
entityName));
}
rootProjections.put(keyName,
createProjection(entityType, rootSelectionField));
});
}
/**
* Construct an {@link EntityProjection} from a GraphQL {@link Field} for an entity type.
*
* @param entityType type of entity to be projected
* @param entityField graphQL field definition
* @return constructed {@link EntityProjection}
*/
private EntityProjection createProjection(Type> entityType, Field entityField) {
final EntityProjectionBuilder projectionBuilder = EntityProjection.builder()
.type(entityType)
.pagination(PaginationImpl.getDefaultPagination(entityType, elideSettings));
// Add the Entity Arguments to the Projection
projectionBuilder.arguments(new HashSet<>(
getArguments(entityField, entityDictionary.getEntityArguments(entityType))
));
entityField.getSelectionSet().getSelections().forEach(selection -> addSelection(selection, projectionBuilder));
entityField.getArguments().forEach(argument -> addArgument(argument, projectionBuilder));
return projectionBuilder.build();
}
/**
* Add a graphQL {@link Selection} to an {@link EntityProjection}.
*
* @param fieldSelection field/fragment to add
* @param projectionBuilder projection that is being built
*/
private void addSelection(Selection fieldSelection, final EntityProjectionBuilder projectionBuilder) {
if (fieldSelection instanceof FragmentSpread) {
addFragment((FragmentSpread) fieldSelection, projectionBuilder);
} else if (fieldSelection instanceof Field) {
if (EDGES.hasName(((Field) fieldSelection).getName())
|| NODE.hasName(((Field) fieldSelection).getName())) {
// if this graphql field is 'edges' or 'node', go one level deeper in the graphql document
((Field) fieldSelection).getSelectionSet().getSelections().forEach(
selection -> addSelection(selection, projectionBuilder));
} else {
addField((Field) fieldSelection, projectionBuilder);
}
} else {
throw new InvalidEntityBodyException(
String.format("Unsupported selection type {%s}.", fieldSelection.getClass()));
}
}
/**
* Resolve a graphQL {@link FragmentSpread} into {@link Selection}s and add them to an {@link EntityProjection}.
*
* @param fragment graphQL fragment
* @param projectionBuilder projection that is being built
*/
private void addFragment(FragmentSpread fragment, EntityProjectionBuilder projectionBuilder) {
String fragmentName = fragment.getName();
FragmentDefinition fragmentDefinition = fragmentResolver.get(fragmentName);
String fragmentTypeName = fragmentDefinition.getTypeCondition().getName();
// type name in type condition of the Fragment must match the entity projection type name
if (fragmentTypeName.equals(nameUtils.toConnectionName(projectionBuilder.getType()))
|| fragmentTypeName.equals(nameUtils.toEdgesName(projectionBuilder.getType()))
|| fragmentTypeName.equals(nameUtils.toNodeName(projectionBuilder.getType()))) {
fragmentDefinition.getSelectionSet().getSelections()
.forEach(selection -> addSelection(selection, projectionBuilder));
}
}
/**
* Add a new graphQL {@link Field} into an {@link EntityProjection}.
*
* @param field graphQL field
* @param projectionBuilder projection that is being built
*/
private void addField(Field field, EntityProjectionBuilder projectionBuilder) {
Type> parentType = projectionBuilder.getType();
String fieldName = field.getName();
// this field would either be a relationship field or an attribute field
if (entityDictionary.getRelationshipType(parentType, fieldName) != RelationshipType.NONE) {
// handle the case of a relationship field
addRelationship(field, projectionBuilder);
} else if (TYPENAME.hasName(fieldName)) {
// '__typename' would not be handled by entityProjection
} else if (PAGE_INFO.hasName(fieldName)) {
// only 'totalRecords' needs to be added into the projection's pagination
if (field.getSelectionSet().getSelections().stream()
.anyMatch(selection -> selection instanceof Field
&& PAGE_INFO_TOTAL_RECORDS.hasName(((Field) selection).getName()))) {
addPageTotal(projectionBuilder);
}
} else {
addAttributeField(field, projectionBuilder);
}
}
/**
* Create a relationship with projection and add it to the parent projection.
*
* @param relationshipField graphQL field for a relationship
* @param projectionBuilder projection that is being built
*/
private void addRelationship(Field relationshipField, EntityProjectionBuilder projectionBuilder) {
Type> parentType = projectionBuilder.getType();
String relationshipName = relationshipField.getName();
String relationshipAlias =
relationshipField.getAlias() == null ? relationshipName : relationshipField.getAlias();
final Type> relationshipType = entityDictionary.getParameterizedType(parentType, relationshipName);
// build new entity projection with only entity type and entity dictionary
EntityProjection relationshipProjection = createProjection(relationshipType, relationshipField);
Relationship relationship = Relationship.builder()
.name(relationshipName)
.alias(relationshipAlias)
.projection(relationshipProjection)
.build();
relationshipMap.put(relationshipField.getSourceLocation(), relationship);
// add this relationship projection to its parent projection
projectionBuilder.relationship(relationship);
}
/**
* Add an attribute to an entity projection.
*
* @param attributeField graphQL field for an attribute
* @param projectionBuilder projection that is being built
*/
private void addAttributeField(Field attributeField, EntityProjectionBuilder projectionBuilder) {
Type> parentType = projectionBuilder.getType();
String attributeName = attributeField.getName();
String attributeAlias = attributeField.getAlias() == null ? attributeName : attributeField.getAlias();
Type> attributeType = entityDictionary.getType(parentType, attributeName);
if (attributeType != null) {
Attribute attribute = Attribute.builder()
.type(attributeType)
.name(attributeName)
.alias(attributeAlias)
.arguments(getArguments(attributeField,
entityDictionary.getAttributeArguments(parentType, attributeName)))
.build();
projectionBuilder.attribute(attribute);
attributeMap.put(attributeField.getSourceLocation(), attribute);
} else {
throw new InvalidEntityBodyException(String.format(
"Unknown attribute field {%s.%s}.",
entityDictionary.getJsonAliasFor(projectionBuilder.getType()),
attributeName));
}
}
/**
* Construct Elide {@link Pagination}, {@link Sorting}, {@link Attribute} from GraphQL {@link Argument} and
* add it to the {@link EntityProjection}.
*
* @param argument graphQL argument
* @param projectionBuilder projection that is being built
*/
private void addArgument(Argument argument, EntityProjectionBuilder projectionBuilder) {
String argumentName = argument.getName();
if (isPaginationArgument(argumentName)) {
addPagination(argument, projectionBuilder);
} else if (isSortingArgument(argumentName)) {
addSorting(argument, projectionBuilder);
} else if (ModelBuilder.ARGUMENT_FILTER.equals(argumentName)) {
addFilter(argument, projectionBuilder);
} else if (!ModelBuilder.ARGUMENT_OPERATION.equals(argumentName)
&& !(ModelBuilder.ARGUMENT_IDS.equals(argumentName))
&& !(ModelBuilder.ARGUMENT_DATA.equals(argumentName))
&& !isEntityArgument(argumentName, entityDictionary, projectionBuilder.getType())) {
Type> entityType = projectionBuilder.getType();
Type> attributeType = entityDictionary.getType(entityType, argumentName);
if (attributeType == null) {
throw new InvalidEntityBodyException(
String.format("Invalid attribute field/alias for argument: {%s}.{%s}",
entityType,
argumentName)
);
}
}
}
/**
* Returns whether or not a GraphQL argument name corresponding to a Entity argument.
*
* @param argumentName Name key of the GraphQL argument
* @param dictionary Instance of EntityDictionary
* @param cls Entity Type Class
*
* @return {@code true} if the name equals to any Entity Argument
*/
private static boolean isEntityArgument(String argumentName, EntityDictionary dictionary, Type> cls) {
return dictionary.getEntityArguments(cls)
.stream()
.anyMatch(a -> a.getName().equals(argumentName));
}
/**
* Returns whether or not a GraphQL argument name corresponding to a pagination argument.
*
* @param argumentName Name key of the GraphQL argument
*
* @return {@code true} if the name equals to {@link ModelBuilder#ARGUMENT_FIRST} or
* {@link ModelBuilder#ARGUMENT_AFTER}
*/
private static boolean isPaginationArgument(String argumentName) {
return ModelBuilder.ARGUMENT_FIRST.equals(argumentName) || ModelBuilder.ARGUMENT_AFTER.equals(argumentName);
}
/**
* Create a {@link Pagination} object from pagination GraphQL argument and attach it to the building
* {@link EntityProjection}.
*
* @param argument graphQL argument
* @param projectionBuilder projection that is being built
*/
private void addPagination(Argument argument, EntityProjectionBuilder projectionBuilder) {
Pagination pagination = projectionBuilder.getPagination() == null
? PaginationImpl.getDefaultPagination(projectionBuilder.getType(), elideSettings)
: projectionBuilder.getPagination();
Object argumentValue = variableResolver.resolveValue(argument.getValue());
int value = argumentValue instanceof BigInteger
? ((BigInteger) argumentValue).intValue()
: Integer.parseInt((String) argumentValue);
if (ModelBuilder.ARGUMENT_FIRST.equals(argument.getName())) {
pagination = new PaginationImpl(
projectionBuilder.getType(),
pagination.getOffset(),
value,
elideSettings.getDefaultPageSize(),
elideSettings.getDefaultPageSize(),
pagination.returnPageTotals(),
false);
} else if (ModelBuilder.ARGUMENT_AFTER.equals(argument.getName())) {
pagination = new PaginationImpl(
projectionBuilder.getType(),
value,
pagination.getLimit(),
elideSettings.getDefaultPageSize(),
elideSettings.getDefaultPageSize(),
pagination.returnPageTotals(),
false);
}
projectionBuilder.pagination(pagination);
}
/**
* Make projection return page total records.
* If the projection already has a pagination, use limit and offset from the existing pagination,
* else use the default pagination vales.
*
* @param projectionBuilder projection that is being built
*/
private void addPageTotal(EntityProjectionBuilder projectionBuilder) {
PaginationImpl pagination;
if (projectionBuilder.getPagination() == null) {
pagination = new PaginationImpl(
projectionBuilder.getType(),
null,
null,
elideSettings.getDefaultPageSize(),
elideSettings.getDefaultMaxPageSize(),
true,
false);
} else {
pagination = new PaginationImpl(
projectionBuilder.getType(),
projectionBuilder.getPagination().getOffset(),
projectionBuilder.getPagination().getLimit(),
elideSettings.getDefaultPageSize(),
elideSettings.getDefaultMaxPageSize(),
true,
false);
}
projectionBuilder.pagination(pagination);
}
/**
* Returns whether or not a GraphQL argument name corresponding to a sorting argument.
*
* @param argumentName Name key of the GraphQL argument
*
* @return {@code true} if the name equals to {@link ModelBuilder#ARGUMENT_SORT}
*/
private static boolean isSortingArgument(String argumentName) {
return ModelBuilder.ARGUMENT_SORT.equals(argumentName);
}
/**
* Creates a {@link Sorting} object from sorting GraphQL argument value and attaches it to the entity sorted
* according to the newly created {@link Sorting} object.
*
* @param argument An argument that contains the value of sorting spec
* @param projectionBuilder projection that is being built
*/
private void addSorting(Argument argument, EntityProjectionBuilder projectionBuilder) {
String sortRule = (String) variableResolver.resolveValue(argument.getValue());
try {
Sorting sorting = SortingImpl.parseSortRule(sortRule, projectionBuilder.getType(),
projectionBuilder.getAttributes(), entityDictionary);
projectionBuilder.sorting(sorting);
} catch (InvalidValueException e) {
throw new BadRequestException("Invalid sorting clause " + sortRule
+ " for type " + entityDictionary.getJsonAliasFor(projectionBuilder.getType()));
}
}
/**
* Add a new filter expression to the entityProjection.
*
* @param argument filter argument
* @param projectionBuilder projection that is being built
*/
private void addFilter(Argument argument, EntityProjectionBuilder projectionBuilder) {
FilterExpression filter = buildFilter(
projectionBuilder,
entityDictionary.getJsonAliasFor(projectionBuilder.getType()),
variableResolver.resolveValue(argument.getValue()));
if (projectionBuilder.getFilterExpression() != null) {
projectionBuilder.filterExpression(
new AndFilterExpression(projectionBuilder.getFilterExpression(), filter));
} else {
projectionBuilder.filterExpression(filter);
}
}
/**
* Construct a filter expression from a string.
*
* @param builder entity projection under construction that is being filtered.
* @param typeName class type name to apply this filter
* @param filterString Elide filter in string format
* @return constructed filter expression
*/
private FilterExpression buildFilter(EntityProjectionBuilder builder, String typeName, Object filterString) {
if (!(filterString instanceof String)) {
throw new BadRequestException("Filter of type " + typeName + " is not StringValue.");
}
try {
return filterDialect.parse(builder.getType(), builder.getAttributes(), (String) filterString, apiVersion);
} catch (ParseException e) {
throw new BadRequestException(e.getMessage() + "\n" + e.getMessage());
}
}
private List getArguments(Field attributeField,
Set availableArguments) {
List arguments = new ArrayList<>();
//Loop through all the arguments available for this field.
availableArguments.forEach((argumentType -> {
//Search to see if the client provided a matching argument.
Optional clientArgument = attributeField.getArguments().stream()
.filter(arg -> arg.getName().equals(argumentType.getName()))
.findFirst();
//If so, use it.
if (clientArgument.isPresent()) {
arguments.add(com.yahoo.elide.core.request.Argument.builder()
.name(clientArgument.get().getName())
.value(
variableResolver.resolveValue(
clientArgument.get().getValue()))
.build());
//If not, check if there is a default value for this argument.
} else if (argumentType.getDefaultValue() != null) {
arguments.add(com.yahoo.elide.core.request.Argument.builder()
.name(argumentType.getName())
.value(argumentType.getDefaultValue())
.build());
}
}));
return arguments;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy