Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire WireCache
 *
 * Simple cache for storing strings (encoded or otherwise) and serves as $cache API var
 * 
 * ProcessWire 3.x, Copyright 2019 by Ryan Cramer
 * https://processwire.com
 *
 * #pw-summary Provides easy, persistent caching of markup, strings, arrays or PageArray objects. 
 * #pw-summary-constants These constants are used for the `$expire` argument of get() and save() cache methods. 
 * #pw-use-constants
 * #pw-body = 
 * ~~~~~
 * // Get a cache named 'foo' that lasts for 1 hour (aka 3600 seconds)
 * $value = $cache->get('foo', 3600, function() {
 *   // this is called if cache expired or does not exist, 
 *   // so generate a new cache value here and return it
 *   return "This is the cached value";
 * });
 * ~~~~~
 * #pw-body
 * 
 */

class WireCache extends Wire {

    /**
     * Expiration constants that may be supplied to WireCache::save $seconds argument. 
     * 
     */

    /**
     * Cache should never expire (unless manually cleared). 
     * 
     */
    const expireNever = '2010-04-08 03:10:10';

    /**
     * Cache should never expire and should not be deleted during deleteAll() calls (for PW internal system use)
     * Can only be deleted by delete() calls that specify it directly or match it specifically with a wildcard. 
     *
     */
    const expireReserved = '2010-04-08 03:10:01';

    /**
     * Cache should expire when a given resource (Page or Template) is saved. 
     * 
     */
    const expireSave = '2010-01-01 01:01:01';

    /**
     * Used internally when a selector is specified. 
     * #pw-internal
     * 
     */
    const expireSelector = '2010-01-02 02:02:02';

    /**
     * Cache should expire now
     * 
     */
    const expireNow = 0;

    /**
     * Cache should expire once per hour
     * 
     */
    const expireHourly = 3600;

    /**
     * Cache should expire once per day
     */
    const expireDaily = 86400;

    /**
     * Cache should expire once per week
     * 
     */
    const expireWeekly = 604800;

    /**
     * Cache should expire once per month
     * 
     */
    const expireMonthly = 2419200;

    /**
     * Date format used by our database queries
     * #pw-internal
     * 
     */
    const dateFormat = 'Y-m-d H:i:s';

    /**
     * String names of expire constants
     * 
     * @var array
     * 
     */
    protected $expireNames = array(
        'now' => self::expireNow,
        'hour' => self::expireHourly,
        'hourly' => self::expireHourly,
        'day' => self::expireDaily,
        'daily' => self::expireDaily,
        'week' => self::expireWeekly,
        'weekly' => self::expireWeekly,
        'month' => self::expireMonthly,
        'monthly' => self::expireMonthly,
    );
    
    /**
     * Preloaded cache values, indexed by cache name
     * 
     * @var array
     * 
     */
    protected $preloads = array();
    
    /**
     * Memory cache used by the maintenancePage method
     * 
     * @var array|null Once determined becomes array of cache names => Selectors objects
     *
     */
    protected $cacheNameSelectors = null;

    /**
     * Whether or not it's worthwhile to attempt Page or Template maintenance after saves
     * 
     * @var null|bool
     * 
     */
    protected $usePageTemplateMaintenance = null;

    /**
     * Preload the given caches, so that they will be returned without query on the next get() call
     * 
     * After a preloaded cache is returned from a get() call, it is removed from local storage. 
     * 
     * #pw-group-advanced
     * 
     * @param string|array $names
     * @param int|string|null $expire
     * 
     */
    public function preload(array $names, $expire = null) {
        if(!is_array($names)) $names = array($names);
        $this->preloads = array_merge($this->preloads, $this->get($names, $expire));
    }

    /**
     * Preload all caches for the given object or namespace
     * 
     * #pw-group-advanced
     * 
     * @param object|string $ns
     * @param int|string|null $expire
     * 
     */
    public function preloadFor($ns, $expire = null) {
        if(is_object($ns)) $ns = wireClassName($ns, false);
        $ns .= '__*';
        $this->preloads = array_merge($this->preloads, $this->get($ns, $expire));
    }
    
