io.stallion.dataAccess.filtering.FilterChain.java Source code

Java tutorial

Introduction

Here is the source code for io.stallion.dataAccess.filtering.FilterChain.java

Source

/*
 * Stallion Core: A Modern Web Framework
 *
 * Copyright (C) 2015 - 2016 Stallion Software LLC.
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation, either version 2 of
 * the License, or (at your option) any later version. 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/gpl-2.0.html>.
 *
 *
 *
 */

package io.stallion.dataAccess.filtering;

import io.stallion.dataAccess.Model;
import io.stallion.dataAccess.LocalMemoryStash;
import io.stallion.exceptions.UsageException;
import io.stallion.reflection.ModelPropertyComparator;
import io.stallion.reflection.PropertyComparator;
import io.stallion.reflection.PropertyUtils;
import io.stallion.services.Log;
import io.stallion.utils.DateUtils;
import io.stallion.utils.Literals;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;

import javax.persistence.Column;
import java.math.BigInteger;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.Consumer;

import static io.stallion.utils.Literals.asArray;
import static io.stallion.utils.Literals.empty;
import static io.stallion.utils.Literals.list;

/**
 * A FilterChain is the default way by which data from a ModelController is actually queried
 * and accessed.
 *
 * You use it as follows:
 *
 * List&lt;Books&gt; books = MyBooksController.instance()
 *     .filter('published', true')
 *     .filter('author', 'Mark Twain')
 *     .exclude('publishDate.year', 2014)
 *     .sort('publishDate', 'desc')
 *     .all();
 *
 * You can chain together as many "filter" commands as you want. The chain is not actually executed
 * until you call one of the access methods: all(), first(), count(), groupBy()
 *
 *
 * @param <T>
 */
public class FilterChain<T extends Model> implements Iterable<T> {
    private List<T> originalObjects;
    private List<T> objects;
    protected ArrayList<FilterOperation> operations = new ArrayList<FilterOperation>();
    private String sortField = "";
    private SortDirection sortDirection;
    private String bucket;
    private String extraCacheKey = "";
    private boolean _includeDeleted = false;
    private Integer matchingCount = 0;
    private LocalMemoryStash<T> stash;
    private boolean useCache = true;
    private boolean idAsSecondarySort = true;
    private List<String> sumColumns = list();
    private List<String> averageColumns = list();

    private Map<String, Double> averages = null;
    private Map<String, Double> sums = null;

    public FilterChain(String bucket, List<T> originalObjects) {
        this.bucket = bucket;
        this.originalObjects = originalObjects;
        // Don't use cache when we pass in a pre-filtered list of objects.
        this.useCache = false;
    }

    public FilterChain(String bucket, LocalMemoryStash<T> stash) {
        this.setBucket(bucket);
        this.stash = stash;
        this.originalObjects = stash.getItems();
    }

    public FilterChain(String bucket, FilterOperation op, LocalMemoryStash<T> stash) {
        this.setBucket(bucket);
        this.stash = stash;
        operations.add(op);
    }

    public FilterChain(String bucket, List<T> originalObjects, LocalMemoryStash<T> stash) {
        this.setBucket(bucket);
        this.stash = stash;
        this.originalObjects = (List<T>) originalObjects;
        this.useCache = false;
    }

    protected FilterChain(FilterChain chain, LocalMemoryStash<T> stash) {
        this.setBucket(chain.getBucket());
        this.stash = stash;
        this.useCache = chain.useCache;
        this.operations = (ArrayList<FilterOperation>) chain.operations.clone();
        this.sortField = chain.sortField;
        this.sortDirection = chain.sortDirection;
        this.originalObjects = chain.originalObjects;
    }

    /**
     * Do a basic equality filter
     *
     * @param name
     * @param val
     * @return
     */
    public FilterChain<T> filter(String name, Object val) {
        return filter(name, val, "eq");
    }

    /**
     * Do a basic equality filter.
     *
     * @param name
     * @param value
     * @return
     */
    public FilterChain<T> filter(String name, Comparable value) {
        return filter(name, value, "eq");
    }

