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(); if(count($args)) { $value = call_user_func_array($func, $args); } else { $value = $func(); } $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->template->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()) { 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 = $this->wire('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'] < time()) { $t = strtotime($row['expires']); foreach($this->wire('templates') as $template) { if($template->id == $t) { $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; } }