    /**
     * Get data from cache with given name
     * 
     * Optionally specify expiration time and/or a cache generation function to use when cache needs to be created.
     * 
     * Cached value can be a string, an array of non-object values, or a PageArray. 
     * 
     * ~~~~~
     * // get single cache value 
     * $str = $cache->get('foo');
     * 
     * // get 3 cached values, returns associative array with foo, bar, baz indexes
     * $array = $cache->get([ 'foo', 'bar', 'baz' ]);
     * 
     * // get all cache values with names starting with “hello”
     * $array = $cache->get('hello*');
     * 
     * // get cache only if it’s less than or equal to 1 hour old (3600 seconds)
     * $str = $cache->get('foo', 3600);
     * 
     * // same as above, but also generates the cache value with function when expired
     * $str = $cache->get('foo', 3600, function() {
     *   return "This is the cached value";
     * });
     * ~~~~~
     * 
     * @param string|array $name Provide a single cache name, an array of cache names, or an asterisk cache name.
     * - If given a single cache name (string) just the contents of that cache will be returned.
     * - If given an array of names, multiple caches will be returned, indexed by cache name. 
     * - If given a cache name with an asterisk in it, it will return an array of all matching caches. 
     * @param int|string|null $expire Optionally specify max age (in seconds) OR oldest date string.
     * - If cache exists and is older, then blank returned. You may omit this to divert to whatever expiration
     *   was specified at save() time. Note: The $expire and $func arguments may optionally be reversed. 
     * - If using a $func, the behavior of $expire becomes the same as that of save(). 
     * @param callable $func Optionally provide a function/closure that generates the cache value and it 
     *  will be used when needed. This option requires that only one cache is being retrieved (not an array of caches). 
     *  Note: The $expire and $func arguments may optionally be reversed. 
     * @return string|array|PageArray|mixed|null Returns null if cache doesn’t exist and no generation function provided. 
     * @throws WireException if given invalid arguments
     * 
     * 
     */
    public function get($name, $expire = null, $func = null) {
    
        $_expire = $expire;
        if(!is_null($expire)) {
            if(!is_int($expire) && !is_string($expire) && !$expire instanceof Wire && is_callable($expire)) {
                $_func = $func;
                $func = $expire; 
                $expire = is_null($_func) ? null : $this->getExpires($_func);
                unset($_func);
            } else {
                $expire = $this->getExpires($expire);
            }
        }

        $multi = is_array($name); // retrieving multiple caches at once?
        if($multi) {
            $names = $name;
        } else {
            if(isset($this->preloads[$name])) {
                $value = $this->preloads[$name];
                unset($this->preloads[$name]); 
                return $value; 
            }
            $names = array($name);  
        }
        
        $where = array();
        $binds = array();
        $wildcards = array();
        $n = 0;
        
        foreach($names as $name) {
            $n++;
            if(strpos($name, '*') !== false || strpos($name, '%') !== false) {
                // retrieve all caches matching wildcard
                $wildcards[$name] = $name; 
                $name = str_replace('*', '%', $name); 
                $multi = true; 
                $where[$n] = "name LIKE :name$n";
            } else {
                $where[$n] = "name=:name$n";
            }
            $binds[":name$n"] = $name; 
        }
        
        if($multi && !is_null($func)) {
            throw new WireException("Function (\$func) may not be specified to \$cache->get() when requesting multiple caches.");
        }
        
        $sql = "SELECT name, data FROM caches WHERE (" . implode(' OR ', $where) . ") ";
        
        if(is_null($expire)) { // || $func) {
            $sql .= "AND (expires>=:now OR expires<=:never) ";
            $binds[':now'] = date(self::dateFormat, time());
            $binds[':never'] = self::expireNever;
        } else if(is_array($expire)) {
            // expire is specified by a page selector, so we just let it through
            // since anything present is assumed to be valid    
        } else {
            $sql .= "AND expires<=:expire ";
            $binds[':expire'] = $expire;
            // $sql .= "AND (expires>=:expire OR expires<=:never) ";
            //$binds[':never'] = self::expireNever;
        }
    
        $query = $this->wire('database')->prepare($sql, "cache.get(" . 
            implode('|', $names) . ", " . ($expire ? print_r($expire, true) : "null") . ")"); 
        
        foreach($binds as $key => $value) $query->bindValue($key, $value);
        
        $value = ''; // return value for non-multi mode
        $values = array(); // return value for multi-mode
        
        if($_expire !== self::expireNow) try {
            $query->execute(); 
            if($query->rowCount() == 0) {
                $value = null; // cache does not exist
            } else while($row = $query->fetch(\PDO::FETCH_NUM)) {
                list($name, $value) = $row;
                if($this->looksLikeJSON($value)) $value = $this->decodeJSON($value); 
                if($multi) $values[$name] = $value; 
            }
            $query->closeCursor();
                
        } catch(\Exception $e) {
            $this->trackException($e, false);
            $value = null;
        }
        
        if($multi) {
            foreach($names as $name) {
                // ensure there is at least a placeholder for all requested caches
                if(!isset($values[$name]) && !isset($wildcards[$name])) $values[$name] = '';
            }
        } else if(empty($value) && !is_null($func) && is_callable($func)) {
            // generate the cache now from the given callable function
            $value = $this->renderCacheValue($name, $expire, $func); 
        }
        
        return $multi ? $values : $value; 
    }

    /**
     * Render and save a cache value, when given a function to do so
     * 
     * Provided $func may specify any arguments that correspond with the names of API vars
     * and it will be sent those arguments. 
     * 
     * Provided $func may either echo or return it's output. If any value is returned by
     * the function it will be used as the cache value. If no value is returned, then 
     * the output buffer will be used as the cache value. 
     * 
     * @param string $name
     * @param int|string|null $expire
     * @param callable $func
     * @return bool|string
     * @since 2.5.28
     * 
     */
    protected function renderCacheValue($name, $expire, $func) {
        
        $ref = new \ReflectionFunction($func);
        $params = $ref->getParameters(); // requested arguments
        $args = array(); // arguments we provide
        
        foreach($params as $param) {
            $arg = null;
            // if requested param is an API variable we will provide it
            if(preg_match('/\$([_a-zA-Z0-9]+)\b/', $param, $matches)) $arg = $this->wire($matches[1]);
            $args[] = $arg;
        }

        ob_start();
    
        try {
            if(count($args)) {
                $value = call_user_func_array($func, $args);
            } else {
                $value = $func();
            }
        } finally { // PHP 5.5+
            $out = ob_get_contents();
            ob_end_clean();
        }
        
        if(empty($value) && !empty($out)) $value = $out; 

        if($value !== false) {
            $this->save($name, $value, $expire);
        }
        
        return $value; 
    }

