OverviewHelper.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.gringlobal.service.impl;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.ParameterizedType;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

import lombok.extern.slf4j.Slf4j;
import org.genesys.blocks.model.EmptyModel;
import org.genesys.blocks.model.filters.EmptyModelFilter;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.service.filter.AccessionFilter;
import org.gringlobal.service.filter.IFullTextFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;

import com.querydsl.core.Tuple;
import com.querydsl.core.types.CollectionExpression;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BeanPath;
import com.querydsl.core.types.dsl.CollectionPathBase;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.util.ReflectionUtils;

/**
 * Utility to generate overview queries.
 * 
 * @author Matija Obreza
 */
@Component
@Slf4j
public class OverviewHelper {

	@Autowired
	protected JPAQueryFactory jpaQueryFactory;

	public <E extends EmptyModel, P extends EntityPathBase<E>> Map<Object, Number> getOverview(Class<E> entityClass, P qdslPath, NumberExpression<?> counter, String group,
			EmptyModelFilter<?, E> filter) {
		if (filter instanceof IFullTextFilter) {
			if (((IFullTextFilter) filter).isFulltextQuery()) {
				// ES filters not supported
				return null;
			}
		}

		JPAQuery<?> query = jpaQueryFactory.from(qdslPath);

		PathBuilder<Object> groupBy;

		// fields
		String[] groups = group.strip().split("\\.");

		Object root = qdslPath;
		Object currentFilter = filter;
		PathBuilder<?> sourceProp = new PathBuilder<>(entityClass, qdslPath.getMetadata());

		String currentPath = "";
		String lookupField = group;

		for (int i = 0; i < groups.length - 1; i++) { // except the last path element
			String fieldName = groups[i];
			currentPath += fieldName + ".";
			log.debug("Looking for {} currentPath={}", fieldName, currentPath);

			try {
				Class<?> propType;
				Object propValue;
				Field field;
				field = ReflectionUtils.findField(root.getClass(), fieldName);
				
				if (field != null) {
					ReflectionUtils.makeAccessible(field);
					log.debug("Found {} {}", field.getType(), field.getName());
					propType = field.getType();
					propValue = field.get(root);
				} else {
					log.debug("Field not found {} {}", root.getClass(), fieldName);
					// Look for generator/accessor method: fieldName()
					var accessor = ReflectionUtils.findMethod(root.getClass(), fieldName);
					if (accessor == null) {
						throw new NoSuchMethodException(fieldName);
					}
					ReflectionUtils.makeAccessible(accessor);
					log.debug("Found {} {}()", accessor.getReturnType(), accessor.getName());
					propType = accessor.getReturnType();
					propValue = accessor.invoke(root);
				}
				
				if (BeanPath.class.isAssignableFrom(propType)) {
					log.debug("{} is an entity", fieldName);
					// No join, but change root
					root = propValue;
					// Must not change the lookup path!

				} else if (CollectionPathBase.class.isAssignableFrom(propType) && field != null) {
					// CollectionPathBase doesn't have an accessor method, so it must have a field
					log.debug("{} is a collection type, must join", fieldName);

					// Figure out what it is
					var fieldType = field.getGenericType();
					var typeArguments = ((ParameterizedType) fieldType).getActualTypeArguments();
					final int entityTypeIndex = 0;
					final int qdslTypeIndex = 1;

					Class<?> entityType = (Class<?>) typeArguments[entityTypeIndex]; // entity type
					Class<?> qdslType = (Class<?>) typeArguments[qdslTypeIndex]; // it's querydsl counterpart

					log.debug("Got {} and {} for {}", entityType.getName(), qdslType.getName(), fieldName);

					// Join
					Path<?> path = (Path<?>) qdslType.getDeclaredConstructor(String.class).newInstance("joined" + i);
					joinQuery(query, (CollectionExpression<?, ?>) propValue, path);

					// Change root
					root = path;

					// Apply any filters on the join path, not on query root
					// Is there a filter field for this entity?
					Field pathFilterField = findPathFilter(fieldName, entityType);

					// We found a matching filter, now apply filters on join path
					if (pathFilterField != null) {
						// If filter has values, skip otherwise
						Object filterObj = pathFilterField.get(currentFilter);
						if (filterObj != null) {
							// We have an entity filter with some values
							EmptyModelFilter<?, ?> emf = EmptyModelFilter.class.cast(filterObj);
							log.debug("Found the filter for {} with values! {}", pathFilterField, emf.getClass().getName());

							// Apply filter on path
							java.lang.reflect.Method collectPredicates = emf.getClass().getMethod("collectPredicates", qdslType);

							// Invoke the `collectPredicates(Q...)` method on join path
							if (collectPredicates != null) {
								Collection<Predicate> predicates = (Collection<Predicate>) collectPredicates.invoke(emf, path);
								log.trace("Got {}", predicates);
								query.where(ExpressionUtils.allOf(predicates));

								// Clear accession filter for this property
								pathFilterField.set(currentFilter, null);

							} else {
								log.warn("No collectPredicates method for {} on {}", qdslType.getName(), emf.getClass().getName());
							}

						} else {
							log.trace("Found the filter for {} but it's null. {}", pathFilterField, entityType.getName());
						}
					}

					// update lookup
					sourceProp = new PathBuilder<>(entityType, "joined" + i);
					log.debug("Changing lookup from {} to {}", lookupField, group.substring(currentPath.length()));
					lookupField = group.substring(currentPath.length());

				} else {
					log.debug("{} is a regular path", fieldName);
					// Must not change the lookup path!
					// Should never reach here.
					throw new InvalidApiUsageException("Invalid field '" + fieldName + "' in path " + group);
				}

			} catch (SecurityException | IllegalArgumentException | IllegalAccessException | InstantiationException | InvocationTargetException
					| NoSuchMethodException e) {
				log.error("Field '{}' not found in {} for path {}: {}", fieldName, root.getClass().getName(), group, e.getMessage(), e);
				throw new InvalidApiUsageException("Field '" + fieldName + "' not found in " + root.getClass().getName() + " for path " + group, e);
			}
		}

		log.debug("Using field {} for {}", lookupField, group);
		groupBy = sourceProp.get(lookupField);

		if (filter != null) {
			query.where(filter.buildPredicate());
		}

		JPAQuery<Tuple> select = query.select(groupBy, counter).groupBy(groupBy);

		Map<Object, Number> results = new HashMap<>();
		select.fetch().forEach(t -> {
			Object key = t.get(0, Object.class);
			results.put(key == null ? "NULL" : key, t.get(1, Number.class));
		});
		return results;
	}

