SuperModelFilter.java
/*
* Copyright 2020 Global Crop Diversity Trust
*
* 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.
*/
package org.genesys.blocks.model.filters;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
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.Set;
import org.genesys.blocks.util.FilterUtils;
import org.springframework.util.ReflectionUtils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.CollectionPathBase;
import com.querydsl.core.types.dsl.DslExpression;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.core.types.dsl.SimpleExpression;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
/**
* @param <T> the generic type
* @param <R> the generic type
*/
@Getter
@Setter
@EqualsAndHashCode
@Accessors(fluent = true)
public abstract class SuperModelFilter<T extends SuperModelFilter<T, R>, R> implements Filter {
private static final long serialVersionUID = -4298821420228268854L;
private static final ObjectMapper jsonizer = new ObjectMapper();
private static final ObjectMapper defaultMapper = new ObjectMapper();
private static final ObjectMapper nonDefault = new ObjectMapper();
static {
// Any objectMapper configuration goes here
jsonizer.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
jsonizer.setDefaultPropertyInclusion(JsonInclude.Include.NON_ABSENT);
defaultMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
defaultMapper.setDefaultPropertyInclusion(JsonInclude.Include.ALWAYS);
nonDefault.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT);
nonDefault.setDefaultPropertyInclusion(JsonInclude.Include.NON_DEFAULT);
// JSR310 java.time
JavaTimeModule javaTimeModule = new JavaTimeModule();
jsonizer.registerModule(javaTimeModule);
defaultMapper.registerModule(javaTimeModule);
nonDefault.registerModule(javaTimeModule);
}
/** Names of properties to test with .isNull() */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public Set<String> NULL;
/** Names of properties to test with .isNotNull() */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public Set<String> NOTNULL;
/** The negative filters, but don't de-/serialize it's own NOT-properties. */
@JsonSerialize(using = SuperModelFilter.NoDefaultValuesSerializer.class)
@JsonDeserialize(using = SuperModelFilter.NonDefaultDeserializer.class)
public T NOT;
/** The AND filters, but not serialized. */
@JsonSerialize(using = SuperModelFilter.NoDefaultValuesSerializer.class)
@JsonDeserialize(using = SuperModelFilter.NonDefaultDeserializer.class)
public T AND;
/** The OR filters, but not serialized. */
@JsonSerialize(using = SuperModelFilter.NoDefaultValuesSerializer.class)
@JsonDeserialize(using = SuperModelFilter.NonDefaultDeserializer.class)
public T OR;
private static final Map<Class<?>, List<Field>> CACHED_FILTER_FIELDS = new HashMap<>();
/**
* Does the property filter specify any conditions?
* @return {@code true} if no conditions are specified.
*/
public final boolean isEmpty() {
List<Field> filterFields = getFilterFields(getClass());
for (Field field : filterFields) {
try {
Object filterField = field.get(this);
if (filterField == null) {
continue;
} else if (filterField instanceof Filter) {
if (! FilterUtils.isEmpty((Filter) filterField)) return false;
} else if (filterField instanceof Collection) {
if (! FilterUtils.isEmpty((Collection<?>) filterField)) return false;
} else if (filterField instanceof String) { // Non-blank string
if (! Objects.equals("", filterField)) return false;
} else if (filterField instanceof Boolean) { // Booleans
return false;
} else {
throw new Exception("Unhandled type" + filterField.getClass());
}
} catch (Exception e) {
throw new RuntimeException("Cannot handle property " + field.getName() + " in " + field.getDeclaringClass().getName(), e);
}
}
return true;
}
private static final List<Field> getFilterFields(Class<?> clazz) {
return CACHED_FILTER_FIELDS.computeIfAbsent(clazz, (key) -> {
List<Field> fields = new LinkedList<>();
ReflectionUtils.doWithFields(clazz, fields::add, field -> {
int modifiers = field.getModifiers();
return Modifier.isPublic(modifiers) && !Modifier.isStatic(modifiers);
});
return Collections.unmodifiableList(fields);
});
}
/**
* The key part of any filter is to generate expressions that translate to search queries.
* This is where it's done.
*
* @return list of predicates
*/
public abstract List<Predicate> collectPredicates();
/**
* Return the fields boosted in ES search
* @return null -- no fields boosted
*/
public String[] boostedFields() {
return null;
}
/**
* Return the map of remapped props
* @return null -- no props remapped
*/
public Map<String, String> remappedProperties() {
return null;
}
/**
* Builds the DSL predicate.
*
* @return the predicate
*/
public BooleanBuilder buildPredicate() {
BooleanBuilder builder = new BooleanBuilder(ExpressionUtils.allOf(collectPredicates()));
if (NOT != null) {
// This is not a regular NOT operation where not(A and B) = not(A) or not(B)
// Our filtering uses: not(A and B) = not(A) and not(B)
var nots = NOT.collectPredicates();
if (nots != null && !nots.isEmpty()) {
var notAny = ExpressionUtils.anyOf(nots);
if (notAny != null) builder.and(notAny.not());
}
}
if (AND != null) {
var ands = AND.buildPredicate();
if (ands != null) builder.and(ands);
}
if (OR != null) {
var ors = OR.buildPredicate();
if (ors != null) builder.or(ors);
}
return builder;
}
/**
* Collects list of this filter predicates. Does not include NOT, OR and AND. Those are used in {@link #buildPredicate()}!
*
* @param instance the instance of Q-type of <em>R</em>
* @return list of predicates
*/
protected List<Predicate> collectPredicates(final EntityPathBase<R> instance) {
List<Predicate> predicates = new ArrayList<>();
if (NULL != null && !NULL.isEmpty()) {
final Class<?> clazz = instance.getClass();
NULL.forEach(nullProp -> {
DslExpression<?> expression = getProperty(instance, clazz, nullProp);
if (expression instanceof SimpleExpression) {
predicates.add(((SimpleExpression<?>) expression).isNull());
} else if (expression instanceof CollectionPathBase) {
predicates.add(((CollectionPathBase<?, ?, ?>) expression).size().eq(0));
}
});
}
if (NOTNULL != null && !NOTNULL.isEmpty()) {
final Class<?> clazz = instance.getClass();
NOTNULL.forEach(notNullProp -> {
DslExpression<?> expression = getProperty(instance, clazz, notNullProp);
if (expression instanceof SimpleExpression) {
predicates.add(((SimpleExpression<?>) expression).isNotNull());
} else if (expression instanceof CollectionPathBase) {
predicates.add(((CollectionPathBase<?, ?, ?>) expression).size().gt(0));
}
});
}
return predicates;
}
public void clearFilter(String jsonPath) throws NoSuchFieldException, IllegalAccessException {
this.clearFilter(jsonPath, true);
}
public final void clearFilter(String jsonPath, boolean clearNullAndNotNull) throws NoSuchFieldException, IllegalAccessException {
if (clearNullAndNotNull) {
removeFromNullAndNotNull(jsonPath);
}
String[] paths = jsonPath.split("\\.");
Object toClear = this;
Class<?> clazz = this.getClass();
Field field = clazz.getField(paths[0]);
for (int i = 1; i < paths.length; i++) {
clazz = field.getType();
if (!SuperModelFilter.class.isAssignableFrom(clazz))
break;
toClear = field.get(toClear);
if (toClear == null)
return;
field = clazz.getField(paths[i]);
}
field.set(toClear, null);
}
/// This removes a JSON path from NULL and NOTNULL and it recreates the set.
protected final void removeFromNullAndNotNull(String jsonPath) {
if (this.NULL != null && this.NULL.contains(jsonPath)) {
this.NULL = new HashSet<>(this.NULL); // Need a new set in case it is immutable
this.NULL.remove(jsonPath);
}
if (this.NOTNULL != null && this.NOTNULL.contains(jsonPath)) {
this.NOTNULL = new HashSet<>(this.NOTNULL); // Need a new set in case it is immutable
this.NOTNULL.remove(jsonPath);
}
}
/**
* Find the property of DSL-generated type.
*
* @param instance the DSL-generated type
* @param clazz type of instance
* @param nullProp property name
* @return
*/
private DslExpression<?> getProperty(final EntityPathBase<R> instance, final Class<?> clazz, String nullProp) {
if (remappedProperties() != null)
nullProp = remappedProperties().getOrDefault(nullProp, nullProp);
try {
// build path for nesting filters
if (nullProp.contains(".")) {
String paths[] = nullProp.split("\\.");
PathBuilder<?> pathBuilder = new PathBuilder<>(instance.getType(), instance.getMetadata());
Class<?> clazzToCheck = clazz;
boolean isCollection = false;
for (String path : paths) {
Field field = ReflectionUtils.findField(clazzToCheck, path);
if (field == null) {
throw new NoSuchFieldException("Property " + nullProp + " is not a SimpleExpression");
}
if (CollectionPathBase.class.isAssignableFrom(field.getType())) {
isCollection = true;
} else {
isCollection = false;
pathBuilder.getSimple(path, field.getDeclaringClass());
}
clazzToCheck = field.getType();
}
if (isCollection) {
return pathBuilder.getSet(nullProp, clazzToCheck);
} else {
return pathBuilder.getSimple(nullProp, clazzToCheck);
}
} else {
final Field prop = ReflectionUtils.findField(clazz, nullProp);
if (prop != null) {
try {
if (SimpleExpression.class.isAssignableFrom(prop.getType())) {
return (SimpleExpression<?>) prop.get(instance);
}
if (CollectionPathBase.class.isAssignableFrom(prop.getType())) {
return (CollectionPathBase<?, ?, ?>) prop.get(instance);
}
} catch (IllegalAccessException e) {
// Look for generator/accessor method: fieldName()
Method accessor = ReflectionUtils.findMethod(clazz, prop.getName());
if (accessor != null) {
if (SimpleExpression.class.isAssignableFrom(prop.getType())) {
return (SimpleExpression<?>) accessor.invoke(instance);
}
if (CollectionPathBase.class.isAssignableFrom(prop.getType())) {
return (CollectionPathBase<?, ?, ?>) accessor.invoke(instance);
}
}
}
}
throw new NoSuchFieldException("Property " + nullProp + " is not a SimpleExpression");
}
} catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
// test if nested objects have required property
// will test only the first level of nesting
if (e instanceof NoSuchFieldException) {
Set<Class<? extends EntityPathBase<? extends R>>> subclasses = getSubClasses();
for (Class<?> subclass : subclasses) {
try {
final Field prop = subclass.getField(nullProp);
PathBuilder<?> pathBuilder = new PathBuilder<>(instance.getType(), instance.getMetadata());
if (CollectionPathBase.class.isAssignableFrom(prop.getType())) {
return pathBuilder.getSet(nullProp, prop.getDeclaringClass());
}
return pathBuilder.getSimple(prop.getName(), prop.getDeclaringClass());
} catch (NoSuchFieldException e1) {
}
}
Field fields[] = clazz.getDeclaredFields();
for (Field field : fields) {
Class<?> superClazz = field.getType().getSuperclass();
if (superClazz != null && EntityPathBase.class.isAssignableFrom(superClazz)) {
try {
Field result = field.getType().getField(nullProp);
PathBuilder<?> pathBuilder = new PathBuilder<>(instance.getType(), instance.getMetadata());
if (CollectionPathBase.class.isAssignableFrom(result.getType())) {
return pathBuilder.getSet(field.getName() + "." + nullProp, result.getDeclaringClass());
}
return pathBuilder.getSimple(field.getName() + "." + nullProp, result.getDeclaringClass());
} catch (NoSuchFieldException e1) {
// Noop
}
}
}
}
throw new RuntimeException("Error accessing field " + nullProp + " for isNull() in " + instance.getClass());
}
}
protected Set<Class<? extends EntityPathBase<? extends R>>> getSubClasses() {
return Set.of();
}
/**
* Copy by serializing to JSON and de-serializing to specified type.
*
* @param <X> the generic type
* @param targetType the target type
* @return the x
*/
public <X> X copy(final Class<X> targetType) {
try {
String json = defaultMapper.writeValueAsString(this);
// System.err.println(json);
return defaultMapper.readValue(json, targetType);
} catch (JsonProcessingException e) {
throw new RuntimeException("Could not copy filter", e);
}
}
@Override
public String toString() {
try {
return jsonizer.writeValueAsString(this);
} catch (final JsonProcessingException e) {
throw new RuntimeException("Could not serialize to JSON: " + e.getMessage(), e);
}
}
/**
* Prepare filter for use. NULL and NOTNULLs will clear any actual values
* provided for those properties.
*
* @param <Y> any SuperModelFilter subtype
* @param filter the filter
* @return the normalized valid filter
*/
public static <Y extends SuperModelFilter<Y, ?>> Y normalize(final Y filter) {
Set<String> toClear = new HashSet<>();
if (filter.NULL != null) {
toClear.addAll(filter.NULL);
}
if (filter.NOTNULL != null) {
toClear.addAll(filter.NOTNULL);
}
for (String path : toClear) {
try {
filter.clearFilter(path, false);
} catch (NoSuchFieldException | IllegalAccessException e) {
// System.err.println("Clearing missing filter: " + path + ": " + e.getMessage());
filter.removeFromNullAndNotNull(path);
}
}
if (filter.AND != null) {
filter.AND = normalize(filter.AND);
}
if (filter.OR != null) {
filter.OR = normalize(filter.OR);
}
if (filter.NOT != null) {
filter.NOT = normalize(filter.NOT);
}
return filter;
}
/**
* Used to deserialize NOT filter without default values
*
* @param <Y> type of filter
*/
static class NonDefaultDeserializer<Y extends SuperModelFilter<Y, ?>> extends JsonDeserializer<Y> implements ContextualDeserializer {
private Class<Y> targetClass;
@Override
public Y deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
Y parsed = (Y) p.getCodec().readValue(p, targetClass);
Y defaultFilter = null;
try {
defaultFilter = targetClass.getDeclaredConstructor().newInstance();
for (Field f : targetClass.getDeclaredFields()) {
if (!Modifier.isStatic(f.getModifiers()) && Modifier.isPublic(f.getModifiers()) && f.get(defaultFilter) != null && f.get(defaultFilter).equals(f.get(parsed))) {
f.set(parsed, null);
}
}
} catch (SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException("Parsing of filter failed, e: " + e.getMessage(), e);
}
return parsed;
}
@SuppressWarnings("unchecked")
@Override
public JsonDeserializer<?> createContextual(DeserializationContext deserializationContext, BeanProperty beanProperty) throws JsonMappingException {
final JavaType type;
if (beanProperty != null)
type = beanProperty.getType();
else {
type = deserializationContext.getContextualType();
}
if (this.targetClass != null && this.targetClass.equals(type.getRawClass())) {
// System.err.println("Using NonDefDeser for " + this.targetClass);
return this;
} else {
// System.err.println("Making new NonDefDeser for " + type.getRawClass());
NonDefaultDeserializer<Y> x = new NonDefaultDeserializer<>();
x.targetClass = (Class<Y>) type.getRawClass();
return x;
}
}
}
/**
* Used to serialize and ignore default values of NOT filter
*
* @param <Y> type of filter
*/
public static class NoDefaultValuesSerializer<Y extends SuperModelFilter<Y, ?>> extends JsonSerializer<Y> {
@Override
public void serialize(Y value, JsonGenerator gen, SerializerProvider provider) throws IOException {
// System.err.println("NDVS def: " + SuperModelFilter.defaultMapper.writeValueAsString(value));
// System.err.println("NDVS nond: " + SuperModelFilter.nonDefault.writeValueAsString(value));
// System.err.println("NDVS json: " + SuperModelFilter.jsonizer.writeValueAsString(value));
gen.writeRawValue(SuperModelFilter.jsonizer.writeValueAsString(value));
}
}
}