layer/layerRec/filter.js

const shared = require('./shared.js')();

/**
 * @class Filter
 */
class Filter {
    // handles state, result caches, and notifications for data filters on feature classes

    /**
     * @param {Object} parent        the FC object that this Filter belongs to
     */
    constructor (parent) {
        this._parent = parent;

        // dictionaries of potential values
        this._sql = {};
        this._cache = {};
    }

    // exposes enumeration of core types to the client
    get coreFilterTypes () { return shared.filterType; }

    /**
     * Returns list of filters that have active filters
     *
     * @method sqlActiveFilters
     * @param {Array} [exclusions] list of any filters to exclude from the result. omission includes all filters
     * @returns {Array} list of filters with active filter sql
     */
    sqlActiveFilters (exclusions = []) {
        const s = this._sql;
        const rawActive = Object.keys(s).filter(k => s[k]);
        if (exclusions.length === 0) {
            return rawActive;
        } else {
            return rawActive.filter(k => exclusions.indexOf(k) === -1);
        }
    }

    /**
     * Indicates if any filters are active
     *
     * @method isActive
     * @returns {Boolean} indicates if any filters are active
     */
    isActive () {
        return this.sqlActiveFilters().length > 0;
    }

    /**
     * Returns a SQL WHERE condition that is combination of active filters.
     *
     * @method getCombinedSql
     * @param {Array} [exclusions] list of any filters to exclude from the result. omission includes all filters
     * @returns {String} all non-excluded sql statements connected with AND operators.
     */
    getCombinedSql (exclusions = []) {
        // list of active, non-excluded filters
        const keys = this.sqlActiveFilters(exclusions);

        const l = keys.length;
        if (l === 0) {
            return '';
        } else if (l === 1) {
            // no need for fancy brackets
            return this._sql[keys[0]];
        } else {
            // wrap each nugget in bracket, connect with AND
            return keys.map(k => `(${this._sql[k]})`).join(' AND ');
        }
    }

    /**
     * Tells what object ids are currently passing the layer's filters.
     *
     * @method getFilterOIDs
     * @param {Array} [exclusions] list of any filters to exclude from the result. omission includes all filters
     * @param {Extent} [extent] if provided, the result list will only include features intersecting the extent
     * @returns {Promise} resolves with array of valid OIDs that layer is filtering. resolves with undefined if there is no filters being used
     */
    getFilterOIDs (exclusions = [], extent) {
        // TODO perhaps key-mapping here? figure out SQL here? meh
        return this._parent.getFilterOIDs(exclusions, extent);
    }

    /**
     * Helper method for raising filter events
     *
     * @method eventRaiser
     * @private
     * @param {String} filterType type of filter event being raised. Should be member of shared.filterType
     */
    eventRaiser (filterType) {
        const fcID = this._parent.fcID;
        this._parent._parent.raiseFilterEvent(fcID.layerId, fcID.layerIdx, filterType);
    }

    /**
     * Helper method generating IN SQL clauses against the OID field
     *
     * @method arrayToIn
     * @private
     * @param {Array} array an array of integers
     * @returns {String} a SQL IN clause that dictates the object id field must match a number in the input array
     */
    arrayToIn (array) {
        // TODO do we need empty array checks? caller should be smart enough to recognize prior to calling this
        return `${this._parent.oidField} IN (${array.join(',')})`;
    }

    /**
     * Updates a SQL filter clause and triggers filter change events.
     *
     * @method setSql
     * @param {String} filterType name of the filter to update
     * @param {String} whereClause clause defining the active filters on symbols. Use '' for no filter. Use '1=2' for everything filtered.
     */
    setSql (filterType, whereClause) {
        this._sql[filterType] = whereClause;

        // invalidate affected caches
        this.clearCacheSet(filterType);

        // tell the world
        this.eventRaiser(filterType);
    }

    /**
     * Returns current SQL for a fitler type
     *
     * @method getSql
     * @param {String} filterType key string indicating what filter the sql belongs to
     * @returns {String} the SQL, if any, that matches the filter type
     */
    getSql (filterType) {
        return this._sql[filterType] || '';
    }

    /**
     * Registers a new extent for cache tracking.
     *
     * @method setExtent
     * @param {Extent} extent the extent to filter against
     */
    setExtent (extent) {
        // NOTE while technically we can support other geometries (for server based layers)
        //      only extent works for file layers. for now, limit to extent.
        //      we can add fancier things later when we need them

        // if our extent is different than our last request, clear the cache
        // and update our tracker
        if (!shared.areExtentsSame(extent, this._extent)) {
            this._extent = extent;

            // clear caches that care about extent.
            this.clearCacheSet(shared.filterType.EXTENT);

            // We don't raise an event here. EXTENT event is a map-level thing, so a layer should not be raising it
            // (we'd have each layer shooting off an event every pan if we did)
        }
    }

    /**
     * Returns cache key depending on the situation we are in.
     *
     * @method getCacheKey
     * @private
     * @param {Array} sqlFilters list of filters influencing this cache
     * @param {Boolean} includeExtent if the cache includes extent based filters
     * @returns {String} the cache key to use
     */
    getCacheKey (sqlFilters, includeExtent) {
        const sqlKey = sqlFilters.sort().join('$');
        return `_cache$${sqlKey}${includeExtent ? '$' + shared.filterType.EXTENT : ''}$`;
    }

    /**
     * Returns cache for a specific filtering scenario.
     *
     * @method getCache
     * @private
     * @param {Array} sqlFilters list of filters influencing this cache
     * @param {Boolean} includeExtent if the cache includes extent based filters
     * @returns {Promise} resolves in a filter result appropriate for the parameters. returns nothing if no cache exists.
     */
    getCache (sqlFilters, includeExtent) {
        const key = this.getCacheKey(sqlFilters, includeExtent);
        return this._cache[key];
    }

    /**
     * Sets a filter query in a cache, so repeated identical requests will only hit the server once
     *
     * @method setCache
     * @param {Promise} queryPromise the query we want to cache
     * @param {Array} sqlFilters list of filters influencing this cache
     * @param {Boolean} includeExtent if the cache includes extent based filters
     */
    setCache (queryPromise, sqlFilters, includeExtent) {
        const key = this.getCacheKey(sqlFilters, includeExtent);
        this._cache[key] = queryPromise;
    }

    /**
     * Returns list of cache keys that have caches
     *
     * @method cacheActiveKeys
     * @returns {Array} list of keys with active caches
     */
    cacheActiveKeys () {
        const c = this._cache;
        return Object.keys(c).filter(k => c[k]);
    }

    /**
     * Resets all internal caches.
     *
     * @method clearAllCaches
     */
    clearAllCaches () {
        // lol
        this._cache = {};
    }

    /**
     * Resets all internal caches related to a filter.
     *
     * @method clearCacheSet
     * @param {String} filterName filter that has changed and needs its caches wiped
     */
    clearCacheSet (filterName) {
        // the keys are wrapped in $ chars to avoid matching similarly named filter keys.
        // e.g. 'plugin' would also match 'plugin1' in an indexOf call, but '$plugin$' won't match '$plugin1$'
        this.cacheActiveKeys().forEach(c => {
            if (c.indexOf(`$${filterName}$`) > -1) {
                this._cache[c] = undefined;
            }
        });
    }

    /**
     * Resets all internal filter settings to have no filter applied. Does not trigger filter change events.
     *
     * @method clearAll
     */
    clearAll () {
        this._sql = {};
        this._extent = undefined; 
        this.clearAllCaches();
    }
}

module.exports = () => ({
    Filter
});