/* * Copyright (C) 2007-2020 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.craftercms.engine.graphql.impl.fetchers; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import graphql.language.Argument; import graphql.language.ArrayValue; import graphql.language.BooleanValue; import graphql.language.Field; import graphql.language.FloatValue; import graphql.language.FragmentDefinition; import graphql.language.FragmentSpread; import graphql.language.InlineFragment; import graphql.language.IntValue; import graphql.language.ObjectField; import graphql.language.ObjectValue; import graphql.language.Selection; import graphql.language.StringValue; import graphql.language.Value; import graphql.language.VariableReference; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.craftercms.search.elasticsearch.ElasticsearchWrapper; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.index.query.BoolQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.builder.SearchSourceBuilder; import org.elasticsearch.search.sort.SortOrder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Required; import org.springframework.util.StopWatch; import static org.craftercms.engine.graphql.SchemaUtils.*; import static org.elasticsearch.index.query.QueryBuilders.*; /** * Implementation of {@link DataFetcher} that queries Elasticsearch to retrieve content based on a content type. * @author joseross * @since 3.1 */ @SuppressWarnings("unchecked, rawtypes") public class ContentTypeBasedDataFetcher extends RequestAwareDataFetcher<Object> { private static final Logger logger = LoggerFactory.getLogger(ContentTypeBasedDataFetcher.class); private static final String QUERY_FIELD_NAME_CONTENT_TYPE = getOriginalName(FIELD_NAME_CONTENT_TYPE); // Lucene regexes always match the entire string, no need to specify ^ or $ public static final String CONTENT_TYPE_REGEX_PAGE = "/?page/.*"; public static final String CONTENT_TYPE_REGEX_COMPONENT = "/?component/.*"; public static final String COMPONENT_INCLUDE_REGEX = ".*\\.item\\.component"; /** * The default value for the 'limit' argument */ protected int defaultLimit; /** * The default value for the 'sortBy' argument */ protected String defaultSortField; /** * The default value for the 'sortOrder' argument */ protected String defaultSortOrder; /** * The instance of {@link ElasticsearchWrapper} */ protected ElasticsearchWrapper elasticsearch; @Required public void setDefaultLimit(final int defaultLimit) { this.defaultLimit = defaultLimit; } @Required public void setDefaultSortField(final String defaultSortField) { this.defaultSortField = defaultSortField; } @Required public void setDefaultSortOrder(final String defaultSortOrder) { this.defaultSortOrder = defaultSortOrder; } @Required public void setElasticsearch(final ElasticsearchWrapper elasticsearch) { this.elasticsearch = elasticsearch; } /** * {@inheritDoc} */ @Override public Object doGet(final DataFetchingEnvironment env) { Field field = env.getMergedField().getSingleField(); String fieldName = field.getName(); // Get arguments for pagination & sorting int offset = Optional.ofNullable(env.<Integer>getArgument(ARG_NAME_OFFSET)).orElse(0); int limit = Optional.ofNullable(env.<Integer>getArgument(ARG_NAME_LIMIT)).orElse(defaultLimit); String sortBy = Optional.ofNullable(env.<String>getArgument(ARG_NAME_SORT_BY)).orElse(defaultSortField); String sortOrder = Optional.ofNullable(env.<String>getArgument(ARG_NAME_SORT_ORDER)).orElse(defaultSortOrder); List<String> queryFieldIncludes = new LinkedList<>(); // Add content-type to includes, we might need it for a GraphQL TypeResolver queryFieldIncludes.add(QUERY_FIELD_NAME_CONTENT_TYPE); List<Map<String, Object>> items = new LinkedList<>(); Map<String, Object> result = new HashMap<>(2); result.put(FIELD_NAME_ITEMS, items); // Setup the ES query SearchSourceBuilder source = new SearchSourceBuilder(); BoolQueryBuilder query = boolQuery(); source .query(query) .from(offset) .size(limit) .sort(sortBy, SortOrder.fromString(sortOrder)); StopWatch watch = new StopWatch(field.getName() + " - " + field.getAlias()); watch.start("build filters"); // Filter by the content-type switch (fieldName) { case FIELD_NAME_CONTENT_ITEMS: query.filter(existsQuery(QUERY_FIELD_NAME_CONTENT_TYPE)); break; case FIELD_NAME_PAGES: query.filter(regexpQuery(QUERY_FIELD_NAME_CONTENT_TYPE, CONTENT_TYPE_REGEX_PAGE)); break; case FIELD_NAME_COMPONENTS: query.filter(regexpQuery(QUERY_FIELD_NAME_CONTENT_TYPE, CONTENT_TYPE_REGEX_COMPONENT)); break; default: // Get the content-type name from the field name query.filter(termQuery(QUERY_FIELD_NAME_CONTENT_TYPE, getOriginalName(fieldName))); break; } // Check the selected fields to build the ES query Optional<Field> itemsField = field.getSelectionSet().getSelections() .stream() .map(f -> (Field) f) .filter(f -> f.getName().equals(FIELD_NAME_ITEMS)) .findFirst(); if (itemsField.isPresent()) { List<Selection> selections = itemsField.get().getSelectionSet().getSelections(); selections.forEach(selection -> processSelection(StringUtils.EMPTY, selection, query, queryFieldIncludes, env)); } // Only fetch the selected fields for better performance source.fetchSource(queryFieldIncludes.toArray(new String[0]), new String[0]); watch.stop(); logger.debug("Executing query: {}", source); watch.start("searching items"); SearchResponse response = elasticsearch.search(new SearchRequest().source(source)); watch.stop(); watch.start("processing items"); result.put(FIELD_NAME_TOTAL, response.getHits().totalHits); if (response.getHits().totalHits > 0) { for(SearchHit hit : response.getHits().getHits()) { items.add(fixItems(hit.getSourceAsMap())); } } watch.stop(); if (logger.isTraceEnabled()) { logger.trace(watch.prettyPrint()); } return result; } /** * Adds the required filters to the ES query for the given field */ protected void processSelection(String path, Selection currentSelection, BoolQueryBuilder query, List<String> queryFieldIncludes, DataFetchingEnvironment env) { if (currentSelection instanceof Field) { // If the current selection is a field Field currentField = (Field) currentSelection; // Get the original field name String propertyName = getOriginalName(currentField.getName()); // Build the ES-friendly path String fullPath = StringUtils.isEmpty(path)? propertyName : path + "." + propertyName; // If the field has sub selection if (Objects.nonNull(currentField.getSelectionSet())) { // If the field is a flattened component if (fullPath.matches(COMPONENT_INCLUDE_REGEX)) { // Include the 'content-type' field to make sure the type can be resolved during runtime String contentTypeFieldPath = fullPath + "." + QUERY_FIELD_NAME_CONTENT_TYPE; if (!queryFieldIncludes.contains(contentTypeFieldPath)) { queryFieldIncludes.add(contentTypeFieldPath); } } // Process recursively and finish currentField.getSelectionSet().getSelections() .forEach(selection -> processSelection(fullPath, selection, query, queryFieldIncludes, env)); return; } // Add the field to the list logger.debug("Adding selected field '{}' to query", fullPath); queryFieldIncludes.add(fullPath); // Check the filters to build the ES query Optional<Argument> arg = currentField.getArguments().stream().filter(a -> a.getName().equals(FILTER_NAME)).findFirst(); if (arg.isPresent()) { logger.debug("Adding filters for field {}", fullPath); Value<?> argValue = arg.get().getValue(); if (argValue instanceof ObjectValue) { List<ObjectField> filters = ((ObjectValue) argValue).getObjectFields(); filters.forEach((filter) -> addFieldFilterFromObjectField(fullPath, filter, query, env)); } else if (argValue instanceof VariableReference && env.getVariables().containsKey(((VariableReference) argValue).getName())) { Map<String, Object> map = (Map<String, Object>) env.getVariables().get(((VariableReference) argValue).getName()); map.entrySet().forEach(filter -> addFieldFilterFromMapEntry(fullPath, filter, query, env)); } } } else if (currentSelection instanceof InlineFragment) { // If the current selection is an inline fragment, process recursively InlineFragment fragment = (InlineFragment) currentSelection; fragment.getSelectionSet().getSelections() .forEach(selection -> processSelection(path, selection, query, queryFieldIncludes, env)); } else if (currentSelection instanceof FragmentSpread) { // If the current selection is a fragment spread, find the fragment and process recursively FragmentSpread fragmentSpread = (FragmentSpread) currentSelection; FragmentDefinition fragmentDefinition = env.getFragmentsByName().get(fragmentSpread.getName()); fragmentDefinition.getSelectionSet().getSelections() .forEach(selection -> processSelection(path, selection, query, queryFieldIncludes, env)); } } protected void addFieldFilterFromObjectField(String path, ObjectField filter, BoolQueryBuilder query, DataFetchingEnvironment env) { if (filter.getValue() instanceof ArrayValue) { ArrayValue actualFilters = (ArrayValue) filter.getValue(); switch (filter.getName()) { case ARG_NAME_NOT: BoolQueryBuilder notQuery = boolQuery(); actualFilters.getValues() .forEach(notFilter -> ((ObjectValue) notFilter).getObjectFields() .forEach(notField -> addFieldFilterFromObjectField(path, notField, notQuery, env))); notQuery.filter().forEach(query::mustNot); break; case ARG_NAME_AND: actualFilters.getValues() .forEach(andFilter -> ((ObjectValue) andFilter).getObjectFields() .forEach(andField -> addFieldFilterFromObjectField(path, andField, query, env))); break; case ARG_NAME_OR: BoolQueryBuilder tempQuery = boolQuery(); BoolQueryBuilder orQuery = boolQuery(); actualFilters.getValues().forEach(orFilter -> ((ObjectValue) orFilter).getObjectFields() .forEach(orField -> addFieldFilterFromObjectField(path, orField, tempQuery, env))); tempQuery.filter().forEach(orQuery::should); query.filter(boolQuery().must(orQuery)); break; default: // never happens } } else if (!(filter.getValue() instanceof VariableReference) || env.getVariables().containsKey(((VariableReference)filter.getValue()).getName())) { query.filter(getFilterQueryFromObjectField(path, filter, env)); } } protected void addFieldFilterFromMapEntry(String path, Map.Entry<String, Object> filter, BoolQueryBuilder query, DataFetchingEnvironment env) { if (filter.getValue() instanceof List) { List<Map<String, Object>> actualFilters = (List<Map<String, Object>>) filter.getValue(); switch (filter.getKey()) { case ARG_NAME_NOT: BoolQueryBuilder notQuery = boolQuery(); actualFilters.forEach(notFilter -> notFilter.entrySet() .forEach(notField -> addFieldFilterFromMapEntry(path, notField, notQuery, env))); notQuery.filter().forEach(query::mustNot); break; case ARG_NAME_AND: actualFilters.forEach(andFilter -> andFilter.entrySet() .forEach(andField -> addFieldFilterFromMapEntry(path, andField, query, env))); break; case ARG_NAME_OR: BoolQueryBuilder tempQuery = boolQuery(); BoolQueryBuilder orQuery = boolQuery(); actualFilters.forEach(orFilter -> orFilter.entrySet() .forEach(orField -> addFieldFilterFromMapEntry(path, orField, tempQuery, env))); tempQuery.filter().forEach(orQuery::should); query.filter(boolQuery().must(orQuery)); break; default: // never happens } } else { query.filter(getFilterQueryFromMapEntry(path, filter)); } } protected QueryBuilder getFilterQueryFromObjectField(String fieldPath, ObjectField filter, DataFetchingEnvironment env) { switch (filter.getName()) { case ARG_NAME_EQUALS: return termQuery(fieldPath, getRealValue(filter.getValue(), env)); case ARG_NAME_MATCHES: return matchQuery(fieldPath, getRealValue(filter.getValue(), env)); case ARG_NAME_REGEX: return regexpQuery(fieldPath, getRealValue(filter.getValue(), env).toString()); case ARG_NAME_LT: return rangeQuery(fieldPath).lt(getRealValue(filter.getValue(), env)); case ARG_NAME_GT: return rangeQuery(fieldPath).gt(getRealValue(filter.getValue(), env)); case ARG_NAME_LTE: return rangeQuery(fieldPath).lte(getRealValue(filter.getValue(), env)); case ARG_NAME_GTE: return rangeQuery(fieldPath).gte(getRealValue(filter.getValue(), env)); case ARG_NAME_EXISTS: boolean exists = (boolean) getRealValue(filter.getValue(), env); if (exists) { return existsQuery(fieldPath); } else { return boolQuery().mustNot(existsQuery(fieldPath)); } default: // never happens return null; } } protected QueryBuilder getFilterQueryFromMapEntry(String fieldPath, Map.Entry<String, Object> filter) { switch (filter.getKey()) { case ARG_NAME_EQUALS: return termQuery(fieldPath, filter.getValue()); case ARG_NAME_MATCHES: return matchQuery(fieldPath, filter.getValue()); case ARG_NAME_REGEX: return regexpQuery(fieldPath, filter.getValue().toString()); case ARG_NAME_LT: return rangeQuery(fieldPath).lt(filter.getValue()); case ARG_NAME_GT: return rangeQuery(fieldPath).gt(filter.getValue()); case ARG_NAME_LTE: return rangeQuery(fieldPath).lte(filter.getValue()); case ARG_NAME_GTE: return rangeQuery(fieldPath).gte(filter.getValue()); case ARG_NAME_EXISTS: boolean exists = (boolean) filter.getValue(); if (exists) { return existsQuery(fieldPath); } else { return boolQuery().mustNot(existsQuery(fieldPath)); } default: // never happens return null; } } /** * Extracts a scalar value, this is needed because of GraphQL strict types */ protected Object getRealValue(Value value, DataFetchingEnvironment env) { if (value instanceof BooleanValue) { return ((BooleanValue)value).isValue(); } else if (value instanceof FloatValue) { return ((FloatValue) value).getValue(); } else if (value instanceof IntValue) { return ((IntValue) value).getValue(); } else if (value instanceof StringValue) { return ((StringValue) value).getValue(); } else if (value instanceof VariableReference) { return env.getVariables().get(((VariableReference) value).getName()); } return null; } /** * Checks for fields containing the 'item' keyword and makes sure they are always a list even if there is only * one value. This is needed because the GraphQL schema always needs to return the same type for a field. */ protected Map<String, Object> fixItems(Map<String, Object> map) { Map<String, Object> temp = new LinkedHashMap<>(); map.forEach((key, value) -> { String graphQLKey = getGraphQLName(key); if (FIELD_NAME_ITEM.equals(key)) { if (!(value instanceof List)) { temp.put(graphQLKey, Collections.singletonList(fixItems((Map<String, Object>)value))); } else { List<Map<String, Object>> list = (List<Map<String, Object>>) value; temp.put(graphQLKey, list.stream().map(this::fixItems).collect(Collectors.toList())); } } else if (value instanceof Map) { temp.put(graphQLKey, fixItems((Map<String, Object>) value)); } else { temp.put(graphQLKey, value); } }); return MapUtils.isNotEmpty(temp)? temp : map; } }