    /**
     * Same as get() but with namespace
     * 
     * Namespace is useful to avoid cache name collisions. The ProcessWire core commonly uses cache 
     * namespace to bind cache values to the object class, which often make a good namespace. 
     * 
     * Please see the `$cache->get()` method for usage of all arguments. 
     * 
     * ~~~~~
     * // specify namespace as a string
     * $value = $cache->getFor('my-namespace', 'my-cache-name');
     * 
     * // or specify namespace is an object instance
     * $value = $cache->get($this, 'my-cache-name'); 
     * ~~~~~
     * 
     * @param string|object $ns Namespace
     * @param string $name Cache name
     * @param null|int|string $expire Optional expiration
     * @param callable|null $func Optional cache generation function
     * @return string|array|PageArray|mixed|null Returns null if cache doesn’t exist and no generation function provided. 
     * @see WireCache::get()
     * 
     */
    public function getFor($ns, $name, $expire = null, $func = null) {
        if(is_object($ns)) $ns = wireClassName($ns, false); 
        return $this->get($ns . "__$name", $expire, $func); 
    }

    /**
     * Save data to cache with given name
     * 
     * ~~~~~
     * $value = "This is the value that will be cached.";
     * 
     * // cache the value, using default expiration (daily)
     * $cache->save("my-cache-name", $value); 
     * 
     * // cache the value, and expire after 1 hour (3600 seconds)
     * $cache->save("my-cache-name", $value, 3600); 
     * ~~~~~
     * 
     * @param string $name Name of cache, can be any string up to 255 chars
     * @param string|array|PageArray $data Data that you want to cache. May be string, array of non-object values, or PageArray.
     * @param int|string|Page $expire Lifetime of this cache, in seconds, OR one of the following:
     *  - Specify one of the `WireCache::expire*` constants. 
     *  - Specify the future date you want it to expire (as unix timestamp or any `strtotime()` compatible date format)  
     *  - Provide a `Page` object to expire when any page using that template is saved.  
     *  - Specify `WireCache::expireNever` to prevent expiration.  
     *  - Specify `WireCache::expireSave` to expire when any page or template is saved.   
     *  - Specify selector string matching pages that–when saved–expire the cache.   
     * @return bool Returns true if cache was successful, false if not
     * @throws WireException if given data that cannot be cached
     * 
     */
    public function save($name, $data, $expire = self::expireDaily) {

        if(is_array($data)) {
            if(array_key_exists('WireCache', $data)) {
                throw new WireException("Cannot cache array that has 'WireCache' array key (reserved for internal use)"); 
            } else if(array_key_exists('PageArray', $data) && array_key_exists('template', $data)) {
                throw new WireException("Cannot cache array that has 'PageArray' combined with 'template' keys (reserved for internal use)"); 
            }
        } else if(is_object($data)) {
            if($data instanceof PageArray) {
                $data = $this->pageArrayToArray($data); 
            } else if(method_exists($data, '__toString')) {
                $data = (string) $data; 
            } else {
                throw new WireException("WireCache::save does not know how to cache values of type " . get_class($data));
            }
        }
        
        $expire = $this->getExpires($expire); 
        
        if(is_array($expire)) {
            // expire based on selector string
            $data = array(
                'selector' => $expire['selector'], 
                'WireCache' => $data
            );
            $expire = self::expireSelector;
            $this->cacheNameSelectors = null; // clear memory cache for maintenancePage method
        }
    
        if(is_array($data)) {
            $data = json_encode($data);
            if($data === false) throw new WireException("Unable to encode array data for cache: $name"); 
        } else if(is_string($data) && $this->looksLikeJSON($data)) {
            // ensure potentially already encoded JSON text remains as text when cache is awakened
            $data = json_encode(array('WireCache' => $data)); 
        }
        
        if(is_null($data)) $data = '';

        $sql = 
            'INSERT INTO caches (`name`, `data`, `expires`) VALUES(:name, :data, :expires) ' . 
            'ON DUPLICATE KEY UPDATE `data`=VALUES(`data`), `expires`=VALUES(`expires`)';
                    
        $query = $this->wire('database')->prepare($sql, "cache.save($name)"); 
        $query->bindValue(':name', $name); 
        $query->bindValue(':data', $data); 
        $query->bindValue(':expires', $expire); 
        
        try {
            $result = $query->execute();
            $this->log($this->_('Saved cache ') . ' - ' . $name);
        } catch(\Exception $e) {
            $this->trackException($e, false);
            $result = false; 
        }

        $this->maintenance();
        
        return $result;
    }