    /**
     * Add a filter to the chain
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> filter(String name, Object value, String op) {
        return filterBy(name, value, FilterOperator.fromString(op));
    }

    public FilterChain<T> filter(String name, Comparable value, String op) {
        return filterBy(name, value, FilterOperator.fromString(op));
    }

    /**
     * Add a filter to the chain with a custom operator
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> filterBy(String name, Object value, FilterOperator op) {

        FilterOperation fop = new FilterOperation();
        fop.setFieldName(name);
        fop.setOperator(op);
        fop.setOriginalValue(value);
        if (value instanceof Iterable) {
            fop.setIterable(value);
        }
        return cloneChainAndAddOperation(fop);
    }

    /**
     * Add a filter to the chain with a custom operator
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> filterBy(String name, Object value, FilterOperator op, boolean caseInsensitive) {

        FilterOperation fop = new FilterOperation();
        fop.setCaseInsensitive(caseInsensitive);
        fop.setFieldName(name);
        fop.setOperator(op);
        fop.setOriginalValue(value);
        if (caseInsensitive && value instanceof String) {
            fop.setOriginalValue(((String) value).toLowerCase());
        }
        if (value instanceof Iterable) {
            fop.setIterable(value);
        }
        return cloneChainAndAddOperation(fop);
    }

    /**
     * Add a filter to the chain with a custom operator
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> filterBy(String name, Comparable value, FilterOperator op) {
        FilterOperation fop = new FilterOperation();
        fop.setFieldName(name);
        fop.setOperator(op);
        fop.setOriginalValue(value);
        return cloneChainAndAddOperation(fop);
    }

    /**
     * Searches for @value in all @fields, using a case-insensitive
     * string contains search.
     *
     * @param value
     * @param value
     * @return
     */
    public FilterChain<T> search(String value, String... fields) {
        if (fields.length == 0) {
            throw new UsageException("You must include at least one field to search");
        }
        if (Literals.empty(value)) {
            throw new UsageException("value must be at least one character long");
        }

        List<Or> ors = list();
        for (String field : fields) {
            ors.add(new Or(field, value, FilterOperator.LIKE));
        }
        FilterChain<T> chain = andAnyOf(asArray(ors, Or.class));
        return chain;
    }

    /**
     * Excludes all matching items instead of including them.
     *
     * @param name
     * @param value
     * @return
     */
    public FilterChain<T> exclude(String name, Object value) {
        return exclude(name, value, "eq");
    }

    /**
     * Excludes all matching items instead of including them.
     *
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> exclude(String name, Object value, String op) {
        return excludeBy(name, value, FilterOperator.fromString(op));
    }

    /**
     * Excludes all matching items instead of including them.
     *
     * @param name
     * @param value
     * @param op
     * @return
     */
    public FilterChain<T> excludeBy(String name, Object value, FilterOperator op) {
        FilterOperation fop = new FilterOperation();
        fop.setFieldName(name);
        fop.setOperator(op);
        fop.setOriginalValue(value);
        fop.setIsExclude(true);
        return cloneChainAndAddOperation(fop);
    }

    public FilterChain<T> andAnyOf(Map<String, Object> allowedMatches) {
        List<Or> ors = list();
        for (Map.Entry<String, Object> allowed : allowedMatches.entrySet()) {
            ors.add(new Or(allowed.getKey(), allowed.getValue()));
        }
        return andAnyOf(asArray(ors, Or.class));
    }

    public FilterChain<T> andAnyOf(List<String>... tuples) {
        List<Or> ors = list();
        for (List<String> tuple : tuples) {
            Or or;
            if (tuple.size() == 2) {
                or = new Or(tuple.get(0), tuple.get(1));
            } else if (tuple.size() == 3) {
                or = new Or(tuple.get(0), tuple.get(1), tuple.get(2));
            } else {
                throw new UsageException(
                        "When calling andAnyOf, you must pass in a list of strings that either [field, value] or [field, value, op]");
            }
            ors.add(or);
        }
        return andAnyOf(asArray(ors, Or.class));
    }

    public FilterChain<T> andAnyOf(Or... ors) {
        if (ors.length == 0) {
            return this;
        }
        FilterOperation operation = new FilterOperation();
        operation.setOrOperation(true);
        operation.setOrSubOperations(list());
        for (Or or : ors) {
            FilterOperation subOp = new FilterOperation();
            subOp.setOriginalValue(or.getValue());
            subOp.setFieldName(or.getField());
            subOp.setOperator(or.getOp());
            operation.getOrSubOperations().add(subOp);
        }
        return cloneChainAndAddOperation(operation);
    }

    public FilterChain<T> excludeAnyOf(Or... ors) {
        if (ors.length == 0) {
            return this;
        }
        FilterOperation operation = new FilterOperation();
        operation.setOrOperation(true);
        operation.setOrSubOperations(list());
        operation.setIsExclude(true);
        for (Or or : ors) {
            FilterOperation subOp = new FilterOperation();
            subOp.setOriginalValue(or.getValue());
            subOp.setFieldName(or.getField());
            subOp.setOperator(or.getOp());
            operation.getOrSubOperations().add(subOp);
        }
        return cloneChainAndAddOperation(operation);
    }

    protected FilterChain<T> cloneChainAndAddOperation(FilterOperation operation) {
        FilterChain<T> chain = newCopy();
        chain.operations.add(operation);
        return chain;
    }

