package com.thinkbiganalytics.metadata.jpa.support;
/*-
* #%L
* thinkbig-operational-metadata-jpa
* %%
* Copyright (C) 2017 ThinkBig Analytics
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
import com.google.common.collect.ImmutableCollection;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Operator;
import com.querydsl.core.types.Ops;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.ComparablePath;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.core.types.dsl.StringPath;
import com.thinkbiganalytics.Formatters;
import com.thinkbiganalytics.metadata.api.SearchCriteria;
import org.apache.commons.beanutils.BeanUtilsBean;
import org.apache.commons.beanutils.ConvertUtilsBean;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Apply a Filter to a given QueryDSL object Filter based upon supplying a list of {@code SearchCriteria} classes or a Filter String: <column><operator><value> where <column> is the jpaColumn name,
* <operator> is a valid {@code this#operators} Operator, and <value> is some string value
*
* Example usage in a Provider:
*
* public Page<? extends BatchJobExecution> findAll(String filter, Pageable pageable) {
* QJpaBatchJobExecution jobExecution = QJpaBatchJobExecution.jpaBatchJobExecution;
* return jobExecutionRepository.findAll(GenericQueryDslFilter.buildFilter(jobExecution, filter), pageable);
* }
*/
public class GenericQueryDslFilter {
/**
* Map of the String operator to respective QueryDSL operator
*/
static final ImmutableMap<String, Operator> operators =
new ImmutableMap.Builder<String, Operator>()
.put("==", Ops.EQ_IGNORE_CASE)
.put("!=", Ops.NE)
.put(">=", Ops.GOE)
.put("<=", Ops.LOE)
.put("=~", Ops.LIKE_IC)
.put(">", Ops.GT)
.put("<", Ops.LT)
.build();
/**
* Map of the QueryDSL operator to its respective QueryDSL path classes Some operators only work with specific Types This map will be used in validation when an operator of a filter is added to
* ensure it will be applied correctly in the Query
*/
static final ImmutableMultimap<Operator, Class<? extends Path>> operatorPaths =
new ImmutableMultimap.Builder<Operator, Class<? extends Path>>()
.put(Ops.GT, NumberPath.class)
.put(Ops.GT, ComparablePath.class)
.put(Ops.LT, NumberPath.class)
.put(Ops.LT, ComparablePath.class)
.put(Ops.GOE, NumberPath.class)
.put(Ops.GOE, ComparablePath.class)
.put(Ops.LOE, NumberPath.class)
.put(Ops.LOE, ComparablePath.class)
.put(Ops.LIKE_IC, StringPath.class)
.build();
private static final Logger log = LoggerFactory.getLogger(GenericQueryDslFilter.class);
public static String NULL_FILTER = "NULL";
public static String NOT_NULL_FILTER = "NOT_NULL";
/**
* Custom converter used to convert the filter String value to the respective Value on the QueryDSL/JPA object A custom converter is needed to add the Enum conversion types
*/
static BeanUtilsBean beanUtilsBean = new BeanUtilsBean(new ConvertUtilsBean() {
@Override
public Object convert(Object value, Class clazz) {
if (clazz.isEnum()) {
return Enum.valueOf(clazz, value.toString());
} else {
return super.convert(value, clazz);
}
}
public Object convert(String value, Class clazz) {
if (clazz.isEnum()) {
return Enum.valueOf(clazz, value.toString());
} else {
return super.convert(value, clazz);
}
}
});
/**
* build the Filter for the base QueryDSL class and the passed in filter list
*
* @param filters name:test, age>10
*/
public static <T> BooleanBuilder buildFilter(EntityPathBase basePath, List<SearchCriteria> filters) {
BooleanBuilder booleanBuilder = new BooleanBuilder();
if (filters != null && !filters.isEmpty()) {
filters.stream().forEach(filter -> {
Path p = buildPathFromFilterColumn(basePath, filter.getKey());
if (p != null) {
Object value = filter.getValue();
Operator op = operators.get(filter.getOperation());
if (validateOperatorPath(op, p, filter.getKey(), value)) {
if (Ops.LIKE_IC.equals(op) && value instanceof String && value != null && !((String) value).endsWith("%")) {
value = ((String) value) + "%";
}
if (value == null || (value instanceof String && ((String) value).equalsIgnoreCase(NULL_FILTER))) {
op = Ops.IS_NULL;
value = null;
} else if (value instanceof String && ((String) value).equalsIgnoreCase(NOT_NULL_FILTER)) {
op = Ops.IS_NOT_NULL;
value = null;
}
if (value != null) {
Object convertedValue = getValueForQuery(p, filter.getKey(), op, value);
if (convertedValue != null && convertedValue instanceof Collection) {
op = Ops.IN;
}
if (convertedValue != null) {
booleanBuilder.and(Expressions.predicate(op, p, Expressions.constant(convertedValue)));
}
} else {
booleanBuilder.and(Expressions.predicate(op, p));
}
}
}
});
}
return booleanBuilder;
}
/**
* Build the QueryDSL where filter from the filter string
*
* Example filter strings:
* endTime==NULL will be where JOB_EXECUTION.END_TIME IS NULL
* endTime==NOT_NULL will be where JOB_EXECUTION.END_TIME IS NOT NULL
* jobinstance.name==jobName,jobExcutionId>=200 will be where JOB_EXECUTION.JOB_INSTANCE.JOB_NAME = 'jobName' AND JOB_EXECUTION.JOB_EXECUTION_ID >=200
* jobInstance.feed.feedType==FEED,jobInstance.feed.name==movies.new_releases will be JOB_EXECUTION.JOB_INSTANCE.FEED.FEED_TYPE = 'FEED' AND JOB_EXECUTION.JOB_INSTANCE.FEED = 'movies.new_releases'
* status=="COMPLETED,FAILED" - in clause. This is mapped with == and the values surrounded by quotes \"VALUE1,VALUE2\"
*
* @param basePath Example: QJpaBatchJobExecution.jpaBatchJobExecution
* @param filterString <column><operator><value> Example: jobinstance.name==jobName,jobExcutionId>=200. that will search for all jobs named 'jobName' and jobExecutionId >= 200
*/
public static BooleanBuilder buildFilter(EntityPathBase basePath, String filterString) {
List<SearchCriteria> searchCriterias = parseFilterString(filterString);
return buildFilter(basePath, searchCriterias);
}
/**
* convert a passed in filter string to a list of criteria objects
*
* @param filterString a filter, <column><operator><value> Example: jobinstance.name==jobName,jobExcutionId>=200. that will search for all jobs named 'jobName' and jobExecutionId >= 200
* @return a list of criteria objects
*/
public static List<SearchCriteria> parseFilterString(String filterString) {
List<SearchCriteria> searchCriterias = new ArrayList<>();
if (StringUtils.isNotBlank(filterString)) {
//first match and split on , for various filters
String[] filterConditions = filterString.split(",(?=(?:[^\\\"]*\\\"[^\\\"]*\\\")*[^\\\"]*$)");
List<String> filterConditionsList = Arrays.asList(filterConditions);
//Pattern used to match the <column><operator><value>
String validOperatorsRegEx = operators.keySet().stream().map(key -> key).collect(Collectors.joining("|"));
Pattern columnOperatorValuePattern = Pattern.compile("(.*)(" + validOperatorsRegEx + ")(.*)");
filterConditionsList.stream().forEach(filter -> {
Matcher matcher = columnOperatorValuePattern.matcher(filter);
while (matcher.find()) {
String field = matcher.group(1);
String operator = matcher.group(2);
String value = matcher.group(3);
searchCriterias.add(new SearchCriteria(field, operator, value));
}
});
}
return searchCriterias;
}
/**
* Validate the Operator is ok for the passed in Path. If not it will exclude it from the query and log a warning as to why
*
* @param operator the operator
* @param path the path to the field/column
* @param column the column to filter
* @param value the value to look for/match
* @return true if the operator is valid for the given column data type, false if not
*/
private static boolean validateOperatorPath(Operator operator, Path path, String column, Object value) {
if (operatorPaths.containsKey(operator)) {
ImmutableCollection<Class<? extends Path>> validPathClasses = operatorPaths.get(operator);
boolean valid = validPathClasses.stream().anyMatch(p -> path.getClass().isAssignableFrom(p));
if (!valid) {
log.warn(
"Filter {} {} {} will not be applied. Operator {} is not valid for the field {}. It will not be applied to the query. The Field {} is a type of {}. it needs to be a type of {} ",
column, operator, value, operator, column, column, path, validPathClasses);
}
return valid;
} else {
return true;
}
}
private static Long convertDateTimeStringToMillis(String filterKey, Object value) {
Long millis = null;
if (CommonFilterTranslations.isDateStoredAsMillisField(filterKey) && value instanceof String) {
//attempt to convert the incoming value to a long
try {
DateTime dateTime = Formatters.parseDateTime((String) value);
millis = dateTime.getMillis();
} catch (IllegalArgumentException e) {
//uanble to parse the value
}
}
return millis;
}
/**
* converts the String filter value to the proper QueryDSL Type based upon the Path provided
*
* @param path the path to the field to query
* @param op the operator to use
* @param value the value to match
* @return the object value applying a comma separated string to a list of objects for IN clause functions
*/
private static Object getValueForQuery(Path path, String filterKey, Operator op, Object value) {
Class type = path.getType();
Object o = null;
boolean isDateTimeMillisField = CommonFilterTranslations.isDateStoredAsMillisField(filterKey);
if (Ops.EQ.equals(op) || Ops.EQ_IGNORE_CASE.equals(op)) {
// handle the in clause correctly
if (value instanceof String) {
String sValue = (String) value;
//remove the Quotes
sValue = sValue.replace("\"", "");
//split on ,
String[] items = StringUtils.split(sValue, ",");
//add to List
List<Object> convertedItems = new ArrayList<>();
for (String item : items) {
try {
Object convertedListItem = null;
if (isDateTimeMillisField) {
convertedListItem = convertDateTimeStringToMillis(filterKey, item);
}
if (convertedListItem == null) {
convertedListItem = beanUtilsBean.getConvertUtils().convert(item, type);
}
convertedItems.add(convertedListItem);
} catch (Exception e) {
//handle conversion error
}
}
o = convertedItems;
}
} else {
if (isDateTimeMillisField) {
o = convertDateTimeStringToMillis(filterKey, value);
}
if (o == null) {
o = beanUtilsBean.getConvertUtils().convert(value, type);
}
}
return o;
}
/**
* Gets the correct path walking the objects if supplied via dot notation. Example a column value of jobInstance.jobName will return the Path for the jobName If an error occurs it will be logged
* and not included in the query
*/
private static Path<?> buildPathFromFilterColumn(EntityPathBase basePath, String column) {
try {
String resolvedFilter = CommonFilterTranslations.resolvedFilter(basePath, column);
return (Path<?>) QueryDslPathInspector.getFieldObject(basePath, resolvedFilter);
} catch (IllegalAccessException e) {
log.warn("Unable to add {} to Query filter. Unable to return the correct Query Path for field: {} on Object: {}, Error: {} ", column, column, basePath, e.getMessage());
}
return null;
}
}