    /**
     * Same as save() except with namespace
     * 
     * Namespace is useful to avoid cache name collisions. The ProcessWire core commonly uses cache
     * namespace to bind cache values to the object class, which often make a good namespace.
     * 
     * ~~~~~
     * // save cache using manually specified namespace
     * $cache->save("my-namespace", "my-cache-name", $value);
     * 
     * // save cache using namespace of current object
     * $cache->save($this, "my-cache-name", $value); 
     * ~~~~~
     *
     * @param string|object $ns Namespace for cache
     * @param string $name Name of cache, can be any string up to 255 chars
     * @param string|array|PageArray $data Data that you want to cache
     * @param int|Page $expire Lifetime of this cache, in seconds, OR one of the following:
     *  - Specify one of the `WireCache::expire*` constants.
     *  - Specify the future date you want it to expire (as unix timestamp or any strtotime compatible date format)
     *  - Provide a `Page` object to expire when any page using that template is saved.
     *  - Specify `WireCache::expireNever` to prevent expiration.
     *  - Specify `WireCache::expireSave` to expire when any page or template is saved.
     *  - Specify selector string matching pages that, when saved, expire the cache.
     * @return bool Returns true if cache was successful, false if not
     * 
     */
    public function saveFor($ns, $name, $data, $expire = self::expireDaily) {
        if(is_object($ns)) $ns = wireClassName($ns, false); 
        return $this->save($ns . "__$name", $data, $expire); 
    }

    /**
     * Given an expiration seconds, date, page, or template, convert it to an ISO-8601 date
     * 
     * Returns an array if expires info requires multiple parts, like with self::expireSelector.
     * In this case it returns array with array('expires' => date, 'selector' => selector);
     * 
     * @param $expire
     * @return string|array
     * 
     */
    protected function getExpires($expire) {
        
        if(is_object($expire) && $expire->id) {

            if($expire instanceof Page) {
                // page object
                $expire = $expire->templates_id;

            } else if($expire instanceof Template) {
                // template object
                $expire = $expire->id;

            } else {
                // unknown object, substitute default
                $expire = time() + self::expireDaily;
            }
            
        } else if(is_array($expire)) {
            // expire value already prepared by a previous call, just return it
            if(isset($expire['selector']) && isset($expire['expire'])) {
                return $expire;
            }

        } else if(is_string($expire) && isset($this->expireNames[$expire])) {
            // named expiration constant like "hourly", "daily", etc. 
            $expire = time() + $this->expireNames[$expire];

        } else if(in_array($expire, array(self::expireNever, self::expireReserved, self::expireSave))) {
            // good, we'll take it as-is
            return $expire;

        } else if(is_string($expire) && Selectors::stringHasSelector($expire)) {
            // expire when page matches selector
            return array(
                'expire' => self::expireSelector, 
                'selector' => $expire
            );

        } else {

            // account for date format as string
            if(is_string($expire) && !ctype_digit("$expire")) {
                $expire = strtotime($expire);
                $isDate = true; 
            } else {
                $isDate = false;
            }

            if($expire === 0 || $expire === "0") {
                // zero is allowed if that's what was specified
                $expire = (int) $expire; 
            } else {
                // zero is not allowed because it indicates failed type conversion
                $expire = (int) $expire;
                if(!$expire) $expire = self::expireDaily;
            }

            if($expire > time()) {
                // a future date has been specified, so we'll keep it
            } else if(!$isDate) {
                // a quantity of seconds has been specified, add it to current time
                $expire = time() + $expire;
            }
        }
        
        $expire = date(self::dateFormat, $expire);
        
        return $expire; 
    }

    /**
     * Delete/clear the cache(s) identified by given name or wildcard
     * 
     * ~~~~~
     * // Delete cache named "my-cache-name"
     * $cache->delete("my-cache-name");
     * 
     * // Delete all caches starting with "my-"
     * $cache->delete("my-*");
     * ~~~~~
     * 
     * @param string $name Name of cache, or partial name with wildcard (i.e. "MyCache*") to clear multiple caches. 
     * @return bool True on success, false on failure
     * 
     */
    public function delete($name) {
        try {
            if(strpos($name, '*') !== false || strpos($name, '%') !== false) {
                // delete all caches matching wildcard
                $name = str_replace('*', '%', $name);
                if($name === '%') return $this->deleteAll() ? true : false;
                $sql = 'DELETE FROM caches WHERE name LIKE :name';
            } else {
                $sql = 'DELETE FROM caches WHERE name=:name';
            }
            $query = $this->wire('database')->prepare($sql, "cache.delete($name)"); 
            $query->bindValue(':name', $name); 
            $query->execute();
            $query->closeCursor();
            $success = true; 
            $this->log($this->_('Cleared cache') . ' - ' . $name);
        } catch(\Exception $e) {
            $this->trackException($e, true);
            $this->error($e->getMessage()); 
            $success = false;
        }
        return $success;
    }

    /**
     * Delete all caches (where allowed)
     * 
     * This method deletes all caches other than those with `WireCache::expireReserved` status. 
     * 
     * @return int Quantity of caches deleted
     * @since 3.0.130
     * 
     */
    public function deleteAll() {
        try {
            $sql = "DELETE FROM caches WHERE expires!=:reserved";
            $query = $this->wire('database')->prepare($sql, "cache.deleteAll()");
            $query->bindValue(':reserved', self::expireReserved);
            $query->execute();
            $qty = $query->rowCount();
            $query->closeCursor();
        } catch(\Exception $e) {
            $this->trackException($e, true);
            $this->error($e->getMessage());
            $qty = 0;
        }
        return $qty;
    }