    /**
     * Create a copy this filter chain.
     *
     * @return
     */
    protected FilterChain<T> newCopy() {
        FilterChain<T> chain = new FilterChain<T>(this.getBucket(), originalObjects, stash);
        return newCopy(chain);
    }

    protected FilterChain<T> newCopy(FilterChain<T> chain) {
        chain.setUseCache(this.isUseCache());
        chain.operations = (ArrayList<FilterOperation>) operations.clone();
        chain.setIncludeDeleted(getIncludeDeleted());
        chain.averageColumns = averageColumns;
        chain.sumColumns = sumColumns;
        return chain;
    }

    /**
     * Count the objects matching this filter, grouped by fieldNames.
     *
     * @param fieldNames
     * @return
     */
    public List<FilterGroup<T>> countBy(String... fieldNames) {
        Object cached = getCached("countBy");
        if (cached != null) {
            return (List<FilterGroup<T>>) cached;
        }
        if (objects == null) {
            process();
        }

        HashMap<String, FilterGroup<T>> groupByGroupKey = new HashMap<>();
        boolean[] hasDot = new boolean[fieldNames.length];
        for (int i = 0; i < fieldNames.length; i++) {
            hasDot[i] = fieldNames[i].contains(".");
        }
        List<String> groupKeys = new ArrayList<>();
        for (T o : objects) {
            String groupKey = "";
            for (int i = 0; i < fieldNames.length; i++) {
                String fieldName = fieldNames[i];
                if (hasDot[i]) {

                } else {
                    groupKey += PropertyUtils.getProperty(o, fieldName).toString() + Literals.GSEP;
                }
            }
            if (!groupByGroupKey.containsKey(groupKey)) {
                groupByGroupKey.put(groupKey, new FilterGroup<T>(groupKey));
                groupKeys.add(groupKey);
                groupByGroupKey.get(groupKey).getItems().add(o);
            }
            groupByGroupKey.get(groupKey).incrCount();
        }
        List<FilterGroup<T>> groups = new ArrayList<>();
        for (String groupKey : groupKeys) {
            groups.add(groupByGroupKey.get(groupKey));
        }
        setCached("countBy", groups);
        return groups;
    }

    /**
     * Group the matching objects by field names. So if I wanted a group of blog posts
     * written by some author, grouped by year/month I would do:
     *
     * BlogPostController.instance()
     *     .filter("author", "Mark Twain")
     *     .groupBy("publishDate.year", "publishDate.month");
     *
     *
     *
     * @param fieldNames
     * @return
     */
    public List<FilterGroup<T>> groupBy(String... fieldNames) {
        Object cached = getCached("groupBy");
        if (cached != null) {
            return (List<FilterGroup<T>>) cached;
        }
        if (objects == null) {
            process();
        }
        List<FilterGroup<T>> groups = groupBy(objects, fieldNames);
        setCached("groupBy", groups);
        return groups;
    }

    /**
     * Do a groupBy of the passed in objects.
     *
     * @param items
     * @param fieldNames
     * @param <Y>
     * @return
     */
    private <Y> List<FilterGroup<Y>> groupBy(List<Y> items, String... fieldNames) {

        List<List<String>> subGroupFields = new ArrayList<>();
        List<String> prior = new ArrayList<>();
        for (String fieldName : fieldNames) {
            prior.add(fieldName);
            subGroupFields.add(prior);
        }

        HashMap<String, FilterGroup<Y>> groupByGroupKey = new HashMap<>();
        boolean[] hasDot = new boolean[fieldNames.length];
        for (int i = 0; i < fieldNames.length; i++) {
            hasDot[i] = fieldNames[i].contains(".");
        }
        List<String> groupKeys = new ArrayList<>();

        for (Y o : items) {
            String groupKey = "";
            for (int i = 0; i < fieldNames.length; i++) {
                String fieldName = fieldNames[i];
                if (hasDot[i]) {
                    groupKey += PropertyUtils.getDotProperty(o, fieldName).toString() + Literals.GSEP;
                } else {
                    groupKey += PropertyUtils.getProperty(o, fieldName).toString() + Literals.GSEP;
                }
            }
            if (!groupByGroupKey.containsKey(groupKey)) {
                FilterGroup newGroup = new FilterGroup<Y>(groupKey);
                groupByGroupKey.put(groupKey, newGroup);
                groupKeys.add(groupKey);
            }
            groupByGroupKey.get(groupKey).getItems().add(o);
        }
        List<FilterGroup<Y>> groups = new ArrayList<>();
        for (String groupKey : groupKeys) {
            groups.add(groupByGroupKey.get(groupKey));
        }

        /*
        Group previousGroup = null;
        for (int i=0; i<groups.size(); i++) {
        Group group = groups.get(i);
        if (previousGroup == null) {
            for(List<String> subGroup: subGroupFields) {
                group.getFirstOfs().add(subGroup);
            }
        }
        if (i+1 == groups.size()) {
            for(List<String> subGroup: subGroupFields) {
                group.getLastOfs().add(subGroup);
            }
        }
        if (previousGroup == null) {
            previousGroup = group;
            continue;
        }
        List<String> previousVals = Arrays.asList(StringUtils.split(previousGroup.getKey(), Literals.GSEP));
        List<String> curVals = Arrays.asList(StringUtils.split(group.getKey(), Literals.GSEP));
        for(int k=0;k< subGroupFields.size(); k++) {
            List<String> subGroup = subGroupFields.get(k);
            if (!curVals.subList(0, k+1).equals(previousVals.subList(0, k+1))) {
                previousGroup.getLastOfs().add(subGroup);
                group.getFirstOfs().add(subGroup);
            }
        }
        previousGroup = group;
        }
        */
        return groups;
    }

