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));
		}
	}

}