artabro/wire/core/WireCache.php
2024-08-27 11:35:37 +02:00

1424 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire WireCache
*
* Simple cache for storing strings (encoded or otherwise) and serves as $cache API var
*
* ProcessWire 3.x, Copyright 2023 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 {
/**
* Default cache class
*
*/
const defaultCacheClass = 'WireCacheDatabase';
/**
* 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;
/**
* Ignore expiration (skips expiration check) 3.0.218+
*
*/
const expireIgnore = false;
/**
* 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,
'ignore' => self::expireIgnore
);
/**
* Preloaded cache values, indexed by cache name
*
* @var array
*
*/
protected $preloads = array();
/**
* Are we currently preloading?
*
* @var bool
*
*/
protected $preloading = false;
/**
* 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;
/**
* @var WireCacheInterface
*
*/
protected $cacher = null;
/**
* Get the current WireClassInterface instance
*
* @return WireCacheInterface
*
*/
protected function cacher() {
$class = __NAMESPACE__ . "\\" . self::defaultCacheClass;
if($this->cacher === null) {
$this->cacher = new $class();
$this->wire($this->cacher);
}
return $this->cacher;
}
/**
* 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 array $names
* @param int|string|null $expire
* @deprecated
*
*/
public function preload(array $names, $expire = null) {
$this->preloading = true;
$this->preloads = array_merge($this->preloads, $this->get($names, $expire));
$this->preloading = false;
}
/**
* Preload all caches for the given object or namespace
*
* #pw-group-advanced
*
* @param object|string $ns
* @param int|string|null $expire
* @deprecated
*
*/
public function preloadFor($ns, $expire = null) {
if(is_object($ns)) $ns = wireClassName($ns, false);
$ns .= '__*';
$this->preloading = true;
$this->preloads = array_merge($this->preloads, $this->get($ns, $expire));
$this->preloading = false;
}
/**
* 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 its 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|false $expire Optionally specify max age (in seconds) OR oldest date string, or false to ignore.
* - If cache exists and is older, then null 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 doesnt exist and no generation function provided.
* @throws WireException if given invalid arguments
*
*/
public function get($name, $expire = null, $func = null) {
$values = array();
$getMultiple = is_array($name); // retrieving multiple caches at once?
$expireNow = false;
$expireIgnores = array(
self::expireIgnore, self::expireReserved, self::expireNever,
self::expireSave, self::expireSelector
);
if($expire !== null && $expire !== self::expireIgnore) {
if(!is_int($expire) && !is_string($expire) && is_callable($expire) && !$expire instanceof Wire) {
$_func = $func;
$func = $expire;
$expire = $_func === null ? null : $this->getExpires($_func);
unset($_func);
} else {
$expire = $this->getExpires($expire);
}
}
if($expire === self::expireNow) {
// forced expiration now
$expireNow = true;
$expires = array();
} else if(is_array($expire)) {
// selector [ 'expire' => self::expireSelector, 'selector' => '...' ]
$expires = array(
'= ' . $expire['expire']
);
} else if(in_array($expire, $expireIgnores, true)) {
// no expires conditions to match when:
// ignore, reserved, never, save, or selector
$expires = array();
} else if($func !== null || empty($expire)) {
// match row only if its expiration is greater than current date/time
// or if it has one of the expirations at or below never (save, reserved, etc.)
// also use this if $func in play since the expire is used for save rather than get
$expires = array(
'> ' . date(self::dateFormat),
'<= ' . self::expireNever
);
} else {
// expire represents date/time of expiration
$expires = array(
'< ' . $expire,
);
}
if($getMultiple) {
$names = $name;
} else {
if(isset($this->preloads[$name])) {
$value = $this->preloads[$name];
unset($this->preloads[$name]);
return $value;
}
$names = array($name);
}
$wildcards = array();
foreach($names as $s) {
if(strpos($s, '%') !== false) $s = str_replace('%', '*', $s);
if(strpos($s, '*') === false) continue;
// retrieve all caches matching wildcard
$getMultiple = true;
$wildcards[$s] = $s;
}
if($getMultiple && $func !== null) {
throw new WireException("Function (\$func) may not be specified to \$cache->get() when requesting multiple caches.");
}
$findOptions = array(
'names' => $names,
'expires' => $expires,
'get' => array('name', 'data')
);
if($getMultiple) {
// get array value
$rows = $expireNow ? array() : $this->cacher()->find($findOptions);
foreach($rows as $row) {
$value = $row['data'];
$name = $row['name'];
if($this->looksLikeJSON($value)) $value = $this->decodeJSON($value);
if($value !== false) $values[$name] = $value;
}
unset($rows);
foreach($names as $s) {
// ensure there is at least a placeholder for all requested caches
if(!isset($values[$s]) && !isset($wildcards[$s])) $values[$s] = '';
}
if($expireNow) {
// warning: expireNow in getMultiple mode does not support render cache value
}
} else {
// get single cache value
$findOptions['get'] = array('data');
$value = $expireNow ? array() : $this->cacher()->find($findOptions);
$value = count($value) ? reset($value) : null;
if(empty($value)) {
if($func !== null && is_callable($func)) {
// generate the cache now from the given callable function
$value = $this->renderCacheValue($name, $expire, $func);
} else {
$value = null;
}
} else {
$value = $value['data'];
if(!empty($value) && $this->looksLikeJSON($value)) {
$value = $this->decodeJSON($value);
if($value === false) $value = null;
}
}
}
return $getMultiple ? $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 doesnt 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 thatwhen savedexpire 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) {
$options = array(); // additional data to pass along to cacher save() method
if(empty($expire) && $expire !== self::expireNow) $expire = self::expireDaily;
if($expire === WireCache::expireSelector) {
$this->cacheNameSelectors = null;
}
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
);
$options['expireArray'] = $expire;
$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 = '';
try {
$result = $this->cacher()->save($name, $data, $expire);
$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('expire' => date, 'selector' => selector);
* To only allow returning of date strings, specify false for the $verbose argument.
* Or to always get an array return value, specify true for $verbose.
*
* #pw-internal
*
* @param Page|Template|string|array|int $expire
* @param bool|null $verbose Return verbose array? true=always, false=never, null=when appropriate (i.e. selector)
* @return string|array
*
*/
public function getExpires($expire, $verbose = null) {
if($expire instanceof Wire && $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'])) {
if($verbose || $verbose === null) return $expire; // return array
$expire = self::expireSelector;
} else {
// array without 'selector' is an unknown array
$expire = self::expireDaily;
}
} else if(is_string($expire) && isset($this->expireNames[$expire])) {
// named expiration constant like "hourly", "daily", etc.
$expire = time() + $this->expireNames[$expire];
} else if(is_string($expire) && Selectors::stringHasSelector($expire)) {
// expire when page matches selector
if($verbose || $verbose === null) {
return array(
'expire' => self::expireSelector,
'selector' => $expire
);
}
return self::expireSelector;
} else if(in_array($expire, array(self::expireNever, self::expireReserved, self::expireSave, self::expireNow))) {
// good, we'll take it as-is
return $verbose ? array('expire' => $expire) : $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);
if($verbose) $expire = array('expire' => $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 if no cache was cleared
*
*/
public function delete($name) {
if(strpos($name, '*') !== false) {
$rows = $this->cacher()->find(array(
'names' => array($name),
'get' => array('name'),
));
} else {
$rows = array(array('name' => $name));
}
$clearedNames = array();
foreach($rows as $row) {
$name = $row['name'];
try {
$success = $this->cacher()->delete($name);
} catch(\Exception $e) {
$this->trackException($e, true);
$this->error($e->getMessage());
$success = false;
}
if($success) $clearedNames[] = $name;
}
if(count($clearedNames)) {
$this->log("Cleared cache: " . implode(', ', $clearedNames));
return true;
}
return false;
}
/**
* 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 {
$qty = $this->cacher()->deleteAll();
} 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 {
$qty = $this->cacher()->expireAll();
} 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 dont 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;
$cacher = $this->cacher();
$useCacherMaint = method_exists($cacher, 'maintenance');
if(!$database || !$config) return false;
if(is_object($obj)) {
if($useCacherMaint) {
$result = call_user_func_array(array($cacher, 'maintenance'), array($obj));
if($result) return true;
}
// check to see if it is worthwhile to perform this kind of maintenance at all
if($this->usePageTemplateMaintenance === null) {
$rows = $cacher->find(array(
'get' => array('name'),
'expiresMode' => 'OR',
'expires' => array(
'= ' . self::expireSave,
'= ' . self::expireSelector
)
));
if(!count($rows)) {
$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;
}
$rows = $this->cacher()->find(array(
'get' => array('name'),
'expiresMode' => 'AND',
'expires' => array(
'>= ' . date(self::dateFormat, $minID),
'<= ' . date(self::dateFormat, $maxID),
)
));
}
$this->usePageTemplateMaintenance = count($rows);
}
if($this->usePageTemplateMaintenance) {
if($obj instanceof Page) return $this->maintenancePage($obj);
if($obj instanceof Template) return $this->maintenanceTemplate($obj);
} 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
if($done) return true;
$done = true;
}
// don't perform general maintenance during ajax requests
if($config->ajax && !$forceRun) return false;
if(!$forceRun) {
// run general maintenance only once every 10 minutes
$filename = $this->wire()->config->paths->cache . 'WireCache.maint';
if(@filemtime($filename) > (time() - 600)) return false;
touch($filename);
}
// perform general maintenance now
if($useCacherMaint) {
$result = (bool) call_user_func_array(array($cacher, 'maintenance'), array(null));
} else {
$result = $this->maintenanceGeneral();
}
return $result;
}
/**
* General maintenance removes expired caches
*
* @return bool
*
*/
protected function maintenanceGeneral() {
$rows = $this->cacher()->find(array(
'get' => array('name'),
'expiresMode' => 'AND',
'expires' => array(
'<= ' . date(self::dateFormat, time()),
'> ' . self::expireNever
)
));
$qty = 0;
foreach($rows as $row) {
if($this->delete($row['name'])) $qty++;
}
if($qty) $this->log(sprintf('General maintenance expired %d cache(s)', $qty));
return $qty > 0;
}
/**
* Run maintenance for a page that was just saved or deleted
*
* @param Page $page
* @return bool
*
*/
protected function maintenancePage(Page $page) {
$qty = 0;
if($this->cacheNameSelectors === null) {
// locate all caches that specify selector strings and cache them so that
// we don't have to re-load them on every page save
$this->cacheNameSelectors = array();
$rows = $this->cacher()->find(array(
'expires' => array(
'= ' . self::expireSelector
)
));
foreach($rows as $row) {
$data = json_decode($row['data'], true);
if($data === false || !isset($data['selector'])) continue;
$name = $row['name'];
/** @var Selectors $selectors */
$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
foreach($this->cacheNameSelectors as $name => $selectors) {
if($page->matches($selectors)) {
if($this->delete($name)) $qty++;
}
}
$rows = $this->cacher()->find(array(
'get' => array('name'),
'expiresMode' => 'OR',
'expires' => array(
'= ' . self::expireSave,
'= ' . date(self::dateFormat, $page->template->id)
),
));
foreach($rows as $row) {
if($this->delete($row['name'])) $qty++;
}
if($qty) $this->log(sprintf('Maintenance expired %d cache(s) for saved page', $qty));
return $qty > 0;
}
/**
* 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) {
$rows = $this->cacher()->find(array(
'get' => array('name'),
'expiresMode' => 'OR',
'expires' => array(
'= ' . self::expireSave,
'= ' . date(self::dateFormat, $template->id)
)
));
$qty = 0;
foreach($rows as $row) {
if($this->delete($row['name'])) $qty++;
}
if($qty) $this->log(sprintf('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=[])
* @param array $cols Columns to get, default = [ 'name', 'expires', 'data', 'size' ]
* @return array of arrays of cache info
*
*/
public function getInfo($verbose = true, $names = array(), $exclude = array(), array $cols = array()) {
if(is_string($names)) $names = empty($names) ? array() : array($names);
if(is_string($exclude)) $exclude = empty($exclude) ? array() : array($exclude);
if(empty($cols)) $cols = array('name', 'expires', 'data', 'size');
$all = array();
$options = count($names) ? array('names' => $names) : array();
$options['get'] = $cols;
$templates = $this->wire()->templates;
foreach($this->cacher()->find($options) as $row) {
if(count($exclude)) {
$skip = false;
foreach($exclude as $value) {
if(stripos($row['name'], $value) !== 0) continue;
$skip = true;
break;
}
if($skip) continue;
}
$info = array(
'name' => $row['name'],
'type' => 'string',
'expires' => '',
'size' => 0
);
if(isset($row['data']) && $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'] === WireCache::expireNever) {
$info['expires'] = $verbose ? 'never' : '';
} else if($row['expires'] === WireCache::expireReserved) {
$info['expires'] = $verbose ? 'reserved' : '';
} else if($row['expires'] === WireCache::expireSave) {
$info['expires'] = $verbose ? 'when any page or template is modified' : 'save';
} else if($row['expires'] < WireCache::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(isset($row['size'])) {
$info['size'] = $row['size'];
} else if(isset($row['data'])) {
$info['size'] = strlen($row['data']);
}
$all[] = $info;
}
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 dont 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 thatwhen savedexpire 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 doesnt exist
* @see WireFileTools::render()
* @since 3.0.130
*
*/
public function renderFile($filename, $expire = null, array $options = array()) {
$defaults = array(
'name' => '',
'vars' => array(),
'throwExceptions' => true,
);
$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 = $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
*
* #pw-internal
*
* @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. (default=true)
* @return string
* @since 3.0.130
* @todo update other methods in this class to use this method
*
*
*/
public 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?
*
* #pw-internal
*
* @param string $str
* @return bool
*
*/
public function looksLikeJSON(&$str) {
if(empty($str) || !is_string($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.
*
* #pw-internal
*
* @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
*
*/
public 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 its some other non-array type
$value = $a;
} else {
// we will return the $value we were given
}
return $value;
}
/**
* Set WireCache module to use for caching
*
* @param WireCacheInterface $module
* @since 3.0.218
*
*/
public function setCacheModule(WireCacheInterface $module) {
/** @var Wire|WireCacheInterface $module */
if($this->cacher !== null && $this->cacher->className() !== self::defaultCacheClass) {
$class1 = $this->cacher->className();
$class2 = $module->className();
$user = $this->wire()->user;
if($user && $user->isSuperuser()) {
$this->warning(
"Warning: there is more than one WireCache module installed. " .
"Please uninstall '$class1' or '$class2'."
);
}
}
$this->cacher = $module;
}
/**
* Get WireCache module that is currently being used
*
* @return WireCacheInterface
* @since 3.0.218
*
*/
public function getCacheModule() {
return $this->cacher();
}
/**
* 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' => 'cache'));
$str = ''; // disable log
return parent::___log($str, $options);
}
}