    /**
     * Return an average of the following columns with the pager.
     *
     * @return
     */
    public FilterChain<T> avg(String... cols) {
        this.averageColumns.addAll(list(cols));
        return this;
    }

    public FilterChain<T> sum(String... cols) {
        this.sumColumns.addAll(list(cols));
        return this;
    }

    /**
     * Executes the filters and returns all matching items.
     *
     * @return
     */
    public List<T> all() {
        List<T> cached = (List<T>) getCached("all");
        if (cached != null) {
            return cached;
        }
        if (objects == null) {
            process();
        }
        setCached("all", objects);
        return objects;
    }

    /**
     * Executes the filters and returns true if there are no matching items found.
     *
     * @return
     */
    public boolean empty() {
        Boolean cached = (Boolean) getCached("empty");
        if (cached != null) {
            return cached;
        }
        if (objects == null) {
            process();
        }
        boolean result = objects.size() == 0;
        setCached("empty", result);
        return result;
    }

    /**
     * Alias for empty(), no matching items.
     * @return
     */
    public boolean isEmpty() {
        return empty();
    }

    /**
     * Executes the filters, and returns a list of all values of the given property name
     * for all matching objects.
     *
     * @param name
     * @param <Y>
     * @return
     */
    public <Y> List<Y> column(String name) {
        List<Y> items = list();
        for (T item : all()) {
            items.add((Y) PropertyUtils.getPropertyOrMappedValue(item, name));
        }
        return items;
    }

    /**
     * Executes the filters and returns the total matching count.
     *
     * @return
     */
    public int count() {
        Integer cached = (Integer) getCached("count");
        if (cached != null) {
            return cached;
        }

        if (objects == null) {
            process();
        }
        int theCount = objects.size();
        setCached("count", theCount);
        return theCount;
    }

    /**
     * Executes the filters and returns the first matching item.
     *
     * @return
     */
    public T first() {
        T cached = (T) getCached("first");
        if (cached != null) {
            if (getStash() instanceof LocalMemoryStash) {
                cached = getStash().getController().detach(cached);
            }
            return cached;
        }
        process(1, 1, false);
        List<T> things = objects;
        if (things.size() > 0) {
            T thing = things.get(0);
            setCached("first", thing);
            if (getStash() instanceof LocalMemoryStash) {
                thing = getStash().detach(thing);
            }
            return thing;
        } else {
            return null;
        }

    }

    /**
     * Adds a sort direction the filter chain response.
     *
     * @param fieldName
     * @param direction - either asc or desc
     * @return
     */
    public FilterChain<T> sort(String fieldName, String direction) {
        return sortBy(fieldName, SortDirection.fromString(direction));
    }

    /**
     * Adds a sort direction the filter chain response.
     *
     * @param fieldName
     * @param direction
     * @return
     */
    public FilterChain<T> sortBy(String fieldName, SortDirection direction) {
        FilterChain<T> chain = newCopy();
        chain.setSortField(fieldName);
        chain.setSortDirection(direction);
        return chain;
    }

    /**
     * Alias for pager(page, 10) (default page size of 10);
     * @param page
     * @return
     */
    public Pager<T> pager(Integer page) {
        return this.pager(page, 10);
    }

