diff --git a/wire/config.php b/wire/config.php index f4fb9e9d..16c479c9 100644 --- a/wire/config.php +++ b/wire/config.php @@ -1662,14 +1662,15 @@ $config->modals = array( * This is an optimization that can reduce some database overhead. * * @var array + * @deprecated No longer in use as of 3.0.218 * */ $config->preloadCacheNames = array( - 'Modules.info', + //'Modules.info', //'ModulesVerbose.info', - 'ModulesVersions.info', - 'Modules.wire/modules/', - 'Modules.site/modules/', + //'ModulesVersions.info', + //'Modules.wire/modules/', + //'Modules.site/modules/', ); /** diff --git a/wire/core/Interfaces.php b/wire/core/Interfaces.php index 49bcd244..da4bacef 100644 --- a/wire/core/Interfaces.php +++ b/wire/core/Interfaces.php @@ -723,29 +723,43 @@ interface InputfieldHasSelectableOptions { /** * Interface for WireCache handler classes * + * For example implementations of this interface see + * WireCacheDatabase (core) and WireCacheFilesystem (module) + * * @since 3.0.218 * */ interface WireCacheInterface { + /** - * Get single cache + * Find caches by names and/or expirations and return requested values * - * @param string $name - * @param string|array|null|false $expire - * @return string|false + * ~~~~~ + * // Default options + * $defaults = [ + * 'names' => [], + * 'expires' => [], + * 'expiresMode' => 'OR', + * 'get' => [ 'name', 'expires', 'data' ], + * ]; * + * // Example options + * $options['names'] = [ 'my-cache', 'your-cache', 'hello-*' ]; + * $options['expires'] => [ + * '<= ' . WireCache::expiresNever, + * '>= ' . date('Y-m-d H:i:s') + * ]; + * ~~~~~ + * + * @param array $options + * - `get` (array): Properties to get in return value, one or more of [ `name`, `expires`, `data`, `size` ] (default=all) + * - `names` (array): Names of caches to find (OR condition), optionally appended with wildcard `*`. + * - `expires` (array): Expirations of caches to match in ISO-8601 date format, prefixed with operator and space (see expiresMode mode below). + * - `expiresMode` (string): Whether it should match any one condition 'OR', or all conditions 'AND' (default='OR') + * @return array Returns array of associative arrays, each containing requested properties + * */ - 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); + public function find(array $options); /** * Save a cache @@ -759,7 +773,7 @@ interface WireCacheInterface { public function save($name, $data, $expire); /** - * Delete cache + * Delete cache by name * * @param string $name * @return bool @@ -768,7 +782,7 @@ interface WireCacheInterface { public function delete($name); /** - * Delete all caches + * Delete all caches (except those reserved by the system) * * @return int * @@ -776,34 +790,10 @@ interface WireCacheInterface { public function deleteAll(); /** - * Expire all caches + * Expire all caches (except those that should never expire) * * @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()); } diff --git a/wire/core/ProcessWire.php b/wire/core/ProcessWire.php index 25966edf..36ce363f 100644 --- a/wire/core/ProcessWire.php +++ b/wire/core/ProcessWire.php @@ -546,11 +546,7 @@ class ProcessWire extends Wire { throw new WireDatabaseException($e->getMessage()); } - /** @var WireCache $cache */ - $cache = $this->wire('cache', new WireCache(), true); - $cacheNames = $config->preloadCacheNames; - if($database->getEngine() === 'innodb') $cacheNames[] = 'InnoDB.stopwords'; - $cache->preload($cacheNames, WireCache::expireIgnore); + $this->wire('cache', new WireCache(), true); $modules = null; try { diff --git a/wire/core/WireCache.php b/wire/core/WireCache.php index 10fadcea..c7436d94 100644 --- a/wire/core/WireCache.php +++ b/wire/core/WireCache.php @@ -26,6 +26,12 @@ class WireCache extends Wire { + /** + * Default cache class + * + */ + const defaultCacheClass = 'WireCacheDatabase'; + /** * Expiration constants that may be supplied to WireCache::save $seconds argument. * @@ -134,6 +140,22 @@ class WireCache extends Wire { */ 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 * @@ -147,8 +169,7 @@ class WireCache extends Wire { * */ protected function cacher() { - $class = __NAMESPACE__ . "\\WireCacheDatabase"; - // $class = __NAMESPACE__ . "\\WireCacheFilesystem"; + $class = __NAMESPACE__ . "\\" . self::defaultCacheClass; if($this->cacher === null) { $this->cacher = new $class(); $this->wire($this->cacher); @@ -165,6 +186,7 @@ class WireCache extends Wire { * * @param array $names * @param int|string|null $expire + * @deprecated * */ public function preload(array $names, $expire = null) { @@ -180,6 +202,7 @@ class WireCache extends Wire { * * @param object|string $ns * @param int|string|null $expire + * @deprecated * */ public function preloadFor($ns, $expire = null) { @@ -221,7 +244,7 @@ class WireCache extends Wire { * - 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 blank returned. You may omit this to divert to whatever expiration + * - 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 @@ -230,26 +253,55 @@ class WireCache extends Wire { * @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) { $values = array(); - $expireNow = $expire === self::expireNow; $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)) { + if(!is_int($expire) && !is_string($expire) && is_callable($expire) && !$expire instanceof Wire) { $_func = $func; $func = $expire; - $expire = $_func === null ? null : $this->getExpires($_func); + $expire = $_func === null ? null : $this->getExpires($_func, false); unset($_func); } else { - $expire = $this->getExpires($expire); + $expire = $this->getExpires($expire, false); } } - - if($expire === WireCache::expireNow) return ($getMultiple ? false : $values); + + if($expire === self::expireNow) { + // forced expiration now + $expireNow = true; + $expires = array(); + + } 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; @@ -275,34 +327,56 @@ class WireCache extends Wire { if($getMultiple && $func !== null) { throw new WireException("Function (\$func) may not be specified to \$cache->get() when requesting multiple caches."); } - - $cacher = $this->cacher(); + $findOptions = array( + 'names' => $names, + 'expires' => $expires, + 'get' => array('name', 'data') + ); + if($getMultiple) { - $values = $expireNow ? array() : $cacher->getMultiple($names, $expire); - foreach($values as $key => $value) { - if($this->looksLikeJSON($value)) { - $value = $this->decodeJSON($value); - $values[$key] = $value; - } + // 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] = ''; } - } else { - $value = $expireNow ? false : $cacher->get($name, $expire); - if($value !== false && $this->looksLikeJSON($value)) { - $value = $this->decodeJSON($value); + if($expireNow) { + // warning: expireNow in getMultiple mode does not support render cache value } - if(empty($value) && $func !== null && is_callable($func)) { - // generate the cache now from the given callable function - $value = $this->renderCacheValue($name, $expire, $func); + + } 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 @@ -414,6 +488,12 @@ class WireCache extends Wire { */ public function save($name, $data, $expire = self::expireDaily) { $options = array(); // additional data to pass along to cacher save() method + + if(empty($expire)) $expire = self::expireDaily; + + if($expire === WireCache::expireSelector) { + $this->cacheNameSelectors = null; + } if(is_array($data)) { if(array_key_exists('WireCache', $data)) { @@ -501,20 +581,23 @@ class WireCache extends Wire { } /** - * Given an expiration seconds, date, page, or template, convert it to an ISO-8601 date + * 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); + * 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 $expire + * @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) { + public function getExpires($expire, $verbose = null) { - if(is_object($expire) && $expire->id) { + if($expire instanceof Wire && $expire->id) { if($expire instanceof Page) { // page object @@ -528,27 +611,34 @@ class WireCache extends Wire { // 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; + 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(in_array($expire, array(self::expireNever, self::expireReserved, self::expireSave))) { + + } else if(in_array($expire, array(self::expireNever, self::expireReserved, self::expireSave, self::expireNow))) { // good, we'll take it as-is - return $expire; + return $verbose ? array('expire' => $expire) : $expire; } else if(is_string($expire) && Selectors::stringHasSelector($expire)) { // expire when page matches selector - return array( - 'expire' => self::expireSelector, - 'selector' => $expire - ); + if($verbose || $verbose === null) { + return array( + 'expire' => self::expireSelector, + 'selector' => $expire + ); + } + return self::expireSelector; } else { @@ -578,6 +668,8 @@ class WireCache extends Wire { } $expire = date(self::dateFormat, $expire); + + if($verbose) $expire = array('expire' => $expire); return $expire; } @@ -594,19 +686,35 @@ class WireCache extends Wire { * ~~~~~ * * @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 + * @return bool True on success, false if no cache was cleared * */ public function delete($name) { - try { - $success = $this->cacher()->delete($name); - $this->log("Cleared cache: $name"); - } catch(\Exception $e) { - $this->trackException($e, true); - $this->error($e->getMessage()); - $success = false; + if(strpos($name, '*') !== false) { + $rows = $this->cacher()->find(array( + 'names' => array($name), + 'get' => array('name'), + )); + } else { + $rows = array(array('name' => $name)); } - return $success; + $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; } /** @@ -685,14 +793,193 @@ class WireCache extends Wire { * */ public function maintenance($obj = null) { - try { - $result = $this->cacher()->maintenance($obj); - } catch(\Exception $e) { - $this->trackException($e, false); - $this->error($e->getMessage(), Notice::debug | Notice::log); - $result = false; + + 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) { + $rows = $this->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; } - return $result; + + // 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 + return $this->maintenanceGeneral(); + } + + /** + * 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; } /** @@ -769,21 +1056,97 @@ class WireCache extends Wire { * @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()) { + 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'); - return $this->cacher()->getInfo(array( - 'verbose' => $verbose, - 'names' => $names, - 'exclude' => $exclude - )); - } + $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 * @@ -943,7 +1306,7 @@ class WireCache extends Wire { * */ public function looksLikeJSON(&$str) { - if(empty($str)) return false; + 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; @@ -989,6 +1352,39 @@ class WireCache extends Wire { 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 diff --git a/wire/core/WireCacheDatabase.php b/wire/core/WireCacheDatabase.php index 47c18bcf..787435a4 100644 --- a/wire/core/WireCacheDatabase.php +++ b/wire/core/WireCacheDatabase.php @@ -11,117 +11,112 @@ */ 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 - * + * Find caches by names or expirations and return requested values + * + * @param array $options + * - `names` (array): Names of caches to find (OR condition), optionally appended with wildcard `*`. + * - `expires` (array): Expirations of caches to match in ISO-8601 date format, prefixed with operator and space (see expiresMode mode below). + * - `expiresMode` (string): Whether it should match any one condition 'OR', or all conditions 'AND' (default='OR') + * - `get` (array): Properties to get in return value, one or more of [ `name`, `expires`, `data`, `size` ] (default=all) + * @return array Returns array of associative arrays, each containing requested properties + * */ - protected $cacheNameSelectors = null; + public function find(array $options) { - /** - * 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) { + $defaults = array( + 'names' => array(), + 'expires' => array(), + 'expiresMode' => 'OR', + 'get' => array('name', 'expires', 'data'), + ); + $database = $this->wire()->database; + $options = array_merge($defaults, $options); $where = array(); + $whereNames = array(); + $whereExpires = 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"; + $cols = array(); + + if(count($options['names'])) { + $n = 0; + foreach($options['names'] as $name) { + $n++; + if(strpos($name, '*') !== false) { + $name = str_replace('*', '%', $name); + $whereNames[] = "name LIKE :name$n"; + } else { + $whereNames[] = "name=:name$n"; + } + $binds[":name$n"] = $name; } - $binds[":name$n"] = $s; } - $sql = "SELECT name, data FROM caches WHERE (" . implode(' OR ', $where) . ") "; + if(count($options['expires'])) { + $n = 0; + foreach($options['expires'] as $expires) { + $operator = '='; + if(strpos($expires, ' ')) { + // string in format: '>= YYYY-MM-DD HH:MM:SS' + list($op, $expires) = explode(' ', $expires, 2); + if($database->isOperator($op)) $operator = $op; + } + $n++; + $whereExpires[] = "expires$operator:expires$n"; + $binds[":expires$n"] = $expires; + } + } - 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 + if(count($whereNames)) { + $where[] = '(' . implode(' OR ', $whereNames) . ')'; + } + + if(count($whereExpires)) { + $mode = strtoupper($options['expiresMode']) === 'AND' ? 'AND' : 'OR'; + $where[] = '(' . implode(" $mode ", $whereExpires) . ')'; + } + + foreach($options['get'] as $col) { + if($col === 'name' || $col === 'expires' || $col === 'data') $cols[] = $col; + if($col === 'size') $cols[] = 'LENGTH(data) AS size'; + } + + if(empty($cols)) return array(); + + $sql = 'SELECT ' . implode(',', $cols) . ' FROM caches '; + if(count($where)) { + $sql .= 'WHERE ' . implode(' AND ', $where); } else { - $sql .= "AND expires<=:expire "; - $binds[':expire'] = $expire; + // getting all + $sql .= 'ORDER BY name'; } - $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); + $query = $database->prepare($sql); + + foreach($binds as $bindKey => $bindValue) { + $query->bindValue($bindKey, $bindValue); } - $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; - } + if(!$this->executeQuery($query)) return array(); + $rows = $query->fetchAll(\PDO::FETCH_ASSOC); $query->closeCursor(); - return $values; + return $rows; } /** * Save a cache * - * @param string $name - * @param string $data - * @param string $expire + * @param string $name Name of cache + * @param string $data Data to save in cache + * @param string $expire String in ISO-8601 date format * @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`)'; @@ -131,8 +126,8 @@ class WireCacheDatabase extends Wire implements WireCacheInterface { $query->bindValue(':data', $data); $query->bindValue(':expires', $expire); - $result = $query->execute(); - + $result = $this->executeQuery($query); + return $result; } @@ -144,390 +139,94 @@ class WireCacheDatabase extends Wire implements WireCacheInterface { * */ 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'; - } + $sql = 'DELETE FROM caches WHERE name=:name'; $query = $this->wire()->database->prepare($sql, "cache.delete($name)"); $query->bindValue(':name', $name); - $result = $query->execute(); + if(!$this->executeQuery($query)) return false; $query->closeCursor(); - return $result; + return true; } /** - * Delete all caches + * Delete all caches (except those reserved by the system) * * @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; + return $this->_deleteAll(); } /** - * Expire all caches + * Expire all caches (except those that should never expire) * * @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(); + return $this->_deleteAll(true); + } + + /** + * Implementation for deleteAll and expireAll methods + * + * @param bool $expireAll + * @return int + * @throws WireException + * + */ + protected function _deleteAll($expireAll = false) { + $sql = 'DELETE FROM caches WHERE ' . ($expireAll ? 'expires>:never' : 'expires!=:reserved'); + $query = $this->wire()->database->prepare($sql, "cache.deleteAll()"); + $query->bindValue(':expires', ($expireAll ? WireCache::expireNever : WireCache::expireReserved)); + if(!$this->executeQuery($query)) return 0; $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 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. + * Execute query + * + * @param \PDOStatement $query * @return bool - * + * */ - public function maintenance($obj = null) { + protected function executeQuery(\PDOStatement $query) { + $install = false; + try { + $result = $query->execute(); + } catch(\PDOException $e) { + $result = false; + $install = $e->getCode() === '42S02'; // table does not exist + if(!$install) throw $e; + } + if($install) $this->install(); + return $result; + } - static $done = false; - - $forceRun = false; + /** + * Create the caches table if it happens to have been deleted + * + */ + protected function install() { $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; + $dbEngine = $config->dbEngine; + $dbCharset = $config->dbCharset; + if($database->tableExists('caches')) return; + try { + $this->wire()->database->exec(" + CREATE TABLE caches ( + `name` VARCHAR(191) NOT NULL PRIMARY KEY, + `data` MEDIUMTEXT NOT NULL, + `expires` DATETIME NOT NULL, + INDEX `expires` (`expires`) + ) ENGINE=$dbEngine DEFAULT CHARSET=$dbCharset; + "); + $this->message("Re-created 'caches' table"); + } catch(\Exception $e) { + $this->error("Unable to create 'caches' table"); } - - // 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); - } - } diff --git a/wire/templates-admin/debug.inc b/wire/templates-admin/debug.inc index 4baadd11..7781d15a 100644 --- a/wire/templates-admin/debug.inc +++ b/wire/templates-admin/debug.inc @@ -263,17 +263,19 @@ echo $timer) { + if($timer1 === '') $timer1 = $timer; $o .= "$name$timer"; $oc++; } ?>
-

($oc)"; ?>

+

($oc) {$timer1}s"; ?>

- +

To add more timers here…

@@ -373,13 +375,13 @@ Debug::saveTimer('timer-name', 'optional notes'); // stop and save timer getInfo(true, array(), array('FileCompiler', 'Modules', 'Permissions.')) as $info) { + foreach($cache->getInfo(true, array(), array('FileCompiler', 'Modules', 'Permissions.'), array('name', 'expires', 'size')) as $info) { $oc++; $o .= ""; foreach($info as $key => $value) { - if($key == 'name') continue; - if($key == 'size') $value = wireBytesStr($value); + if($key === 'name' || $key === 'type') continue; + if($key === 'size') $value = wireBytesStr($value); $key = $sanitizer->entities($key); $value = $sanitizer->entities($value); $o .= ""; @@ -390,6 +392,7 @@ Debug::saveTimer('timer-name', 'optional notes'); // stop and save timer

($oc)"; ?>

+

getCacheModule()->className(); ?>

"; $o .= $sanitizer->entities($info['name']) . "
$key$value