    /**
     * Deletes all caches that have expiration dates (only)
     * 
     * This method does not delete caches that are expired by saving of resources or matching selectors.
     * 
     * @return int
     * @since 3.0.130
     * 
     */
    public function expireAll() {
        try {
            $sql = "DELETE FROM caches WHERE expires>:never";
            $query = $this->wire('database')->prepare($sql, "cache.expireAll()");
            $query->bindValue(':never', self::expireNever);
            $query->execute();
            $qty = $query->rowCount();
            $query->closeCursor();
        } catch(\Exception $e) {
            $this->trackException($e, true);
            $this->error($e->getMessage());
            $qty = 0;
        }
        return $qty;
    }

    /**
     * Delete one or more caches in a given namespace
     * 
     * ~~~~~
     * // Delete all in namespace
     * $cache->deleteFor("my-namespace"); 
     * 
     * // Delete one cache in namespace
     * $cache->deleteFor("my-namespace", "my-cache-name"); 
     * ~~~~~
     *
     * @param string $ns Namespace of cache.
     * @param string $name Name of cache. If none specified, all for namespace are deleted.
     * @return bool True on success, false on failure
     *
     */
    public function deleteFor($ns, $name = '') {
        if(is_object($ns)) $ns = wireClassName($ns, false);
        if(!strlen($name)) $name = "*";
        return $this->delete($ns . "__$name");
    }

    /**
     * Cache maintenance removes expired caches
     * 
     * Should be called as part of a regular maintenance routine and after page/template save/deletion.
     * ProcessWire already calls this automatically, so you don’t typically need to call this method on your own. 
     * 
     * #pw-group-advanced
     * 
     * @param Template|Page|null|bool Item to run maintenance for or, if not specified, general maintenance is performed.
     *  General maintenance only runs once per request. Specify boolean true to force general maintenance to run.
     * @return bool
     * 
     */
    public function maintenance($obj = null) {
        
        static $done = false;
        
        $forceRun = false;
        $database = $this->wire()->database;
        $config = $this->wire()->config;
        
        if(!$database || !$config) return false;
        
        if(is_object($obj)) {
        
            // check to see if it is worthwhile to perform this kind of maintenance at all
            if(is_null($this->usePageTemplateMaintenance)) {
                $templates = $this->wire()->templates;
                if(!$templates) $templates = array();
                $minID = 999999;
                $maxID = 0;
                foreach($templates as $template) {
                    if($template->id > $maxID) $maxID = $template->id;
                    if($template->id < $minID) $minID = $template->id;
                }
                $sql = 
                    "SELECT COUNT(*) FROM caches " . 
                    "WHERE (expires=:expireSave OR expires=:expireSelector) " . 
                    "OR (expires>=:minID AND expires<=:maxID)";
                
                $query = $database->prepare($sql);
                $query->bindValue(':expireSave', self::expireSave);
                $query->bindValue(':expireSelector', self::expireSelector);
                $query->bindValue(':minID', date(self::dateFormat, $minID));
                $query->bindValue(':maxID', date(self::dateFormat, $maxID));
                $query->execute();
                $this->usePageTemplateMaintenance = (int) $query->fetchColumn();
                $query->closeCursor();
            }
        
            if($this->usePageTemplateMaintenance) {
                if($obj instanceof Page) return $this->maintenancePage($obj);
                if($obj instanceof Template) return $this->maintenanceTemplate($obj);
                return true;
            } else {
                // skip it: no possible caches to maintain
                return true; 
            }
            
        } else if($obj === true) {
            // force run general maintenance, even if run earlier
            $forceRun = true;
            $done = true;
            
        } else {
            // general maintenance: only perform maintenance once per request
            if($done) return true; 
            $done = true; 
        }
        
        // don't perform general maintenance during ajax requests
        if($config->ajax && !$forceRun) return false;

        // perform general maintenance now  
        return $this->maintenanceGeneral();
    }

    /**
     * General maintenance removes expired caches
     * 
     * @return bool
     * 
     */
    protected function maintenanceGeneral() {
    
        $database = $this->wire()->database;
        
        $sql = 'DELETE FROM caches WHERE (expires<=:now AND expires>:never) ';
        $query = $database->prepare($sql, "cache.maintenance()");
        $query->bindValue(':now', date(self::dateFormat, time()));
        $query->bindValue(':never', self::expireNever);

        try {
            $result = $query->execute();
            $qty = $result ? $query->rowCount() : 0;
            if($qty) $this->log(sprintf($this->_('General maintenance expired %d cache(s)'), $qty));
            $query->closeCursor();

        } catch(\Exception $e) {
            $this->trackException($e, false);
            $this->error($e->getMessage(), Notice::debug | Notice::log);
            $result = false;
        }
        
        return $result;
    }

    /**
     * Run maintenance for a page that was just saved or deleted
     * 
     * @param Page $page
     * @return bool
     * 
     */
    protected function maintenancePage(Page $page) {
        
        $database = $this->wire()->database;
        
        if(is_null($this->cacheNameSelectors)) {
            // locate all caches that specify selector strings and cache them so that 
            // we don't have to re-load them on every page save
            try {
                $query = $database->prepare("SELECT * FROM caches WHERE expires=:expire");
                $query->bindValue(':expire', self::expireSelector);
                $query->execute();
                $this->cacheNameSelectors = array();
            } catch(\Exception $e) {
                $this->trackException($e, false);
                $this->error($e->getMessage(), Notice::log);
                return false;
            }
            if($query->rowCount()) {
                while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
                    $data = json_decode($row['data'], true);
                    if($data !== false && isset($data['selector'])) {
                        $name = $row['name'];
                        $selectors = $this->wire(new Selectors($data['selector']));
                        $this->cacheNameSelectors[$name] = $selectors;
                    }
                }
            }
        } else {
            // cacheNameSelectors already loaded once and is in cache
        }