    /**
     * Executes the filters and returns a pager object. A page object
     * has a subset of the items based on the desired page and the page size
     *
     * @param page - which page to return
     * @param size - how many items are on a page
     * @return
     */
    public Pager<T> pager(Integer page, Integer size) {
        String methodKey = "pager" + Literals.GSEP + page + Literals.GSEP + size;
        Object cached = getCached(methodKey);
        if (cached != null) {
            return (Pager) cached;
        }
        if (page == null || page < 1) {
            page = 1;
        }
        if (size == null) {
            size = 10;
        }

        if (objects == null) {
            process(page, size, true);
        }

        Pager pager = new Pager();
        pager.setCurrentItems(objects);
        pager.setSums(sums);
        pager.setAverages(averages);

        if (objects.size() == 0) {
            return pager;
        }

        pager.setCurrentPage(page);
        pager.setItemsPerPage(size);
        pager.setHasPreviousPage(true);
        pager.setHasNextPage(true);
        pager.setPreviousPageNumber(page - 1);
        pager.setNextPageNumber(pager.getCurrentPage() + 1);

        Integer startingIndex = (pager.getCurrentPage() - 1) * pager.getItemsPerPage();
        Integer endingIndex = startingIndex + pager.getItemsPerPage();
        if (endingIndex > getMatchingCount()) {
            endingIndex = getMatchingCount();
        }
        if (endingIndex >= getMatchingCount()) {
            pager.setHasNextPage(false);
            pager.setNextPageNumber(pager.getCurrentPage());
        }
        if (pager.getCurrentPage() <= 1) {
            pager.setHasPreviousPage(false);
            pager.setPreviousPageNumber(pager.getCurrentPage());
        }
        if (size > 0) {
            pager.setPageCount((getMatchingCount() / size));
            if (getMatchingCount() % size > 0) {
                pager.setPageCount(pager.getPageCount() + 1);
            }
        }
        setCached(methodKey, pager);
        return pager;
    }

    /**
     * Alias for process(0, 100000, false)
     */
    protected void process() {
        process(0, 100000, false);
    }

    /**
     * Actually applies all filters and sorts to reduce the originalObjects into
     * a subset of matched objects.
     *
     * @param page - the page to start from when returning results
     * @param size - the number of results per page (the number of results to return)
     * @param fetchTotalMatching - hydrate the total matching count field, even if
     *                             we are returning a subet
     */
    protected void process(int page, int size, boolean fetchTotalMatching) {
        List<T> availableItems = originalObjects;

        boolean hydrated = tryHydrateObjectsBasedOnUniqueKey();
        if (hydrated) {
            return;
        }

        // If we are filtering on a key, then we can reduce to a subset the number of items we have to look at
        for (FilterOperation op : operations) {
            if (op.isOrOperation()) {
                continue;
            }
            if (!op.getIsExclude() && op.getOperator().equals(FilterOperator.EQUAL) && stash != null
                    && stash.getUniqueFields().contains(op.getFieldName())) {
                T availableItem = stash.forUniqueKey(op.getFieldName(), op.getOriginalValue());
                if (availableItem != null) {
                    availableItems = list(availableItem);
                } else {
                    availableItems = list();
                }
                break;
            }
            if (!op.getIsExclude() && op.getOperator().equals(FilterOperator.EQUAL) && stash != null
                    && stash.getKeyFields().contains(op.getFieldName())) {
                availableItems = stash.listForKey(op.getFieldName(), op.getOriginalValue());
                if (availableItems == null) {
                    availableItems = list();
                }
                break;
            }
        }

        // Filter down availableItems into items, based on applying the filter operations to each item
        List<T> items = new ArrayList<T>();
        for (T o : availableItems) {
            Boolean exclude = false;
            if (getIncludeDeleted() != true && o.getDeleted() == true) {
                continue;
            }
            for (FilterOperation operation : getOperations()) {
                boolean matches = checkItemMatchesFilterAndExcludes(operation, o);
                if (!matches) {
                    exclude = true;
                    break;
                }
            }
            if (!exclude) {
                items.add(o);
            }
        }

        // Apply the sort
        if (!Literals.empty(getSortField())) {
            //BeanComparator beanComparator = new BeanComparator(getSortField());
            ModelPropertyComparator comparator = new ModelPropertyComparator(getSortField(), isIdAsSecondarySort());
            Collections.sort(items, comparator);
            if (getSortDirection().equals(SortDirection.DESC)) {
                Collections.reverse(items);
            }
        }

        // Set the internal objects fields to the filtered and sorted items;
        objects = items;
        // Get the total count that matched the filtering
        matchingCount = objects.size();

        // If we are implementing paging, and want to limit the objects to a page,
        // we do so here.
        if (size > 0) {
            if (page < 1) {
                page = 1;
            }
            int start = (page - 1) * size;
            int end = start + size;
            if (end > objects.size()) {
                end = objects.size();
            }
            if (start >= objects.size()) {
                objects = new ArrayList<>();
            } else if (end > start) {
                objects = objects.subList(start, end);
            }
        }
    }

