1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-09 08:17:12 +02:00

Major refactor of WireCache which now isolates the cache getting/saving/deleting to a separate module/class implementing the WireCacheInterface interface. Eventually this will enable one to modify/replace where and how PW's cache data is stored. For instance, file system, Redis, Memcache, etc. The default class is WireCacheDatabase which stores cache data in the database, as WireCache did prior to this update.

This commit is contained in:
Ryan Cramer
2023-05-19 11:12:49 -04:00
parent a76e54193e
commit 6981e3009e
6 changed files with 915 additions and 586 deletions

View File

@@ -533,8 +533,12 @@ class Debug {
$suffix = $options['ellipsis']; $suffix = $options['ellipsis'];
} }
foreach($value as $k => $v) { foreach($value as $k => $v) {
if(is_string($k) && strlen($k)) {
$value[$k] = "$$k => " . self::traceStr($v, $options);
} else {
$value[$k] = self::traceStr($v, $options); $value[$k] = self::traceStr($v, $options);
} }
}
$str = '[ ' . implode(', ', $value) . $suffix . ' ]'; $str = '[ ' . implode(', ', $value) . $suffix . ' ]';
} }
} else if(is_string($value)) { } else if(is_string($value)) {
@@ -632,6 +636,7 @@ class Debug {
case 'json_encode': case 'json_encode':
$value = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $value = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$value = str_replace(' ', ' ', $value); $value = str_replace(' ', ' ', $value);
if(strpos($value, '\\"') !== false) $value = str_replace('\\"', "'", $value);
break; break;
case 'var_export': case 'var_export':
$value = var_export($value, true); $value = var_export($value, true);

View File

@@ -720,4 +720,90 @@ interface InputfieldHasSelectableOptions {
public function addOptionLabel($value, $label, $language = null); public function addOptionLabel($value, $label, $language = null);
} }
/**
* Interface for WireCache handler classes
*
* @since 3.0.218
*
*/
interface WireCacheInterface {
/**
* Get single cache
*
* @param string $name
* @param string|array|null|false $expire
* @return string|false
*
*/
public function get($name, $expire);
/**
* Get multiple caches
*
* @param array $names
* @param string|array|null|false $expire
* @return array
*
*/
public function getMultiple(array $names, $expire);
/**
* Save a cache
*
* @param string $name
* @param string $data
* @param string $expire
* @return bool
*
*/
public function save($name, $data, $expire);
/**
* Delete cache
*
* @param string $name
* @return bool
*
*/
public function delete($name);
/**
* Delete all caches
*
* @return int
*
*/
public function deleteAll();
/**
* Expire all caches
*
* @return int
*
*/
public function expireAll();
/**
* Cache maintenance / remove expired caches
*
* Called as part of a regular maintenance routine and after page/template save/deletion.
*
* @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);
/**
* Get info about caches
*
* @param array $options
* - `verbose` (bool): Return verbose details? (default=true)
* - `names` (array): Names of caches to return info for, or omit for all (default=[])
* - `exclude` (array): Name prefixes of caches to exclude from return value (default=[])
* @return array
*
*/
public function getInfo(array $options = array());
}

View File

@@ -550,7 +550,7 @@ class ProcessWire extends Wire {
$cache = $this->wire('cache', new WireCache(), true); $cache = $this->wire('cache', new WireCache(), true);
$cacheNames = $config->preloadCacheNames; $cacheNames = $config->preloadCacheNames;
if($database->getEngine() === 'innodb') $cacheNames[] = 'InnoDB.stopwords'; if($database->getEngine() === 'innodb') $cacheNames[] = 'InnoDB.stopwords';
$cache->preload($cacheNames); $cache->preload($cacheNames, WireCache::expireIgnore);
$modules = null; $modules = null;
try { try {

View File

@@ -378,9 +378,12 @@ class Sanitizer extends Wire {
$value = mb_strtolower($value); $value = mb_strtolower($value);
if(empty($replacements)) { if(empty($replacements)) {
$modules = $this->wire()->modules;
if($modules) {
$configData = $this->wire()->modules->getModuleConfigData('InputfieldPageName'); $configData = $this->wire()->modules->getModuleConfigData('InputfieldPageName');
$replacements = empty($configData['replacements']) ? InputfieldPageName::$defaultReplacements : $configData['replacements']; $replacements = empty($configData['replacements']) ? InputfieldPageName::$defaultReplacements : $configData['replacements'];
} }
}
foreach($replacements as $from => $to) { foreach($replacements as $from => $to) {
if(mb_strpos($value, $from) !== false) { if(mb_strpos($value, $from) !== false) {
@@ -5788,4 +5791,3 @@ class Sanitizer extends Wire {
} }
} }

View File

@@ -5,7 +5,7 @@
* *
* Simple cache for storing strings (encoded or otherwise) and serves as $cache API var * Simple cache for storing strings (encoded or otherwise) and serves as $cache API var
* *
* ProcessWire 3.x, Copyright 2019 by Ryan Cramer * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com * https://processwire.com
* *
* #pw-summary Provides easy, persistent caching of markup, strings, arrays or PageArray objects. * #pw-summary Provides easy, persistent caching of markup, strings, arrays or PageArray objects.
@@ -86,6 +86,12 @@ class WireCache extends Wire {
*/ */
const expireMonthly = 2419200; const expireMonthly = 2419200;
/**
* Ignore expiration (skips expiration check) 3.0.218+
*
*/
const expireIgnore = false;
/** /**
* Date format used by our database queries * Date format used by our database queries
* #pw-internal * #pw-internal
@@ -109,6 +115,7 @@ class WireCache extends Wire {
'weekly' => self::expireWeekly, 'weekly' => self::expireWeekly,
'month' => self::expireMonthly, 'month' => self::expireMonthly,
'monthly' => self::expireMonthly, 'monthly' => self::expireMonthly,
'ignore' => self::expireIgnore
); );
/** /**
@@ -120,20 +127,34 @@ class WireCache extends Wire {
protected $preloads = array(); protected $preloads = array();
/** /**
* Memory cache used by the maintenancePage method * Are we currently preloading?
* *
* @var array|null Once determined becomes array of cache names => Selectors objects * @var bool
* *
*/ */
protected $cacheNameSelectors = null; protected $preloading = false;
/** /**
* Whether or not it's worthwhile to attempt Page or Template maintenance after saves * @var WireCacheInterface
*
* @var null|bool
* *
*/ */
protected $usePageTemplateMaintenance = null; protected $cacher = null;
/**
* Get the current WireClassInterface instance
*
* @return WireCacheInterface
*
*/
protected function cacher() {
$class = __NAMESPACE__ . "\\WireCacheDatabase";
// $class = __NAMESPACE__ . "\\WireCacheFilesystem";
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 * Preload the given caches, so that they will be returned without query on the next get() call
@@ -142,13 +163,14 @@ class WireCache extends Wire {
* *
* #pw-group-advanced * #pw-group-advanced
* *
* @param string|array $names * @param array $names
* @param int|string|null $expire * @param int|string|null $expire
* *
*/ */
public function preload(array $names, $expire = null) { public function preload(array $names, $expire = null) {
if(!is_array($names)) $names = array($names); $this->preloading = true;
$this->preloads = array_merge($this->preloads, $this->get($names, $expire)); $this->preloads = array_merge($this->preloads, $this->get($names, $expire));
$this->preloading = false;
} }
/** /**
@@ -163,7 +185,9 @@ class WireCache extends Wire {
public function preloadFor($ns, $expire = null) { public function preloadFor($ns, $expire = null) {
if(is_object($ns)) $ns = wireClassName($ns, false); if(is_object($ns)) $ns = wireClassName($ns, false);
$ns .= '__*'; $ns .= '__*';
$this->preloading = true;
$this->preloads = array_merge($this->preloads, $this->get($ns, $expire)); $this->preloads = array_merge($this->preloads, $this->get($ns, $expire));
$this->preloading = false;
} }
/** /**
@@ -196,7 +220,7 @@ class WireCache extends Wire {
* - If given a single cache name (string) just the contents of that cache will be returned. * - 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 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. * - 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. * @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 blank returned. You may omit this to divert to whatever expiration * - 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. * 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(). * - If using a $func, the behavior of $expire becomes the same as that of save().
@@ -210,20 +234,24 @@ class WireCache extends Wire {
*/ */
public function get($name, $expire = null, $func = null) { public function get($name, $expire = null, $func = null) {
$_expire = $expire; $values = array();
if(!is_null($expire)) { $expireNow = $expire === self::expireNow;
if(!is_int($expire) && !is_string($expire) && !$expire instanceof Wire && is_callable($expire)) { $getMultiple = is_array($name); // retrieving multiple caches at once?
if($expire !== null && $expire !== self::expireIgnore) {
if(!is_int($expire) && !is_string($expire) && is_callable($expire)) {
$_func = $func; $_func = $func;
$func = $expire; $func = $expire;
$expire = is_null($_func) ? null : $this->getExpires($_func); $expire = $_func === null ? null : $this->getExpires($_func);
unset($_func); unset($_func);
} else { } else {
$expire = $this->getExpires($expire); $expire = $this->getExpires($expire);
} }
} }
$multi = is_array($name); // retrieving multiple caches at once? if($expire === WireCache::expireNow) return ($getMultiple ? false : $values);
if($multi) {
if($getMultiple) {
$names = $name; $names = $name;
} else { } else {
if(isset($this->preloads[$name])) { if(isset($this->preloads[$name])) {
@@ -234,80 +262,46 @@ class WireCache extends Wire {
$names = array($name); $names = array($name);
} }
$where = array();
$binds = array();
$wildcards = array(); $wildcards = array();
$n = 0;
foreach($names as $name) { foreach($names as $s) {
$n++; if(strpos($s, '%') !== false) $s = str_replace('%', '*', $s);
if(strpos($name, '*') !== false || strpos($name, '%') !== false) { if(strpos($s, '*') === false) continue;
// retrieve all caches matching wildcard // retrieve all caches matching wildcard
$wildcards[$name] = $name; $getMultiple = true;
$name = str_replace('*', '%', $name); $wildcards[$s] = $s;
$multi = true;
$where[$n] = "name LIKE :name$n";
} else {
$where[$n] = "name=:name$n";
}
$binds[":name$n"] = $name;
} }
if($multi && !is_null($func)) { if($getMultiple && $func !== null) {
throw new WireException("Function (\$func) may not be specified to \$cache->get() when requesting multiple caches."); 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) . ") "; $cacher = $this->cacher();
if(is_null($expire)) { // || $func) { if($getMultiple) {
$sql .= "AND (expires>=:now OR expires<=:never) "; $values = $expireNow ? array() : $cacher->getMultiple($names, $expire);
$binds[':now'] = date(self::dateFormat, time()); foreach($values as $key => $value) {
$binds[':never'] = self::expireNever; if($this->looksLikeJSON($value)) {
} else if(is_array($expire)) { $value = $this->decodeJSON($value);
// expire is specified by a page selector, so we just let it through $values[$key] = $value;
// 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(); foreach($names as $s) {
} 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 // ensure there is at least a placeholder for all requested caches
if(!isset($values[$name]) && !isset($wildcards[$name])) $values[$name] = ''; if(!isset($values[$s]) && !isset($wildcards[$s])) $values[$s] = '';
} }
} else if(empty($value) && !is_null($func) && is_callable($func)) { } else {
$value = $expireNow ? false : $cacher->get($name, $expire);
if($value !== false && $this->looksLikeJSON($value)) {
$value = $this->decodeJSON($value);
}
if(empty($value) && $func !== null && is_callable($func)) {
// generate the cache now from the given callable function // generate the cache now from the given callable function
$value = $this->renderCacheValue($name, $expire, $func); $value = $this->renderCacheValue($name, $expire, $func);
} }
}
return $multi ? $values : $value; return $getMultiple ? $values : $value;
} }
/** /**
@@ -382,6 +376,7 @@ class WireCache extends Wire {
* @param string $name Cache name * @param string $name Cache name
* @param null|int|string $expire Optional expiration * @param null|int|string $expire Optional expiration
* @param callable|null $func Optional cache generation function * @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. * @return string|array|PageArray|mixed|null Returns null if cache doesnt exist and no generation function provided.
* @see WireCache::get() * @see WireCache::get()
* *
@@ -418,6 +413,7 @@ class WireCache extends Wire {
* *
*/ */
public function save($name, $data, $expire = self::expireDaily) { public function save($name, $data, $expire = self::expireDaily) {
$options = array(); // additional data to pass along to cacher save() method
if(is_array($data)) { if(is_array($data)) {
if(array_key_exists('WireCache', $data)) { if(array_key_exists('WireCache', $data)) {
@@ -443,8 +439,9 @@ class WireCache extends Wire {
'selector' => $expire['selector'], 'selector' => $expire['selector'],
'WireCache' => $data 'WireCache' => $data
); );
$options['expireArray'] = $expire;
$expire = self::expireSelector; $expire = self::expireSelector;
$this->cacheNameSelectors = null; // clear memory cache for maintenancePage method // $this->cacheNameSelectors = null; // clear memory cache for maintenancePage method
} }
if(is_array($data)) { if(is_array($data)) {
@@ -457,18 +454,10 @@ class WireCache extends Wire {
if(is_null($data)) $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 { try {
$result = $query->execute(); $result = $this->cacher()->save($name, $data, $expire);
$this->log($this->_('Saved cache ') . ' - ' . $name); $this->log($this->_('Saved cache ') . ' - ' . $name);
} catch(\Exception $e) { } catch(\Exception $e) {
$this->trackException($e, false); $this->trackException($e, false);
$result = false; $result = false;
@@ -517,11 +506,13 @@ class WireCache extends Wire {
* Returns an array if expires info requires multiple parts, like with self::expireSelector. * 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); * In this case it returns array with array('expires' => date, 'selector' => selector);
* *
* #pw-internal
*
* @param $expire * @param $expire
* @return string|array * @return string|array
* *
*/ */
protected function getExpires($expire) { public function getExpires($expire) {
if(is_object($expire) && $expire->id) { if(is_object($expire) && $expire->id) {
@@ -608,20 +599,8 @@ class WireCache extends Wire {
*/ */
public function delete($name) { public function delete($name) {
try { try {
if(strpos($name, '*') !== false || strpos($name, '%') !== false) { $success = $this->cacher()->delete($name);
// delete all caches matching wildcard $this->log("Cleared cache: $name");
$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) { } catch(\Exception $e) {
$this->trackException($e, true); $this->trackException($e, true);
$this->error($e->getMessage()); $this->error($e->getMessage());
@@ -641,12 +620,7 @@ class WireCache extends Wire {
*/ */
public function deleteAll() { public function deleteAll() {
try { try {
$sql = "DELETE FROM caches WHERE expires!=:reserved"; $qty = $this->cacher()->deleteAll();
$query = $this->wire('database')->prepare($sql, "cache.deleteAll()");
$query->bindValue(':reserved', self::expireReserved);
$query->execute();
$qty = $query->rowCount();
$query->closeCursor();
} catch(\Exception $e) { } catch(\Exception $e) {
$this->trackException($e, true); $this->trackException($e, true);
$this->error($e->getMessage()); $this->error($e->getMessage());
@@ -666,12 +640,7 @@ class WireCache extends Wire {
*/ */
public function expireAll() { public function expireAll() {
try { try {
$sql = "DELETE FROM caches WHERE expires>:never"; $qty = $this->cacher()->expireAll();
$query = $this->wire('database')->prepare($sql, "cache.expireAll()");
$query->bindValue(':never', self::expireNever);
$query->execute();
$qty = $query->rowCount();
$query->closeCursor();
} catch(\Exception $e) { } catch(\Exception $e) {
$this->trackException($e, true); $this->trackException($e, true);
$this->error($e->getMessage()); $this->error($e->getMessage());
@@ -716,194 +685,16 @@ class WireCache extends Wire {
* *
*/ */
public function maintenance($obj = null) { 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 { try {
$result = $query->execute(); $result = $this->cacher()->maintenance($obj);
$qty = $result ? $query->rowCount() : 0;
if($qty) $this->log(sprintf($this->_('General maintenance expired %d cache(s)'), $qty));
$query->closeCursor();
} catch(\Exception $e) { } catch(\Exception $e) {
$this->trackException($e, false); $this->trackException($e, false);
$this->error($e->getMessage(), Notice::debug | Notice::log); $this->error($e->getMessage(), Notice::debug | Notice::log);
$result = false; $result = false;
} }
return $result; 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 * Convert a cacheable array to a PageArray
* *
@@ -922,12 +713,12 @@ class WireCache extends Wire {
} }
$options = array(); $options = array();
$template = empty($data['template']) ? null : $this->wire('templates')->get((int) $data['template']); $template = empty($data['template']) ? null : $this->wire()->templates->get((int) $data['template']);
if($template) $options['template'] = $template; if($template) $options['template'] = $template;
if($pageArrayClass != 'PageArray') $options['pageArrayClass'] = $pageArrayClass; if($pageArrayClass != 'PageArray') $options['pageArrayClass'] = $pageArrayClass;
if(!empty($data['pageClass']) && $data['pageClass'] != 'Page') $options['pageClass'] = $data['pageClass']; if(!empty($data['pageClass']) && $data['pageClass'] != 'Page') $options['pageClass'] = $data['pageClass'];
return $this->wire('pages')->getById($data['PageArray'], $options); return $this->wire()->pages->getById($data['PageArray'], $options);
} }
/** /**
@@ -983,106 +774,14 @@ class WireCache extends Wire {
*/ */
public function getInfo($verbose = true, $names = array(), $exclude = array()) { 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($names)) $names = empty($names) ? array() : array($names);
if(is_string($exclude)) $exclude = empty($exclude) ? array() : array($exclude); if(is_string($exclude)) $exclude = empty($exclude) ? array() : array($exclude);
$all = array(); return $this->cacher()->getInfo(array(
$binds = array(); 'verbose' => $verbose,
$wheres = array(); 'names' => $names,
$sql = "SELECT name, data, expires FROM caches "; 'exclude' => $exclude
));
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;
} }
/** /**
@@ -1134,9 +833,8 @@ class WireCache extends Wire {
'throwExceptions' => true, 'throwExceptions' => true,
); );
$out = null; $paths = $this->wire()->config->paths;
$paths = $this->wire('config')->paths; $files = $this->wire()->files;
$files = $this->wire('files');
$filename = $files->unixFileName($filename); $filename = $files->unixFileName($filename);
if(strpos($filename, '/') !== 0 && strpos($filename, ':') === false && strpos($filename, '//') === false) { if(strpos($filename, '/') !== 0 && strpos($filename, ':') === false && strpos($filename, '//') === false) {
@@ -1164,7 +862,7 @@ class WireCache extends Wire {
// cache value is array where [ 0=created, 1='value' ] // cache value is array where [ 0=created, 1='value' ]
if(!is_array($data) || $data[0] < $mtime) { if(!is_array($data) || $data[0] < $mtime) {
// cache does not exist or is older source file mtime // cache does not exist or is older source file mtime
$out = $this->wire('files')->render($filename, $options['vars'], $options); $out = $files->render($filename, $options['vars'], $options);
if($out === false) return false; if($out === false) return false;
$data = array(time(), $out); $data = array(time(), $out);
if($expire === null) $expire = self::expireDaily; if($expire === null) $expire = self::expireDaily;
@@ -1179,15 +877,17 @@ class WireCache extends Wire {
/** /**
* Make sure a cache name is of the right length and format for a cache name * 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 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. * @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 * @return string
* @since 3.0.130 * @since 3.0.130
* @todo update other methods in this class to use this method * @todo update other methods in this class to use this method
* *
* *
*/ */
protected function cacheName($name, $ns = true) { public function cacheName($name, $ns = true) {
$maxLength = 190; $maxLength = 190;
$name = trim($name); $name = trim($name);
@@ -1233,15 +933,16 @@ class WireCache extends Wire {
return $name; return $name;
} }
/** /**
* Does the given string look like it might be JSON? * Does the given string look like it might be JSON?
* *
* #pw-internal
*
* @param string $str * @param string $str
* @return bool * @return bool
* *
*/ */
protected function looksLikeJSON(&$str) { public function looksLikeJSON(&$str) {
if(empty($str)) return false; if(empty($str)) return false;
$c = substr($str, 0, 1); $c = substr($str, 0, 1);
if($c === '{' && substr(trim($str), -1) === '}') return true; if($c === '{' && substr(trim($str), -1) === '}') return true;
@@ -1254,12 +955,14 @@ class WireCache extends Wire {
* *
* Returns the given $value if it cannot be decoded. * Returns the given $value if it cannot be decoded.
* *
* #pw-internal
*
* @param string $value JSON encoded text value * @param string $value JSON encoded text value
* @param bool $toArray Decode to associative array? Specify false to decode to object. (default=true) * @param bool $toArray Decode to associative array? Specify false to decode to object. (default=true)
* @return array|mixed|PageArray * @return array|mixed|PageArray
* *
*/ */
protected function decodeJSON($value, $toArray = true) { public function decodeJSON($value, $toArray = true) {
$a = json_decode($value, $toArray); $a = json_decode($value, $toArray);
@@ -1298,9 +1001,9 @@ class WireCache extends Wire {
* *
*/ */
public function ___log($str = '', array $options = array()) { public function ___log($str = '', array $options = array()) {
//parent::___log($str, array('name' => 'modules')); //parent::___log($str, array('name' => 'cache'));
return null; $str = ''; // disable log
return parent::___log($str, $options);
} }
} }

View File

@@ -0,0 +1,533 @@
<?php namespace ProcessWire;
/**
* Database cache handler for WireCache
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @since 2.0.218
*
*/
class WireCacheDatabase extends Wire implements WireCacheInterface {
const useLog = 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;
/**
* Get cache by name
*
* @param string $name Cache name to get
* @param string|array|null $expire Datetime in 'YYYY-MM-DD HH:MM:SS' format or array of them, or null for any
* @return string|false
*
*/
public function get($name, $expire) {
$values = $this->getMultiple(array($name), $expire);
return count($values) ? reset($values) : false;
}
/**
* Find multiple caches by name and return them
*
* @param array $names Cache names to get
* @param string|array|null|false $expire Datetime in 'YYYY-MM-DD HH:MM:SS' format or array of them, or null for any, false to ignore
* @return array
*
*/
public function getMultiple(array $names, $expire) {
$where = array();
$binds = array();
$n = 0;
foreach($names as $s) {
$n++;
if(strpos($s, '*') !== false) {
// retrieve all caches matching wildcard
$s = str_replace('*', '%', $s);
$where[$n] = "name LIKE :name$n";
} else {
$where[$n] = "name=:name$n";
}
$binds[":name$n"] = $s;
}
$sql = "SELECT name, data FROM caches WHERE (" . implode(' OR ', $where) . ") ";
if($expire === null) {
$sql .= "AND (expires>=:now OR expires<=:never) ";
$binds[':now'] = date(WireCache::dateFormat, time());
$binds[':never'] = WireCache::expireNever;
} else if($expire === WireCache::expireIgnore) {
// ignore expiration
} 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;
}
$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);
}
$values = array(); // return value for multi-mode
$query->execute();
if(!$query->rowCount()) return $values;
while($row = $query->fetch(\PDO::FETCH_NUM)) {
list($name, $value) = $row;
$values[$name] = $value;
}
$query->closeCursor();
return $values;
}
/**
* Save a cache
*
* @param string $name
* @param string $data
* @param string $expire
* @return bool
*
*/
public function save($name, $data, $expire) {
if($expire === WireCache::expireSelector) {
$this->cacheNameSelectors = null;
}
$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);
$result = $query->execute();
return $result;
}
/**
* Delete a cache by name
*
* @param string $name
* @return bool
*
*/
public function delete($name) {
if(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);
$result = $query->execute();
$query->closeCursor();
return $result;
}
/**
* Delete all caches
*
* @return int
*
*/
public function deleteAll() {
$sql = "DELETE FROM caches WHERE expires!=:reserved";
$query = $this->wire()->database->prepare($sql, "cache.deleteAll()");
$query->bindValue(':reserved', WireCache::expireReserved);
$query->execute();
$qty = $query->rowCount();
$query->closeCursor();
return $qty;
}
/**
* Expire all caches
*
* @return int
*
*/
public function expireAll() {
$sql = "DELETE FROM caches WHERE expires>:never";
$query = $this->wire()->database->prepare($sql, "cache.expireAll()");
$query->bindValue(':never', WireCache::expireNever);
$query->execute();
$qty = $query->rowCount();
$query->closeCursor();
return $qty;
}
/**
* 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;
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($this->usePageTemplateMaintenance === null) {
$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', WireCache::expireSave);
$query->bindValue(':expireSelector', WireCache::expireSelector);
$query->bindValue(':minID', date(WireCache::dateFormat, $minID));
$query->bindValue(':maxID', date(WireCache::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);
} 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(WireCache::dateFormat, time()));
$query->bindValue(':never', WireCache::expireNever);
$result = $query->execute();
$qty = $result ? $query->rowCount() : 0;
if(self::useLog && $qty) $this->wire()->cache->log(sprintf('General maintenance expired %d cache(s)', $qty));
$query->closeCursor();
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($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();
try {
$query = $database->prepare("SELECT * FROM caches WHERE expires=:expire");
$query->bindValue(':expire', WireCache::expireSelector);
$query->execute();
} 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', WireCache::expireSave);
$query->bindValue(':expireTemplateID', date(WireCache::dateFormat, $page->template->id));
foreach($clearNames as $key => $name) {
$query->bindValue(":$key", $name);
}
$result = $query->execute();
$qty = $result ? $query->rowCount() : 0;
if(self::useLog && $qty) {
$this->wire()->cache->log(sprintf('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', WireCache::expireSave);
$query->bindValue(':expireTemplateID', date(WireCache::dateFormat, $template->id));
$result = $query->execute();
$qty = $result ? $query->rowCount() : 0;
if(self::useLog && $qty) $this->wire()->cache->log(sprintf('Maintenance expired %d cache(s) for saved template', $qty));
return $qty > 0;
}
/**
* Get info about caches
*
* @param array $options
* - `verbose` (bool): Return verbose details? (default=true)
* - `names` (array): Names of caches to return info for, or omit for all (default=[])
* - `exclude` (array): Name prefixes of caches to exclude from return value (default=[])
* @return array
*
*/
public function getInfo(array $options = array()) {
$templates = $this->wire()->templates;
$database = $this->wire()->database;
$defaults = array(
'verbose' => true,
'names' => array(),
'exclude' => array()
);
$options = array_merge($defaults, $options);
$verbose = (bool) $options['verbose'];
$names = $options['names'];
$exclude = $options['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->wire()->cache->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($verbose) $info['size'] = strlen($row['data']);
$all[] = $info;
}
$query->closeCursor();
return $all;
}
/**
* 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'));
if(self::useLog) {
return $this->wire()->cache->log($str, $options);
} else {
$str = ''; // disable log
}
return parent::___log($str, $options);
}
}