ObjectCache.php
| 1 | <?php |
| 2 | |
| 3 | namespace Automattic\WooCommerce\Caching; |
| 4 | |
| 5 | /** |
| 6 | * Base class for caching objects (or associative arrays) that have a unique identifier. |
| 7 | * At the very least, derived classes need to implement the 'get_object_type' method, |
| 8 | * but usually it will be convenient to override some of the other protected members. |
| 9 | * |
| 10 | * The actual caching is delegated to an instance of CacheEngine. By default WpCacheEngine is used, |
| 11 | * but a different engine can be used by either overriding the get_cache_engine_instance method |
| 12 | * or capturing the wc_object_cache_get_engine filter. |
| 13 | * |
| 14 | * Objects are identified by ids that are either integers or strings. The actual cache keys passed |
| 15 | * to the cache engine will be prefixed with the object type and a random string. The 'flush' operation |
| 16 | * just forces the generation a new prefix and lets the old cached objects expire. |
| 17 | */ |
| 18 | abstract class ObjectCache { |
| 19 | |
| 20 | /** |
| 21 | * Expiration value to be passed to 'set' to use the value of $default_expiration. |
| 22 | */ |
| 23 | public const DEFAULT_EXPIRATION = -1; |
| 24 | |
| 25 | /** |
| 26 | * Maximum expiration time value, in seconds, that can be passed to 'set'. |
| 27 | */ |
| 28 | public const MAX_EXPIRATION = MONTH_IN_SECONDS; |
| 29 | |
| 30 | /** |
| 31 | * This needs to be set in each derived class. |
| 32 | * |
| 33 | * @var string |
| 34 | */ |
| 35 | private $object_type; |
| 36 | |
| 37 | /** |
| 38 | * Default value for the duration of the objects in the cache, in seconds |
| 39 | * (may not be used depending on the cache engine used WordPress cache implementation). |
| 40 | * |
| 41 | * @var int |
| 42 | */ |
| 43 | protected $default_expiration = HOUR_IN_SECONDS; |
| 44 | |
| 45 | /** |
| 46 | * Temporarily used when retrieving data in 'get'. |
| 47 | * |
| 48 | * @var array |
| 49 | */ |
| 50 | private $last_cached_data; |
| 51 | |
| 52 | /** |
| 53 | * The cache engine to use. |
| 54 | * |
| 55 | * @var ?CacheEngine |
| 56 | */ |
| 57 | private $cache_engine = null; |
| 58 | |
| 59 | /** |
| 60 | * Gets an identifier for the types of objects cached by this class. |
| 61 | * This identifier will be used to compose the keys passed to the cache engine. |
| 62 | * It must be unique for each class inheriting from ObjectCache. |
| 63 | * |
| 64 | * @return string |
| 65 | */ |
| 66 | abstract public function get_object_type(): string; |
| 67 | |
| 68 | /** |
| 69 | * Creates a new instance of the class. |
| 70 | * |
| 71 | * @throws CacheException If get_object_type returns null or an empty string. |
| 72 | */ |
| 73 | public function __construct() { |
| 74 | $this->object_type = $this->get_object_type(); |
| 75 | if ( empty( $this->object_type ) ) { |
| 76 | throw new CacheException( 'Class ' . get_class( $this ) . ' returns an empty value for get_object_type', $this ); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Get the default expiration time for cached objects, in seconds. |
| 82 | * |
| 83 | * @return int |
| 84 | */ |
| 85 | public function get_default_expiration_value(): int { |
| 86 | return $this->default_expiration; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Get the cache engine to use and cache it internally. |
| 91 | * |
| 92 | * @return CacheEngine |
| 93 | */ |
| 94 | private function get_cache_engine(): CacheEngine { |
| 95 | if ( null === $this->cache_engine ) { |
| 96 | $engine = $this->get_cache_engine_instance(); |
| 97 | |
| 98 | /** |
| 99 | * Filters the underlying cache engine to be used by an instance of ObjectCache. |
| 100 | * |
| 101 | * @since 7.4.0 |
| 102 | * |
| 103 | * @param CacheEngine $engine The cache engine to be used by default. |
| 104 | * @param ObjectCache $cache_instance The instance of ObjectCache that will use the cache engine. |
| 105 | * @returns CacheEngine The actual cache engine that will be used. |
| 106 | */ |
| 107 | $this->cache_engine = apply_filters( 'wc_object_cache_get_engine', $engine, $this ); |
| 108 | } |
| 109 | return $this->cache_engine; |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Add an object to the cache, or update an already cached object. |
| 114 | * |
| 115 | * @param object|array $object The object to be cached. |
| 116 | * @param int|string|null $id Id of the object to be cached, if null, get_object_id will be used to get it. |
| 117 | * @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value. |
| 118 | * @return bool True on success, false on error. |
| 119 | * @throws CacheException Invalid parameter, or null id was passed and get_object_id returns null too. |
| 120 | */ |
| 121 | public function set( $object, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool { |
| 122 | if ( null === $object ) { |
| 123 | throw new CacheException( "Can't cache a null value", $this, $id ); |
| 124 | } |
| 125 | |
| 126 | if ( ! is_array( $object ) && ! is_object( $object ) ) { |
| 127 | throw new CacheException( "Can't cache a non-object, non-array value", $this, $id ); |
| 128 | } |
| 129 | |
| 130 | if ( ! is_string( $id ) && ! is_int( $id ) && ! is_null( $id ) ) { |
| 131 | throw new CacheException( "Object id must be an int, a string, or null for 'set'", $this, $id ); |
| 132 | } |
| 133 | |
| 134 | $this->verify_expiration_value( $expiration ); |
| 135 | |
| 136 | $errors = $this->validate( $object ); |
| 137 | if ( ! is_null( $errors ) ) { |
| 138 | try { |
| 139 | $id = $this->get_id_from_object_if_null( $object, $id ); |
| 140 | } catch ( \Throwable $ex ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch |
| 141 | // Nothing else to do, we won't be able to add any significant object id to the CacheException and that's it. |
| 142 | } |
| 143 | |
| 144 | if ( count( $errors ) === 1 ) { |
| 145 | throw new CacheException( 'Object validation/serialization failed: ' . $errors[0], $this, $id, $errors ); |
| 146 | } elseif ( ! empty( $errors ) ) { |
| 147 | throw new CacheException( 'Object validation/serialization failed', $this, $id, $errors ); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | $id = $this->get_id_from_object_if_null( $object, $id ); |
| 152 | |
| 153 | $this->last_cached_data = $object; |
| 154 | return $this->get_cache_engine()->cache_object( |
| 155 | $id, |
| 156 | $object, |
| 157 | self::DEFAULT_EXPIRATION === $expiration ? $this->default_expiration : $expiration, |
| 158 | $this->get_object_type() |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Update an object in the cache, but only if an object is already cached with the same id. |
| 164 | * |
| 165 | * @param object|array $object The new object that will replace the already cached one. |
| 166 | * @param int|string|null $id Id of the object to be cached, if null, get_object_id will be used to get it. |
| 167 | * @param int $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value. |
| 168 | * @return bool True on success, false on error or if no object with the supplied id was cached. |
| 169 | * @throws CacheException Invalid parameter, or null id was passed and get_object_id returns null too. |
| 170 | */ |
| 171 | public function update_if_cached( $object, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool { |
| 172 | $id = $this->get_id_from_object_if_null( $object, $id ); |
| 173 | |
| 174 | if ( ! $this->is_cached( $id ) ) { |
| 175 | return false; |
| 176 | } |
| 177 | |
| 178 | return $this->set( $object, $id, $expiration ); |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Get the id from an object if the id itself is null. |
| 183 | * |
| 184 | * @param object|array $object The object to get the id from. |
| 185 | * @param int|string|null $id An object id or null. |
| 186 | * |
| 187 | * @return int|string|null Passed $id if it wasn't null, otherwise id obtained from $object using get_object_id. |
| 188 | * |
| 189 | * @throws CacheException Passed $id is null and get_object_id returned null too. |
| 190 | */ |
| 191 | private function get_id_from_object_if_null( $object, $id ) { |
| 192 | if ( null === $id ) { |
| 193 | $id = $this->get_object_id( $object ); |
| 194 | if ( null === $id ) { |
| 195 | throw new CacheException( "Null id supplied and the cache class doesn't implement get_object_id", $this ); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | return $id; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Check if the given expiration time value is valid, throw an exception if not. |
| 204 | * |
| 205 | * @param int $expiration Expiration time to check. |
| 206 | * @return void |
| 207 | * @throws CacheException Expiration time is negative or higher than MAX_EXPIRATION. |
| 208 | */ |
| 209 | private function verify_expiration_value( int $expiration ): void { |
| 210 | if ( self::DEFAULT_EXPIRATION !== $expiration && ( ( $expiration < 1 ) || ( $expiration > self::MAX_EXPIRATION ) ) ) { |
| 211 | throw new CacheException( 'Invalid expiration value, must be ObjectCache::DEFAULT_EXPIRATION or a value between 1 and ObjectCache::MAX_EXPIRATION', $this ); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Retrieve a cached object, and if no object is cached with the given id, |
| 217 | * try to get one via get_from_datastore method or by supplying a callback and then cache it. |
| 218 | * |
| 219 | * If you want to provide a callable but still use the default expiration value, |
| 220 | * pass "ObjectCache::DEFAULT_EXPIRATION" as the second parameter. |
| 221 | * |
| 222 | * @param int|string $id The id of the object to retrieve. |
| 223 | * @param int $expiration Expiration of the cached data in seconds from the current time, used if an object is retrieved from datastore and cached. |
| 224 | * @param callable|null $get_from_datastore_callback Optional callback to get the object if it's not cached, it must return an object/array or null. |
| 225 | * @return object|array|null Cached object, or null if it's not cached and can't be retrieved from datastore or via callback. |
| 226 | * @throws CacheException Invalid id parameter. |
| 227 | */ |
| 228 | public function get( $id, int $expiration = self::DEFAULT_EXPIRATION, ?callable $get_from_datastore_callback = null ) { |
| 229 | if ( ! is_string( $id ) && ! is_int( $id ) ) { |
| 230 | throw new CacheException( "Object id must be an int or a string for 'get'", $this ); |
| 231 | } |
| 232 | |
| 233 | $this->verify_expiration_value( $expiration ); |
| 234 | |
| 235 | $data = $this->get_cache_engine()->get_cached_object( $id, $this->get_object_type() ); |
| 236 | if ( null === $data ) { |
| 237 | $object = null; |
| 238 | if ( $get_from_datastore_callback ) { |
| 239 | $object = $get_from_datastore_callback( $id ); |
| 240 | } |
| 241 | |
| 242 | if ( null === $object ) { |
| 243 | return null; |
| 244 | } |
| 245 | $this->set( $object, $id, $expiration ); |
| 246 | $data = $this->last_cached_data; |
| 247 | } |
| 248 | |
| 249 | return $data; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Remove an object from the cache. |
| 254 | * |
| 255 | * @param int|string $id The id of the object to remove. |
| 256 | * @return bool True if the object is removed from the cache successfully, false otherwise (because the object wasn't cached or for other reason). |
| 257 | */ |
| 258 | public function remove( $id ): bool { |
| 259 | return $this->get_cache_engine()->delete_cached_object( $id, $this->get_object_type() ); |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Remove all the objects from the cache. |
| 264 | * |
| 265 | * @return bool True on success, false on error. |
| 266 | */ |
| 267 | public function flush(): bool { |
| 268 | return $this->get_cache_engine()->delete_cache_group( $this->get_object_type() ); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Is a given object cached? |
| 273 | * |
| 274 | * @param int|string $id The id of the object to check. |
| 275 | * @return bool True if there's a cached object with the specified id. |
| 276 | */ |
| 277 | public function is_cached( $id ): bool { |
| 278 | return $this->get_cache_engine()->is_cached( $id, $this->get_object_type() ); |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * Get the id of an object. This is used by 'set' when a null id is passed. |
| 283 | * If the object id can't be determined the method must return null. |
| 284 | * |
| 285 | * @param array|object $object The object to get the id for. |
| 286 | * @return int|string|null |
| 287 | */ |
| 288 | abstract protected function get_object_id( $object ); |
| 289 | |
| 290 | /** |
| 291 | * Validate an object before it's cached. |
| 292 | * |
| 293 | * @param array|object $object Object to validate. |
| 294 | * @return array|null An array with validation error messages, null or an empty array if there are no errors. |
| 295 | */ |
| 296 | abstract protected function validate( $object ): ?array; |
| 297 | |
| 298 | /** |
| 299 | * Get the instance of the cache engine to use. |
| 300 | * |
| 301 | * @return CacheEngine |
| 302 | */ |
| 303 | protected function get_cache_engine_instance(): CacheEngine { |
| 304 | return wc_get_container()->get( WPCacheEngine::class ); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Get a random string to be used to compose the cache key prefix. |
| 309 | * It should return a different string each time. |
| 310 | * |
| 311 | * @return string |
| 312 | */ |
| 313 | protected function get_random_string(): string { |
| 314 | return dechex( microtime( true ) * 1000 ) . bin2hex( random_bytes( 8 ) ); |
| 315 | } |
| 316 | } |
| 317 |