    private boolean tryHydrateObjectsBasedOnUniqueKey() {
        boolean matchedUnique = true;

        if (operations.size() > 1) {
            return false;
        }

        // If we are filtering on the id, or on a unique key, then we can short circuit the process
        for (FilterOperation op : operations) {
            if (!"=".equals(op.getOperator()) || op.getIsExclude()) {
                continue;
            }
            if (!op.getFieldName().equals("id") && !stash.getUniqueFields().contains(op.getFieldName())) {
                continue;
            }
            T o = null;
            if (op.getFieldName().equals("id")) {
                o = stash.forId((Long) op.getOriginalValue());
            } else {
                o = stash.forUniqueKey(op.getFieldName(), op.getOriginalValue());
            }
            if (o == null) {
                objects = list();
                return true;
            }
            if (!getIncludeDeleted() && o.getDeleted()) {
                objects = list();
                return true;
            }
            objects = list(o);
            matchingCount = 1;
            return true;
        }
        return false;
    }

    /**
     * Checks whether the object matches the complete FilterOperation, taking into account
     * OR subOperations and the isExcludes flag.
     *
     * @param op
     * @param o
     * @return
     */
    private boolean checkItemMatchesFilterAndExcludes(FilterOperation op, T o) {
        // Handle an OR operation by getting a list of or requirements and recursing down
        boolean matches = false;
        if (op.isOrOperation()) {
            for (FilterOperation subOp : op.getOrSubOperations()) {
                boolean thisMatches = checkItemMatchesFilterConditions(subOp, o);
                if (thisMatches) {
                    matches = true;
                    break;
                }
            }
        } else {
            matches = checkItemMatchesFilterConditions(op, o);
        }
        if (op.getIsExclude()) {
            return !matches;
        } else {
            return matches;
        }
    }

    /**
     * Check whether the operation conditions match the object, without taking into account
     * isExcludes or "OR" subOperations.
     *
     * @param op
     * @param o
     * @return
     */
    private boolean checkItemMatchesFilterConditions(FilterOperation op, T o) {

        Object propValue = null;
        if (op.hasDot()) {
            propValue = PropertyUtils.getDotProperty(o, op.getFieldName());
        } else {
            propValue = PropertyUtils.getPropertyOrMappedValue(o, op.getFieldName());
        }

        // Filter out nulls
        if (propValue == null && op.getOriginalValue() != null) {
            return false;
        }
        if (propValue == null && op.getOriginalValue() == null && op.getOperator().equals(FilterOperator.EQUAL)) {
            return true;
        }

        if (op.isCaseInsensitive() && propValue instanceof String) {
            propValue = ((String) propValue).toLowerCase();
        }

        // Handle the IN operator
        if (op.getOperator().equals(FilterOperator.IN)) {
            Boolean isIn = false;
            Iterable values = (Iterable) propValue;
            for (Object val : values) {
                if (val.equals(op.getOriginalValue())) {
                    isIn = true;
                    break;
                }
            }
            return isIn;
        }
        if (op.getOperator().equals(FilterOperator.ANY)) {
            Iterable vals = (Iterable) op.getOriginalValue();
            boolean matches = false;
            for (Object val : vals) {
                if (val.equals(propValue)) {
                    matches = true;
                    break;
                }
            }
            return matches;
        }

        // Apply a bunch of heuristics to make sure we are comparing like types,
        // we don't want to filter something out because we are comparing a Long to an Integer
        hydrateTypedValue(op, propValue);

        // Filter out nulls again, based on type conversion
        if (propValue == null && op.getTypedValue() != null) {
            Log.info("Null value: id:{0} field:{1} objVal:''{2}'' ", o.getId(), op.getFieldName(), propValue);
            return false;
        }
        Log.finest(
                "Compare id:{0} field:{1} objVal:''{2}'' objValType:{3} filterTypedVal:''{4}'' filterValType: {5}",
                o.getId(), op.getFieldName(), propValue, propValue.getClass().getCanonicalName(),
                op.getTypedValue(), op.getTypedValue().getClass().getName());

        // When comparing booleans the string "true" should be considered true, and "false" false, this
        // is important when applying a filter coming from an untyped query string.
        if (op.getTypedValue() instanceof Boolean && propValue instanceof String) {
            return op.getTypedValue().toString().toLowerCase().equals((String) propValue.toString().toLowerCase());
        }

        if (op.getOperator().equals(FilterOperator.EQUAL)) {
            return op.getTypedValue().equals(propValue);
        }
        if (op.getOperator().equals(FilterOperator.NOT_EQUAL)) {
            return !op.getTypedValue().equals(propValue);
        }

        if (op.getOperator().equals(FilterOperator.LIKE)) {
            return StringUtils.containsIgnoreCase(propValue.toString(), op.getTypedValue().toString());
        }

        int i;
        if (propValue instanceof ZonedDateTime) {
            i = ((ZonedDateTime) op.getTypedValue()).compareTo((ZonedDateTime) propValue);
        } else {
            i = op.getComparableValue().compareTo(propValue);
        }
        if (op.getOperator().equals(FilterOperator.GREATER_THAN)) {
            return i < 0;
        }
        if (op.getOperator().equals(FilterOperator.LESS_THAN)) {
            return i > 0;
        }
        if (op.getOperator().equals(FilterOperator.GREATER_THAN_OR_EQUAL)) {
            return i <= 0;
        }
        if (op.getOperator().equals(FilterOperator.LESS_THAN_OR_EQUAL)) {
            return i >= 0;
        }
        throw new UsageException("You used an uninplemented filter operation: " + op);
    }