	/**
	 * Find the entity filter field of specified type in AccessionFilter
	 */
	private Field findPathFilter(String fieldName, Class<?> entityType) throws IllegalArgumentException {

		for (Field filterField : AccessionFilter.class.getFields()) {
			// Must be a model filter
			if (EmptyModelFilter.class.isAssignableFrom(filterField.getType())) {
				ResolvableType filterType = ResolvableType.forClass(EmptyModelFilter.class, filterField.getType());
				Class<?> gen0 = filterType.resolveGeneric(0);
				Class<?> gen1 = filterType.resolveGeneric(1);

				log.debug("Looking at {} as subfilter {} {}", filterField, gen0, gen1);

				// If the filter is for the specified type
				if (gen1 != null && gen1.equals(entityType)) {
					if (!filterField.getName().equalsIgnoreCase(fieldName)) {
						log.warn("Potential filter mismatch between {} and {}!", filterField.getName(), fieldName);

						// TODO We have multiple SiteFilters in AccessionFilter. Need the right one!
					}
					return filterField;
				}
			} else {
				// LOG.trace("Not a sub-filter {} {}", filterField,
				// filterField.getType().getName());
			}
		}

		return null;
	}

	@SuppressWarnings("unchecked")
	private <E> void joinQuery(JPAQuery<?> query, CollectionExpression<?, ?> collectionPath, Path<?> alias)
			throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException {

		query.join((CollectionExpression<?, E>) collectionPath, (Path<E>) alias);
	}

}