        // determine which selectors match the page: the $clearNames array
        // will hold the selectors that match this $page
        $n = 0;
        $clearNames = array();
        foreach($this->cacheNameSelectors as $name => $selectors) {
            if($page->matches($selectors)) {
                $clearNames["name" . (++$n)] = $name;
            }
        }

        // clear any caches that expire on expireSave or specific page template
        $sql = "expires=:expireSave OR expires=:expireTemplateID ";
        
        // expire any caches that match names found in cacheNameSelectors
        foreach($clearNames as $key => $name) {
            $sql .= "OR name=:$key ";
        }
    
        $query = $database->prepare("DELETE FROM caches WHERE $sql");
    
        // bind values
        $query->bindValue(':expireSave', self::expireSave); 
        $query->bindValue(':expireTemplateID', date(self::dateFormat, $page->template->id));
        
        foreach($clearNames as $key => $name) {
            $query->bindValue(":$key", $name);
        }
        
        $result = $query->execute();
        $qty = $result ? $query->rowCount() : 0;
        if($qty) $this->log(sprintf($this->_('Maintenance expired %d cache(s) for saved page'), $qty));
        
        return $result;
    }

    /**
     * Run maintenance for a template that was just saved or deleted
     *
     * @param Template $template
     * @return bool Returns true if any caches were deleted, false if not
     *
     */
    protected function maintenanceTemplate(Template $template) {
        
        $sql = 'DELETE FROM caches WHERE expires=:expireTemplateID OR expires=:expireSave';
        $query = $this->wire()->database->prepare($sql);

        $query->bindValue(':expireSave', self::expireSave);
        $query->bindValue(':expireTemplateID', date(self::dateFormat, $template->id));
        
        $result = $query->execute();
        $qty = $result ? $query->rowCount() : 0;
        if($qty) $this->log(sprintf($this->_('Maintenance expired %d cache(s) for saved template'), $qty));
        
        return $qty > 0;
    }
    
    /**
     * Convert a cacheable array to a PageArray
     *
     * @param array $data
     * @return PageArray
     * @since 2.5.28
     *
     */
    protected function arrayToPageArray(array $data) {

        $pageArrayClass = isset($data['pageArrayClass']) ? $data['pageArrayClass'] : 'PageArray';

        if(!isset($data['PageArray']) || !is_array($data['PageArray'])) {
            $class = wireClassName($pageArrayClass, true);
            return $this->wire(new $class());
        }

        $options = array();
        $template = empty($data['template']) ? null : $this->wire('templates')->get((int) $data['template']);
        if($template) $options['template'] = $template;
        if($pageArrayClass != 'PageArray') $options['pageArrayClass'] = $pageArrayClass;
        if(!empty($data['pageClass']) && $data['pageClass'] != 'Page') $options['pageClass'] = $data['pageClass'];

        return $this->wire('pages')->getById($data['PageArray'], $options);
    }

    /**
     * Given a PageArray, convert it to a cachable array
     *
     * @param PageArray $items
     * @return array
     * @throws WireException
     * @since 2.5.28
     *
     */
    protected function pageArrayToArray(PageArray $items) {

        $templates = array();
        $ids = array();
        $pageClasses = array();

        foreach($items as $item) {
            $templates[$item->template->id] = $item->template->id;
            $ids[] = $item->id;
            $pageClass = $item->className();
            $pageClasses[$pageClass] = $pageClass;
        }

        if(count($pageClasses) > 1) {
            throw new WireException("Can't cache multiple page types together: " . implode(', ', $pageClasses));
        }

        $data = array(
            'PageArray' => $ids,
            'template'  => count($templates) == 1 ? reset($templates) : 0,
        );

        $pageClass = reset($pageClasses);
        if($pageClass && $pageClass != 'Page') $data['pageClass'] = $pageClass;

        $pageArrayClass = $items->className();
        if($pageArrayClass != 'PageArray') $data['pageArrayClass'] = $pageArrayClass;

        return $data;
    }

    /**
     * Get information about all the caches in this WireCache
     * 
     * #pw-group-advanced
     * 
     * @param bool $verbose Whether to be more verbose for human readability
     * @param array|string $names Optionally specify name(s) of cache to get info. If omitted, all caches are included.
     * @param array|string $exclude Exclude any caches that begin with any of these namespaces (default=[])
     * @return array of arrays of cache info
     * 
     */
    public function getInfo($verbose = true, $names = array(), $exclude = array()) {
        
        $templates = $this->wire()->templates;
        $database = $this->wire()->database;
        
        if(is_string($names)) $names = empty($names) ? array() : array($names);
        if(is_string($exclude)) $exclude = empty($exclude) ? array() : array($exclude);
        
        $all = array();
        $binds = array();
        $wheres = array();
        $sql = "SELECT name, data, expires FROM caches ";
        
        if(count($names)) {
            $a = array();
            foreach($names as $n => $s) {
                $a[] = "name=:name$n";
                $binds[":name$n"] = $s;
            }
            $wheres[] = '(' . implode(' OR ', $a) . ')';
        }
            
        if(count($exclude)) {
            foreach($exclude as $n => $s) {
                $wheres[] = "name NOT LIKE :ex$n";
                $binds[":ex$n"] = $s . '%';
            }
        }

        if(count($wheres)) {
            $sql .= "WHERE " . implode(' AND ', $wheres);
        }
        
        $query = $database->prepare($sql);
        
        foreach($binds as $key => $val) {
            $query->bindValue($key, $val);
        }
        
        $query->execute();
        
        while($row = $query->fetch(\PDO::FETCH_ASSOC)) {

            $info = array(
                'name' => $row['name'],     
                'type' => 'string',
                'expires' => '',
            );
            
            if($this->looksLikeJSON($row['data'])) {
                // json encoded
                $data = json_decode($row['data'], true);
                if(is_array($data)) {
                    if(array_key_exists('WireCache', $data)) {
                        if(isset($data['selector'])) {
                            $selector = $data['selector'];
                            $info['expires'] = $verbose ? 'when selector matches modified page' : 'selector';
                            $info['selector'] = $selector;
                        }
                        $data = $data['WireCache'];
                    }
                    if(is_array($data) && array_key_exists('PageArray', $data) && array_key_exists('template', $data)) {
                        $info['type'] = 'PageArray'; 
                        if($verbose) $info['type'] .= ' (' . count($data['PageArray']) . ' pages)';
                    } else if(is_array($data)) {
                        $info['type'] = 'array'; 
                        if($verbose) $info['type'] .= ' (' . count($data) . ' items)';
                    }
                }
            }
            
            if(empty($info['expires'])) {
                if($row['expires'] === self::expireNever) {
                    $info['expires'] = $verbose ? 'never' : '';
                } else if($row['expires'] === self::expireReserved) {
                    $info['expires'] = $verbose ? 'reserved' : '';
                } else if($row['expires'] === self::expireSave) {
                    $info['expires'] = $verbose ? 'when any page or template is modified' : 'save';
                } else if($row['expires'] < self::expireSave) {
                    // potential template ID encoded as date string
                    $templateId = strtotime($row['expires']); 
                    $template = $templates->get($templateId);
                    if($template) {
                        $info['expires'] = $verbose ? "when '$template->name' page or template is modified" : 'save';
                        $info['template'] = $template->id;
                        break;
                    }
                }
                if(empty($info['expires'])) {
                    $info['expires'] = $row['expires'];
                    if($verbose) $info['expires'] .= " (" . wireRelativeTimeStr($row['expires']) . ")";
                }
            }

            if($verbose) $info['size'] = strlen($row['data']);
            
            $all[] = $info;
        }
        
        $query->closeCursor();
        
        return $all;    
    }
    
    /**
     * Render a file as a ProcessWire template file and cache the output
     *
     * This method is similar to the `$files->render()` method and actually delegates the file
     * rendering to that method (when creating the cache). The important difference is that this
     * method caches the output according to WireCache rules for the `$expire` argument, rather
     * than re-rendering the file on every call. 
     *
     * If there are any changes to the source file `$filename` the cache will be automatically
     * re-created, regardless of what is specified for the `$expire` argument.
     * 
     * ~~~~~~
     * // render primary nav from site/templates/partials/primary-nav.php 
     * // and cache for 3600 seconds (1 hour)
     * echo $cache->renderFile('partials/primary-nav.php', 3600); 
     * ~~~~~~
     *
     * @param string $filename Filename to render (typically PHP file). 
     *   Can be full path/file, or dir/file relative to current work directory (which is typically /site/templates/).
     *   If providing a file relative to current dir, it should not start with "/". 
     *   File must be somewhere within site/templates/, site/modules/ or wire/modules/, or provide your own `allowedPaths` option. 
     *   Please note that $filename receives API variables already (you don’t have to provide them).
     * @param int|Page|string|null $expire Lifetime of this cache, in seconds, OR one of the following:
     *  - Specify one of the `WireCache::expire*` constants.
     *  - Specify the future date you want it to expire (as unix timestamp or any `strtotime()` compatible date format)
     *  - Provide a `Page` object to expire when any page using that template is saved.
     *  - Specify `WireCache::expireNever` to prevent expiration.
     *  - Specify `WireCache::expireSave` to expire when any page or template is saved.
     *  - Specify selector string matching pages that–when saved–expire the cache.
     *  - Omit for default value, which is `WireCache::expireDaily`. 
     * @param array $options Accepts all options for the `WireFileTools::render()` method, plus these additional ones:
     *  - `name` (string): Optionally specify a unique name for this cache, otherwise $filename will be used as the unique name. (default='')
     *  - `vars` (array): Optional associative array of extra variables to send to template file. (default=[])
     *  - `allowedPaths` (array): Array of paths that are allowed (default is anywhere within templates, core modules and site modules)
     *  - `throwExceptions` (bool): Throw exceptions when fatal error occurs? (default=true)
     * @return string|bool Rendered template file or boolean false on fatal error (and throwExceptions disabled)
     * @throws WireException if given file doesn’t exist
     * @see WireFileTools::render()
     * @since 3.0.130
     *
     */
    public function renderFile($filename, $expire = null, array $options = array()) {

        $defaults = array(
            'name' => '',
            'vars' => array(),
            'throwExceptions' => true,
        );

        $out = null;
        $paths = $this->wire('config')->paths;
        $files = $this->wire('files');
        $filename = $files->unixFileName($filename);
        
        if(strpos($filename, '/') !== 0 && strpos($filename, ':') === false && strpos($filename, '//') === false) {
            // make relative to current path
            $currentPath = $files->currentPath();
            if($files->fileInPath($filename, $currentPath)) {
                $f = $currentPath . $filename;
                if(file_exists($f)) $filename = $f;
            }
        }
        
        $options = array_merge($defaults, $options);
        $mtime = filemtime($filename);
        $name = str_replace($paths->root, '', $filename);
        $ns = 'cache.' . ($options['name'] ? $options['name'] : 'renderFile');
        $cacheName = $this->cacheName($name, $ns);

        if($mtime === false) {
            if($options['throwExceptions']) throw new WireException("File not found: $filename");
            return false;
        }

        $data = $this->get($cacheName, $expire);

        // cache value is array where [ 0=created, 1='value' ]
        if(!is_array($data) || $data[0] < $mtime) {
            // cache does not exist or is older source file mtime
            $out = $this->wire('files')->render($filename, $options['vars'], $options);
            if($out === false) return false;
            $data = array(time(), $out);
            if($expire === null) $expire = self::expireDaily;
            $this->save($cacheName, $data, $expire);
        } else {
            $out = $data[1];
        }

        return $out;
    }

    /**
     * Make sure a cache name is of the right length and format for a cache name
     *
     * @param string $name Name including namespace (if applicable)
     * @param bool|string $ns True to allow namespace present, false to prevent, or specify namespace to add to name if not already present.
     * @return string
     * @since 3.0.130
     * @todo update other methods in this class to use this method
     *
     *
     */
    protected function cacheName($name, $ns = true) {

        $maxLength = 190;
        $name = trim($name);

        if($ns === false) {
            // namespace not allowed (cache name is NAME only)
            while(strpos($name, '__') !== false) $name = str_replace('__', '_', $name);
            if(strlen($name) > $maxLength) $name = md5($name);
            return $name;
        }

        if(is_string($ns) && strlen($ns)) {
            // a namespace has been supplied
            while(strpos($name, '__') !== false) $name = str_replace('__', '_', $name);
            while(strpos($ns, '__') !== false) $ns = str_replace('__', '_', $ns);
            $ns = rtrim($ns, '_') . '__';
            if(strpos($name, $ns) === 0) {
                // name already has this namespace
            } else {
                // prepend namespace to name
                $name = $ns . $name;
            }
        }

        if(strlen($name) <= $maxLength) {
            // name already in bounds
            return $name;
        }

        // at this point we have a cache name that is too long
        if(strpos($name, '__') !== false) {
            // has namespace
            list($ns, $name) = explode('__', $name, 2);
            while(strpos($name, '__') !== false) $name = str_replace('__', '_', $name);
            if(strlen($name) > 32) $name = md5($name);
            if(strlen($ns . '__' . $name) > $maxLength) $ns = md5($ns); // not likely
            $name = $ns . '__' . $name;
        } else {
            // no namespace
            $name = md5($name);
        }

        return $name;
    }

    
    /**
     * Does the given string look like it might be JSON?
     *
     * @param string $str
     * @return bool
     *
     */
    protected function looksLikeJSON(&$str) {
        if(empty($str)) return false;
        $c = substr($str, 0, 1);
        if($c === '{' && substr(trim($str), -1) === '}') return true;
        if($c === '[' && substr(trim($str), -1) === ']') return true;
        return false;
    }

    /**
     * Decode a JSON string (typically to array)
     *
     * Returns the given $value if it cannot be decoded.
     *
     * @param string $value JSON encoded text value
     * @param bool $toArray Decode to associative array? Specify false to decode to object. (default=true)
     * @return array|mixed|PageArray
     *
     */
    protected function decodeJSON($value, $toArray = true) {

        $a = json_decode($value, $toArray);

        if(is_array($a)) {
            // if there is a 'WireCache' key in the array, value becomes whatever is present in its value
            if(array_key_exists('WireCache', $a)) $a = $a['WireCache'];

            if(is_array($a) && isset($a['PageArray']) && is_array($a['PageArray']) && array_key_exists('template', $a)) {
                // convert to PageArray if keys for 'PageArray' and 'template' are both present and 'PageArray' value is an array
                $value = $this->arrayToPageArray($a);
            } else {
                // some other array
                $value = $a;
            }

        } else if($a !== null) {
            // it was JSON and now it’s some other non-array type
            $value = $a;

        } else {
            // we will return the $value we were given
        }

        return $value;
    }


    /**
     * Save to the cache log
     * 
     * #pw-internal
     *
     * @param string $str Message to log
     * @param array $options
     * @return WireLog
     *
     */
    public function ___log($str = '', array $options = array()) {
        //parent::___log($str, array('name' => 'modules'));
        return null;
    }
    
}