    /**
     * Hydrate the FilterOperation.typedValue based on gueessing or reflecting
     * on the type of the property as passed in.
     *
     * @param op
     * @param propValue
     */
    private void hydrateTypedValue(FilterOperation op, Object propValue) {
        if (op.getTypedValue() != null) {
            return;
        }
        if (op.getOriginalValue() == null) {
            return;
        }
        if (op.getOriginalValue().getClass().equals(propValue.getClass())) {
            op.setTypedValue(op.getOriginalValue());
            return;
        }
        if (op.getOriginalValue().getClass().equals(String.class)) {
            String val = (String) op.getOriginalValue();
            if (propValue != null) {
                if (propValue instanceof Enum) {
                    op.setTypedValue(Enum.valueOf((Class<? extends Enum>) propValue.getClass(), val));
                } else if (propValue instanceof ZonedDateTime) {
                    if (((String) op.getOriginalValue()).length() == 16) {
                        op.setTypedValue(ZonedDateTime.parse((String) op.getOriginalValue() + ":00 UTC",
                                DateUtils.SQL_FORMAT_ZONED));
                    } else if (((String) op.getOriginalValue()).length() == 19) {
                        op.setTypedValue(ZonedDateTime.parse((String) op.getOriginalValue() + " UTC",
                                DateUtils.SQL_FORMAT_ZONED));
                    } else {
                        op.setTypedValue(ZonedDateTime.parse((String) op.getOriginalValue(), DateUtils.ISO_FORMAT));
                    }
                } else if (propValue.getClass().equals(Integer.class)) {
                    op.setTypedValue(Integer.parseInt(val));
                } else if (propValue.getClass().equals(Long.class)) {
                    op.setTypedValue(Long.parseLong(val));
                } else if (propValue.getClass().equals(Boolean.class)) {
                    op.setTypedValue(Boolean.parseBoolean(val));

                } else {
                    op.setTypedValue(op.getOriginalValue());
                }
            } else {
                op.setTypedValue(op.getOriginalValue());
            }
        } else if (op.getOriginalValue() instanceof BigInteger) {
            op.setTypedValue(((BigInteger) op.getOriginalValue()).longValue());

        } else if (op.getOriginalValue() instanceof Integer && propValue instanceof Long) {
            op.setTypedValue(new Long((Integer) op.getOriginalValue()));
        } else if (op.getOriginalValue() instanceof Integer && propValue instanceof Float) {
            op.setTypedValue(((Integer) op.getOriginalValue()).floatValue());
        } else if (op.getOriginalValue() instanceof Integer && propValue instanceof Double) {
            op.setTypedValue(((Integer) op.getOriginalValue()).doubleValue());
        } else if (op.getOriginalValue() instanceof Long && propValue instanceof Double) {
            op.setTypedValue(((Long) op.getOriginalValue()).doubleValue());
        } else if (op.getOriginalValue() instanceof Long && propValue instanceof Float) {
            op.setTypedValue(((Long) op.getOriginalValue()).floatValue());
        } else if (propValue instanceof Boolean) {
            if (op.getOriginalValue() instanceof Integer || op.getOriginalValue() instanceof Long) {
                if ((Integer) op.getOriginalValue() == 0) {
                    op.setTypedValue(false);
                } else if ((Integer) op.getOriginalValue() == 1) {
                    op.setTypedValue(true);
                }
            }
        }
        if (op.getTypedValue() == null) {
            op.setTypedValue(op.getOriginalValue());
        }
    }

    protected Object getCached(String methodName) {
        if (!useCache) {
            return null;
        }
        String key = buildKey(methodName);
        if (checkSkipCache(key)) {
            return null;
        }
        Object result = FilterCache.get(this.getBucket(), key);
        return result;
    }

    protected boolean checkSkipCache(String key) {
        return false;
    }

    protected void setCached(String methodName, Object val) {
        if (!useCache) {
            return;
        }
        String key = buildKey(methodName);
        FilterCache.set(this.getBucket(), key, val);
    }

    private String buildKey(String methodName) {
        StringBuilder builder = new StringBuilder();
        builder.append(methodName + Literals.GSEP);
        if (this.stash != null) {
            builder.append(this.stash.getBucket() + Literals.GSEP);
        } else if (this.originalObjects.size() > 0) {
            builder.append(this.originalObjects.get(0).getClass().getCanonicalName());
        }

        for (FilterOperation op : operations) {
            //Log.finest("fn: {0} op: {1} isExclude: {2} orgVal: {3}", op.getFieldName(), op.getOperator(), op.getIsExclude(), op.getOriginalValue());
            String ov = "<null>";
            if (op.getOriginalValue() != null) {
                ov = op.getOriginalValue().toString();
            }
            builder.append(
                    op.getFieldName() + op.getOperator() + op.getIsExclude() + Literals.GSEP + ov + Literals.GSEP);
            if (op.isOrOperation()) {
                for (FilterOperation subOp : op.getOrSubOperations()) {
                    ov = "<null>";
                    if (subOp.getOriginalValue() != null) {
                        ov = subOp.getOriginalValue().toString();
                    }
                    builder.append(subOp.getFieldName() + subOp.getOperator() + subOp.getIsExclude() + Literals.GSEP
                            + ov + Literals.GSEP);
                }
            }
        }
        builder.append(getIncludeDeleted());
        builder.append(getSortField());
        builder.append(getSortDirection());
        builder.append(Literals.GSEP + getExtraCacheKey());
        String fullKey = builder.toString();
        return DigestUtils.md5Hex(fullKey);
    }

    @Override
    public Iterator<T> iterator() {
        if (objects == null) {
            process();
        }
        return objects.iterator();
    }

    @Override
    public void forEach(Consumer<? super T> action) {
        if (objects == null) {
            try {
                process();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        objects.forEach(action);
    }

    @Override
    public Spliterator<T> spliterator() {
        if (objects == null) {
            try {
                process();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        return objects.spliterator();
    }

    public String getSortField() {
        return sortField;
    }

    protected void setSortField(String sortField) {
        this.sortField = sortField;
    }

    public boolean isIdAsSecondarySort() {
        return idAsSecondarySort;
    }

    public FilterChain setIdAsSecondarySort(boolean idAsSecondarySort) {
        this.idAsSecondarySort = idAsSecondarySort;
        return this;
    }

    public SortDirection getSortDirection() {
        return sortDirection;
    }

    protected void setSortDirection(SortDirection sortDirection) {
        this.sortDirection = sortDirection;
    }

    public String getBucket() {
        return bucket;
    }

    protected void setBucket(String bucket) {
        this.bucket = bucket;
    }

    public String getExtraCacheKey() {
        return extraCacheKey;
    }

    protected void setExtraCacheKey(String extraCacheKey) {
        this.extraCacheKey = extraCacheKey;
    }

    public Boolean getIncludeDeleted() {
        return _includeDeleted;
    }

    protected FilterChain<T> setIncludeDeleted(Boolean includeDeleted) {
        this._includeDeleted = includeDeleted;
        return this;
    }

    public FilterChain<T> includeDeleted() {
        _includeDeleted = true;
        return this;
    }

    public boolean isUseCache() {
        return useCache;
    }

    public FilterChain<T> setUseCache(boolean useCache) {
        this.useCache = useCache;
        return this;
    }

    public ArrayList<FilterOperation> getOperations() {
        return operations;
    }

    protected Integer getMatchingCount() {
        return matchingCount;
    }

    protected void setMatchingCount(Integer matchingCount) {
        this.matchingCount = matchingCount;
    }

    protected List<T> getObjects() {
        return objects;
    }

    protected void setObjects(List<T> objects) {
        this.objects = objects;
    }

    LocalMemoryStash<T> getStash() {
        return stash;
    }

    FilterChain setStash(LocalMemoryStash<T> stash) {
        this.stash = stash;
        return this;
    }

    protected List<String> getSumColumns() {
        return sumColumns;
    }

    public FilterChain setSumColumns(List<String> sumColumns) {
        this.sumColumns = sumColumns;
        return this;
    }

    protected List<String> getAverageColumns() {
        return averageColumns;
    }

    public FilterChain setAverageColumns(List<String> averageColumns) {
        this.averageColumns = averageColumns;
        return this;
    }

    public Map<String, Double> getAverages() {
        return averages;
    }

    public FilterChain setAverages(Map<String, Double> averages) {
        this.averages = averages;
        return this;
    }

    public Map<String, Double> getSums() {
        return sums;
    }

    public FilterChain setSums(Map<String, Double> sums) {
        this.sums = sums;
        return this;
    }
}